diff --git a/code/__DEFINES/client_prefs.dm b/code/__DEFINES/client_prefs.dm index d2e35f039322..a370571de69c 100644 --- a/code/__DEFINES/client_prefs.dm +++ b/code/__DEFINES/client_prefs.dm @@ -71,6 +71,21 @@ // NOTE: Don't add flags past 1<<23, it'll break things due to BYOND limitations. //================================================= +//toggles_vehicle bits from /datum/preferences +//================================================= +///Vehicle picks its own gear automatically instead of the driver shifting manually. +#define VEHICLE_SIMPLE_TRANSMISSION (1<<0) +///Vehicle controls use legacy WASD movement instead of tank-relative gas/brake/turn. +#define VEHICLE_SIMPLE_CONTROLS (1<<1) +///Vehicle acceleration uses keypress-driven momentum instead of the continuous cruise loop +#define VEHICLE_SIMPLE_ACCELERATION (1<<2) +///Driver HUD shows speed/acceleration in mph instead of the default km/h +#define VEHICLE_UNITS_MPH (1<<3) + +///Default toggles_vehicle value. +#define TOGGLES_VEHICLE_DEFAULT 0 +//================================================= + #define JOB_SLOT_RANDOMISED_SLOT -1 #define JOB_SLOT_CURRENT_SLOT 0 #define JOB_SLOT_RANDOMISED_TEXT "Randomise name and appearance" diff --git a/code/__DEFINES/conflict.dm b/code/__DEFINES/conflict.dm index 1603bba39fc4..ca487396cb75 100644 --- a/code/__DEFINES/conflict.dm +++ b/code/__DEFINES/conflict.dm @@ -34,7 +34,7 @@ /// Whether or not the bullet hits the target that was clicked or if it keeps travelling #define AMMO_HITS_TARGET_TURF (1<<17) #define AMMO_ALWAYS_FF (1<<18) -// (1<<19) unused, previously was AMMO_HOMING +#define AMMO_PASSES_OVER_VEHICLES (1<<19) /// Can't be deflected #define AMMO_NO_DEFLECT (1<<20) ///Can only hit people with criminal status @@ -209,6 +209,16 @@ #define ANTISTRUCT_DMG_MULT_WALL 2.5 #define ANTISTRUCT_DMG_MULT_TANK 1.5 +/// Acid spits and acid shotgun were too weak against tanks, so multiply damage and wound chance. +#define ACID_SPIT_TANK_DAMAGE_MULT 6 +#define ACID_SHOTGUN_TANK_DAMAGE_MULT 6 +#define ACID_SHOTGUN_TANK_WOUND_CHANCE_MULT 6 +#define ACID_SPIT_TANK_WOUND_CHANCE_MULT 6 +/// Another acid shotgun tweak to make it less shit against vehicles. +#define ACID_SHOTGUN_TANK_EFFECTIVE_PENETRATION 10 +/// Multiplies bone chips' wound chances. They still deal very little damage. +#define BONE_CHIPS_TANK_WOUND_CHANCE_MULT 2 + // human armor #define CLOTHING_ARMOR_NONE 0 #define CLOTHING_ARMOR_VERYLOW 5 diff --git a/code/__DEFINES/dcs/signals/atom/mob/signals_mob.dm b/code/__DEFINES/dcs/signals/atom/mob/signals_mob.dm index 04afcd3beb90..d0fa9bdf55cd 100644 --- a/code/__DEFINES/dcs/signals/atom/mob/signals_mob.dm +++ b/code/__DEFINES/dcs/signals/atom/mob/signals_mob.dm @@ -1,5 +1,7 @@ ///from base of mob/set_stat(): (new_stat, old_stat) #define COMSIG_MOB_STATCHANGE "mob_statchange" +/// Sent when the player toggles a vehicle preference. +#define COMSIG_MOB_VEHICLE_PREFS_CHANGED "mob_vehicle_prefs_changed" /// From /obj/structure/machinery/door/airlock/proc/take_damage #define COMSIG_MOB_DESTROY_AIRLOCK "mob_destroy_airlock" @@ -143,6 +145,7 @@ #define COMSIG_MOB_MOUSEDOWN "mob_mousedown" //from /client/MouseDown(): (atom/object, turf/location, control, params) #define COMSIG_MOB_MOUSEUP "mob_mouseup" //from /client/MouseUp(): (atom/object, turf/location, control, params) #define COMSIG_MOB_MOUSEDRAG "mob_mousedrag" //from /client/MouseDrag(): (atom/src_object, atom/over_object, turf/src_location, turf/over_location, src_control, over_control, params) +#define COMSIG_MOB_MOUSEMOVE "mob_mousemove" //from /client/MouseMove(): (atom/object, turf/location, control, params) #define COMSIG_MOB_CLICK_CANCELED (1<<0) #define COMSIG_MOB_CLICK_HANDLED (1<<1) diff --git a/code/__DEFINES/dcs/signals/atom/signals_projectile.dm b/code/__DEFINES/dcs/signals/atom/signals_projectile.dm index 89393d1f8e55..43611132fc9e 100644 --- a/code/__DEFINES/dcs/signals/atom/signals_projectile.dm +++ b/code/__DEFINES/dcs/signals/atom/signals_projectile.dm @@ -29,6 +29,8 @@ #define COMSIG_BULLET_PRE_HANDLE_TURF "bullet_pre_handle_turf" #define COMPONENT_BULLET_PASS_THROUGH (1<<0) #define COMSIG_BULLET_TERMINAL "bullet_terminal" +/// From /obj/projectile/fly(), every tick of flight (unlike COMSIG_BULLET_TERMINAL, which only fires once, right before impact): () +#define COMSIG_BULLET_STEP "bullet_step" /// Called when a bullet hits a living mob on a sprite click (original target is final target) #define COMSIG_BULLET_DIRECT_HIT "bullet_direct_hit" diff --git a/code/__DEFINES/keybinding.dm b/code/__DEFINES/keybinding.dm index 3c48bb26ac00..69b3dfdd3a17 100644 --- a/code/__DEFINES/keybinding.dm +++ b/code/__DEFINES/keybinding.dm @@ -236,6 +236,11 @@ #define COMSIG_KB_VEHICLE_CHANGE_SELECTED_WEAPON "keybinding_change_selected_weapon" #define COMSIG_KB_VEHICLE_ACTIVATE_HORN "keybinding_activate_horn" #define COMSIG_KB_VEHICLE_RELOAD_WEAPON "keybinding_reload_weapon" +#define COMSIG_KB_VEHICLE_TOGGLE_HARDPOINT_FIRE_MODE "keybinding_vehicle_toggle_hardpoint_fire_mode" +#define COMSIG_KB_VEHICLE_SHIFT_GEAR_UP "keybinding_vehicle_shift_gear_up" +#define COMSIG_KB_VEHICLE_SHIFT_GEAR_DOWN "keybinding_vehicle_shift_gear_down" +#define COMSIG_KB_VEHICLE_TOGGLE_CRUISE_CONTROL "keybinding_vehicle_toggle_cruise_control" +#define COMSIG_KB_VEHICLE_TOGGLE_TURRET_SAFETY "keybinding_vehicle_toggle_turret_safety" #define CATEGORY_CLIENT "CLIENT" #define CATEGORY_EMOTE "EMOTE" @@ -247,7 +252,7 @@ #define CATEGORY_HUMAN_INVENTORY "HUMAN INVENTORY" #define CATEGORY_ROBOT "ROBOT" #define CATEGORY_YAUTJA "YAUTJA" -#define CATEGORY_VEHICLE "Vehicles" +#define CATEGORY_VEHICLE "VEHICLE" #define CATEGORY_MISC "MISC" #define CATEGORY_MOVEMENT "MOVEMENT" #define CATEGORY_COMMUNICATION "COMMUNICATION" diff --git a/code/__DEFINES/layers.dm b/code/__DEFINES/layers.dm index 8f1afc5130b6..6200b12647df 100644 --- a/code/__DEFINES/layers.dm +++ b/code/__DEFINES/layers.dm @@ -132,8 +132,21 @@ #define FACEHUGGER_LAYER 4.13 /// For Signs above everything but below weather #define BILLBOARD_LAYER 4.13 + +/// For objs atop a tank +#define TANK_RIDER_OBJ_LAYER 4.505 +/// Mostly for bodybags atop a tank +#define TANK_RIDER_ABOVE_OBJ_LAYER 4.506 +/// For mobs riding atop a tank +#define TANK_BELOW_RIDER_LAYER 4.508 +#define TANK_LYING_RIDER_LAYER 4.509 +#define TANK_RIDER_LAYER 4.51 +#define TANK_ABOVE_RIDER_LAYER 4.515 +/// For a barricade currently covered by a vehicle on the same tile. +#define VEHICLE_COVERED_BARRICADE_LAYER 4.52 + /// For WEATHER -#define WEATHER_LAYER 4.14 +#define WEATHER_LAYER 5 //#define FLY_LAYER 5 diff --git a/code/__DEFINES/objects.dm b/code/__DEFINES/objects.dm index 33b6c3d1af88..ae4cf83652b3 100644 --- a/code/__DEFINES/objects.dm +++ b/code/__DEFINES/objects.dm @@ -172,6 +172,8 @@ GLOBAL_LIST_INIT(RESTRICTED_CAMERA_NETWORKS, list( //Those networks can only be #define FLAME_REAGENT_USE_AMOUNT 1 +#define FLAME_MOUNT_AUTO "flame_mount_auto" + #define CLEANABLE_SPLATTER "splatter" // Vomit, oil #define CLEANABLE_BLOOD "blood" #define CLEANABLE_BLOOD_SPLATTER "blood splatter" diff --git a/code/__DEFINES/vehicle.dm b/code/__DEFINES/vehicle.dm index d40e89826eff..3714ae449209 100644 --- a/code/__DEFINES/vehicle.dm +++ b/code/__DEFINES/vehicle.dm @@ -7,12 +7,273 @@ #define HDPT_TURRET "turret" #define HDPT_SPECIAL "special" //special pre-installed hardpoints with unique behaviour +// Tank Desant logistics parts +// traction, movement, acceleration, power generation +#define HDPT_ENGINE "engine" +// fuel levels & fuel pump +#define HDPT_FUEL_TANK "fuel tank" +// engine heat management, cooling of the fighting compartment +#define HDPT_RADIATOR "radiator" +// engine ignition; internal climatization; IFF, hatch locks, visual sensors, turret ring drive, air filtering +#define HDPT_BATTERY "battery" +// blocks movement into the tank. When destroyed, xenos can go in and out almost instantly. +#define HDPT_HATCH "hatch" +// can be interacted with to change IFF mode like turrets. When broken, weapons lose projectile IFF +#define HDPT_IFF_MODULE "iff module" +// external cameras. Can be hawk tuah'd by spitters and praes to obscure. Requires constant cleaning +#define HDPT_VISUAL_SENSORS "visual sensors" +// turret rotation speed +#define HDPT_TURRET_RING "turret ring" +// protects against boiler clouds & smoke from entering the tank +#define HDPT_AIR_FILTER "air filter" +// front-mounted plow +#define HDPT_SNOWPLOW "snowplow" +// Not a real install slot. Lets the UA flag use its own wound text instead of gun-part wording. +#define HDPT_UA_FLAG "ua flag" + #define HDPT_LAYER_WHEELS 0.01 // so it appears below xenomorphs and other mobs #define HDPT_LAYER_SUPPORT 2 #define HDPT_LAYER_ARMOR 3 #define HDPT_LAYER_TURRET 4 #define HDPT_LAYER_MAX 4 +/// Fixed view radius for a seated driver/gunner/passenger +#define VEHICLE_SEAT_VIEW_RADIUS 8 + +// Gear transmission +/// Torque floor so a wrecked engine/treads combo can still crawl. +#define GEAR_MIN_TORQUE_FRACTION 0.15 +/// Gear 1's own min-power floor +#define GEAR_1_MIN_POWER_FRACTION 0.6 +/// Gear 2's own min-power floor +#define GEAR_2_MIN_POWER_FRACTION 0.45 +// Reverse's own power floor. Made a little stronger than gear 2 so Crewmen can retreat more easily. +#define GEAR_REVERSE_MIN_POWER_FRACTION 0.6 +/// Below this fraction of nominal torque, is_underpowered() reports TRUE. +#define GEAR_UNDERPOWERED_FRACTION 0.5 +/// Engine and ttreads performance only degrades below this health level. +#define DEGRADE_GRACE_THRESHOLD_PCT 95 +/// Speed floor (tiles/sec) to avoid a divide-by-zero when scheduling the next move. +#define VEHICLE_MIN_CRAWL_SPEED 0.05 +/// Deciseconds between each movement loop (gear_movement_loop()) progress check. +#define GEAR_MOVEMENT_POLL_INTERVAL 1 +/// Flat deceleration (tiles/sec^2) applied by the brake key, independent of gear. +#define VEHICLE_BRAKE_DECELERATION 1.5 +/// Traction malus applied to a turf weeded at WEED_LEVEL_WEAK. +#define WEED_TRACTION_CAP_WEAK 0.8 +/// Traction malus for WEED_LEVEL_STANDARD. +#define WEED_TRACTION_CAP_STANDARD 0.6 +/// Traction malus for WEED_LEVEL_HARDY. +#define WEED_TRACTION_CAP_HARDY 0.55 +/// Traction malus for WEED_LEVEL_HIVE. +#define WEED_TRACTION_CAP_HIVE 0.4 +/// How much of a turf's traction deviation from neutral (1.0) a vehicle actually feels. +#define TRACTION_DAMPENING_TREADS 0.4 +#define TRACTION_DAMPENING_WHEELS 1.0 +/// Fuel burn multiplier for reagents other than the vehicle's intended fuel (welding fuel, cooking oil, etc etc). +#define OFF_LABEL_FUEL_PENALTY 1.5 +/// Units/sec a fuel tank or radiator drains its own contents at once fully destroyed. +#define HARDPOINT_BUSTED_LEAK_RATE 100 +/// Extra blunt damage a knockback-shoved tank takes when slammed into a wall. +#define KNOCKBACK_WALL_SLAM_DAMAGE 15 +/// Fixed physics tick (deciseconds) gear_cruise_loop() runs on. +#define GEAR_TICK_INTERVAL 2 +/// How long (deciseconds) a gas/brake key event still counts as "held". +#define GEAR_INPUT_GRACE_PERIOD 3 +/// Default cruise control granularity as a fraction of the top gear's max_speed. +#define CRUISE_CONTROL_DEFAULT_GRANULARITY_FRACTION (1/3) +/// Extra fuel (units) burned per (tile/sec) of speed changed in one tick, on top of baseline cruising cost. +#define ACCEL_FUEL_PER_SPEED_DELTA 0.3 +/// Fuel (units/sec) burned just from the engine idling. +#define ENGINE_IDLE_FUEL_USE 0.1 +/// How much of top_speed gear 1 (low range) targets. +#define GEAR_1_SPEED_FRACTION 0.5 +/// How much of top_speed gear 2 (low range) targets. +#define GEAR_2_SPEED_FRACTION 0.75 +/// Fraction of D gear's own stopping distance that gear 2 is tuned to reach its target speed within. +#define GEAR_2_DISTANCE_FRACTION (1/3) +/// Coefficient (1/sec) of speed-scaled drag decay applied while a gear-transmission vehicle has momentum. +#define SPEED_DECAY_COEFFICIENT 0.2 +/// Coefficient (1/sec) of the decay applied to drift_speed (Complex acceleration only). +#define DRIFT_DECAY_COEFFICIENT 1.2 +/// Fraction of top_speed below which a 90-degree turn creates no drift at all. +#define DRIFT_MIN_SPEED_FRACTION 0.8 + +// Hardpoint status HUD (west-side vertical icon stack, see multitile_hardpoint_hud.dm) +/// Deciseconds between each hardpoint HUD refresh. +#define HARDPOINT_HUD_UPDATE_INTERVAL 5 +/// Deciseconds each color holds during a 3-wound slot's blinking outline. +#define HARDPOINT_HUD_BLINK_HALF_PERIOD 5 +/// Pixel offset the hardpoint HUD's icon stack sits at. +#define HARDPOINT_HUD_PIXEL_X -24 +/// Vertical pixel offset applied on top of the icon stack's own centered layout. +#define HARDPOINT_HUD_PIXEL_Y_OFFSET 64 +/// tankhud.dmi's own sprite size. +#define HARDPOINT_HUD_ICON_SIZE 16 +/// Color a slot's icon renders as once destroyed or missing. +#define HARDPOINT_HUD_DESTROYED_COLOR "#404040" + +// Crew HUD (see tank_hud.dm) +/// Deciseconds between each crew HUD text refresh while a driver/gunner is seated. +#define CREW_HUD_UPDATE_INTERVAL 3 +/// Assumed real-world tile size (meters) for the driver HUD's display-only speed readout. +#define VEHICLE_HUD_METERS_PER_TILE 2 +/// Artistic-license multiplier so the driver HUD's displayed top speed reads like a real light tank. Wouln't make sense if a 22 century light tank cralwed at 20kmh, would it? +#define VEHICLE_HUD_SPEED_DISPLAY_MULT (10/3) +/// (tiles/sec) * this = km/h. +#define VEHICLE_HUD_KMH_PER_TILE (VEHICLE_HUD_METERS_PER_TILE * 3.6 * VEHICLE_HUD_SPEED_DISPLAY_MULT) +/// (tiles/sec) * this = mph. +#define VEHICLE_HUD_MPH_PER_TILE (VEHICLE_HUD_METERS_PER_TILE * 2.23694 * VEHICLE_HUD_SPEED_DISPLAY_MULT) + +// Engine temperature/overheat model +/// Kelvin above which the engine hardpoint starts taking overheat condition damage. +#define ENGINE_OVERHEAT_THRESHOLD (T20C + 60) +/// Heat generated (Kelvin/sec) per unit of nominal gear torque under full load. +#define ENGINE_HEAT_PER_LOAD 20 +/// Baseline heat generated (Kelvin/sec) just from the engine running. +#define ENGINE_HEAT_IDLE 1 +/// Torque-equivalent used for heat generation while revving in Park/Neutral. +#define ENGINE_REV_TORQUE_EQUIVALENT 0.1 +/// Fuel (units/sec) burned while actively revving in Park/Neutral. +#define ENGINE_REV_FUEL_USE 0.08 +/// Newton's-law-of-cooling coefficient (1/sec) at 100% radiator integrity and coolant. +#define ENGINE_RADIATOR_COOLING_COEFFICIENT 0.5 +/// Cooling coefficient (1/sec) with no radiator installed, or one fully wrecked/dry. +#define ENGINE_PASSIVE_COOLING_COEFFICIENT 0.05 +/// Condition damage/sec applied to the engine hardpoint while above ENGINE_OVERHEAT_THRESHOLD. +#define ENGINE_OVERHEAT_DAMAGE_PER_SEC 15 +/// Hard temperature ceiling. Reaching it forces an emergency engine shutdown. (200%) +#define ENGINE_OVERHEAT_SHUTDOWN_THRESHOLD (T20C + 120) +/// How long (deciseconds) after the engine turns on before it can build any new torque. +#define ENGINE_SPINUP_TIME (3 SECONDS) +/// Kelvin within which engine_temperature counts as "setttled" near ambient. +#define ENGINE_TEMPERATURE_SETTLE_THRESHOLD 0.5 +/// Extra heat (Kelvin/sec) added per flame mounted atop the hull. +#define ENGINE_HEAT_PER_MOUNTED_FLAME_TILE 5 +/// Extra heat (Kelvin/sec) added per flame burning on a turf the tank occupies without being mounted. +#define ENGINE_HEAT_PER_UNDERNEATH_FLAME_TILE (ENGINE_HEAT_PER_MOUNTED_FLAME_TILE * 0.25) + +// Overheat wound rolls +/// How often check_overheat_wound_trigger() actually rolls. +#define ENGINE_OVERHEAT_WOUND_ROLL_INTERVAL (1 SECONDS) +/// Temperature threshold above which the engine can gain a fresh Tier 1 (Blown Gasket) wound. +#define ENGINE_OVERHEAT_WOUND_TIER1_THRESHOLD_PCT 125 +/// Percent chance per roll of gaining a Tier 1 wound in the Tier 1 heat band. +#define ENGINE_OVERHEAT_WOUND_TIER1_CHANCE_PCT 5 +/// Temperature threshold above which the engine can advance to Tier 2 (Cracked Block). +#define ENGINE_OVERHEAT_WOUND_TIER2_THRESHOLD_PCT 150 +/// Percent chance per roll of gaining a Tier 1 wound in the Tier 2 heat band. +#define ENGINE_OVERHEAT_WOUND_TIER1_CHANCE_PCT_HIGH 10 +/// Percent chance per roll of an existing Tier 1 wound advancing to Tier 2. +#define ENGINE_OVERHEAT_WOUND_TIER2_ADVANCE_CHANCE_PCT 5 + +// Battery draw/recharge +/// How often (deciseconds) the battery charge/drain loop ticks. +#define BATTERY_TICK_INTERVAL 10 +/// Charge (units/sec) restored to the battery while the engine is on. +#define BATTERY_RECHARGE_RATE 10 +/// Baseline power draw (units/sec) for the IFF module. +#define IFF_MODULE_POWER_DRAW 0.5 +/// Baseline power draw (units/sec) for visual sensors (external cameras). +#define VISUAL_SENSORS_POWER_DRAW 0.5 +/// Baseline power draw (units/sec) for the air filter. +#define AIR_FILTER_POWER_DRAW 0.5 +/// Baseline power draw (units/sec) for the turret ring while idle. +#define TURRET_RING_IDLE_POWER_DRAW 0.2 +/// Power draw (units/sec) for the turret ring while actively rotating. +#define TURRET_RING_ROTATING_POWER_DRAW 2 + +// Simple acceleration mode +/// Number of discrete notches between 0 and a gear's max_speed under Simple acceleration mode. +#define SIMPLE_ACCEL_STEP_COUNT 4 +/// Deciseconds of no accelerate/brake input before speed_notch starts passively decaying. +#define SIMPLE_ACCEL_IDLE_GRACE 10 +/// Deciseconds between each passive decay step once idle. +#define SIMPLE_ACCEL_DECAY_INTERVAL 5 + +// Turn signals +/// Deciseconds between blink toggles for the turn signal overlay. +#define TURN_SIGNAL_BLINK_INTERVAL 5 +/// Deciseconds the turn signal's relay sound plays after being (re)activated. +#define TURN_SIGNAL_SOUND_DURATION 50 + +// Tank engine sound +/// Playback frequency at a standstill/idle. +#define ENGINE_SOUND_MIN_FREQUENCY 24000 +/// Playback frequency at the current gear's max_speed. +#define ENGINE_SOUND_MAX_FREQUENCY 44000 +/// Volume at a standstill/idle. +#define ENGINE_SOUND_MIN_VOLUME 30 +/// Volume at the current gear's max_speed. +#define ENGINE_SOUND_MAX_VOLUME 55 +/// Minimum tile range the outside-hearer copy is audible at, even at idle. +#define ENGINE_SOUND_MIN_RANGE 9 +/// How fast engine_rev_level (0-1) climbs per second while revving in Park/Neutral. +#define ENGINE_REV_RISE_RATE (1 / 1.5) +/// How fast engine_rev_level decays back down per second once gas is released. +#define ENGINE_REV_DECAY_RATE (1 / 2.5) + +// Engine exhaust smoke +/// Tint for exhaust smoke triggered by engine_cracked_block (Brute). +#define ENGINE_SMOKE_COLOR_BRUTE_WOUND "#d9d9d9" +/// Tint for exhaust smoke triggered by Fouled Carburator (tier-1 engine_fouled_injector). +#define ENGINE_SMOKE_COLOR_FOULED_CARBURATOR "#262626" +/// Tint for exhaust smoke triggered by Acid Intake (tier-2 engine_fouled_injector). +#define ENGINE_SMOKE_COLOR_ACID_WOUND "#1f2a12" +/// Tint for exhaust smoke from running on off-label fuel (welding fuel/cornoil). +#define ENGINE_SMOKE_COLOR_OFF_LABEL_FUEL "#8c8c8c" +/// Percent of a fuel ttank's total volume an off-label reagent needs before it smokes at all. +#define VEHICLE_FUEL_SMOKE_THRESHOLD_PCT 51 +/// Minimum seconds between exhaust smoke puffs from cornoil alone over the threshold. +#define ENGINE_SMOKE_INTERVAL_COOKING_OIL 8 SECONDS +/// Minimum seconds between exhaust smoke puffs from welding fuel alone over the threshold. +#define ENGINE_SMOKE_INTERVAL_WELDING_FUEL 3 SECONDS +/// Minimum seconds between exhaust smoke puffs from an active engine wound. +#define ENGINE_SMOKE_INTERVAL_WOUND 1.5 SECONDS +/// Exhaust smoke spread/linger amount for the default (off-label fuel/no wound) case. +#define ENGINE_SMOKE_AMOUNT_DEFAULT 2 +/// Exhaust smoke spread/linger amount for an actve engine wound. +#define ENGINE_SMOKE_AMOUNT_WOUND 4 + +/// Minimum seconds between a Boiler glob re-checking a non-tank vehicle it's sitting on. +#define VEHICLE_GAS_EXPOSURE_INTERVAL 8 SECONDS + +/// Minimum seconds between a Boiler glob re-checking a tank it's sitting on. +#define TANK_GLOB_EXPOSURE_INTERVAL 1 SECONDS + +/// Percent of a tank part's own remaining health dealt per full-strength acid glob tick. +#define TANK_GLOB_ACID_HEALTH_PCT 12 + +/// Flat minimum damage per full-strength acid glob tick. +#define TANK_GLOB_ACID_FLOOR 12 + +/// Multiplies a neuro-foulable slot's own wound chance for an AOE neurotoxin glob tick. +#define TANK_GLOB_NEURO_CHANCE_MULT 4 + +/// Ceiling on the per-tick neuro wound chance after TANK_GLOB_NEURO_CHANCE_MULT is applied. +#define TANK_GLOB_NEURO_CHANCE_CAP_PCT 95 + +/// Fraction of a bridged interior gas cloud that gets through a lightly damaged air filter. +#define AIR_FILTER_LEAK_FRACTION_TIER_1 0.08 +/// Fraction of a bridged interior gas cloud that gets through a badly damaged air filter. +#define AIR_FILTER_LEAK_FRACTION_TIER_2 0.35 +/// Filter integrity% below which gas leak-through starts climbing within its current wound tier. +#define AIR_FILTER_LEAK_HEALTH_THRESHOLD_PCT 75 +/// Alpha a bridged interior gas cloud needs before it's thick enough to block sight. +#define AIR_FILTER_LEAK_OPACITY_ALPHA_THRESHOLD 128 + +/// Minimum seconds between separate Acid Mine detonations hitting the same vehicle. +#define ACID_MINE_VEHICLE_DAMAGE_COOLDOWN 2 SECONDS + +/// Minimum seconds between a lingering acid puddle re-applying its hit to the same vehicle. +#define LINGERING_ACID_VEHICLE_DAMAGE_COOLDOWN 3 SECONDS + +/// Fraction of a vehicle's speed/momentum retained after driving over a Trapper Boiler trap. +#define BOILER_TRAP_VEHICLE_MOMENTUM_RETAINED 0.85 + +/// Minimum collision-damage multiplier a gear-transmission vehicle can hit for, even near a standstill. +#define COLLISION_SPEED_SCALE_FLOOR 0.2 + #define VEHICLE_DRIVER "driver" #define VEHICLE_GUNNER "primary gunner" #define VEHICLE_SUPPORT_GUNNER_ONE "1st support gunner" @@ -20,7 +281,7 @@ #define VEHICLE_SPEED_STATIC 5000 //500 seconds per tile, while not actually static, it's much better than adding check for each movement attempt. #define VEHICLE_SPEED_SLOW 30 //3 seconds per tile -#define VEHICLE_SPEED_NORMAL 10 //default 1 second per tile +#define VEHICLE_SPEED_NORMAL 10 //default 1 second per ttile #define VEHICLE_SPEED_FASTNORMAL 7 #define VEHICLE_SPEED_FAST 5 //half a second per tile #define VEHICLE_SPEED_FASTER 4 @@ -50,6 +311,17 @@ #define VEHICLE_CLASS_LIGHT (1<<2) //light class armor (APC, tank) #define VEHICLE_CLASS_MEDIUM (1<<3) //medium class armor (tank) #define VEHICLE_CLASS_HEAVY (1<<4) //heavy class armor (tank) + +// Vehicle-vs-vehicle ramming +/// Fraction of the ramming vehicle's own top speed needed for a collision to deal any damage/knockback. +#define VEHICLE_COLLISION_MOMENTUM_THRESHOLD 0.5 +/// Knockback distance (tiles) a collision deals at the ramming vehicle's own full top speed. +#define VEHICLE_COLLISION_MAX_KNOCKBACK_TILES 3 +/// Damage a collision deals at the ramming vehicle's own full top speed. +#define VEHICLE_COLLISION_MAX_DAMAGE 60 +/// Fraction of its own momentum a heavier vehicle bleeds off crashing into a lighter one. +#define VEHICLE_SOFT_CRASH_MOMENTUM_LOSS 0.25 + // Other vehicle flags /// Vehicle can bypass vehicle blockers, typically going further into maps than intended #define VEHICLE_BYPASS_BLOCKERS (1<<5) @@ -73,3 +345,186 @@ #define BUCKLE_PREVENTS_PULL (1<<3) #define BUCKLE_NEEDS_HAND (1<<4) #define BUCKLE_NEEDS_TWO_HANDS (1<<5) + +//Tank turret mouse-aim rotation + +/// How close current_angle needs to be to desired_angle before the rotation loop stops ticking. +#define ROTATION_SETTLE_TOLERANCE 0.5 +/// How close current_angle needs to be to target before a turret-mounted weapon can fire. +#define FIRING_GATE_TOLERANCE 5 +/// Degrees the turret can drift off a rangefinder target before Ctrl+Click rangefinding aborts. +#define RANGEFINDER_TURRET_ARC_TOLERANCE 10 +/// Baseline turn speed (degrees/decisecond) for a turret at TURRET_ARC_NORMALIZATION traverse_arc. +#define TURRET_BASE_ANGULAR_VELOCITY 6 +/// The traverse_arc value that maps 1:1 onto TURRET_BASE_ANGULAR_VELOCITY. +#define TURRET_ARC_NORMALIZATION 90 +/// Turn speed floor, so a turret can never be fully immobilized. +#define TURRET_MIN_ANGULAR_VELOCITY 2 +/// Initial max_angular_velocity before recalculate_turn_rate() first runs. +#define TURRET_DEFAULT_ANGULAR_VELOCITY 4 +/// Flat angular acceleration (degrees/decisecond^2) used to ramp angular_velocity up/down. +#define TURRET_ANGULAR_ACCEL 1 +/// Turn rate multiplier applied with no vehicle power. +#define TURRET_NO_POWER_TURN_RATE_FRACTION 0.2 +/// Half-widtth, in degrees, of a self-gimballed weapon's aim clamp around the turret's own facing. +#define SLAVED_GIMBAL_ARC_HALF_WIDTH 30 +/// Fire delay multiplier while a seconday is self-gimballed (slaved to the driver's own aim). (higher is worse) +#define SECONDARY_SLAVED_COOLDOWN_MULT 1.33 + +// Weapon/turret-ring degradation +/// Turret ring/weapon integrity% above which raw damage has no effect on performance at all. +#define WEAPON_DEGRADE_GRACE_THRESHOLD_PCT 90 +/// Accuracy multiplier floor. +#define WEAPON_MIN_ACCURACY_MULT 0.25 +/// Scatter multiplier ceiling. +#define WEAPON_MAX_SCATTER_MULT 8 +/// Projectile speed multiplier floor. +#define WEAPON_MIN_PROJECTILE_SPEED_MULT 0.4 +/// Damage multiplier floor. +#define WEAPON_MIN_DAMAGE_MULT 0.4 +/// Absolute final turn-rate floor, applied after every other multiplier. +#define TURRET_ABSOLUTE_MIN_ANGULAR_VELOCITY 0.3 +/// traverse_arc used when no primary weapon is mounted at all. +#define TURRET_EMPTY_TRAVERSE_ARC 150 + +// Flamer hardpoint fire modes +/// Directional line stream (walks toward the target, self-propagating tile by tile). +#define FLAME_MODE_STREAM "stream" +/// Single traveling projectile that plants one AOE ignition point on impact. +#define FLAME_MODE_GLOB "glob" +/// should these go into a _DEFINES for hardpoints instead??? + +/// Flat extra tiles of range the DRG-N gets in stream mode. +#define DRG_N_STREAM_RANGE_BONUS 1 +/// Flat extra tiles of range the DRG-N gets in glob mode. +#define DRG_N_GLOB_RANGE_BONUS 2 + +/// Tiles the DRG-N's own smoke ttank disperses smoke across. +#define DRG_N_SMOKE_RANGE 6 +/// Reagent units per dispersed smoke puff for the DRG-N's own smoke tank. +#define DRG_N_SMOKE_UNITS 45 + +// Hardpoint wounds +/// A wound family gated by/rolled off the hardpoint's acid_damage_taken sub-pool. +#define WOUND_DAMTYPE_ACID "acid" +/// A wound family gated by/rolled off the hardpoint's brute_damage_taken sub-pool. +#define WOUND_DAMTYPE_BRUTE "brute" +/// A wound family gated by/rolled off overall integrity rather than either sub-pool alone. +#define WOUND_DAMTYPE_UNIFIED "unified" +/// Synthetic part_slot for the hull_melted/hull_hole families, tracking the vehicle's own frame health. +#define WOUND_SLOT_HULL "hull" + +// Single-target damage resolution +/// Fraction of a struck parent's hit that splashes into its directly-mounted external children. +#define HARDPOINT_BLEED_THROUGH_FRACTION 0.1 +/// Armor's own splash fraction from every external hit. +#define ARMOR_SPLASH_FRACTION 0.5 +/// Percent chance per point of incoming damage that a struck parent's internal-module pool also takes a hit. +#define HARDPOINT_ORGAN_DAMAGE_CHANCE_PER_DAMAGE 1.2 +/// Cap on the per-damage term alone, before wound/integrity bonuses are added. +#define HARDPOINT_ORGAN_DAMAGE_CHANCE_CAP 30 +/// Percent chance added per currently-active wound family on the struck parent. +#define HARDPOINT_ORGAN_DAMAGE_WOUND_BONUS 20 +/// Integrity percent below which low health starts adding extra internal-hit chance. +#define HARDPOINT_ORGAN_DAMAGE_INTEGRITY_THRESHOLD 60 +/// Percent chance added per integrity point below HARDPOINT_ORGAN_DAMAGE_INTEGRITY_THRESHOLD. +#define HARDPOINT_ORGAN_DAMAGE_INTEGRITY_SCALE 1.5 +/// Percent chance a neurotoxin spit at the Turret bleeds through to the Air Filter's neuro family. +#define TURRET_NEURO_SPIT_AIR_FILTER_BLEED_CHANCE_PCT 50 + +// Directional armor +/// A hit landing within 45 degres of the tank hull's own facing. +#define HIT_ZONE_FRONT "front" +/// A hit landing on either flank. +#define HIT_ZONE_SIDE "side" +/// A hit landing within 45 degrees of directly behind the tank. +#define HIT_ZONE_REAR "rear" +/// Fraction of Armor's own contribution a side hit ignores outright. +#define ARMOR_BYPASS_FRACTION_SIDE 0.25 +/// Fraction of Armor's own contribution a rear hit ignores outright. +#define ARMOR_BYPASS_FRACTION_REAR 0.75 +/// Percent chance bonus added to internal-module-damage rolls for a side hit. +#define ARMOR_BYPASS_INTERNAL_CHANCE_BONUS_SIDE 10 +/// Percent chance bonus added to internal-module-damage rolls for a rear hit. +#define ARMOR_BYPASS_INTERNAL_CHANCE_BONUS_REAR 35 + +// Hatch-wound-driven lock access +/// Entry/exit delay multiplier for an authorized marine at maximum hatch instability. +#define HATCH_WOUNDED_MARINE_MAX_DELAY_MULT 5 +/// Entry/exit delay multiplier for a xeno the moment the hatch first becomes wounded while locked. +#define HATCH_WOUNDED_XENO_START_MULT 1.5 +/// Entry/exit delay multiplier for a xeno at maximum hatch instability. +#define HATCH_WOUNDED_XENO_MIN_MULT 0.25 +/// Entry/exit delay multiplier for a non-USCM human once the hatch is wounded while locked. +#define HATCH_WOUNDED_NON_USCM_DELAY_MULT 6 + +// Caste-specific tank interactions +// Would those be better in the defines of those abilities themselves??? I don't know, so I placed them here. - BWSB +/// Damage a Crusher's Headbutt deals to the tank on contact. +#define CRUSHER_HEADBUTT_TANK_DAMAGE 20 +/// Tiles the tank is shoved back when a Crusher's Charge slams into it. +#define CRUSHER_CHARGE_TANK_KNOCKBACK_TILES 3 +/// Damage a plain Crusher Charge deals to the tank on contact. +#define CRUSHER_CHARGE_TANK_DAMAGE 60 +/// Deciseconds paused between each individual tile of a knockback shove. +#define KNOCKBACK_STEP_DELAY 3 +/// Knockback tiles for the Charger strain's toggled ram. +#define CRUSHER_CHARGER_RAM_TANK_KNOCKBACK_TILES 2 +/// Degrees a tank's turret is forcibly turned by a Warrior's Punch. +#define WARRIOR_PUNCH_TURRET_TURN_DEGREES 110 +/// Multiplier applied to a Warrior Punch's damage when it lands on the Turret Ring hardpoint. +#define WARRIOR_PUNCH_TURRET_RING_DAMAGE_MULT 2 +/// Overall multiplier on Warrior Punch's tank damage. +#define WARRIOR_PUNCH_TANK_DAMAGE_MULT 0.5 +/// Same as WARRIOR_PUNCH_TANK_DAMAGE_MULT, for Praetorian Oppressor's Dislocate. +#define OPPRESSOR_DISLOCATE_TANK_DAMAGE_MULT 0.75 +/// Angular velocity a Warrior Punch's forced turret turn uses. +#define WARRIOR_PUNCH_TURRET_ANGULAR_VELOCITY 20 +/// Angular acceleration to match WARRIOR_PUNCH_TURRET_ANGULAR_VELOCITY. +#define WARRIOR_PUNCH_TURRET_ANGULAR_ACCEL 6 +/// Fraction of Queen Screech's own stun duration applied to a tank's seated crew. +#define QUEEN_SCREECH_CREW_STUN_FRACTION 0.5 +/// Fraction of Visual Sensors' max health dealt as unblocked damage by a Queen Screech. +#define QUEEN_SCREECH_CAMERA_DAMAGE_PCT 0.05 +/// Same idea as QUEEN_SCREECH_CAMERA_DAMAGE_PCT, for the King's Doom ability. +#define KING_DOOM_CAMERA_DAMAGE_PCT 0.05 +/// Unblocked damage dealt to every part on a tank per AOE tile of a King's Destroy landing. +#define KING_JUMP_DAMAGE_PER_TILE 50 + +/// Traction malus cap for a turf covered in full-strength sticky resin. +#define STICKY_RESIN_TRACTION_CAP 0.3 +/// Traction malus cap for the weaker/temporary "thin" sticky resin variant. +#define STICKY_RESIN_WEAK_TRACTION_CAP 0.5 +/// Traction malus cap for a turf with a resin spike on it. +#define RESIN_SPIKE_TRACTION_CAP 0.8 + +/// Deciseconds a repeated acid spray hit still counts as "the same spray" for streak decay. +#define ACID_SPRAY_STREAK_WINDOW 20 + +/// Damage multiplier for Dancer's Impale/Vanguard's Pierce when aimed at an external module. +#define GUARANTEED_EXTERNAL_HIT_DAMAGE_MULT 1.5 + +/// Fraction of a tank's own momentum retained after a Praetorian Valkyrie's High Gallop clips it. +#define HIGH_GALLOP_MOMENTUM_LOSS_MULT 0.85 + +// Hull-destruction grand finale ammo cookoff +/// Seconds between the hull-death warning starting and the cookoff detonating. +#define HULL_COOKOFF_WARNING_TIME 10 SECONDS +/// Seconds between repeats of the on-screen "get out now" banner. +#define HULL_COOKOFF_PASSENGER_WARNING_INTERVAL 5 SECONDS +/// Radius of the small warning-phase napalm flame lit the instant the countdown starts. +#define HULL_COOKOFF_WARNING_FLAME_RADIUS 0 +/// cell_explosion() power at the tank's own exterior epicenter. +#define HULL_COOKOFF_EXTERIOR_EXPLOSION_POWER 150 +/// cell_explosion() falloff to pair with HULL_COOKOFF_EXTERIOR_EXPLOSION_POWER. +#define HULL_COOKOFF_EXTERIOR_EXPLOSION_FALLOFF 16 +/// cell_explosion() power for the separate interior blast if anyone's still trapped inside. +#define HULL_COOKOFF_INTERIOR_EXPLOSION_POWER 300 +/// cell_explosion() falloff for the interior blast. +#define HULL_COOKOFF_INTERIOR_EXPLOSION_FALLOFF 100 +/// Nearest a piece of launched turret wreckage is allowed to land from the tank's own turf. +#define HULL_COOKOFF_TURRET_LAUNCH_MIN_RANGE 2 +/// Farthest a piece of launched turret wreckage is allowed to land from the tank's own turf. +#define HULL_COOKOFF_TURRET_LAUNCH_MAX_RANGE 4 +/// Layer for the blinking cookoff warning icon. +#define HULL_COOKOFF_WARNING_ICON_LAYER (TANK_ABOVE_RIDER_LAYER + 0.01) diff --git a/code/__DEFINES/vendors.dm b/code/__DEFINES/vendors.dm index dd39912b020c..d6d357edec58 100644 --- a/code/__DEFINES/vendors.dm +++ b/code/__DEFINES/vendors.dm @@ -55,8 +55,9 @@ #define VEHICLE_SUPPORT_AVAILABLE 8 #define VEHICLE_ARMOR_AVAILABLE 16 #define VEHICLE_TREADS_AVAILABLE 32 +#define VEHICLE_UTILITY_AVAILABLE 64 -#define VEHICLE_ALL_AVAILABLE (VEHICLE_INTEGRAL_AVAILABLE|VEHICLE_PRIMARY_AVAILABLE|VEHICLE_SECONDARY_AVAILABLE|VEHICLE_SUPPORT_AVAILABLE|VEHICLE_ARMOR_AVAILABLE|VEHICLE_TREADS_AVAILABLE) +#define VEHICLE_ALL_AVAILABLE (VEHICLE_INTEGRAL_AVAILABLE|VEHICLE_PRIMARY_AVAILABLE|VEHICLE_SECONDARY_AVAILABLE|VEHICLE_SUPPORT_AVAILABLE|VEHICLE_ARMOR_AVAILABLE|VEHICLE_TREADS_AVAILABLE|VEHICLE_UTILITY_AVAILABLE) #define VENDOR_THEME_COMPANY 0 #define VENDOR_THEME_USCM 1 diff --git a/code/__HELPERS/unsorted.dm b/code/__HELPERS/unsorted.dm index 4876b8c1d0ba..a213519eca3c 100644 --- a/code/__HELPERS/unsorted.dm +++ b/code/__HELPERS/unsorted.dm @@ -63,11 +63,14 @@ // GLOBAL PROCS // /// Gives X position on pixel grid of an object, accounting for offsets -/proc/get_pixel_position_x(atom/subject, relative = FALSE) +/proc/get_pixel_position_x(atom/subject, relative = FALSE, include_size_adjustment = TRUE) . = subject.pixel_x + subject.base_pixel_x if(!relative) . += world.icon_size * subject.x + if(!include_size_adjustment) + return + if(ismob(subject)) // Mobs use baked in icon_size due to eg. Xenos only using a visual size var/mob/mob_subject = subject . += (mob_subject.icon_size - world.icon_size) / 2 @@ -77,11 +80,14 @@ . += (big_subject.bound_width - world.icon_size) / 2 /// Gives Y position on pixel grid of an object, accounting for offsets -/proc/get_pixel_position_y(atom/subject, relative = FALSE) +/proc/get_pixel_position_y(atom/subject, relative = FALSE, include_size_adjustment = TRUE) . = subject.pixel_y + subject.base_pixel_y if(!relative) . += world.icon_size * subject.y + if(!include_size_adjustment) + return + if(ismob(subject)) // Mobs use baked in icon_size due to eg. Xenos only using a visual size var/mob/mob_subject = subject . += (mob_subject.icon_size - world.icon_size) / 2 @@ -106,12 +112,43 @@ var/dx = get_pixel_position_x(end) - get_pixel_position_x(start) return delta_to_angle(dx, dy) +/** + * Same as Get_Angle(), but skips the automatic "sprite is bigger than a tile, so nudge the position + * by half the extra size" compensation baked into get_pixel_position_x/y() + * + * only used for actual tturret aiming/hit-resolution math (update_desired_angle(), track_and_charge()), not for purely + * cosmetic uses (LTB reticle placement, muzzle flashes, beams). + * + * That automatic guess assumes a sprite bigger than world.icon_size is centered on its tile, which + * is wrong for a mob like the Queen (icon_size 64) whose sprite is anchored at the feet and simply + * extends upward... +*/ +/proc/Get_Angle_Grounded(atom/start, atom/end) + if(!start || !end) + return 0 + if(!start.z) + start = get_turf(start) + if(!start) + return 0 + if(!end.z) + end = get_turf(end) + if(!end) + return 0 + var/dy = get_pixel_position_y(end, include_size_adjustment = FALSE) - get_pixel_position_y(start, include_size_adjustment = FALSE) + var/dx = get_pixel_position_x(end, include_size_adjustment = FALSE) - get_pixel_position_x(start, include_size_adjustment = FALSE) + return delta_to_angle(dx, dy) + /// Calculate the angle produced by a pair of x and y deltas. Uses north-clockwise convention: NORTH = 0, EAST = 90, etc. /proc/delta_to_angle(dx, dy) . = arctan(dy, dx) //y-then-x results in north-clockwise convention: https://en.wikipedia.org/wiki/Atan2#East-counterclockwise,_north-clockwise_and_south-clockwise_conventions,_etc. if(. < 0) . += 360 +/// Shortest signed angle difference from b to a, in the range -180..180. Positive means a is clockwise of b. +/proc/angle_delta(a, b) + // Uses %% instead of % since a/b can be fractional, and % truncates toward zero. + return ((a - b + 540) %% 360) - 180 + /proc/angle_to_dir(angle) angle = ((angle % 360) + 382.5) % 360 switch(angle) //diagonal directions get priority over straight directions in edge cases @@ -341,6 +378,57 @@ return vehicles +/** + * Finds the multitile vehicle (if any) whose footprint covers `checked_turf`. + * A plain contents check only finds a vehicle on its one real .loc tile. + */ +/proc/get_multitile_vehicle_at(turf/checked_turf) + if(!checked_turf) + return null + for(var/obj/vehicle/multitile/vehicle as anything in GLOB.all_multi_vehicles) + if(checked_turf in vehicle.locs) + return vehicle + return null + +/** + * Checks real adjacency against every turf in `vehicle.locs`, instead of just its center tile. + */ +/proc/is_adjacent_to_multitile_vehicle(atom/movable/mover, obj/vehicle/multitile/vehicle) + if(!mover || !vehicle) + return FALSE + var/turf/mover_turf = get_turf(mover) + if(!mover_turf) + return FALSE + if(mover_turf in vehicle.locs) + return TRUE + for(var/turf/vehicle_turf in vehicle.locs) + if(mover_turf.Adjacent(vehicle_turf)) + return TRUE + return FALSE + +/** + * Returns the distance to the closest tile of the vehicle's footprint, not just its center tile. + */ +/proc/get_dist_to_multitile_vehicle(atom/mover, obj/vehicle/multitile/vehicle) + var/closest = INFINITY + for(var/turf/vehicle_turf in vehicle.locs) + closest = min(closest, get_dist(mover, vehicle_turf)) + return closest + +/** + * Returns the closest tile of the vehicle's footprint to use as a get_line() endpoint, + * instead of collapsing to its center tile. Null if the vehicle has no locs. + */ +/proc/get_closest_turf_of_multitile_vehicle(atom/mover, obj/vehicle/multitile/vehicle) + var/turf/closest_turf = null + var/closest_dist = INFINITY + for(var/turf/vehicle_turf in vehicle.locs) + var/dist = get_dist(mover, vehicle_turf) + if(dist < closest_dist) + closest_dist = dist + closest_turf = vehicle_turf + return closest_turf + //Orders mobs by type then by name /proc/sortmobs() var/list/moblist = list() @@ -1353,6 +1441,9 @@ GLOBAL_LIST_INIT(WALLITEMS, list( var/step_count = get_dist(start_turf, end_turf) if(!step_count) + // get_dist() ignores z, so a target directly above/below also reports 0 steps here. + if(end_turf_fall.z != start_turf.z) + line += end_turf_fall return line //as step_count and step size (1) are known can pre-calculate a lerp step, tiny number (1e-5) for rounding consistency diff --git a/code/_onclick/click_hold.dm b/code/_onclick/click_hold.dm index 38bcd34e11b6..5d4228559666 100644 --- a/code/_onclick/click_hold.dm +++ b/code/_onclick/click_hold.dm @@ -107,6 +107,19 @@ // Add the hovered atom to the trace LAZYADD(mouse_trace_history, over_obj) +// Fires at near-pixel granularity while the cursor moves over the map, even with no button held. +// if this ever gets used anywhere else, be careful: Listeners are expected to rate-limit themselves - BWSB +/client/MouseMove(atom/object, turf/location, control, params) + if(!object) + return + + var/click_catcher_click = FALSE + CONVERT_CLICK_CATCHER(object, location, click_catcher_click) + if(click_catcher_click) + params += CLICK_CATCHER_ADD_PARAM + + SEND_SIGNAL(mob, COMSIG_MOB_MOUSEMOVE, object, location, control, params) + /client/MouseDrop(datum/src_object, datum/over_object, src_location, over_location, src_control, over_control, params) . = ..() if(HAS_TRAIT(usr, TRAIT_HAULED)) diff --git a/code/_onclick/hud/alien.dm b/code/_onclick/hud/alien.dm index 1d29d6c4b728..5c22df4df12d 100644 --- a/code/_onclick/hud/alien.dm +++ b/code/_onclick/hud/alien.dm @@ -16,6 +16,7 @@ draw_pull(ui_alien_datum) draw_throw(ui_alien_datum) draw_zone_sel(ui_alien_datum) + draw_vehicle_zone_sel(ui_alien_datum) draw_healths(ui_alien_datum) draw_alien_nightvision(ui_alien_datum) draw_alien_plasma_display(ui_alien_datum) diff --git a/code/_onclick/hud/hud.dm b/code/_onclick/hud/hud.dm index f74a06aa876f..5db2ebf0d8f5 100644 --- a/code/_onclick/hud/hud.dm +++ b/code/_onclick/hud/hud.dm @@ -47,6 +47,7 @@ var/atom/movable/screen/unique_action var/atom/movable/screen/zone_sel/zone_sel + var/atom/movable/screen/zone_sel/vehicle/vehicle_zone_sel var/atom/movable/screen/pull_icon var/atom/movable/screen/throw_icon var/atom/movable/screen/oxygen_icon @@ -155,6 +156,7 @@ unique_action = null zone_sel = null + vehicle_zone_sel = null pull_icon = null throw_icon = null oxygen_icon = null @@ -416,6 +418,18 @@ zone_sel.update_icon(mymob) static_inventory += zone_sel +/// Vehicle-part doll, placed directly above the human doll. +/datum/hud/proc/draw_vehicle_zone_sel(datum/custom_hud/ui_datum, ui_alpha, ui_color) + vehicle_zone_sel = new /atom/movable/screen/zone_sel/vehicle() + vehicle_zone_sel.icon = ui_datum.ui_style_icon + vehicle_zone_sel.screen_loc = ui_datum.ui_vehicle_zonesel + if(ui_alpha) + vehicle_zone_sel.alpha = ui_alpha + if(ui_color) + vehicle_zone_sel.color = ui_color + vehicle_zone_sel.update_icon(mymob) + static_inventory += vehicle_zone_sel + // Re-render all alerts - also called in /datum/hud/show_hud() because it's needed there /datum/hud/proc/reorganize_alerts(mob/viewmob) var/mob/screenmob = viewmob || mymob diff --git a/code/_onclick/hud/human.dm b/code/_onclick/hud/human.dm index a42c20539923..be0493a13571 100644 --- a/code/_onclick/hud/human.dm +++ b/code/_onclick/hud/human.dm @@ -96,6 +96,7 @@ draw_nutrition(ui_datum) draw_locator_spot(owner, ui_datum) draw_zone_sel(ui_datum, ui_alpha, ui_color) + draw_vehicle_zone_sel(ui_datum, ui_alpha, ui_color) draw_gun_related(ui_datum, ui_alpha) diff --git a/code/_onclick/hud/screen_objects.dm b/code/_onclick/hud/screen_objects.dm index 05c0dfc563ff..10a14cdb1148 100644 --- a/code/_onclick/hud/screen_objects.dm +++ b/code/_onclick/hud/screen_objects.dm @@ -154,6 +154,45 @@ else color = null +/// Human body zone to vehicle hardpoint slot, keeps the vehicle doll in sync with the human doll. +GLOBAL_LIST_INIT(human_zone_to_vehicle_part, list( + "head" = HDPT_TURRET, + "eyes" = HDPT_VISUAL_SENSORS, + "mouth" = HDPT_IFF_MODULE, + "chest" = WOUND_SLOT_HULL, + "r_arm" = HDPT_PRIMARY, + "r_hand" = HDPT_PRIMARY, + "l_arm" = HDPT_SECONDARY, + "l_hand" = HDPT_SECONDARY, + "groin" = WOUND_SLOT_HULL, + "r_leg" = HDPT_TREADS, + "l_leg" = HDPT_TREADS, + "r_foot" = HDPT_TREADS, + "l_foot" = HDPT_TREADS, +)) + +/// Vehicle hardpoint slot to the vehicle doll's own highlight icon_state in zone_sel.dmi. +GLOBAL_LIST_INIT(vehicle_part_to_zone_sel_icon_state, list( + (HDPT_TURRET) = "turret", + (HDPT_VISUAL_SENSORS) = "sensor", + (HDPT_IFF_MODULE) = "iff", + (WOUND_SLOT_HULL) = "hull", + (HDPT_PRIMARY) = "primary", + (HDPT_SECONDARY) = "secondary", + (HDPT_TREADS) = "treads", +)) + +/// Reverse of human_zone_to_vehicle_part. First match wins where several zones share a part. +GLOBAL_LIST_INIT(vehicle_part_to_human_zone, list( + (HDPT_TURRET) = "head", + (HDPT_VISUAL_SENSORS) = "eyes", + (HDPT_IFF_MODULE) = "mouth", + (WOUND_SLOT_HULL) = "chest", + (HDPT_PRIMARY) = "r_arm", + (HDPT_SECONDARY) = "l_arm", + (HDPT_TREADS) = "r_leg", +)) + /atom/movable/screen/zone_sel name = "damage zone" icon_state = "zone_sel" @@ -163,6 +202,119 @@ overlays.Cut() overlays += image('icons/mob/hud/zone_sel.dmi', "[selecting]") +/** + * Vehicle-part doll shown directly above the human doll, cycles together with it. + * Has its own body silhouette and 7-state highlight overlay instead of reusing the human doll's. + */ +/atom/movable/screen/zone_sel/vehicle + name = "vehicle part" + icon_state = "zone_sel_vehicle" + +/atom/movable/screen/zone_sel/vehicle/update_icon(mob/living/user) + overlays.Cut() + var/vehicle_part = GLOB.human_zone_to_vehicle_part[selecting] || WOUND_SLOT_HULL + overlays += image('icons/mob/hud/zone_sel.dmi', GLOB.vehicle_part_to_zone_sel_icon_state[vehicle_part] || "hull") + +/** + * + * Let me explain what this does and why it is needed, for anyone wondering why there is a giant tank ascii art in here. + * + * Adding a separate targeting system was a headache and half. I used an image to ascii converter online to pass the tank targeting doll sprite that Holdeman made to + * make the ascii list below, and then I slowly built it up and refined it so it was 'pixel perfect' comparing to a zoomed up version in aseprite. + * + * Why was this needed? Well, when you click the targeting doll, BYOND's clicked() will hand you the pixel you clicked in. This is a set of coordinates with X from 1-32 + * and Y from 1-32. + * + * This set of coordinates is then compared against that list, and it returns the matching ASCII key of the part it hit. D. H. I. T. C. '.' - Etc. + * + * So, why do we need to do this instead of doing whatever the human targeting doll already does??, you may ask, and to that, I reply: + * + * A vehicle's shape is more complex than a human doll. The treads aren't one continuous, rectangular shape. The hull has a big hole in the middle, which is then + * filled by another hardpoint. The shapes of the guns and other hardpoints are complex. This is compared to hte human doll which is a selection of rectangles and squares. + * If we followed the same approach that the human doll targeting resolve uses, we'd be looking at an enormous set of x_ranges and switch(icon_y) -> switch(icon_x) + * lines of code. Just take a look at how the human doll handles it and you'll understand why I didn't do it like that. Or maybe I didn't see an obvious easy way. + * + * So, + * Instead of writing code that is algebraically complex and nigh unmaintainable to represent those complex shapes, I've decided to use an ASCII hitmap, which + * is both easier to build, tweak, test, and maintain. But it is 'different', yes. + * + * In short, the way this works is: We get the XY coordinates from clicked() when clicking the doll sprite and figure out to which part they resolve... + * By comparing them to the hitmap below, grabbing whatever letter it lands on. It's fast, reliable and also pixel perfect! + * - BWSB + */ +GLOBAL_LIST_INIT(vehicle_doll_hitmap, list("................................", + "................................", + "....DDDDDD............DDDDDD....", + "....DDDDDDHHHHHHHHHHHHDDDDDD....", + "....DDDDHHHHHIHHHHHHHHHHDDDD....", + "....DDDDHHHHHIHHHHHHHHHHDDDD....", + "....DDDDHHHHHIHHHHHHHHHHDDDD....", + "....DDDDHHHHTITTTTTTHHHHDDDD....", + "....DDDDHHHTTITTTTTTTHHHDDDD....", + "....DDDDHHTTTITTTTTTTTHHDDDD....", + "....DDDDHHTTIITTTTCCTTHHDDDD....", + "....DDDDHTTIIITTTCCCCTTHDDDD....", + "....DDDDHTTIIITTTCCCCTTHDDDD....", + "....DDDDHHTIIITTTCCCCTHHDDDD....", + "....DDDDHHTTTTTTTTCCCTHHDDDD....", + "....DDDDHHHTTTTTTTCCCHHHDDDD....", + "....DDDDHHHTTTTPPTCCCHHHDDDD....", + "....DDDDHHHTTTPPPPCCCHHHDDDD....", + "....DDDDHHHTTTPPPPCCCHHHDDDD....", + "....DDDDHHHHTTPPPPTCHHHHDDDD....", + "....DDDDHHHHHHPPPPHHHHHHDDDD....", + "....DDDDHHHHHHPPPPHHHHHHDDDD....", + "....DDDDHHHHHHPPPPHHHHHHDDDD....", + "....DDDDHHHHHHPPPPSSSSSHDDDD....", + "....DDDDHHHHHHPPPPSSSSSHDDDD....", + "....DDDDDDHHHHPPPPSSSSDDDDDD....", + "....DDDDDDHHHHPPPPSSSSDDDDDD....", + "....DDDDDDHHHHHPPHSSSSDDDDDD....", + "....DDDDDDHHHHHHHHHHHHDDDDDD....", + "....DDDDDDHHHHHHHHHHHHDDDDDD....", + "....DDDDDD............DDDDDD....", + "................................")) + +/// vehicle_doll_hitmap character to hardpoint slot. +GLOBAL_LIST_INIT(vehicle_doll_hitmap_chars, list( + "I" = HDPT_IFF_MODULE, + "S" = HDPT_VISUAL_SENSORS, + "P" = HDPT_PRIMARY, + "C" = HDPT_SECONDARY, + "T" = HDPT_TURRET, + "D" = HDPT_TREADS, + "H" = WOUND_SLOT_HULL, +)) + +/atom/movable/screen/zone_sel/vehicle/proc/get_vehicle_part_at(icon_x, icon_y) + if(icon_x < 1 || icon_x > 32 || icon_y < 1 || icon_y > 32) + return null + var/row_string = GLOB.vehicle_doll_hitmap[33 - icon_y] + var/hit_char = copytext(row_string, icon_x, icon_x + 1) + return GLOB.vehicle_doll_hitmap_chars[hit_char] + +/atom/movable/screen/zone_sel/vehicle/clicked(mob/user, list/mods) + // Deliberately not calling ..(), the immediate parent is the human doll's own hit-test switch. + // Replicating the real base check directly instead. + if(!user) + return TRUE + if(isobserver(user)) + return TRUE + + var/icon_x = text2num(mods[ICON_X]) + var/icon_y = text2num(mods[ICON_Y]) + var/old_selecting = selecting + + var/vehicle_part = get_vehicle_part_at(icon_x, icon_y) + if(!vehicle_part) + return 1 + + selecting = GLOB.vehicle_part_to_human_zone[vehicle_part] || "chest" + + if(old_selecting != selecting) + user.select_body_zone(selecting) + return 1 + /atom/movable/screen/zone_sel/clicked(mob/user, list/mods) if (..()) return TRUE @@ -223,8 +375,7 @@ selecting = "eyes" if(old_selecting != selecting) - user.zone_selected = selecting - update_icon(user) + user.select_body_zone(selecting) return 1 /atom/movable/screen/gun diff --git a/code/_onclick/human.dm b/code/_onclick/human.dm index 6fdc1f80578e..fa6adbcc21fc 100644 --- a/code/_onclick/human.dm +++ b/code/_onclick/human.dm @@ -156,6 +156,23 @@ grab_level = GRAB_CARRY + var/obj/vehicle/multitile/tank = get_tank_on_top_of() + if(tank && !target.get_tank_on_top_of()) + + if(istype(tank)) + var/turf/target_turf = get_turf(target) + var/adjacent_to_tank = FALSE + for(var/turf/tank_turf in tank.locs) + if(get_dist(target_turf, tank_turf) <= 1 && target_turf != tank_turf) + adjacent_to_tank = TRUE + break + + if(adjacent_to_tank) + target.forceMove(user.loc) + tank.mark_on_top(target) + target.update_transform(TRUE) + return + target.Move(user.loc, get_dir(target.loc, user.loc)) target.update_transform(TRUE) diff --git a/code/controllers/subsystem/minimap.dm b/code/controllers/subsystem/minimap.dm index a96212ec9567..3c1bc58262df 100644 --- a/code/controllers/subsystem/minimap.dm +++ b/code/controllers/subsystem/minimap.dm @@ -530,6 +530,11 @@ SUBSYSTEM_DEF(minimaps) var/actual_z = target.z if(ismob(target) && target.loc && !isturf(target.loc)) actual_z = target.loc.z + else if(SSinterior.in_interior(target)) + // Starting out inside a vehicle interior, use the vehicle's real z for minimap bucketing. + var/datum/interior/interior = SSinterior.get_interior_by_coords(target.x, target.y, target.z) + if(interior?.exterior) + actual_z = interior.exterior.z if(!initialized || !(minimaps_by_z["[actual_z]"])) //the minimap doesn't exist yet, z level was probably loaded after init for(var/datum/callback/callback as anything in LAZYACCESS(earlyadds, "[actual_z]")) @@ -543,6 +548,10 @@ SUBSYSTEM_DEF(minimaps) var/turf/target_turf = get_turf(target) if(ismob(target) && target.loc && !isturf(target.loc)) target_turf = get_turf(target.loc) + else if(SSinterior.in_interior(target)) + var/datum/interior/interior = SSinterior.get_interior_by_coords(target.x, target.y, target.z) + if(interior?.exterior) + target_turf = get_turf(interior.exterior) blip.pixel_x = MINIMAP_PIXEL_FROM_WORLD(target_turf.x) + minimaps_by_z["[target_turf.z]"].x_offset + image_x blip.pixel_y = MINIMAP_PIXEL_FROM_WORLD(target_turf.y) + minimaps_by_z["[target_turf.z]"].y_offset + image_y @@ -624,20 +633,25 @@ SUBSYSTEM_DEF(minimaps) */ /image/proc/minimap_on_move(atom/movable/source, oldloc) SIGNAL_HANDLER - if(source.z) + if(source.z && !SSinterior.in_interior(source)) var/datum/hud_displays/minimap = SSminimaps.minimaps_by_z["[source.z]"] pixel_x = MINIMAP_PIXEL_FROM_WORLD(source.x) + minimap.x_offset pixel_y = MINIMAP_PIXEL_FROM_WORLD(source.y) + minimap.y_offset return - var/atom/movable/movable_loc = source.loc // How does none of this just crash if the loc isn't on map? - source.override_minimap_tracking() + var/atom/movable/movable_loc = source.loc + if(isturf(movable_loc) && SSinterior.in_interior(source)) + // Inside a vehicle's interior, track the vehicle itself instead, same as the container case below. + var/datum/interior/interior = SSinterior.get_interior_by_coords(source.x, source.y, source.z) + if(interior?.exterior) + movable_loc = interior.exterior + source.override_minimap_tracking(movable_loc) var/datum/hud_displays/minimap = SSminimaps.minimaps_by_z["[movable_loc.z]"] pixel_x = MINIMAP_PIXEL_FROM_WORLD(movable_loc.x) + minimap.x_offset pixel_y = MINIMAP_PIXEL_FROM_WORLD(movable_loc.y) + minimap.y_offset ///Used to handle minimap tracking inside other movables -/atom/movable/proc/override_minimap_tracking() +/atom/movable/proc/override_minimap_tracking(atom/movable/loc) var/image/blip = SSminimaps.images_by_source[src] blip.RegisterSignal(loc, COMSIG_MOVABLE_MOVED, TYPE_PROC_REF(/image, minimap_on_move)) RegisterSignal(loc, COMSIG_ATOM_EXITED, PROC_REF(cancel_override_minimap_tracking)) @@ -1141,7 +1155,8 @@ SUBSYSTEM_DEF(minimaps) // Apply ceiling protection overlay if client has preference enabled if(owner.client.prefs?.show_minimap_ceiling_protection) map.update_ceiling_overlay(owner.client) - locator.link_locator(map, owner) + // Must be `tracking`, not `owner`, or an active locator_override like a vehicle interior gets lost. + locator.link_locator(map, tracking) locator.update(tracking, null, null) // Show ceiling protection toggle action when minimap opens for(var/datum/action/minimap_ceiling/ceiling_action in owner.actions) diff --git a/code/datums/ammo/bullet/tank.dm b/code/datums/ammo/bullet/tank.dm index fdaf98df875b..dd8653c2501c 100644 --- a/code/datums/ammo/bullet/tank.dm +++ b/code/datums/ammo/bullet/tank.dm @@ -12,11 +12,11 @@ sound_hit = 'sound/weapons/vehicles/autocannon_flak_hit.ogg' damage_falloff = 0 flags_ammo_behavior = AMMO_BALLISTIC - accurate_range_min = 4 + accurate_range_min = 3 accuracy = HIT_ACCURACY_TIER_8 scatter = 0 - damage = 60 + damage = 140 damage_var_high = PROJECTILE_VARIANCE_TIER_8 penetration = ARMOR_PENETRATION_TIER_6 accurate_range = 32 @@ -24,21 +24,36 @@ shell_speed = AMMO_SPEED_TIER_6 /datum/ammo/bullet/tank/flak/on_hit_mob(mob/M,obj/projectile/P) - burst(get_turf(M),P,damage_type, 2 , 3) - burst(get_turf(M),P,damage_type, 1 , 3 , 0) + create_shrapnel(get_turf(M), 24, rand(0, 359), 180, /datum/ammo/bullet/shrapnel/flak, P.weapon_cause_data, FALSE, 0.15, TRUE) + apply_micro_stun(M) /datum/ammo/bullet/tank/flak/on_near_target(turf/T, obj/projectile/P) - burst(get_turf(T),P,damage_type, 2 , 3) - burst(get_turf(T),P,damage_type, 1 , 3, 0) + create_shrapnel(get_turf(T), 24, rand(0, 359), 180, /datum/ammo/bullet/shrapnel/flak, P.weapon_cause_data, FALSE, 0.15, TRUE) return 1 /datum/ammo/bullet/tank/flak/on_hit_obj(obj/O,obj/projectile/P) - burst(get_turf(P),P,damage_type, 2 , 3) - burst(get_turf(P),P,damage_type, 1 , 3 , 0) + create_shrapnel(get_turf(P), 24, rand(0, 359), 180, /datum/ammo/bullet/shrapnel/flak, P.weapon_cause_data, FALSE, 0.15, TRUE) /datum/ammo/bullet/tank/flak/on_hit_turf(turf/T,obj/projectile/P) - burst(get_turf(T),P,damage_type, 2 , 3) - burst(get_turf(T),P,damage_type, 1 , 3 , 0) + create_shrapnel(get_turf(P), 24, rand(0, 359), 180, /datum/ammo/bullet/shrapnel/flak, P.weapon_cause_data, FALSE, 0.15, TRUE) + +// applies a micro-stun modelled after the AMR SPEC's second shot. +// unlike the AMR spec, this only works on xenos that aren't big sized. +/datum/ammo/bullet/tank/flak/proc/apply_micro_stun(mob/living/living_mob) + if(!isxeno(living_mob) || living_mob.mob_size >= MOB_SIZE_BIG) + return + var/mob/living/carbon/xenomorph/target = living_mob + target.KnockDown(0.15) + +/datum/ammo/bullet/tank/flak/set_bullet_traits() + . = ..() + LAZYADD(traits_to_give, list( + BULLET_TRAIT_ENTRY(/datum/element/bullet_trait_iff) + )) + +/// same shrapnel as any other fragmentation source, just capped to a 3 tile radius +/datum/ammo/bullet/shrapnel/flak + max_range = 3 /datum/ammo/bullet/tank/dualcannon name = "dualcannon bullet" @@ -90,7 +105,25 @@ damage = 40 penetration = ARMOR_PENETRATION_TIER_6 damage_armor_punch = 1 + // Close-range weapon by design: full damage out to 4 tiles, roughly halved by 7 tiles, tapers to nothing by 10 tiles. + effective_range_max = 4 + damage_falloff = 6.7 /datum/ammo/bullet/tank/setup_faction_clash_values() . = ..() damage = 15 + +/** + * Cosmetic shrapnel + */ +/datum/ammo/bullet/shrapnel/tank_wound + name = "shrapnel" + accuracy = -HIT_ACCURACY_TIER_MAX + damage = 2 + damage_var_low = 0 + damage_var_high = 0 + shrapnel_chance = 0 + // Bumped past the 20-tile angle-target distance so it covers the whole distance. + accurate_range = 20 + // This might be a bit ttoo much. Early in testing lower values were disappearing very, very fast. Would need to test more to see if its ok. + max_range = 24 diff --git a/code/datums/ammo/misc.dm b/code/datums/ammo/misc.dm index 64110516306f..b2bc66637ba5 100644 --- a/code/datums/ammo/misc.dm +++ b/code/datums/ammo/misc.dm @@ -48,22 +48,39 @@ /datum/ammo/flamethrower/do_at_max_range(obj/projectile/P) drop_flame(get_turf(P), P.weapon_cause_data) +// "Glob shot" mode ammo for the tank's primary/secondary flamer hardpoints /datum/ammo/flamethrower/tank_flamer flamer_reagent_id = "highdamagenapalm" max_range = 8 shell_speed = 1.5 + var/loaded_chem_id /datum/ammo/flamethrower/tank_flamer/drop_flame(turf/turf, datum/cause_data/cause_data) if(!istype(turf)) return - var/datum/reagent/napalm/high_damage/reagent = new() - new /obj/flamer_fire(turf, cause_data, reagent, 1) + var/datum/reagent/reagent = loaded_chem_id ? GLOB.chemical_reagents_list[loaded_chem_id] : null + if(!reagent) + reagent = new /datum/reagent/napalm/high_damage() + // Shape is forced to FLAMESHAPE_DEFAULT so the glob always spreads as a 5-tile cardinal cross. + new /obj/flamer_fire(turf, cause_data, reagent, 1, null, FLAMESHAPE_DEFAULT, null, null, 1, reagent.fire_type) - var/datum/effect_system/smoke_spread/landingsmoke = new /datum/effect_system/smoke_spread - landingsmoke.set_up(1, 0, turf, null, 4, cause_data) - landingsmoke.start() - landingsmoke = null +/// Same as tank_flamer above, but for the secondary flamer hardpoint's smaller glob. +/datum/ammo/flamethrower/tank_flamer_secondary + flamer_reagent_id = "highdamagenapalm" + max_range = 8 + shell_speed = 1.5 + var/loaded_chem_id + +/datum/ammo/flamethrower/tank_flamer_secondary/drop_flame(turf/turf, datum/cause_data/cause_data) + if(!istype(turf)) + return + + var/datum/reagent/reagent = loaded_chem_id ? GLOB.chemical_reagents_list[loaded_chem_id] : null + if(!reagent) + reagent = new /datum/reagent/napalm/high_damage() + // fire_spread_amount 0, so this glob stays confined to the tile it lands on. + new /obj/flamer_fire(turf, cause_data, reagent, 0, null, reagent.flameshape, null, null, 1, reagent.fire_type) /datum/ammo/flamethrower/sentry_flamer flags_ammo_behavior = AMMO_IGNORE_ARMOR|AMMO_IGNORE_COVER|AMMO_FLAME|AMMO_NO_DEFLECT @@ -316,6 +333,29 @@ damage = 2.5 flare_type = /obj/item/device/flashlight/flare/on/starshell_ash +// Fired from the tank turret's flare launcher +/datum/ammo/flare/starshell/burst + name = "star shell burst" + max_range = 7 + +/datum/ammo/flare/starshell/burst/on_hit_mob(mob/M, obj/projectile/P) + detonate(get_turf(M), P) + +/datum/ammo/flare/starshell/burst/on_hit_obj(obj/O, obj/projectile/P) + detonate(get_turf(P), P) + +/datum/ammo/flare/starshell/burst/on_hit_turf(turf/T, obj/projectile/P) + if(T.density && isturf(P.loc)) + detonate(P.loc, P) + else + detonate(T, P) + +/datum/ammo/flare/starshell/burst/do_at_max_range(obj/projectile/P, mob/firer) + detonate(get_turf(P), P) + +/datum/ammo/flare/starshell/burst/proc/detonate(turf/hit_turf, obj/projectile/fired_projectile) + create_shrapnel(hit_turf, 8, , 360, /datum/ammo/flare/starshell, fired_projectile.weapon_cause_data, FALSE, 0) + /datum/ammo/flare/starshell/set_bullet_traits() LAZYADD(traits_to_give, list( BULLET_TRAIT_ENTRY(/datum/element/bullet_trait_iff), @@ -392,41 +432,39 @@ var/nade_type = /obj/item/explosive/grenade/high_explosive icon_state = "grenade" flags_ammo_behavior = AMMO_IGNORE_COVER|AMMO_SKIPS_ALIENS + /// Fuse (deciseconds) set on the dropped nade_type once it lands. + var/dropped_det_time = 10 damage = 15 accuracy = HIT_ACCURACY_TIER_3 max_range = 6 /datum/ammo/grenade_container/on_hit_mob(mob/M,obj/projectile/P) - drop_nade(P) + drop_nade(get_turf(M), P) /datum/ammo/grenade_container/on_hit_obj(obj/O,obj/projectile/P) - drop_nade(P) + drop_nade(get_turf(O), P) /datum/ammo/grenade_container/on_hit_turf(turf/T,obj/projectile/P) - drop_nade(P) + drop_nade(T.density ? get_turf(P) : T, P) /datum/ammo/grenade_container/do_at_max_range(obj/projectile/P) - drop_nade(P) + drop_nade(get_turf(P), P) -/datum/ammo/grenade_container/proc/drop_nade(obj/projectile/P) - var/turf/T = get_turf(P) +/datum/ammo/grenade_container/proc/drop_nade(turf/T, obj/projectile/P) var/obj/item/explosive/grenade/G = new nade_type(T) G.visible_message(SPAN_WARNING("\A [G] lands on [T]!")) - G.det_time = 10 + G.det_time = dropped_det_time G.cause_data = P.weapon_cause_data G.activate() /datum/ammo/grenade_container/rifle flags_ammo_behavior = NO_FLAGS -/datum/ammo/grenade_container/smoke - name = "smoke grenade shell" - nade_type = /obj/item/explosive/grenade/smokebomb - icon_state = "smoke_shell" - /datum/ammo/grenade_container/tank_glauncher max_range = 8 + flags_ammo_behavior = AMMO_IGNORE_COVER|AMMO_SKIPS_ALIENS|AMMO_HITS_TARGET_TURF // let me know if this is too much. Idea being that those shells are being 'lobbed up' into the air. + dropped_det_time = 3 SECONDS /datum/ammo/hugger_container name = "hugger shell" diff --git a/code/datums/ammo/rocket.dm b/code/datums/ammo/rocket.dm index db7766917190..03948b2c99aa 100644 --- a/code/datums/ammo/rocket.dm +++ b/code/datums/ammo/rocket.dm @@ -140,20 +140,26 @@ shell_speed = AMMO_SPEED_TIER_3 /datum/ammo/rocket/ltb/on_hit_mob(mob/mob, obj/projectile/projectile) - cell_explosion(get_turf(mob), 220, 50, EXPLOSION_FALLOFF_SHAPE_LINEAR, null, projectile.weapon_cause_data) - cell_explosion(get_turf(mob), 200, 100, EXPLOSION_FALLOFF_SHAPE_LINEAR, null, projectile.weapon_cause_data) + cell_explosion(get_turf(mob), 200, 200, EXPLOSION_FALLOFF_SHAPE_LINEAR, null, projectile.weapon_cause_data) + cell_explosion(get_turf(mob), 100, 25, EXPLOSION_FALLOFF_SHAPE_LINEAR, null, projectile.weapon_cause_data) /datum/ammo/rocket/ltb/on_hit_obj(obj/object, obj/projectile/projectile) - cell_explosion(get_turf(object), 220, 50, EXPLOSION_FALLOFF_SHAPE_LINEAR, null, projectile.weapon_cause_data) - cell_explosion(get_turf(object), 200, 100, EXPLOSION_FALLOFF_SHAPE_LINEAR, null, projectile.weapon_cause_data) + cell_explosion(get_turf(object), 200, 200, EXPLOSION_FALLOFF_SHAPE_LINEAR, null, projectile.weapon_cause_data) + cell_explosion(get_turf(object), 100, 25, EXPLOSION_FALLOFF_SHAPE_LINEAR, null, projectile.weapon_cause_data) /datum/ammo/rocket/ltb/on_hit_turf(turf/turf, obj/projectile/projectile) - cell_explosion(get_turf(turf), 220, 50, EXPLOSION_FALLOFF_SHAPE_LINEAR, null, projectile.weapon_cause_data) - cell_explosion(get_turf(turf), 200, 100, EXPLOSION_FALLOFF_SHAPE_LINEAR, null, projectile.weapon_cause_data) + cell_explosion(get_turf(turf), 200, 200, EXPLOSION_FALLOFF_SHAPE_LINEAR, null, projectile.weapon_cause_data) + cell_explosion(get_turf(turf), 100, 25, EXPLOSION_FALLOFF_SHAPE_LINEAR, null, projectile.weapon_cause_data) /datum/ammo/rocket/ltb/do_at_max_range(obj/projectile/projectile) - cell_explosion(get_turf(projectile), 220, 50, EXPLOSION_FALLOFF_SHAPE_LINEAR, null, projectile.weapon_cause_data) - cell_explosion(get_turf(projectile), 200, 100, EXPLOSION_FALLOFF_SHAPE_LINEAR, null, projectile.weapon_cause_data) + cell_explosion(get_turf(projectile), 200, 200, EXPLOSION_FALLOFF_SHAPE_LINEAR, null, projectile.weapon_cause_data) + cell_explosion(get_turf(projectile), 100, 25, EXPLOSION_FALLOFF_SHAPE_LINEAR, null, projectile.weapon_cause_data) + +/datum/ammo/rocket/ltb/set_bullet_traits() + . = ..() + LAZYADD(traits_to_give, list( + BULLET_TRAIT_ENTRY(/datum/element/bullet_trait_iff) + )) /datum/ammo/rocket/wp name = "white phosphorous rocket" diff --git a/code/datums/ammo/xeno.dm b/code/datums/ammo/xeno.dm index be964cb6cd07..00dabf119dee 100644 --- a/code/datums/ammo/xeno.dm +++ b/code/datums/ammo/xeno.dm @@ -432,7 +432,7 @@ name = "tail hook" icon_state = "none" ping = null - flags_ammo_behavior = AMMO_XENO|AMMO_SKIPS_ALIENS|AMMO_STOPPED_BY_COVER + flags_ammo_behavior = AMMO_XENO|AMMO_SKIPS_ALIENS|AMMO_STOPPED_BY_COVER|AMMO_PASSES_OVER_VEHICLES damage_type = BRUTE damage = XENO_DAMAGE_TIER_5 @@ -441,7 +441,7 @@ /datum/ammo/xeno/oppressor_tail/on_bullet_generation(obj/projectile/generated_projectile, mob/bullet_generator) //The projectile has no icon, so the overlay shows up in FRONT of the projectile, and the beam connects to it in the middle. - var/image/hook_overlay = new(icon = 'icons/effects/beam.dmi', icon_state = "oppressor_tail_hook", layer = BELOW_MOB_LAYER) + var/image/hook_overlay = new(icon = 'icons/effects/beam.dmi', icon_state = "oppressor_tail_hook", layer = TANK_ABOVE_RIDER_LAYER) generated_projectile.overlays += hook_overlay /datum/ammo/xeno/oppressor_tail/on_hit_mob(mob/target, obj/projectile/fired_proj) @@ -450,7 +450,7 @@ return shake_camera(target, 5, 0.1 SECONDS) - var/obj/effect/beam/tail_beam = fired_proj.firer.beam(target, "oppressor_tail", 'icons/effects/beam.dmi', 0.5 SECONDS, 5) + var/obj/effect/beam/tail_beam = fired_proj.firer.beam(target, "oppressor_tail", 'icons/effects/beam.dmi', 0.5 SECONDS, 5, /obj/effect/ebeam/above_tank) var/image/tail_image = image('icons/effects/status_effects.dmi', "hooked") target.overlays += tail_image diff --git a/code/datums/beam.dm b/code/datums/beam.dm index 8dd2dd53184c..42b4345f0907 100644 --- a/code/datums/beam.dm +++ b/code/datums/beam.dm @@ -143,6 +143,12 @@ anchored = TRUE var/datum/beam/owner +/** + * A plain ebeam segment rendered above a tank's hull and riders, like Oppressor's Tail Seize rope. + */ +/obj/effect/ebeam/above_tank + layer = TANK_ABOVE_RIDER_LAYER + /obj/effect/ebeam/laser name = "laser beam" desc = "A laser beam!" diff --git a/code/datums/components/vehicle_rider.dm b/code/datums/components/vehicle_rider.dm new file mode 100644 index 000000000000..aa82a7713624 --- /dev/null +++ b/code/datums/components/vehicle_rider.dm @@ -0,0 +1,39 @@ +/** + * vehicle_rider tracks whether an atom/movable (a mob or a droppable obj) is currently riding atop a multitile vehicle's hull. + * + * Added by /obj/vehicle/multitile/proc/mark_on_top and obj_mark_on_top when something climbs onto, or is otherwise placed atop, a vehicle. Removed by clear_on_top/obj_clear_on_top when it leaves. + * + * Holds a weakref to the vehicle rather than a direct reference, so a rider never keeps a destroyed vehicle referenced, and so the component can detect a qdel'd vehicle and clean itself up on the next lookup. + */ +/datum/component/vehicle_rider + dupe_mode = COMPONENT_DUPE_UNIQUE_PASSARGS + var/datum/weakref/vehicle_ref + +/datum/component/vehicle_rider/Initialize(obj/vehicle/multitile/vehicle) + if(!ismovable(parent) || !istype(vehicle)) + return COMPONENT_INCOMPATIBLE + vehicle_ref = WEAKREF(vehicle) + +/datum/component/vehicle_rider/InheritComponent(datum/component/vehicle_rider/new_comp, i_am_original, obj/vehicle/multitile/vehicle) + if(istype(vehicle)) + vehicle_ref = WEAKREF(vehicle) + +/datum/component/vehicle_rider/Destroy(force, silent) + vehicle_ref = null + return ..() + +/datum/component/vehicle_rider/RegisterWithParent() + RegisterSignal(parent, COMSIG_PARENT_QDELETING, PROC_REF(on_parent_qdel)) + +/datum/component/vehicle_rider/UnregisterFromParent() + UnregisterSignal(parent, COMSIG_PARENT_QDELETING) + +/datum/component/vehicle_rider/proc/on_parent_qdel() + SIGNAL_HANDLER + qdel(src) + +/datum/component/vehicle_rider/proc/get_vehicle() + var/obj/vehicle/multitile/vehicle = vehicle_ref?.resolve() + if(!vehicle) + qdel(src) + return vehicle diff --git a/code/datums/custom_hud.dm b/code/datums/custom_hud.dm index d9abdbf36097..027fe4554981 100644 --- a/code/datums/custom_hud.dm +++ b/code/datums/custom_hud.dm @@ -20,12 +20,14 @@ //Lower right, persistent menu var/ui_dropbutton = "EAST-4:22,1:5" - var/ui_drop_throw = "EAST-1:28,2:7" - var/ui_pull = "EAST-2:26,2:7" - var/ui_resist = "EAST-2:26,2:7" + var/ui_drop_throw = "EAST-1:28,3:9" + var/ui_pull = "EAST-2:26,3:9" + var/ui_resist = "EAST-2:26,3:9" var/ui_acti = "EAST-2:26,1:5" var/ui_movi = "EAST-3:24,1:5" var/ui_zonesel = "EAST-1:28,1:5" + //Vehicle-part doll, directly above ui_zonesel + var/ui_vehicle_zonesel = "EAST-1:28,2:7" //Gun buttons var/ui_gun1 = "EAST-2:26,3:7" diff --git a/code/datums/keybinding/vehicles.dm b/code/datums/keybinding/vehicles.dm index 22c20e678c4b..b54699b06199 100644 --- a/code/datums/keybinding/vehicles.dm +++ b/code/datums/keybinding/vehicles.dm @@ -70,6 +70,51 @@ vehicle_user.activate_horn() return TRUE +/datum/keybinding/vehicles/shift_gear_up + hotkey_keys = list("Unbound") + classic_keys = list("Unbound") + name = "Shift gear up" + full_name = "Shift Gear Up" + keybind_signal = COMSIG_KB_VEHICLE_SHIFT_GEAR_UP + +/datum/keybinding/vehicles/shift_gear_up/down(client/user) + . = ..() + if(.) + return + var/obj/vehicle/multitile/vehicle_user = user.mob.interactee + vehicle_user.cycle_gear_up() + return TRUE + +/datum/keybinding/vehicles/shift_gear_down + hotkey_keys = list("Unbound") + classic_keys = list("Unbound") + name = "Shift gear down" + full_name = "Shift Gear Down" + keybind_signal = COMSIG_KB_VEHICLE_SHIFT_GEAR_DOWN + +/datum/keybinding/vehicles/shift_gear_down/down(client/user) + . = ..() + if(.) + return + var/obj/vehicle/multitile/vehicle_user = user.mob.interactee + vehicle_user.cycle_gear_down() + return TRUE + +/datum/keybinding/vehicles/toggle_cruise_control + hotkey_keys = list("Unbound") + classic_keys = list("Unbound") + name = "Toggle cruise control" + full_name = "Toggle Cruise Control" + keybind_signal = COMSIG_KB_VEHICLE_TOGGLE_CRUISE_CONTROL + +/datum/keybinding/vehicles/toggle_cruise_control/down(client/user) + . = ..() + if(.) + return + var/obj/vehicle/multitile/vehicle_user = user.mob.interactee + vehicle_user.toggle_cruise_control() + return TRUE + /datum/keybinding/vehicles/reload_weapon hotkey_keys = list("Unbound") classic_keys = list("Unbound") @@ -84,3 +129,47 @@ var/obj/vehicle/multitile/vehicle_user = user.mob.interactee vehicle_user.reload_firing_port_weapon() return TRUE + +/** + * Toggles the fire mode of hardpoints with more than one fire mode. currently only used for the primary and secondary + * flamers + */ +/datum/keybinding/vehicles/toggle_hardpoint_fire_mode + hotkey_keys = list("Unbound") + classic_keys = list("Unbound") + name = "Toggle hardpoint fire mode" + full_name = "Toggle Hardpoint Fire Mode" + keybind_signal = COMSIG_KB_VEHICLE_TOGGLE_HARDPOINT_FIRE_MODE + +/datum/keybinding/vehicles/toggle_hardpoint_fire_mode/down(client/user) + . = ..() + if(.) + return + var/obj/vehicle/multitile/vehicle_user = user.mob.interactee + var/obj/item/hardpoint/HP = vehicle_user.get_mob_hp(user.mob) + if(!HP) + return + HP.toggle_fire_mode(user.mob) + // This bypasses the action button, so refresh its icon manually. + for(var/datum/action/human_action/vehicle_action/toggle_hardpoint_fire_mode/action in user.mob.actions) + action.update_button_icon() + return TRUE + +/// Same gunner-only toggle as the right-click verb and ALT+Click shortcut. +/datum/keybinding/vehicles/toggle_turret_safety + hotkey_keys = list("Unbound") + classic_keys = list("Unbound") + name = "Toggle turret safety" + full_name = "Toggle Turret Rotation and Hardpoint Safety" + keybind_signal = COMSIG_KB_VEHICLE_TOGGLE_TURRET_SAFETY + +/datum/keybinding/vehicles/toggle_turret_safety/down(client/user) + . = ..() + if(.) + return + var/obj/vehicle/multitile/vehicle_user = user.mob.interactee + vehicle_user.toggle_turret_safety(user.mob) + // Same icon refresh gap as toggle_hardpoint_fire_mode above. + for(var/datum/action/human_action/vehicle_action/toggle_gyro/action in user.mob.actions) + action.update_button_icon() + return TRUE diff --git a/code/datums/looping_sounds/vehicle_sounds.dm b/code/datums/looping_sounds/vehicle_sounds.dm new file mode 100644 index 000000000000..0c8526be034e --- /dev/null +++ b/code/datums/looping_sounds/vehicle_sounds.dm @@ -0,0 +1,267 @@ +// Source: https://pixabay.com/sound-effects/film-special-effects-long-blinker-loop-24863/ +/datum/looping_sound/turn_signal + mid_sounds = 'sound/vehicles/blinkerloop.ogg' + mid_length = 1.2 SECONDS + volume = 35 + +// Source: https://pixabay.com/sound-effects/film-special-effects-tank-turret-rotate-14879/ +/datum/looping_sound/tank_turret + mid_sounds = 'sound/vehicles/tankturret.ogg' + mid_length = 1 SECONDS + // Matches this file's other vehicle loops. Also applies to turretdamaged.ogg. + volume = 25 + +// Source: freesound.org/s/541240/ (Garuda1982, Attribution 4.0). See sound/ATTRIBUTION.txt +/datum/looping_sound/tank_tracks + mid_sounds = 'sound/vehicles/trackrattling.ogg' + mid_length = 1 SECONDS + volume = 25 + /// Fixed channel reused for every play(), so on_stop() can immediately cut whatever's still playing. + var/track_channel + +/datum/looping_sound/tank_tracks/play(soundfile, volume_override) + if(!track_channel) + track_channel = get_free_channel() + playsound(parent, soundfile, volume_override || volume, vary, extra_range, VOLUME_SFX, track_channel) + +/** + * Immediately silences whatever's still playing on track_channel, both to seated crew and to + * anyone near the vehicle outside it. + */ +/datum/looping_sound/tank_tracks/on_stop() + ..() + if(!track_channel || !parent) + return + + var/obj/vehicle/multitile/vehicle = parent + if(istype(vehicle)) + for(var/client/crew_client in vehicle.get_interior_mob_clients()) + SEND_SOUND(crew_client, sound(null, channel = track_channel)) + + var/turf/source_turf = get_turf(parent) + if(!source_turf) + return + var/datum/sound_template/template = new() + template.channel = track_channel + template.x = source_turf.x + template.y = source_turf.y + template.z = source_turf.z + template.range = extra_range || floor(0.25 * volume) + SSsound.queue(template) + +// Source: freesound.org/s/681435/ (Department64, Attribution 4.0). See sound/ATTRIBUTION.txt +/datum/looping_sound/tank_drift + mid_sounds = 'sound/vehicles/treadedDrift.ogg' + // treadedDrift.ogg's exact duration. mid_length must match precisely or the loop cuts short or gaps. + mid_length = 1.289478 SECONDS + volume = 25 + /// Same reasoning as tank_tracks' track_channel above. + var/drift_channel + +/datum/looping_sound/tank_drift/play(soundfile, volume_override) + if(!drift_channel) + drift_channel = get_free_channel() + playsound(parent, soundfile, volume_override || volume, vary, extra_range, VOLUME_SFX, drift_channel) + +/// Same reasoning and mechanism as tank_tracks/on_stop() above. +/datum/looping_sound/tank_drift/on_stop() + ..() + if(!drift_channel || !parent) + return + + var/obj/vehicle/multitile/vehicle = parent + if(istype(vehicle)) + for(var/client/crew_client in vehicle.get_interior_mob_clients()) + SEND_SOUND(crew_client, sound(null, channel = drift_channel)) + + var/turf/source_turf = get_turf(parent) + if(!source_turf) + return + var/datum/sound_template/template = new() + template.channel = drift_channel + template.x = source_turf.x + template.y = source_turf.y + template.z = source_turf.z + template.range = extra_range || floor(0.25 * volume) + SSsound.queue(template) + +// Source: freesound.org/s/542582/ (adr1911, Creative Commons 0). See sound/ATTRIBUTION.txt +/datum/looping_sound/tank_engine + mid_sounds = 'sound/vehicles/turbineidle.ogg' + volume = ENGINE_SOUND_MIN_VOLUME + /// turbineidle.ogg's real duration at its native frequency. Raising playback frequency for the + /// pitch effect shrinks the clip length, so this subtype reschedules off the real per-play + /// duration instead of a fixed mid_length. See sound_loop() below. + var/native_duration = 2.630260 + // Played once before the loop starts / once after it stops. Both route through this subtype's + // play() override, so they get the same speed-based volume/pitch treatment. + start_sound = 'sound/vehicles/turbinestartup.ogg' + start_length = 5.054898 SECONDS + end_sound = 'sound/vehicles/turbineoff.ogg' + /// Fixed channels reused for every play(), same reasoning as tank_tracks/track_channel above. + var/crew_channel + var/outside_channel + +/** + * Overrides the base play() entirely. Volume/frequency are computed fresh each trigger from + * whichever of current_speed or engine_rev_level is currently higher. + * + * Sends ttwo separate copies via play_direct_to_crew() and play_to_outside_hearers(), so both the + * crew and anyone standing near the tank hear the engine without doubling up. + * + * Arguments: + * * soundfile = The sound file picked by get_sound(). + * * volume_override = Unused. Volume is always computed from speed/revving for this subtype. + * + * Returns: + * * The frequency actually used to play this instance, so sound_loop() can reschedule off the + * real pitch-shifted playback duration. + */ +/datum/looping_sound/tank_engine/play(soundfile, volume_override) + var/obj/vehicle/multitile/vehicle = parent + var/speed_fraction = 0 + if(istype(vehicle) && vehicle.uses_gear_transmission) + var/list/stats = vehicle.get_current_gear_stats() + if(stats["max_speed"]) + // current_speed and drift_speed sit on perpendicular axes, so this combines them as a + // vector magnitude instead of a flat sum. + var/effective_speed = sqrt(vehicle.current_speed ** 2 + vehicle.drift_speed ** 2) + speed_fraction = clamp(effective_speed / stats["max_speed"], 0, 1) + + // Revving in Park/Neutral never moves current_speed. Blended in as a separate fraction (not + // added) so idle revving sounds as loud/pitched as reaching that fraction of top speed would. + var/rev_fraction = istype(vehicle) ? vehicle.engine_rev_level : 0 + var/effective_fraction = max(speed_fraction, rev_fraction) + + var/computed_volume = ENGINE_SOUND_MIN_VOLUME + effective_fraction * (ENGINE_SOUND_MAX_VOLUME - ENGINE_SOUND_MIN_VOLUME) + var/computed_frequency = ENGINE_SOUND_MIN_FREQUENCY + effective_fraction * (ENGINE_SOUND_MAX_FREQUENCY - ENGINE_SOUND_MIN_FREQUENCY) + + play_direct_to_crew(vehicle, soundfile, computed_volume, computed_frequency) + play_to_outside_hearers(vehicle, soundfile, computed_volume, computed_frequency) + return computed_frequency + +/** + * Queues a sound_template to every client inside the vehicle's interior, seated or not, so it's + * also audible to unbuckled crew walking around the fighting compartment. + * + * Deliberately leaves template.x/y/z unset so the sound plays centered, same as any other + * non-positional sound, rather than going through the world-posittion/stereo-pan/reverb branch. + * + * Arguments: + * * vehicle = The vehicle whose interior occupants shoul hear this. + * * soundfile = The sound file to play. + * * sound_volume = Playback volume. + * * sound_frequency = Playback frequency (pitch/speed) override. + */ +/datum/looping_sound/tank_engine/proc/play_direct_to_crew(obj/vehicle/multitile/vehicle, soundfile, sound_volume, sound_frequency) + if(!istype(vehicle)) + return + + var/list/hearer_clients = vehicle.get_interior_mob_clients() + if(!length(hearer_clients)) + return + + if(!crew_channel) + crew_channel = get_free_channel() + + var/datum/sound_template/template = new() + template.file = get_sfx(soundfile) + template.volume = sound_volume + template.frequency = sound_frequency + template.channel = crew_channel + SSsound.queue(template, hearer_clients) + +/** + * Queues a normal positional sound_template centered on the vehicle's exterior turf, for anyone + * standing near it on the main map. + * + * Built manualy rather than via playsound() to skip its automatic interior-forwarding step, + * which would otherwise double up with play_direct_to_crew()'s copy. + * + * Arguments: + * * vehicle = The vehicle the sound originates from. + * * soundfile = The sound file to play. + * * sound_volume = Playback volume. + * * sound_frequency = Playback frequency (pitch/speed) override. + */ +/datum/looping_sound/tank_engine/proc/play_to_outside_hearers(obj/vehicle/multitile/vehicle, soundfile, sound_volume, sound_frequency) + var/turf/source_turf = get_turf(vehicle) + if(!source_turf) + return + + if(!outside_channel) + outside_channel = get_free_channel() + + var/datum/sound_template/template = new() + template.file = get_sfx(soundfile) + template.volume = sound_volume + template.frequency = sound_frequency + template.channel = outside_channel + template.x = source_turf.x + template.y = source_turf.y + template.z = source_turf.z + template.range = max(ENGINE_SOUND_MIN_RANGE, floor(0.25 * sound_volume)) + // falloff is the range before volume starts dropping. Half of range keeps it strong through + // the inner half of the audible radius, only tapering near the edge. + template.falloff = template.range * 0.5 + + SSsound.queue(template) + +/** + * Immediately silences whatever's still playing on the crew/outside channels, same trick as + * tank_tracks/on_stop() doubled up for two channels. Runs before ..() so the shutdown one-shot + * starts clean instead of racing the idle loop's tail. + */ +/datum/looping_sound/tank_engine/on_stop() + if(crew_channel && parent) + var/obj/vehicle/multitile/vehicle = parent + if(istype(vehicle)) + for(var/client/crew_client in vehicle.get_interior_mob_clients()) + SEND_SOUND(crew_client, sound(null, channel = crew_channel)) + + if(outside_channel) + var/turf/source_turf = get_turf(parent) + if(source_turf) + var/datum/sound_template/template = new() + template.channel = outside_channel + template.x = source_turf.x + template.y = source_turf.y + template.z = source_turf.z + template.range = extra_range || floor(0.25 * volume) + SSsound.queue(template) + + ..() + +/// Doesn't use the base TIMER_LOOP since clip duration changes with pitch. Schedules its own +/// first play() and lets sound_loop() reschedule itself each time. +/datum/looping_sound/tank_engine/start_sound_loop() + loop_started = TRUE + sound_loop() + +/** + * Re-implements the base sound_loop() to reschedule off the actual pitch-shifted playback + * duration instead of a fixed mid_length, avoiding an audible gap between loops. + */ +/datum/looping_sound/tank_engine/sound_loop(start_time) + if(!parent) + return + + var/computed_frequency = play(get_sound()) + var/playback_duration = native_duration * (ENGINE_SOUND_MIN_FREQUENCY / computed_frequency) + timer_id = addtimer(CALLBACK(src, PROC_REF(sound_loop)), playback_duration SECONDS, TIMER_CLIENT_TIME | TIMER_STOPPABLE | TIMER_DELETE_ME, timer_subsystem = SSsound_loops) + +// Source: quicksounds.com/sound/21067/tank-m551-start-stop. See sound/ATTRIBUTION.txt +/datum/looping_sound/tank_engine/apc + mid_sounds = 'sound/vehicles/apcidle.ogg' + native_duration = 1.662653 + start_sound = 'sound/vehicles/apcstart.ogg' + start_length = 3.052517 SECONDS + end_sound = 'sound/vehicles/apcstop.ogg' + +// Source: quicksounds.com/sound/21044/tank-m2-bradley-start-and-stop. See sound/ATTRIBUTION.txt +/datum/looping_sound/tank_engine/arc + mid_sounds = 'sound/vehicles/arcidle.ogg' + native_duration = 3.058571 + start_sound = 'sound/vehicles/arcstart.ogg' + start_length = 5.019161 SECONDS + end_sound = 'sound/vehicles/arcstop.ogg' diff --git a/code/datums/supply_packs/explosives.dm b/code/datums/supply_packs/explosives.dm index bf17a60a7880..b987103b2bfb 100644 --- a/code/datums/supply_packs/explosives.dm +++ b/code/datums/supply_packs/explosives.dm @@ -238,8 +238,14 @@ contains = list( /obj/item/storage/box/packet/flare, /obj/item/storage/box/packet/flare, + /obj/item/storage/box/packet/flare, + /obj/item/storage/box/packet/flare, + /obj/item/storage/box/packet/flare, + /obj/item/storage/box/packet/flare, + /obj/item/storage/box/packet/flare, + /obj/item/storage/box/packet/flare, ) - cost = 40 + cost = 10 containertype = /obj/structure/closet/crate/explosives containername = "M74 AGM-Star Shell Grenade Crate" group = "Explosives" diff --git a/code/datums/supply_packs/vehicle_ammo.dm b/code/datums/supply_packs/vehicle_ammo.dm index 43ce36ec2b64..b50076da7006 100644 --- a/code/datums/supply_packs/vehicle_ammo.dm +++ b/code/datums/supply_packs/vehicle_ammo.dm @@ -2,15 +2,29 @@ //Vehicle Ammo //*******************************************************************************/ +/datum/supply_packs/jp8_canisters + name = "JP-8 Canisters (x2)" + contains = list( + /obj/item/reagent_container/glass/beaker/jp8_canister/full, + /obj/item/reagent_container/glass/beaker/jp8_canister/full, + ) + cost = 15 + containertype = /obj/structure/closet/crate/ammo + containername = "JP-8 canister crate" + group = "Vehicle Ammo" + +// Uses the single-round he_shell magazine to match what the LTB Cannon actually loads. /datum/supply_packs/ammo_ltb_cannon - name = "LTB Cannon magazines (x6)" + name = "86x455mm HE Shells (x8)" contains = list( - /obj/item/ammo_magazine/hardpoint/ltb_cannon, - /obj/item/ammo_magazine/hardpoint/ltb_cannon, - /obj/item/ammo_magazine/hardpoint/ltb_cannon, - /obj/item/ammo_magazine/hardpoint/ltb_cannon, - /obj/item/ammo_magazine/hardpoint/ltb_cannon, - /obj/item/ammo_magazine/hardpoint/ltb_cannon, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, ) cost = 30 containertype = /obj/structure/closet/crate/ammo @@ -55,6 +69,19 @@ containername = "Primary Flamer fuel tanks crate" group = "Vehicle Ammo" +// Smoke-tank equivalent of the fuel-tank pack above. +/datum/supply_packs/ammo_drgn_flamer_smoke + name = "DRG-N Offensive Flamer Unit smoke tanks (x3)" + contains = list( + /obj/item/ammo_magazine/hardpoint/primary_flamer/smoke, + /obj/item/ammo_magazine/hardpoint/primary_flamer/smoke, + /obj/item/ammo_magazine/hardpoint/primary_flamer/smoke, + ) + cost = 30 + containertype = /obj/structure/closet/crate/ammo/alt/flame + containername = "Primary Flamer smoke tanks crate" + group = "Vehicle Ammo" + /datum/supply_packs/ammo_dualcannon name = "PARS-159 Boyars Dualcannon magazines (x5)" contains = list( @@ -83,18 +110,25 @@ containername = "M92T Grenade Launcher ammo crate" group = "Vehicle Ammo" +// Mimicks the star shell pack in the explosive sections. Also, does it really make sense to have those cost 4k ?? /datum/supply_packs/ammo_slauncher - name = "M34A2-A Multipurpose Turret smoke screen magazines (x4)" + name = "M74 AGM-S star shell packets (x8)" contains = list( - /obj/item/ammo_magazine/hardpoint/turret_smoke, - /obj/item/ammo_magazine/hardpoint/turret_smoke, - /obj/item/ammo_magazine/hardpoint/turret_smoke, + /obj/item/storage/box/packet/flare, + /obj/item/storage/box/packet/flare, + /obj/item/storage/box/packet/flare, + /obj/item/storage/box/packet/flare, + /obj/item/storage/box/packet/flare, + /obj/item/storage/box/packet/flare, + /obj/item/storage/box/packet/flare, + /obj/item/storage/box/packet/flare, ) - cost = 20 - containertype = /obj/structure/closet/crate/ammo - containername = "M34A2-A Multipurpose Turret smoke screen ammo crate" + cost = 10 + containertype = /obj/structure/closet/crate/explosives + containername = "M34A2-A Multipurpose Turret flare ammo crate" group = "Vehicle Ammo" +/* /datum/supply_packs/ammo_towlauncher name = "TOW Launcher magazines (x3)" contains = list(/obj/item/hardpoint/secondary/towlauncher) @@ -102,17 +136,7 @@ containertype = /obj/structure/closet/crate/ammo containername = "TOW launcher ammo crate" group = "Vehicle Ammo" - -/datum/supply_packs/ammo_m56_cupola - name = "M56 Cupola magazines (x2)" - contains = list( - /obj/item/ammo_magazine/hardpoint/m56_cupola, - /obj/item/ammo_magazine/hardpoint/m56_cupola, - ) - cost = 20 - containertype = /obj/structure/closet/crate/ammo - containername = "M56 Cupola ammo crate" - group = "Vehicle Ammo" +*/ /datum/supply_packs/tank_flamer_ammo name = "LZR-N Flamer Unit fuel tanks (x2)" @@ -136,19 +160,6 @@ containername = "Bleihagel RE-RE700 Frontal Cannon ammo crate" group = "Vehicle Ammo" -/datum/supply_packs/ammo_flarelauncher - name = "M-87F Flare Launcher magazines (x4)" - contains = list( - /obj/item/ammo_magazine/hardpoint/flare_launcher, - /obj/item/ammo_magazine/hardpoint/flare_launcher, - /obj/item/ammo_magazine/hardpoint/flare_launcher, - /obj/item/ammo_magazine/hardpoint/flare_launcher, - ) - cost = 20 - containertype = /obj/structure/closet/crate/ammo - containername = "M-87F Flare Launcher ammo crate" - group = "Vehicle Ammo" - /datum/supply_packs/ammo_arcsentry name = "RE700 Rotary Cannon magazines (x3)" contains = list( diff --git a/code/datums/supply_packs/vehicle_equipment.dm b/code/datums/supply_packs/vehicle_equipment.dm index df106761d467..9410cff0238d 100644 --- a/code/datums/supply_packs/vehicle_equipment.dm +++ b/code/datums/supply_packs/vehicle_equipment.dm @@ -1,3 +1,5 @@ +// --- Weapons (Bundled with ammo to artificially inflate costs to make it a bit more penalizing to lose them.) --- + /datum/supply_packs/arcsentry_replacement name = "Replacement RE700 Rotary Cannon (x1)" contains = list( @@ -7,3 +9,386 @@ containertype = /obj/structure/closet/crate/weapon containername = "RE700 Rotary Cannon crate" group = "Vehicle Equipment" + +/datum/supply_packs/tank_spare_primary_flamer + name = "Replacement DRG-N Offensive Flamer Unit (x1)" + contains = list( + /obj/item/hardpoint/primary/flamer, + /obj/item/ammo_magazine/hardpoint/primary_flamer, + /obj/item/ammo_magazine/hardpoint/primary_flamer, + /obj/item/ammo_magazine/hardpoint/primary_flamer, + ) + cost = 45 // ammo_drgn_flamer (30) + 15 hardware premium + containertype = /obj/structure/closet/crate/weapon + containername = "DRG-N Offensive Flamer Unit crate" + group = "Vehicle Equipment" + +/datum/supply_packs/tank_spare_ltb_cannon + name = "Replacement LTB Cannon (x1)" + contains = list( + /obj/item/hardpoint/primary/cannon, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, + ) + cost = 45 // ammo_ltb_cannon (30) + 15 hardware premium + containertype = /obj/structure/closet/crate/weapon + containername = "LTB Cannon crate" + group = "Vehicle Equipment" + +/datum/supply_packs/tank_spare_minigun + name = "Replacement LTAA-AP Minigun (x1)" + contains = list( + /obj/item/hardpoint/primary/minigun, + /obj/item/ammo_magazine/hardpoint/ltaaap_minigun, + /obj/item/ammo_magazine/hardpoint/ltaaap_minigun, + /obj/item/ammo_magazine/hardpoint/ltaaap_minigun, + ) + cost = 45 // ammo_ltaaap_minigun (30) + 15 hardware premium + containertype = /obj/structure/closet/crate/weapon + containername = "LTAA-AP Minigun crate" + group = "Vehicle Equipment" + +/datum/supply_packs/tank_spare_autocannon + name = "Replacement AC3-E Autocannon (x1)" + contains = list( + /obj/item/hardpoint/primary/autocannon, + /obj/item/ammo_magazine/hardpoint/ace_autocannon, + /obj/item/ammo_magazine/hardpoint/ace_autocannon, + /obj/item/ammo_magazine/hardpoint/ace_autocannon, + ) + cost = 45 // ammo_ace_autocannon (30) + 15 hardware premium + containertype = /obj/structure/closet/crate/weapon + containername = "AC3-E Autocannon crate" + group = "Vehicle Equipment" + +/datum/supply_packs/tank_spare_small_flamer + name = "Replacement LZR-N Flamer Unit (x1)" + contains = list( + /obj/item/hardpoint/secondary/small_flamer, + /obj/item/ammo_magazine/hardpoint/secondary_flamer, + /obj/item/ammo_magazine/hardpoint/secondary_flamer, + ) + cost = 35 // tank_flamer_ammo (20) + 15 hardware premium + containertype = /obj/structure/closet/crate/weapon + containername = "LZR-N Flamer Unit crate" + group = "Vehicle Equipment" + +/* Commenting this out since the TOW wasn't really touched by Desant yet. +/datum/supply_packs/tank_spare_towlauncher + name = "Replacement TOW Launcher (x1)" + contains = list( + /obj/item/hardpoint/secondary/towlauncher, + /obj/item/ammo_magazine/hardpoint/towlauncher, + ) + cost = 45 // ammo_towlauncher (30) + 15 hardware premium + containertype = /obj/structure/closet/crate/weapon + containername = "TOW Launcher crate" + group = "Vehicle Equipment" +*/ + +/datum/supply_packs/tank_spare_m56cupola + name = "Replacement M56 Cupola (x1)" + contains = list( + /obj/item/hardpoint/secondary/m56cupola, + /obj/item/ammo_magazine/m56d, + ) + cost = 35 // ammo_m56_cupola (20) + 15 hardware premium + containertype = /obj/structure/closet/crate/weapon + containername = "M56 Cupola crate" + group = "Vehicle Equipment" + +/datum/supply_packs/tank_spare_grenade_launcher + name = "Replacement M92T Grenade Launcher (x1)" + contains = list( + /obj/item/hardpoint/secondary/grenade_launcher, + /obj/item/ammo_magazine/hardpoint/tank_glauncher, + /obj/item/ammo_magazine/hardpoint/tank_glauncher, + /obj/item/ammo_magazine/hardpoint/tank_glauncher, + /obj/item/ammo_magazine/hardpoint/tank_glauncher, + /obj/item/ammo_magazine/hardpoint/tank_glauncher, + ) + cost = 35 // ammo_glauncher (20) + 15 hardware premium + containertype = /obj/structure/closet/crate/weapon + containername = "M92T Grenade Launcher crate" + group = "Vehicle Equipment" + +/datum/supply_packs/tank_spare_brute_launcher + name = "Replacement M6H-BRUTE Launcher (x1)" + contains = list( + /obj/item/hardpoint/secondary/brute_launcher, + /obj/item/ammo_magazine/rocket/brute, + /obj/item/ammo_magazine/rocket/brute, + /obj/item/ammo_magazine/rocket/brute, + /obj/item/ammo_magazine/rocket/brute, + /obj/item/ammo_magazine/rocket/brute, + /obj/item/ammo_magazine/rocket/brute, + ) + cost = 45 // brute_rockets (30) + 15 hardware premium + containertype = /obj/structure/closet/crate/weapon + containername = "M6H-BRUTE Launcher crate" + group = "Vehicle Equipment" + +/datum/supply_packs/tank_spare_flag_mount + name = "Replacement United Americas Flag Mount (x1)" + contains = list(/obj/item/hardpoint/secondary/united_americas_flag) + cost = 20 + containertype = /obj/structure/closet/crate/weapon + containername = "United Americas Flag Mount crate" + group = "Vehicle Equipment" + +/datum/supply_packs/apc_spare_dualcannon + name = "Replacement PARS-159 Boyars Dualcannon (x1)" + contains = list( + /obj/item/hardpoint/primary/dualcannon, + /obj/item/ammo_magazine/hardpoint/boyars_dualcannon, + /obj/item/ammo_magazine/hardpoint/boyars_dualcannon, + /obj/item/ammo_magazine/hardpoint/boyars_dualcannon, + /obj/item/ammo_magazine/hardpoint/boyars_dualcannon, + /obj/item/ammo_magazine/hardpoint/boyars_dualcannon, + ) + cost = 45 // ammo_dualcannon (30) + 15 hardware premium + containertype = /obj/structure/closet/crate/weapon + containername = "PARS-159 Boyars Dualcannon crate" + group = "Vehicle Equipment" + +/datum/supply_packs/apc_spare_frontalcannon + name = "Replacement Bleihagel RE-RE700 Frontal Cannon (x1)" + contains = list( + /obj/item/hardpoint/secondary/frontalcannon, + /obj/item/ammo_magazine/hardpoint/m56_cupola/frontal_cannon, + /obj/item/ammo_magazine/hardpoint/m56_cupola/frontal_cannon, + ) + cost = 35 // ammo_frontalcannon (20) + 15 hardware premium + containertype = /obj/structure/closet/crate/weapon + containername = "Bleihagel RE-RE700 Frontal Cannon crate" + group = "Vehicle Equipment" + +// --- Structural/utility hardpoints --- + +/datum/supply_packs/tank_spare_turret_mount + name = "Replacement Turret Mount (x1)" + contains = list(/obj/item/hardpoint/holder/tank_turret) + cost = 25 // bare weapon-mounting socket, no weapon included + containertype = /obj/structure/closet/crate/construction + containername = "Turret Mount crate" + group = "Vehicle Equipment" + +/datum/supply_packs/tank_spare_engine + name = "Replacement Engine (x1)" + contains = list(/obj/item/hardpoint/engine/tank) + cost = 20 + containertype = /obj/structure/closet/crate/construction + containername = "Engine crate" + group = "Vehicle Equipment" + +/datum/supply_packs/apc_spare_engine + name = "Replacement APC Engine (x1)" + contains = list(/obj/item/hardpoint/engine/apc) + cost = 20 + containertype = /obj/structure/closet/crate/construction + containername = "APC Engine crate" + group = "Vehicle Equipment" + +/datum/supply_packs/arc_spare_engine + name = "Replacement ARC Engine (x1)" + contains = list(/obj/item/hardpoint/engine/arc) + cost = 20 + containertype = /obj/structure/closet/crate/construction + containername = "ARC Engine crate" + group = "Vehicle Equipment" + +/datum/supply_packs/tank_spare_fuel_tank + name = "Replacement Fuel Tank (x1)" + contains = list(/obj/item/hardpoint/fuel_tank/uscm) + cost = 10 + containertype = /obj/structure/closet/crate/construction + containername = "Fuel Tank crate" + group = "Vehicle Equipment" + +/datum/supply_packs/arc_spare_fuel_tank + name = "Replacement ARC Fuel Tank (x1)" + contains = list(/obj/item/hardpoint/fuel_tank/arc) + cost = 10 + containertype = /obj/structure/closet/crate/construction + containername = "ARC Fuel Tank crate" + group = "Vehicle Equipment" + +/datum/supply_packs/tank_spare_radiator + name = "Replacement Radiator (x1)" + contains = list(/obj/item/hardpoint/radiator/uscm) + cost = 10 + containertype = /obj/structure/closet/crate/construction + containername = "Radiator crate" + group = "Vehicle Equipment" + +/datum/supply_packs/tank_spare_battery + name = "Replacement Battery (x1)" + contains = list(/obj/item/hardpoint/battery/uscm) + cost = 10 + containertype = /obj/structure/closet/crate/construction + containername = "Battery crate" + group = "Vehicle Equipment" + +/datum/supply_packs/tank_spare_hatch + name = "Replacement Hatch (x1)" + contains = list(/obj/item/hardpoint/hatch/armored/uscm) + cost = 5 + containertype = /obj/structure/closet/crate/construction + containername = "Hatch crate" + group = "Vehicle Equipment" + +/datum/supply_packs/tank_spare_iff_module + name = "Replacement IFF Module (x1)" + contains = list(/obj/item/hardpoint/iff_module/uscm) + cost = 10 + containertype = /obj/structure/closet/crate/construction + containername = "IFF Module crate" + group = "Vehicle Equipment" + +/datum/supply_packs/tank_spare_visual_sensors + name = "Replacement Visual Sensors (x1)" + contains = list(/obj/item/hardpoint/visual_sensors/uscm) + cost = 10 + containertype = /obj/structure/closet/crate/construction + containername = "Visual Sensors crate" + group = "Vehicle Equipment" + +/datum/supply_packs/tank_spare_turret_ring + name = "Replacement Turret Ring (x1)" + contains = list(/obj/item/hardpoint/turret_ring/uscm) + cost = 10 + containertype = /obj/structure/closet/crate/construction + containername = "Turret Ring crate" + group = "Vehicle Equipment" + +/datum/supply_packs/tank_spare_air_filter + name = "Replacement Air Filter (x1)" + contains = list(/obj/item/hardpoint/air_filter/uscm) + cost = 5 + containertype = /obj/structure/closet/crate/construction + containername = "Air Filter crate" + group = "Vehicle Equipment" + +/datum/supply_packs/tank_spare_treads + name = "Replacement Treads (x1)" + contains = list(/obj/item/hardpoint/locomotion/treads) + cost = 15 + containertype = /obj/structure/closet/crate/construction + containername = "Treads crate" + group = "Vehicle Equipment" + +/datum/supply_packs/tank_spare_treads_robust + name = "Replacement Reinforced Treads (x1)" + contains = list(/obj/item/hardpoint/locomotion/treads/robust) + cost = 15 + containertype = /obj/structure/closet/crate/construction + containername = "Reinforced Treads crate" + group = "Vehicle Equipment" + +/datum/supply_packs/apc_spare_wheels + name = "Replacement APC Wheels (x1)" + contains = list(/obj/item/hardpoint/locomotion/apc_wheels) + cost = 15 + containertype = /obj/structure/closet/crate/construction + containername = "APC Wheels crate" + group = "Vehicle Equipment" + +/datum/supply_packs/arc_spare_wheels + name = "Replacement ARC Wheels (x1)" + contains = list(/obj/item/hardpoint/locomotion/arc_wheels) + cost = 15 + containertype = /obj/structure/closet/crate/construction + containername = "ARC Wheels crate" + group = "Vehicle Equipment" + +// --- Armor plating --- + +/datum/supply_packs/tank_spare_armor_ballistic + name = "Ballistic Armor Plating (x1)" + contains = list(/obj/item/hardpoint/armor/ballistic) + cost = 20 + containertype = /obj/structure/closet/crate/construction + containername = "Ballistic Armor Plating crate" + group = "Vehicle Equipment" + +/datum/supply_packs/tank_spare_armor_caustic + name = "Caustic-Resistant Armor Plating (x1)" + contains = list(/obj/item/hardpoint/armor/caustic) + cost = 20 + containertype = /obj/structure/closet/crate/construction + containername = "Caustic-Resistant Armor Plating crate" + group = "Vehicle Equipment" + +/datum/supply_packs/tank_spare_armor_concussive + name = "Concussive-Dampening Armor Plating (x1)" + contains = list(/obj/item/hardpoint/armor/concussive) + cost = 20 + containertype = /obj/structure/closet/crate/construction + containername = "Concussive-Dampening Armor Plating crate" + group = "Vehicle Equipment" + +/datum/supply_packs/tank_spare_armor_paladin + name = "Paladin Armor Plating (x1)" + contains = list(/obj/item/hardpoint/armor/paladin) + cost = 20 + containertype = /obj/structure/closet/crate/construction + containername = "Paladin Armor Plating crate" + group = "Vehicle Equipment" + +// --- Not armor, just a utility bumper in its own hardpoint slot --- + +/datum/supply_packs/tank_spare_armor_snowplow + name = "Snowplow (x1)" + contains = list(/obj/item/hardpoint/snowplow) + cost = 5 + containertype = /obj/structure/closet/crate/construction + containername = "Snowplow crate" + group = "Vehicle Equipment" + +// --- Support modules --- + +/datum/supply_packs/tank_spare_overdrive + name = "Overdrive Enhancer (x1)" + contains = list(/obj/item/hardpoint/support/overdrive_enhancer) + cost = 20 + containertype = /obj/structure/closet/crate/construction + containername = "Overdrive Enhancer crate" + group = "Vehicle Equipment" + +/datum/supply_packs/tank_spare_iwsa + name = "Integrated Weapons Sensor Array (x1)" + contains = list(/obj/item/hardpoint/support/weapons_sensor) + cost = 20 + containertype = /obj/structure/closet/crate/construction + containername = "Integrated Weapons Sensor Array crate" + group = "Vehicle Equipment" + +/datum/supply_packs/tank_spare_artillery_module + name = "Artillery Module (x1)" + contains = list(/obj/item/hardpoint/support/artillery_module) + cost = 20 + containertype = /obj/structure/closet/crate/construction + containername = "Artillery Module crate" + group = "Vehicle Equipment" + +/datum/supply_packs/apc_spare_flare_launcher + name = "Replacement M-87F Flare Launcher (x1)" + contains = list(/obj/item/hardpoint/support/flare_launcher) + cost = 20 + containertype = /obj/structure/closet/crate/construction + containername = "M-87F Flare Launcher crate" + group = "Vehicle Equipment" + +/datum/supply_packs/arc_spare_antenna + name = "Replacement U-56 Radar Antenna (x1)" + contains = list(/obj/item/hardpoint/support/arc_antenna) + cost = 20 + containertype = /obj/structure/closet/crate/construction + containername = "U-56 Radar Antenna crate" + group = "Vehicle Equipment" diff --git a/code/datums/vehicle_hardpoint_hud.dm b/code/datums/vehicle_hardpoint_hud.dm new file mode 100644 index 000000000000..d2253c7a033a --- /dev/null +++ b/code/datums/vehicle_hardpoint_hud.dm @@ -0,0 +1,74 @@ +/** + * Global visibility gate for the vehicle hardpoint status HUD. Mirrors /datum/mob_hud, just + * re-targeted at vehicles instead of mobs. Only one kind exists, so a single global instance is enough. + */ +/datum/vehicle_hardpoint_hud + /// Every vehicle currenttly enrolled. + var/list/obj/vehicle/multitile/hudvehicles = list() + /// Viewer mob to list of sources currently granting them this HUD. Only removing the last one revokes it. + var/list/mob/hudusers = list() + +/// Allow user to see every enrolled vehicle's hardpoint HUD (e.g. flipping down a compatible helmet visor). +/datum/vehicle_hardpoint_hud/proc/add_hud_to(mob/user, source) + if(!user) + return + if(LAZYLEN(hudusers[user])) + hudusers[user] |= list(source) + else + hudusers[user] = list(source) + for(var/obj/vehicle/multitile/vehicle as anything in hudvehicles) + add_to_single_hud(user, vehicle) + +/// Stop showing every enrolled vehicle's hardpoint HUD to user, once EVERY granting source has been removed. +/datum/vehicle_hardpoint_hud/proc/remove_hud_from(mob/user, source) + if(!user || !LAZYLEN(hudusers[user])) + return + hudusers[user] -= source + if(LAZYLEN(hudusers[user])) + return + for(var/obj/vehicle/multitile/vehicle as anything in hudvehicles) + remove_from_single_hud(user, vehicle) + hudusers -= user + +/// "Enroll" a freshly-created vehicle. Every current viewer immediately sees its hardpoint images. +/datum/vehicle_hardpoint_hud/proc/add_to_hud(obj/vehicle/multitile/vehicle) + hudvehicles |= vehicle + for(var/mob/user as anything in hudusers) + add_to_single_hud(user, vehicle) + +/// "Unenroll" a vehicle that's about to be deleted. +/datum/vehicle_hardpoint_hud/proc/remove_from_hud(obj/vehicle/multitile/vehicle) + for(var/mob/user as anything in hudusers) + remove_from_single_hud(user, vehicle) + hudvehicles -= vehicle + +/datum/vehicle_hardpoint_hud/proc/add_to_single_hud(mob/user, obj/vehicle/multitile/vehicle) + if(!user.client) + return + for(var/slot in vehicle.hardpoint_hud_images) + user.client.images |= vehicle.hardpoint_hud_images[slot] + +/datum/vehicle_hardpoint_hud/proc/remove_from_single_hud(mob/user, obj/vehicle/multitile/vehicle) + if(!user.client) + return + for(var/slot in vehicle.hardpoint_hud_images) + user.client.images -= vehicle.hardpoint_hud_images[slot] + +/** + * Keeps every current viewer's client.images in sync the instant one specific slot's image is + * added, replaced, or removed on a vehicle. + * + * Arguments: + * * old_image = The image to revoke from every current viewer, or null if this slot didn't exist before. + * * new_image = The image to grant to every current viewer, or null if this slot no longer exists. + */ +/datum/vehicle_hardpoint_hud/proc/sync_single_image(obj/vehicle/multitile/vehicle, image/old_image, image/new_image) + for(var/mob/user as anything in hudusers) + if(!user.client) + continue + if(old_image) + user.client.images -= old_image + if(new_image) + user.client.images |= new_image + +GLOBAL_DATUM_INIT(vehicle_hardpoint_hud, /datum/vehicle_hardpoint_hud, new) diff --git a/code/game/atoms.dm b/code/game/atoms.dm index a76a2e442808..c0d2b4190b9a 100644 --- a/code/game/atoms.dm +++ b/code/game/atoms.dm @@ -651,6 +651,13 @@ Parameters are passed from New. /atom/proc/handle_tail_stab(mob/living/carbon/xenomorph/xeno, blunt_stab) return TAILSTAB_COOLDOWN_NONE +/** + * A short "attack target" description for flavor messages. Multitile vehicles override this to also name + * whichever module is being aimed at. + */ +/atom/proc/get_attack_desc(atom/attacker) + return "\the [src]" + /atom/proc/handle_flamer_fire(obj/flamer_fire/fire, damage, delta_time) return diff --git a/code/game/atoms_movable.dm b/code/game/atoms_movable.dm index 68ea2b137818..17309c805257 100644 --- a/code/game/atoms_movable.dm +++ b/code/game/atoms_movable.dm @@ -39,6 +39,13 @@ /// Holds a reference to the emissive blocker overlay var/emissive_overlay + /// Is this item allowed atop a climbable vehicle? + /// Not at all intended for structures: This is meant for grabbable items. Weapons, ammo, pizza crates, etc... + /// ...However, defining it as part of obj instead of obj/item helps us handle exceptions. + /// E.G: Exceptionally, as QoL for corpsmen: bodybags, stasis bags, and roller beds are allowed. + var/is_allowed_atop_vehicle = FALSE + /// If TRUE, a tank crash never scatters this atom off the hull, for effects that track the tank itself. used, for example, for flames and acid effects. + var/immune_to_tank_crash_scatter = FALSE /// A weakref to the mob currently interacting with us. var/datum/weakref/interactor @@ -500,8 +507,18 @@ SEND_SIGNAL(src, COMSIG_OBJ_AFTER_BUCKLE, buckled_mob) if(!buckled_mob) UnregisterSignal(buckle_target, COMSIG_PARENT_QDELETING) + if(isliving(buckle_target)) + var/mob/living/living_M = buckle_target + var/obj/vehicle/multitile/tank/tank = living_M.get_tank_on_top_of() + if(tank) + tank.clear_on_top(living_M) else RegisterSignal(buckled_mob, COMSIG_PARENT_QDELETING, PROC_REF(unbuckle)) + if(isliving(buckled_mob)) + var/mob/living/living_M = buckled_mob + var/obj/vehicle/multitile/tank/tank = get_tank_on_top_of() + if(tank) + tank.mark_on_top(living_M) return buckled_mob /atom/movable/proc/handle_rotation() @@ -548,3 +565,12 @@ return NO_BLOCKED_MOVEMENT return ..() + +/atom/movable/proc/get_tank_on_top_of() + var/datum/component/vehicle_rider/rider = GetComponent(/datum/component/vehicle_rider) + if(!rider) + return null + return rider.get_vehicle() + +/atom/movable/proc/is_atop_vehicle() + return GetComponent(/datum/component/vehicle_rider) ? TRUE : FALSE diff --git a/code/game/jobs/job/command/auxiliary/tank_crew.dm b/code/game/jobs/job/command/auxiliary/tank_crew.dm index e9c3dd769cc9..2bda385eaaf2 100644 --- a/code/game/jobs/job/command/auxiliary/tank_crew.dm +++ b/code/game/jobs/job/command/auxiliary/tank_crew.dm @@ -5,12 +5,13 @@ allow_additional = TRUE scaled = TRUE supervisors = "the acting commanding officer" - flags_startup_parameters = ROLE_ADD_TO_DEFAULT + // ROLE_ADD_TO_SQUAD defaults them to Foxtrot, but they're still transferable to any squad afterward. + flags_startup_parameters = ROLE_ADD_TO_DEFAULT|ROLE_ADD_TO_SQUAD gear_preset = /datum/equipment_preset/uscm/tank entry_message_body = "Your job is to operate and maintain the ship's armored vehicles. You are in charge of representing the armored presence amongst the marines during the operation, as well as maintaining and repairing your own vehicles." /datum/job/command/tank_crew/set_spawn_positions(count) - if (length(GLOB.clients) >= 200) + if (length(GLOB.clients) >= 130) spawn_positions = 2 else spawn_positions = 0 @@ -18,7 +19,7 @@ /datum/job/command/tank_crew/get_total_positions(latejoin = FALSE) if(SStechtree.trees[TREE_MARINE].get_node(/datum/tech/arc).unlocked) return 0 - if(length(GLOB.clients) >= 200 || total_positions_so_far > 0) + if(length(GLOB.clients) >= 130 || total_positions_so_far > 0) return 2 return 0 diff --git a/code/game/jobs/job/marine/squads.dm b/code/game/jobs/job/marine/squads.dm index 8d6f74f22bc3..2d31f1ed41c1 100644 --- a/code/game/jobs/job/marine/squads.dm +++ b/code/game/jobs/job/marine/squads.dm @@ -89,6 +89,8 @@ var/count = 0 //Current # in the squad var/list/marines_list = list() // list of mobs (or name, not always a mob ref) in that squad. + /// Vehicles whose overwatch is assigned to this squad + var/list/overwatch_vehicles = list() var/mob/living/carbon/human/overwatch_officer = null //Who's overwatching this squad? COOLDOWN_DECLARE(next_supplydrop) @@ -184,7 +186,8 @@ omni_squad_vendor = TRUE radio_freq = CRYO_FREQ - active = FALSE + // Active from round start so overwatch can reach Foxtrot now that tank crewmen start off into them. Remind me to remove this once ASS gets merged to allow tank crewmen to get assigned in accordance to their prefs. + active = TRUE roundstart = FALSE locked = TRUE @@ -761,6 +764,16 @@ if(slot_check && !isnull(roles_cap[slot_check])) roles_in[slot_check]-- +/// Adds a vehicle to this squad's overwatch roster. No-op if already tracked. +/datum/squad/proc/add_overwatch_vehicle(obj/vehicle/multitile/V) + if(V in overwatch_vehicles) + return + overwatch_vehicles += V + +/// Removes a vehicle from this squad's overwatch roster. +/datum/squad/proc/remove_overwatch_vehicle(obj/vehicle/multitile/V) + overwatch_vehicles -= V + //proc for demoting current Squad Leader /datum/squad/proc/demote_squad_leader(leader_killed) var/mob/living/carbon/human/old_lead = squad_leader diff --git a/code/game/jobs/role_authority.dm b/code/game/jobs/role_authority.dm index ce8e2666e480..a899a99c1ccf 100644 --- a/code/game/jobs/role_authority.dm +++ b/code/game/jobs/role_authority.dm @@ -578,6 +578,15 @@ I hope it's easier to tell what the heck this proc is even doing, unlike previou intel_squad.put_marine_in_squad(human) return + // Tank crew default to Foxtrot instead of a randomized combat squad, but are still fully transferable. + if(human.job == JOB_TANK_CREW) + var/datum/squad/tank_crew_squad = get_squad_by_name(SQUAD_MARINE_CRYO) + if(!tank_crew_squad || !istype(tank_crew_squad)) //Something went horribly wrong! + to_chat(human, "Something went wrong with randomize_squad()! Tell a coder!") + return + tank_crew_squad.put_marine_in_squad(human) + return + var/slot_check if(human.job != "Reinforcements") slot_check = GET_DEFAULT_ROLE(human.job) diff --git a/code/game/machinery/doors/multi_tile.dm b/code/game/machinery/doors/multi_tile.dm index b6fbe25f214c..dfda9034fca9 100644 --- a/code/game/machinery/doors/multi_tile.dm +++ b/code/game/machinery/doors/multi_tile.dm @@ -303,6 +303,14 @@ queen_pryable = TRUE var/obj/docking_port/mobile/marine_dropship/linked_dropship +/** + * The tank's designed entry/exit point, so just open for it instead of the generic vehicle bump handling. + * Sets V.skip_crash_response_entirely, since this "blocked" attempt isn't a real crash. + */ +/obj/structure/machinery/door/airlock/multi_tile/almayer/dropshiprear/handle_vehicle_bump(obj/vehicle/multitile/V) + bumpopen(V.get_seat_mob(VEHICLE_DRIVER)) + V.skip_crash_response_entirely = TRUE + return FALSE /obj/structure/machinery/door/airlock/multi_tile/almayer/dropshiprear/ex_act(severity) return diff --git a/code/game/machinery/vending/vending_types.dm b/code/game/machinery/vending/vending_types.dm index 6500d265898f..a090d5a13208 100644 --- a/code/game/machinery/vending/vending_types.dm +++ b/code/game/machinery/vending/vending_types.dm @@ -709,6 +709,7 @@ /obj/item/device/cassette_tape/heavymetal = 10, /obj/item/device/cassette_tape/hairmetal = 10, /obj/item/device/cassette_tape/indie = 10, + /obj/item/device/cassette_tape/liberation = 10, /obj/item/device/walkman = 50, /obj/item/device/walkman/white_band = 50, /obj/item/storage/pouch/cassette = 15, @@ -743,6 +744,7 @@ /obj/item/device/cassette_tape/heavymetal = 5, /obj/item/device/cassette_tape/hairmetal = 5, /obj/item/device/cassette_tape/indie = 5, + /obj/item/device/cassette_tape/liberation = 5, /obj/item/device/walkman = 15, /obj/item/device/walkman/white_band = 15, /obj/item/storage/pouch/cassette = 10, diff --git a/code/game/machinery/vending/vendor_types/crew/vehicle_crew.dm b/code/game/machinery/vending/vendor_types/crew/vehicle_crew.dm index 3ced90bfe322..f2507e26da2e 100644 --- a/code/game/machinery/vending/vendor_types/crew/vehicle_crew.dm +++ b/code/game/machinery/vending/vendor_types/crew/vehicle_crew.dm @@ -141,11 +141,14 @@ GLOBAL_LIST_INIT(cm_vending_vehicle_crew_tank, list( list("AC3-E Autocannon", 0, /obj/effect/essentials_set/tank/autocannon, VEHICLE_PRIMARY_AVAILABLE, VENDOR_ITEM_RECOMMENDED), list("DRG-N Offensive Flamer Unit", 0, /obj/effect/essentials_set/tank/dragonflamer, VEHICLE_PRIMARY_AVAILABLE, VENDOR_ITEM_REGULAR), list("LTAA-AP Minigun", 0, /obj/effect/essentials_set/tank/gatling, VEHICLE_PRIMARY_AVAILABLE, VENDOR_ITEM_REGULAR), + list("LTB Cannon", 0, /obj/effect/essentials_set/tank/ltb, VEHICLE_PRIMARY_AVAILABLE, VENDOR_ITEM_RECOMMENDED), list("SECONDARY WEAPON", 0, null, null, null), list("M92T Grenade Launcher", 0, /obj/effect/essentials_set/tank/tankgl, VEHICLE_SECONDARY_AVAILABLE, VENDOR_ITEM_REGULAR), - list("M56 Cupola", 0, /obj/effect/essentials_set/tank/m56cupola, VEHICLE_SECONDARY_AVAILABLE, VENDOR_ITEM_REGULAR), - list("LZR-N Flamer Unit", 0, /obj/effect/essentials_set/tank/tankflamer, VEHICLE_SECONDARY_AVAILABLE, VENDOR_ITEM_RECOMMENDED), + list("M56 Cupola", 0, /obj/effect/essentials_set/tank/m56cupola, VEHICLE_SECONDARY_AVAILABLE, VENDOR_ITEM_RECOMMENDED), + list("M6H-BRUTE Launcher", 0, /obj/effect/essentials_set/tank/brute_launcher, VEHICLE_SECONDARY_AVAILABLE, VENDOR_ITEM_REGULAR), + list("LZR-N Flamer Unit", 0, /obj/effect/essentials_set/tank/tankflamer, VEHICLE_SECONDARY_AVAILABLE, VENDOR_ITEM_REGULAR), + list("Mounted UA Flag", 0, /obj/effect/essentials_set/tank/united_americas_flag, VEHICLE_SECONDARY_AVAILABLE, VENDOR_ITEM_RECOMMENDED), list("SUPPORT MODULE", 0, null, null, null), list("Artillery Module", 0, /obj/item/hardpoint/support/artillery_module, VEHICLE_SUPPORT_AVAILABLE, VENDOR_ITEM_REGULAR), @@ -153,7 +156,13 @@ GLOBAL_LIST_INIT(cm_vending_vehicle_crew_tank, list( list("Overdrive Enhancer", 0, /obj/item/hardpoint/support/overdrive_enhancer, VEHICLE_SUPPORT_AVAILABLE, VENDOR_ITEM_RECOMMENDED), list("ARMOR", 0, null, null, null), - list("Snowplow", 0, /obj/item/hardpoint/armor/snowplow, VEHICLE_ARMOR_AVAILABLE, VENDOR_ITEM_REGULAR), + list("Ballistic Armor", 0, /obj/item/hardpoint/armor/ballistic, VEHICLE_ARMOR_AVAILABLE, VENDOR_ITEM_REGULAR), + list("Caustic Armor", 0, /obj/item/hardpoint/armor/caustic, VEHICLE_ARMOR_AVAILABLE, VENDOR_ITEM_REGULAR), + list("Concussive Armor", 0, /obj/item/hardpoint/armor/concussive, VEHICLE_ARMOR_AVAILABLE, VENDOR_ITEM_REGULAR), + list("Paladin Armor", 0, /obj/item/hardpoint/armor/paladin, VEHICLE_ARMOR_AVAILABLE, VENDOR_ITEM_REGULAR), + + list("UTILITY", 0, null, null, null), + list("Snowplow", 0, /obj/item/hardpoint/snowplow, VEHICLE_UTILITY_AVAILABLE, VENDOR_ITEM_REGULAR), list("TREADS", 0, null, null, null), list("Reinforced Treads", 0, /obj/item/hardpoint/locomotion/treads/robust, VEHICLE_TREADS_AVAILABLE, VENDOR_ITEM_REGULAR), @@ -193,7 +202,7 @@ GLOBAL_LIST_INIT(cm_vending_vehicle_crew_apc_spare, list( list("M-97F Flare Launcher", 300, /obj/item/hardpoint/support/flare_launcher, null, VENDOR_ITEM_REGULAR), list("SUPPORT AMMUNITION", 0, null, null, null), - list("M-97F Flare Launcher Magazine", 50, /obj/item/ammo_magazine/hardpoint/flare_launcher, null, VENDOR_ITEM_REGULAR), + list("M74 AGM-S Star Shell Packet", 50, /obj/item/storage/box/packet/flare, null, VENDOR_ITEM_REGULAR), list("WHEELS", 0, null, null, null), list("APC Wheels", 200, /obj/item/hardpoint/locomotion/apc_wheels, null, VENDOR_ITEM_REGULAR))) @@ -270,6 +279,7 @@ GLOBAL_LIST_INIT(cm_vending_clothing_vehicle_crew, list( list("Tanker Armor", 0, /obj/item/clothing/suit/storage/marine/tanker, MARINE_CAN_BUY_ARMOR, VENDOR_ITEM_MANDATORY), list("M50 Tanker Helmet", 0, /obj/item/clothing/head/helmet/marine/tech/tanker, MARINE_CAN_BUY_HELMET, VENDOR_ITEM_MANDATORY), list("Medical Helmet Optic", 0, /obj/item/device/helmet_visor/medical, MARINE_CAN_BUY_GLASSES, VENDOR_ITEM_MANDATORY), + list("Vehicle Diagnostics Optic", 0, /obj/item/device/helmet_visor/vehicle, MARINE_CAN_BUY_GLASSES, VENDOR_ITEM_MANDATORY), list("Welding Kit", 0, /obj/item/tool/weldpack, MARINE_CAN_BUY_BACKPACK, VENDOR_ITEM_MANDATORY), list("MRE", 0, /obj/item/storage/box/mre, MARINE_CAN_BUY_MRE, VENDOR_ITEM_MANDATORY), @@ -347,6 +357,7 @@ GLOBAL_LIST_INIT(cm_vending_clothing_vehicle_crew, list( list("M44 Heavy Speed Loader (.44)", 10, /obj/item/ammo_magazine/revolver/heavy, null, VENDOR_ITEM_REGULAR), list("UTILITIES", 0, null, null, null), + list("JP-8 Fuel Canister", 25, /obj/item/reagent_container/glass/beaker/jp8_canister/full, null, VENDOR_ITEM_REGULAR), list("Binoculars", 10, /obj/item/device/binoculars, null, VENDOR_ITEM_REGULAR), list("Fire Extinguisher (Portable)", 5, /obj/item/tool/extinguisher/mini, null, VENDOR_ITEM_REGULAR), list("Fuel Tank Strap Pouch", 5, /obj/item/storage/pouch/flamertank, null, VENDOR_ITEM_REGULAR), @@ -375,11 +386,18 @@ GLOBAL_LIST_INIT(cm_vending_clothing_vehicle_crew, list( desc = "A giant cannon firing explosive 86mm shells. You'd be lucky if this even leaves the dust of whatever you hit with it." spawned_gear_list = list( /obj/item/hardpoint/primary/cannon, - /obj/item/ammo_magazine/hardpoint/ltb_cannon, - /obj/item/ammo_magazine/hardpoint/ltb_cannon, - /obj/item/ammo_magazine/hardpoint/ltb_cannon, - /obj/item/ammo_magazine/hardpoint/ltb_cannon, - /obj/item/ammo_magazine/hardpoint/ltb_cannon, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, + /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell, ) /obj/effect/essentials_set/tank/gatling @@ -427,7 +445,7 @@ GLOBAL_LIST_INIT(cm_vending_clothing_vehicle_crew, list( desc = "A permanently fixed M56D, firing standard issue 10x28mm rounds." spawned_gear_list = list( /obj/item/hardpoint/secondary/m56cupola, - /obj/item/ammo_magazine/hardpoint/m56_cupola, + /obj/item/ammo_magazine/m56d, ) /obj/effect/essentials_set/tank/tankgl @@ -440,11 +458,29 @@ GLOBAL_LIST_INIT(cm_vending_clothing_vehicle_crew, list( /obj/item/ammo_magazine/hardpoint/tank_glauncher, ) +/obj/effect/essentials_set/tank/brute_launcher + desc = "A tank-mounted BRUTE breaching rocket launcher. Laser-guides a single 90mm shaped-charge rocket onto structures and walls, given a few seconds to lock on." + spawned_gear_list = list( + /obj/item/hardpoint/secondary/brute_launcher, + /obj/item/ammo_magazine/rocket/brute, + /obj/item/ammo_magazine/rocket/brute, + /obj/item/ammo_magazine/rocket/brute, + /obj/item/ammo_magazine/rocket/brute, + /obj/item/ammo_magazine/rocket/brute, + /obj/item/ammo_magazine/rocket/brute, + ) + +/obj/effect/essentials_set/tank/united_americas_flag + desc = "An United Americas Flag for mounting atop the tank." + spawned_gear_list = list( + /obj/item/hardpoint/secondary/united_americas_flag, + ) + /obj/effect/essentials_set/tank/turret spawned_gear_list = list( /obj/item/hardpoint/holder/tank_turret, - /obj/item/ammo_magazine/hardpoint/turret_smoke, - /obj/item/ammo_magazine/hardpoint/turret_smoke, + /obj/item/storage/box/packet/flare, + /obj/item/storage/box/packet/flare, ) /obj/effect/essentials_set/apc/dualcannon @@ -465,7 +501,6 @@ GLOBAL_LIST_INIT(cm_vending_clothing_vehicle_crew, list( /obj/effect/essentials_set/apc/flarelauncher spawned_gear_list = list( /obj/item/hardpoint/support/flare_launcher, - /obj/item/ammo_magazine/hardpoint/flare_launcher, - /obj/item/ammo_magazine/hardpoint/flare_launcher, - /obj/item/ammo_magazine/hardpoint/flare_launcher, + /obj/item/storage/box/packet/flare, + /obj/item/storage/box/packet/flare, ) diff --git a/code/game/machinery/vending/vendor_types/general.dm b/code/game/machinery/vending/vendor_types/general.dm index 9f72a2930f09..2a7751aab081 100644 --- a/code/game/machinery/vending/vendor_types/general.dm +++ b/code/game/machinery/vending/vendor_types/general.dm @@ -22,6 +22,7 @@ GLOBAL_LIST_INIT(cm_vending_walkman, list( list("Red-Black Cassette", 10, /obj/item/device/cassette_tape/heavymetal, VENDOR_ITEM_REGULAR), list("Red Striped Cassette", 10, /obj/item/device/cassette_tape/hairmetal, VENDOR_ITEM_REGULAR), list("Rising Sun Cassette", 10, /obj/item/device/cassette_tape/indie, VENDOR_ITEM_REGULAR), + list("Liberation Cassette", 10, /obj/item/device/cassette_tape/liberation, VENDOR_ITEM_REGULAR), list("Walkman", 50, /obj/item/device/walkman, VENDOR_ITEM_REGULAR), list("Walkman (White Band)", 50, /obj/item/device/walkman/white_band, VENDOR_ITEM_REGULAR), list("Cassette Pouch", 15, /obj/item/storage/pouch/cassette, VENDOR_ITEM_REGULAR), diff --git a/code/game/objects/effects/aliens.dm b/code/game/objects/effects/aliens.dm index 77865af44596..c89c1504c9c4 100644 --- a/code/game/objects/effects/aliens.dm +++ b/code/game/objects/effects/aliens.dm @@ -43,7 +43,7 @@ density = FALSE opacity = FALSE anchored = TRUE - layer = ABOVE_OBJ_LAYER + layer = TANK_RIDER_LAYER mouse_opacity = MOUSE_OPACITY_TRANSPARENT var/datum/cause_data/cause_data @@ -126,15 +126,16 @@ continue - if(isVehicleMultitile(atm)) - var/obj/vehicle/multitile/V = atm - V.handle_acidic_environment(src) - continue if (istype(loc, /turf/open)) var/turf/open/scorch_turf_target = loc if(scorch_turf_target.scorchable) scorch_turf_target.scorch(damage_amount) + // Needed since a multitile vehicle only really occupies its one anchor turf. + var/obj/vehicle/multitile/vehicle_here = get_multitile_vehicle_at(loc) + if(vehicle_here) + vehicle_here.handle_acidic_environment(src) + START_PROCESSING(SSobj, src) addtimer(CALLBACK(src, PROC_REF(die)), time_to_live) animate(src, time_to_live, alpha = 128) @@ -700,6 +701,11 @@ for (var/obj/structure/barricade/B in loc) B.take_acid_damage(damage*(1.15 + 0.55 * empowered)) + // Needed since a parked tank's footprint can span several of the mine's detonating tiles. + var/obj/vehicle/multitile/vehicle_here = get_multitile_vehicle_at(loc) + if(vehicle_here) + vehicle_here.expose_to_acid_mine(src) + for (var/mob/living/carbon/human in loc) if (human.stat == DEAD) continue diff --git a/code/game/objects/effects/effect_system/smoke.dm b/code/game/objects/effects/effect_system/smoke.dm index 3c975f5ae83e..2355f4f6be35 100644 --- a/code/game/objects/effects/effect_system/smoke.dm +++ b/code/game/objects/effects/effect_system/smoke.dm @@ -23,6 +23,10 @@ var/datum/cause_data/cause_data = null //does it obscure aim var/obscuring = TRUE + /// Vehicle this instance was bridged into. Null for a non-bridged placement. + var/obj/vehicle/multitile/bridged_vehicle + /// Fraction (0-1) of a bridged instance's normal damage, effects and alpha that gets through. + var/gas_leak_fraction = 1 //Remove this bit to use the old smoke icon = 'icons/effects/96x96.dmi' @@ -535,10 +539,58 @@ smokeranking = SMOKE_RANK_HARMLESS amount = 0 +/** + * Harmless, but still neat to add into vehicles for flavor. + */ +/obj/effect/particle_effect/smoke/acid_runner_harmless/apply_smoke_effect(turf/cur_turf) + if(bridged_vehicle && world.time > bridged_vehicle.next_gas_exposure_time) + qdel(src) + return + ..() + handle_vehicle_gas_exposure(cur_turf) + ///////////////////////////////////////// // BOILER SMOKES ///////////////////////////////////////// +/** + * Exposes a vehicle sitting in Boiler gas to damage and wounds, and leaks a weakened + * copy into the crew compartmentt through the air filter. + */ +/obj/effect/particle_effect/smoke/proc/handle_vehicle_gas_exposure(turf/cur_turf) + var/obj/vehicle/multitile/vehicle = get_multitile_vehicle_at(cur_turf) + if(!vehicle) + return + + var/obj/vehicle/multitile/tank/tank = istype(vehicle, /obj/vehicle/multitile/tank) ? vehicle : null + // acid_runner_harmless counts as acid too, even though it isn't a xeno_burn subtype. + var/is_acid = istype(src, /obj/effect/particle_effect/smoke/xeno_burn) || istype(src, /obj/effect/particle_effect/smoke/acid_runner_harmless) + + if(world.time < vehicle.next_gas_exposure_time) + return + vehicle.next_gas_exposure_time = world.time + (tank ? TANK_GLOB_EXPOSURE_INTERVAL : VEHICLE_GAS_EXPOSURE_INTERVAL) + + if(tank) + // Matches tile coverage against this call's own smoke type, rather than a hardcoded pick. + var/gas_type_to_match = src.type + var/covered_tiles = 0 + for(var/turf/vehicle_turf as anything in tank.locs) + if(locate(gas_type_to_match) in vehicle_turf) + covered_tiles++ + var/covered_fraction = covered_tiles / length(tank.locs) + + if(is_acid) + tank.apply_glob_acid_tick(covered_fraction, cause_data?.resolve_mob()) + else + tank.apply_glob_neuro_tick(covered_fraction) + else + if(is_acid) + vehicle.take_damage_type(15, "acid") + vehicle.expose_to_boiler_gas() + + // Interior leak-in is shared with every other smoke source that touches the vehicle. + vehicle.bridge_smoke_into_interior(src.type) + //Xeno acid smoke. /obj/effect/particle_effect/smoke/xeno_burn time_to_live = 12 @@ -546,6 +598,8 @@ anchored = TRUE spread_speed = 6 smokeranking = SMOKE_RANK_BOILER + // Sits above a tank's turret so the glob cloud visibly covers the whole tank. + layer = TANK_ABOVE_RIDER_LAYER affect_on_cross = FALSE var/hivenumber = XENO_HIVE_NORMAL @@ -562,18 +616,22 @@ return ..() /obj/effect/particle_effect/smoke/xeno_burn/apply_smoke_effect(turf/cur_turf) + if(bridged_vehicle && world.time > bridged_vehicle.next_gas_exposure_time) + qdel(src) + return ..() for(var/obj/structure/barricade/barricade in cur_turf) barricade.take_acid_damage(XENO_ACID_GAS_BARRICADE_DAMAGE) if(prob(75)) // anti sound spam playsound(src, pick("acid_sizzle", "acid_hit"), 25) - for(var/obj/vehicle/multitile/vehicle in cur_turf) - vehicle.take_damage_type(15, "acid") + // Vehicle damage is handled by handle_vehicle_gas_exposure() below instead of here. for(var/obj/structure/machinery/m56d_hmg/auto/gun in cur_turf) gun.update_health(XENO_ACID_HMG_DAMAGE) + handle_vehicle_gas_exposure(cur_turf) + /obj/effect/particle_effect/smoke/xeno_burn/affect(mob/living/carbon/affected_mob) . = ..() if(!.) @@ -588,14 +646,14 @@ return FALSE affected_mob.last_damage_data = cause_data - affected_mob.apply_damage(3, OXY) //Basic oxyloss from "can't breathe" + affected_mob.apply_damage(3 * gas_leak_fraction, OXY) //Basic oxyloss from "can't breathe" if(isxeno(affected_mob)) - affected_mob.apply_damage(gas_damage * XVX_ACID_DAMAGEMULT, BURN) //Inhalation damage + affected_mob.apply_damage(gas_damage * XVX_ACID_DAMAGEMULT * gas_leak_fraction, BURN) //Inhalation damage else - affected_mob.apply_damage(gas_damage, BURN) //Inhalation damage + affected_mob.apply_damage(gas_damage * gas_leak_fraction, BURN) //Inhalation damage - if(affected_mob.coughedtime < world.time && !affected_mob.stat && ishuman(affected_mob)) //Coughing/gasping + if(affected_mob.coughedtime < world.time && !affected_mob.stat && ishuman(affected_mob) && prob(gas_leak_fraction * 100)) //Coughing/gasping - a light leak only occasionally catches your throat affected_mob.coughedtime = world.time + 1.5 SECONDS if(issynth(affected_mob)) affected_mob.visible_message(SPAN_DANGER("[affected_mob]'s skin is sloughing off!"), @@ -610,9 +668,9 @@ to_chat(affected_mob, SPAN_DANGER("Your skin feels like it is melting away!")) if(ishuman(affected_mob)) var/mob/living/carbon/human/human = affected_mob - human.apply_armoured_damage(amount*rand(15, 20), ARMOR_BIO, BURN) //Burn damage, randomizes between various parts //Amount corresponds to upgrade level, 1 to 2.5 + human.apply_armoured_damage(amount*rand(15, 20) * gas_leak_fraction, ARMOR_BIO, BURN) //Burn damage, randomizes between various parts //Amount corresponds to upgrade level, 1 to 2.5 else - affected_mob.burn_skin(5) //Failsafe for non-humans + affected_mob.burn_skin(5 * gas_leak_fraction) //Failsafe for non-humans affected_mob.last_damage_data = cause_data return TRUE @@ -623,12 +681,20 @@ spread_speed = 5 amount = 1 //Amount depends on Boiler upgrade! smokeranking = SMOKE_RANK_BOILER + // Same layer fix as xeno_burn above. + layer = TANK_ABOVE_RIDER_LAYER affect_on_cross = FALSE /// How much neuro is dosed per tick var/neuro_dose = 6 var/msg = "Your skin tingles as the gas consumes you!" // Message given per tick. Changes depending on which species is hit. +/obj/effect/particle_effect/smoke/xeno_weak/apply_smoke_effect(turf/cur_turf) + if(bridged_vehicle && world.time > bridged_vehicle.next_gas_exposure_time) + qdel(src) + return + ..() + handle_vehicle_gas_exposure(cur_turf) /obj/effect/particle_effect/smoke/xeno_weak/affect(mob/living/carbon/moob) // This applies every tick someone is in the smoke . = ..() @@ -649,18 +715,18 @@ if(human_moob.chem_effect_flags & CHEM_EFFECT_RESIST_NEURO) return FALSE - var/effect_amt = floor(6 + amount*6) + var/effect_amt = floor((6 + amount*6) * gas_leak_fraction) moob.eye_blurry = max(moob.eye_blurry, effect_amt) moob.EyeBlur(max(moob.eye_blurry, effect_amt)) - moob.apply_damage(5, OXY) // Base "I can't breath oxyloss" Slightly more longer lasting then stamina damage + moob.apply_damage(5 * gas_leak_fraction, OXY) // Base "I can't breath oxyloss" Slightly more longer lasting then stamina damage // reworked code below if(!issynth(moob)) var/datum/effects/neurotoxin/neuro_effect = locate() in moob.effects_list if(!neuro_effect) neuro_effect = new(moob, cause_data.resolve_mob()) neuro_effect.strength = effect_amt - neuro_effect.duration += neuro_dose - if(human_moob && moob.coughedtime < world.time && !moob.stat) //Coughing/gasping + neuro_effect.duration += neuro_dose * gas_leak_fraction + if(human_moob && moob.coughedtime < world.time && !moob.stat && prob(gas_leak_fraction * 100)) //Coughing/gasping - a light leak only occasionally catches your throat moob.coughedtime = world.time + 1.5 SECONDS if(prob(50)) moob.Slow(1) @@ -669,7 +735,8 @@ moob.emote("gasp") else msg = "You are consumed by the harmless gas, it is hard to navigate in!" - moob.Slow(1) + if(prob(gas_leak_fraction * 100)) + moob.Slow(1) to_chat(moob, SPAN_DANGER(msg)) return TRUE diff --git a/code/game/objects/items.dm b/code/game/objects/items.dm index fe0e01e50186..8ef8ba3507f4 100644 --- a/code/game/objects/items.dm +++ b/code/game/objects/items.dm @@ -167,6 +167,9 @@ /// Special storages this item prioritizes var/list/preferred_storage + /// Rifles, shells, grenades, metal sheets, et-cetera should all be able to 'fit' atop the tank. + is_allowed_atop_vehicle = TRUE + /obj/item/Initialize(mapload, ...) . = ..() @@ -410,6 +413,18 @@ playsound(src, drop_sound, dropvol, drop_vary) src.do_drop_animation(user) + if(isliving(user)) + var/mob/living/living_mob = user + var/obj/vehicle/multitile/tank/tank = living_mob.get_tank_on_top_of() + var/obj/vehicle/multitile/tank/current_tank = src.get_tank_on_top_of() + if(tank && !current_tank) // tank exists, our user is atop the tank, we are not marked as atop the tank yet. + tank.obj_mark_on_top(src) + else if (!tank && current_tank) // only remove from vehicle if it is atop a vehicle to begin with + current_tank.obj_clear_on_top(src) + else if (tank && current_tank) // User on tank AND already marked + src.pixel_y = initial(src.pixel_y) + 12 // Just refresh the pixel offset and layer to avoid a visual bug with animations + src.layer = TANK_RIDER_OBJ_LAYER + appearance_flags &= ~NO_CLIENT_COLOR //So saturation/desaturation etc. effects affect it. /// Called just as an item is picked up (loc is not yet changed) and will return TRUE if the pickup wasn't canceled. diff --git a/code/game/objects/items/bodybag.dm b/code/game/objects/items/bodybag.dm index 937867a7f443..4f70bd0150b8 100644 --- a/code/game/objects/items/bodybag.dm +++ b/code/game/objects/items/bodybag.dm @@ -26,9 +26,17 @@ deploy_bodybag(user, T) /obj/item/bodybag/proc/deploy_bodybag(mob/user, atom/location) - var/obj/structure/closet/bodybag/R = new unfolded_path(location, src) - R.add_fingerprint(user) - R.open(user) + var/obj/structure/closet/bodybag/bodybag = new unfolded_path(location, src) + bodybag.add_fingerprint(user) + bodybag.open(user) + if(isliving(user)) + var/mob/living/living_mob = user + var/obj/vehicle/multitile/tank/tank = living_mob.get_tank_on_top_of() + var/obj/vehicle/multitile/tank/current_tank = bodybag.get_tank_on_top_of() + if(tank && !current_tank) // tank exists, our user is atop the tank, we are not marked as atop the tank yet. + tank.obj_mark_on_top(bodybag) + else if (!tank && current_tank) // only remove from vehicle if it is atop a vehicle to begin with + current_tank.obj_clear_on_top(bodybag) user.temp_drop_inv_item(src) qdel(src) @@ -83,6 +91,7 @@ /// How many extra pixels to offset the bag by when buckled, since rollerbeds are set up to offset a centered horizontal human sprite. var/buckle_offset = 5 store_items = FALSE + is_allowed_atop_vehicle = TRUE // Allows bodybags, exceptionally, as structures, to be used atop vehicles such as the tank. /obj/structure/closet/bodybag/Initialize() . = ..() @@ -208,7 +217,8 @@ /obj/structure/closet/bodybag/forceMove(atom/destination) if(roller_buckled) - roller_buckled.unbuckle() + if(!is_atop_vehicle() && !roller_buckled.is_atop_vehicle()) + roller_buckled.unbuckle() . = ..() @@ -272,10 +282,16 @@ overlays.Cut() // makes sure any previous triage cards are removed if(!stasis_mob) - layer = initial(layer) + if(is_atop_vehicle()) + layer = TANK_RIDER_OBJ_LAYER + else + layer = initial(layer) return - layer = LYING_BETWEEN_MOB_LAYER + if(is_atop_vehicle()) + layer = TANK_RIDER_OBJ_LAYER + else + layer = LYING_BETWEEN_MOB_LAYER if(stasis_mob.holo_card_color && !opened) var/image/holo_card_icon = image('icons/obj/bodybag.dmi', src, "cryocard_[stasis_mob.holo_card_color]") @@ -297,6 +313,12 @@ if(stasis_mob) stasis_mob.in_stasis = FALSE UnregisterSignal(stasis_mob, COMSIG_HUMAN_TRIAGE_CARD_UPDATED) + var/obj/vehicle/multitile/tank/tank = src.get_tank_on_top_of() + var/obj/vehicle/multitile/tank/mob_tank = stasis_mob.get_tank_on_top_of() + if(tank) + tank.mark_on_top(stasis_mob) + else if (mob_tank) + mob_tank.clear_on_top(stasis_mob) stasis_mob = null STOP_PROCESSING(SSobj, src) if(used > max_uses) diff --git a/code/game/objects/items/devices/binoculars.dm b/code/game/objects/items/devices/binoculars.dm index 948a0f5fc47f..4c032b80aed5 100644 --- a/code/game/objects/items/devices/binoculars.dm +++ b/code/game/objects/items/devices/binoculars.dm @@ -1,3 +1,7 @@ +/// Whether the given mob is inside a tank's interior, where binoculars don't make sense. +/proc/is_in_tank_interior(mob/user) + return user && istype(get_area(user), /area/interior/vehicle/tank) + /obj/item/device/binoculars name = "binoculars" gender = PLURAL @@ -32,6 +36,11 @@ /obj/item/device/binoculars/attack_self(mob/user) ..() + // Only blocks a real held pair, not a vehicle-owned hidden instance like gunner_rangefinder. + if((loc == user) && is_in_tank_interior(user)) + to_chat(user, SPAN_WARNING("There's not enough room to use [src] properly in here.")) + return + if(SEND_SIGNAL(user, COMSIG_BINOCULAR_ATTACK_SELF, src)) return @@ -88,6 +97,7 @@ var/laser_cooldown = 0 var/cooldown_duration = 200 //20 seconds var/obj/effect/overlay/temp/laser_coordinate/coord + var/acquiring = FALSE var/target_acquisition_delay = 100 //10 seconds var/rangefinder_popup = TRUE //Whether coordinates are displayed in a separate popup window. var/last_x = "UNKNOWN" @@ -136,6 +146,9 @@ /obj/item/device/binoculars/range/handle_click(mob/living/carbon/human/user, atom/targeted_atom, list/mods) if(!istype(user)) return + if((loc == user) && is_in_tank_interior(user)) + to_chat(user, SPAN_WARNING("There's not enough room to use [src] properly in here.")) + return FALSE if(mods[CTRL_CLICK]) if(user.stat != CONSCIOUS) to_chat(user, SPAN_WARNING("You cannot use [src] while incapacitated.")) @@ -144,14 +157,16 @@ return FALSE if(mods[CLICK_CATCHER]) return FALSE - if(user.z != targeted_atom.z && !coord) + // Use the vehicle's real-world position for a hidden gunner rangefinder instance. + var/atom/position_ref = (loc == user) ? user : loc + if(position_ref.z != targeted_atom.z && !coord) to_chat(user, SPAN_WARNING("You cannot get a direct laser from where you are.")) return FALSE if(!(is_ground_level(targeted_atom.z))) to_chat(user, SPAN_WARNING("INVALID TARGET: target must be on the surface.")) return FALSE if(user.sight & SEE_TURFS) - var/list/turf/path = get_line(user, targeted_atom, include_start_atom = FALSE) + var/list/turf/path = get_line(position_ref, targeted_atom, include_start_atom = FALSE) for(var/turf/T in path) if(T.opacity) to_chat(user, SPAN_WARNING("There is something in the way of the laser!")) @@ -171,6 +186,9 @@ if(coord) to_chat(user, SPAN_WARNING("You're already targeting something.")) return + if(acquiring) + to_chat(user, SPAN_WARNING("You're already acquiring a target.")) + return if(world.time < laser_cooldown) to_chat(user, SPAN_WARNING("[src]'s laser battery is recharging.")) return @@ -192,10 +210,38 @@ var/turf/TU = get_turf(targeted_atom) if(!istype(TU) || user.action_busy) return + + // Cleared right before every way out of the wind-up, so it never gets stuck TRUE. + acquiring = TRUE playsound(src, 'sound/effects/nightvision.ogg', 35) to_chat(user, SPAN_NOTICE("INITIATING LASER TARGETING. Stand still.")) - if(!do_after(user, acquisition_time, INTERRUPT_ALL, BUSY_ICON_GENERIC) || world.time < laser_cooldown) + + // A gunner rangefinder must keep its turret aimed at TU for the whole acquisition, aborting if it drifts. + var/obj/vehicle/multitile/tank/tracking_tank = istype(loc, /obj/vehicle/multitile/tank) ? loc : null + var/obj/item/hardpoint/holder/tank_turret/tracking_turret = tracking_tank ? (locate() in tracking_tank.hardpoints) : null + + var/success = TRUE + if(tracking_turret) + var/elapsed = 0 + while(elapsed < acquisition_time) + sleep(1) + elapsed += 1 + if(QDELETED(src) || QDELETED(user) || QDELETED(tracking_turret) || user.is_mob_incapacitated(TRUE)) + success = FALSE + break + if(abs(angle_delta(Get_Angle_Grounded(get_turf(tracking_tank), TU), tracking_turret.current_angle)) > RANGEFINDER_TURRET_ARC_TOLERANCE) + to_chat(user, SPAN_WARNING("The turret drifted off target - lasing aborted.")) + success = FALSE + break + if(success && world.time < laser_cooldown) + success = FALSE + else if(!do_after(user, acquisition_time, INTERRUPT_ALL, BUSY_ICON_GENERIC) || world.time < laser_cooldown) + success = FALSE + + acquiring = FALSE + if(!success || QDELETED(src)) return + var/obj/effect/overlay/temp/laser_coordinate/LT = new (TU, las_name, user) coord = LT last_x = obfuscate_x(coord.x) @@ -204,7 +250,16 @@ playsound(src, 'sound/effects/binoctarget.ogg', 35) show_coords(user) while(coord) - if(!do_after(user, 30, INTERRUPT_ALL, BUSY_ICON_GENERIC)) + if(tracking_turret) + sleep(30) + if(QDELETED(src) || QDELETED(coord) || QDELETED(tracking_turret) || QDELETED(user) || user.is_mob_incapacitated(TRUE)) + QDEL_NULL(coord) + break + if(abs(angle_delta(Get_Angle_Grounded(get_turf(tracking_tank), TU), tracking_turret.current_angle)) > RANGEFINDER_TURRET_ARC_TOLERANCE) + to_chat(user, SPAN_WARNING("The turret drifted off target - lasing lost.")) + QDEL_NULL(coord) + break + else if(!do_after(user, 30, INTERRUPT_ALL, BUSY_ICON_GENERIC)) QDEL_NULL(coord) break @@ -221,6 +276,9 @@ ui.open() /obj/item/device/binoculars/range/ui_state(mob/user) + // the UI silently fails to open for the gunner without this + if(loc != user) + return GLOB.always_state return GLOB.inventory_state /obj/item/device/binoculars/range/ui_data(mob/user) diff --git a/code/game/objects/items/devices/helmet_visors.dm b/code/game/objects/items/devices/helmet_visors.dm index 33ab12807b35..074baca38448 100644 --- a/code/game/objects/items/devices/helmet_visors.dm +++ b/code/game/objects/items/devices/helmet_visors.dm @@ -174,6 +174,23 @@ action_icon_string = "sec_sight_down" helmet_overlay = "sec_sight_right" +/** + * Reads hardpoint status telemetry broadcast by USCM vehicles. + */ +/obj/item/device/helmet_visor/vehicle + name = "vehicle diagnostics optic" + desc = "An insertable visor HUD into a standard USCM helmet. Reads hardpoint status telemetry broadcast by USCM vehicles, showing engine/tread/weapon/armor condition and damage at a glance." + icon_state = "veh_sight" + hud_type = null + action_icon_string = "veh_sight_down" + helmet_overlay = "veh_sight_right" + +/obj/item/device/helmet_visor/vehicle/activate_visor(obj/item/clothing/head/helmet/marine/attached_helmet, mob/living/carbon/human/user) + GLOB.vehicle_hardpoint_hud.add_hud_to(user, attached_helmet) + +/obj/item/device/helmet_visor/vehicle/deactivate_visor(obj/item/clothing/head/helmet/marine/attached_helmet, mob/living/carbon/human/user) + GLOB.vehicle_hardpoint_hud.remove_hud_from(user, attached_helmet) + /obj/item/device/helmet_visor/welding_visor name = "welding visor" icon_state = "sight_empty" diff --git a/code/game/objects/items/devices/radio/headset.dm b/code/game/objects/items/devices/radio/headset.dm index 1912e4a4fe97..3380b71c83ad 100644 --- a/code/game/objects/items/devices/radio/headset.dm +++ b/code/game/objects/items/devices/radio/headset.dm @@ -1472,11 +1472,13 @@ has_hud = TRUE hud_type = MOB_HUD_FACTION_MARINE -/obj/item/device/radio/headset/almayer/mcom/vc +// Parented under marine, not mcom, so put_marine_in_squad() keeps retuning ";" to the crew's +// assigned squad channel +/obj/item/device/radio/headset/almayer/marine/vc name = "marine vehicle crew radio headset" - desc = "Used by USCM vehicle crew, features a non-standard brace. Channels are as follows: :v - marine command, :n - engineering, :m - medbay, :u - requisitions." - initial_keys = list(/obj/item/device/encryptionkey/vc) - volume = RADIO_VOLUME_RAISED + desc = "Used by USCM vehicle crew, features a non-standard brace. Channels are as follows: :u - requisitions, :n - engineering, :j - JTAC. Speaking with a leading ';' uses your squad's channel." + icon_state = "mcom_headset" + initial_keys = list(/obj/item/device/encryptionkey/almayer, /obj/item/device/encryptionkey/jtac, /obj/item/device/encryptionkey/engi) multibroadcast_cooldown = HIGH_MULTIBROADCAST_COOLDOWN /obj/item/device/radio/headset/distress/UPP/recon diff --git a/code/game/objects/items/devices/walkman.dm b/code/game/objects/items/devices/walkman.dm index 42f7df2be166..8143ab4321fb 100644 --- a/code/game/objects/items/devices/walkman.dm +++ b/code/game/objects/items/devices/walkman.dm @@ -540,6 +540,31 @@ "sound/music/walkman/surf/10-2-3.ogg",\ "sound/music/walkman/surf/10-2-4.ogg")) +// This one might need to be moved server-side. +/obj/item/device/cassette_tape/liberation // Allies world war songs & american civil war songs. Lyrics to all songs are in the public domain. + name = "liberation cassette" + id = 19 + desc = "A plastic cassette tape with the UA Flag" + icon_state = "cassette_liberation" + side1_icon = "cassette_liberation" + songs = list( + "side1" = list( + "sound/music/walkman/liberation/10-1-1.ogg", /* "Over There" (George M. Cohan, 1917).*/\ + /* Sung in 1917 by Billy Murray. This recording is in the public domain.*/\ + "sound/music/walkman/liberation/10-1-2.ogg", /* "When Johnny Comes Marching Home" (Patrick Gilmore, 1863)*/\ + /* 1990 U.S. Military Academy Band recording, also in the public domain.*/\ + "sound/music/walkman/liberation/10-1-3.ogg", /* "Battle Cry Of Freedom" (Geo. F. Root, 1862).*/\ + /* Recording by UC Regents, licensed CC BY-NC 2.5. Cleaned up in Audacity.*/\ + "sound/music/walkman/liberation/10-1-4.ogg"), /* "Blood On The Risers" (US Paratrooper song from WW2).*/\ + /* Unsure if this specific recording, by Alorsx, is in the public domain, but the lyrics are.*/\ + "side2" = list( + "sound/music/walkman/liberation/10-2-1.ogg", /* "Chanson de l'oignon" (French marching song. Melody from 1797, lyrics from 1800).*/\ + /* Unsure if this specific recording, by the Chorale De Saint Cyr, is in the public domain, but the lyrics are.*/\ + "sound/music/walkman/liberation/10-2-2.ogg", /* "Ride of the Valkyries" (Wagner, from Die Walküre, 1870) In the US public domain.*/\ + /* This is a 2022 recording, conducted by Philip Milman, licensed under 3.0 CC*/\ + "sound/music/walkman/liberation/10-2-3.ogg")) /* "It's a Long Way to Tipperary" (British WW2 song. Albert Farrington, 1915).*/ + /* This specific recording is in the US public domain.*/ + // hotline reference /obj/item/device/cassette_tape/aesthetic name = "aesthetic cassette" diff --git a/code/game/objects/items/explosives/grenades/marines.dm b/code/game/objects/items/explosives/grenades/marines.dm index 38938cacdd38..4c6acd59a61e 100644 --- a/code/game/objects/items/explosives/grenades/marines.dm +++ b/code/game/objects/items/explosives/grenades/marines.dm @@ -321,7 +321,7 @@ R.intensityfire = burn_level R.rangefire = radius - new /obj/flamer_fire(T, cause_data, R, R.rangefire, null, flameshape, target, , , fire_type) + new /obj/flamer_fire(T, cause_data, R, R.rangefire, null, flameshape, target, , , fire_type, resolve_flame_mount(T, T, null)) /obj/item/explosive/grenade/incendiary/molotov name = "\improper improvised firebomb" diff --git a/code/game/objects/items/explosives/mine.dm b/code/game/objects/items/explosives/mine.dm index 2bb8aa68f0e7..3a6a93eb5d8b 100644 --- a/code/game/objects/items/explosives/mine.dm +++ b/code/game/objects/items/explosives/mine.dm @@ -235,6 +235,8 @@ return if(HAS_TRAIT(enemy, TRAIT_ABILITY_BURROWED)) return + if(enemy.is_on_tank_hull()) + return enemy.visible_message(SPAN_DANGER("[icon2html(src, viewers(src))] The [name] clicks as [enemy] moves in front of it."), SPAN_DANGER("[icon2html(src, enemy)] The [name] clicks as you move in front of it."), diff --git a/code/game/objects/items/pamphlets/pamphlet_upgrades.dm b/code/game/objects/items/pamphlets/pamphlet_upgrades.dm index 15eb36985c4c..9dfd21964f39 100644 --- a/code/game/objects/items/pamphlets/pamphlet_upgrades.dm +++ b/code/game/objects/items/pamphlets/pamphlet_upgrades.dm @@ -87,6 +87,13 @@ skill_upgrade = SKILL_INTEL skill_cap = 2 +/obj/item/pamphlet/upgradeable/vehicle + name = "vehicular operation instructional pamphlet" + desc = "A pamphlet used to quickly impart vital knowledge of operating vehicles and simple turret systems." + icon_state = "pamphlet_reading" + skill_upgrade = SKILL_VEHICLE + skill_cap = 1 + /obj/item/pamphlet/upgradeable/machinegunner name = "heavy machinegunner instructional pamphlet" desc = "A pamphlet used to quickly impart vital knowledge on heavier-duty equipments and firearms. You suppose you can utilize some of its teachings for some engineering practices." diff --git a/code/game/objects/items/reagent_containers/jp8_canister.dm b/code/game/objects/items/reagent_containers/jp8_canister.dm new file mode 100644 index 000000000000..7f923fcabb5e --- /dev/null +++ b/code/game/objects/items/reagent_containers/jp8_canister.dm @@ -0,0 +1,22 @@ +/obj/item/reagent_container/glass/beaker/jp8_canister + name = "\improper JP-8 canister" + desc = "A rugged steel canister for storing and transporting bulk liquids, sized for topping off a vehicle's fuel tank or radiator. Holds up to 500 units." + icon = 'icons/obj/items/tank.dmi' + icon_state = "gas" + item_state = "gas" + w_class = SIZE_MEDIUM + matter = list("metal" = 2000) + volume = 500 + amount_per_transfer_from_this = 50 + possible_transfer_amounts = list(10, 25, 50, 100, 250, 500) + +/obj/item/reagent_container/glass/beaker/jp8_canister/update_icon() + icon_state = is_open_container() ? "gas" : "gas_closed" + +/obj/item/reagent_container/glass/beaker/jp8_canister/full + desc = "A rugged steel canister for storing and transporting bulk liquids, sized for topping off a vehicle's fuel tank or radiator. Holds up to 500 units. This one is pre-filled with JP-8." + +/obj/item/reagent_container/glass/beaker/jp8_canister/full/Initialize(mapload) + . = ..() + reagents.add_reagent("jp8", volume) + update_icon() diff --git a/code/game/objects/items/storage/backpack.dm b/code/game/objects/items/storage/backpack.dm index e798568b7493..2c8bc5f11d01 100644 --- a/code/game/objects/items/storage/backpack.dm +++ b/code/game/objects/items/storage/backpack.dm @@ -1331,6 +1331,34 @@ GLOBAL_LIST_EMPTY_TYPED(radio_packs, /obj/item/storage/backpack/marine/satchel/r FTL.caliber = "UT-Napthal Fuel" to_chat(user, SPAN_NOTICE("You refill [FTL] with [FTL.caliber].")) FTL.update_icon() + else if(istype(W, /obj/item/ammo_magazine/hardpoint/primary_flamer)) + var/obj/item/ammo_magazine/hardpoint/primary_flamer/vehicle_tank = W + var/missing_volume = vehicle_tank.max_rounds - vehicle_tank.reagents.total_volume + + if((vehicle_tank.flamer_chem == fuel_type || !vehicle_tank.reagents.total_volume) && missing_volume && reagents.total_volume) + var/fuel_available = min(reagents.total_volume, missing_volume) + reagents.remove_reagent(fuel_type, fuel_available) + vehicle_tank.reagents.add_reagent(fuel_type, fuel_available) + playsound(loc, 'sound/effects/refill.ogg', 25, TRUE, 3) + vehicle_tank.caliber = fuel_type + vehicle_tank.flamer_chem = fuel_type + vehicle_tank.current_rounds = round(vehicle_tank.reagents.total_volume) + to_chat(user, SPAN_NOTICE("You refill [vehicle_tank] with [vehicle_tank.caliber].")) + vehicle_tank.update_icon() + else if(istype(W, /obj/item/ammo_magazine/hardpoint/secondary_flamer)) + var/obj/item/ammo_magazine/hardpoint/secondary_flamer/vehicle_tank = W + var/missing_volume = vehicle_tank.max_rounds - vehicle_tank.reagents.total_volume + + if((vehicle_tank.flamer_chem == fuel_type || !vehicle_tank.reagents.total_volume) && missing_volume && reagents.total_volume) + var/fuel_available = min(reagents.total_volume, missing_volume) + reagents.remove_reagent(fuel_type, fuel_available) + vehicle_tank.reagents.add_reagent(fuel_type, fuel_available) + playsound(loc, 'sound/effects/refill.ogg', 25, TRUE, 3) + vehicle_tank.caliber = fuel_type + vehicle_tank.flamer_chem = fuel_type + vehicle_tank.current_rounds = round(vehicle_tank.reagents.total_volume) + to_chat(user, SPAN_NOTICE("You refill [vehicle_tank] with [vehicle_tank.caliber].")) + vehicle_tank.update_icon() . = ..() /obj/item/storage/backpack/marine/engineerpack/flamethrower/kit diff --git a/code/game/objects/items/storage/belt.dm b/code/game/objects/items/storage/belt.dm index e94a4dde335f..f6dc00ca617a 100644 --- a/code/game/objects/items/storage/belt.dm +++ b/code/game/objects/items/storage/belt.dm @@ -2825,9 +2825,9 @@ /obj/item/ammo_magazine/hardpoint/secondary_flamer, /obj/item/ammo_magazine/hardpoint/ace_autocannon, /obj/item/ammo_magazine/hardpoint/towlauncher, - /obj/item/ammo_magazine/hardpoint/m56_cupola, + /obj/item/ammo_magazine/m56d, /obj/item/ammo_magazine/hardpoint/tank_glauncher, - /obj/item/ammo_magazine/hardpoint/turret_smoke, + /obj/item/storage/box/packet/flare, /obj/item/ammo_magazine/hardpoint/boyars_dualcannon, /obj/item/ammo_magazine/hardpoint/flare_launcher, ) diff --git a/code/game/objects/objs.dm b/code/game/objects/objs.dm index b635123a1b0c..966cd010cf02 100644 --- a/code/game/objects/objs.dm +++ b/code/game/objects/objs.dm @@ -36,7 +36,6 @@ vis_flags = VIS_INHERIT_PLANE - /obj/Initialize(mapload, ...) . = ..() if(garbage) @@ -235,6 +234,55 @@ buckle_mob(M, user) else . = ..() +/obj/Moved(atom/oldloc, direction, Forced = FALSE) + . = ..() + var/obj/vehicle/multitile/tank/tank = src.get_tank_on_top_of() + if(tank && isturf(loc)) + var/still_on_tank = FALSE + for(var/obj/vehicle/multitile/tank/candidate in loc) + if(loc in candidate.locs) + still_on_tank = TRUE + break + if(!still_on_tank) + tank.obj_clear_on_top(src) + +/obj/Move(NewLoc, direct) + . = ..() + handle_rotation() + if(src.is_atop_vehicle()) + var/still_on_tank = FALSE + if(isturf(NewLoc)) + for(var/obj/vehicle/multitile/tank/T in NewLoc) + if(NewLoc in T.locs) + still_on_tank = TRUE + break + + if(!still_on_tank && buckled_mob && ismob(buckled_mob)) + var/mob/living/buckled_living = buckled_mob + var/obj/vehicle/multitile/tank/temp_tank = buckled_living.get_tank_on_top_of() + if(temp_tank) + temp_tank.clear_on_top(buckled_living) + + src.forceMove(NewLoc) + if(buckled_mob) + buckled_mob.forceMove(NewLoc) + return + if(. && buckled_mob && !handle_buckled_mob_movement(loc,direct)) + . = FALSE + +/obj/forceMove(atom/dest) + . = ..() + + // Bring the buckled_mob with us. No Move(), on_move callbacks, or any of this bullshit, we just got teleported + if(buckled_mob && loc == dest) + buckled_mob.forceMove(dest) + +/obj/BlockedPassDirs(atom/movable/mover, target_dir) + if(mover == buckled_mob) //can't collide with the thing you're buckled to + return NO_BLOCKED_MOVEMENT + + return ..() + /obj/item/proc/get_mob_overlay(mob/user_mob, slot, default_bodytype = "Default") var/bodytype = default_bodytype var/mob/living/carbon/human/user_human @@ -332,6 +380,10 @@ return /obj/handle_flamer_fire(obj/flamer_fire/fire, damage, delta_time) + if(isitem(src)) + var/obj/item/dropped_item = src + if(dropped_item.is_atop_vehicle()) // don't process flamer fire if the item is atop a climbable vehicle. + return . = ..() flamer_fire_act(damage, fire.weapon_cause_data) diff --git a/code/game/objects/structures/barricade/barricade.dm b/code/game/objects/structures/barricade/barricade.dm index 38c0916ea634..2cbf2f84800c 100644 --- a/code/game/objects/structures/barricade/barricade.dm +++ b/code/game/objects/structures/barricade/barricade.dm @@ -44,6 +44,8 @@ var/metallic = TRUE /// Lower limit of damage beyond which the barricade cannot be fixed by welder. Compared to damage_state. If null it can be repaired at any damage_state. var/welder_lower_damage_limit = null + /// TRUE while a vehicle is occupying this barricade's tile. Forces it to render above the vehicle. + var/covered_by_vehicle = FALSE /obj/structure/barricade/Initialize(mapload, mob/user) . = ..() @@ -122,6 +124,9 @@ icon_state = "[barricade_type]_closed" layer = OBJ_LAYER + if(covered_by_vehicle) + layer = VEHICLE_COVERED_BARRICADE_LAYER + // Pixelshift to indicate anchored state pixel_y = initial(pixel_y) if(!anchored) @@ -196,6 +201,9 @@ * If the object is completely solid, returns ALL */ /obj/structure/barricade/BlockedExitDirs(atom/movable/mover, target_dir) + // A multi-tile vehicle resolves barricades and windows through its own directional scan instead. + if(istype(mover, /obj/vehicle/multitile)) + return NO_BLOCKED_MOVEMENT if(closed) return NO_BLOCKED_MOVEMENT @@ -212,6 +220,9 @@ * mobs. */ /obj/structure/barricade/BlockedPassDirs(atom/movable/mover, target_dir) + // See the identical vehicle exemption/reasoning in BlockedExitDirs() just above. + if(istype(mover, /obj/vehicle/multitile)) + return NO_BLOCKED_MOVEMENT if(closed) return NO_BLOCKED_MOVEMENT diff --git a/code/game/objects/structures/barricade/deployable.dm b/code/game/objects/structures/barricade/deployable.dm index 727629f763b9..28f62aecc2c4 100644 --- a/code/game/objects/structures/barricade/deployable.dm +++ b/code/game/objects/structures/barricade/deployable.dm @@ -147,6 +147,12 @@ to_chat(usr, SPAN_WARNING("[singular_name] cannot be built here!")) return + if(isliving(user)) + var/mob/living/L = user + if(L.is_on_tank_hull()) + to_chat(user, SPAN_WARNING("[singular_name] cannot be built here!")) + return + user.visible_message(SPAN_NOTICE("[user] begins deploying [singular_name]."), SPAN_NOTICE("You begin deploying [singular_name].")) diff --git a/code/game/objects/structures/crates_lockers/closets.dm b/code/game/objects/structures/crates_lockers/closets.dm index f817ba2e196e..7edefcf837b3 100644 --- a/code/game/objects/structures/crates_lockers/closets.dm +++ b/code/game/objects/structures/crates_lockers/closets.dm @@ -76,8 +76,17 @@ /obj/structure/closet/proc/dump_contents() - for(var/obj/I in src) - I.forceMove(loc) + var/obj/vehicle/multitile/tank/tank = src.get_tank_on_top_of() + + for(var/obj/object in src) + object.forceMove(loc) + // closets can't go atop vehicles, but this sets behavior for bodybags, which can. + if(tank) + tank.obj_mark_on_top(object) + else + var/obj/vehicle/multitile/tank/obj_tank = object.get_tank_on_top_of() + if(obj_tank) + obj_tank.obj_clear_on_top(object) for(var/mob/M in src) M.forceMove(loc) @@ -88,6 +97,13 @@ if(living_M.mobility_flags & MOBILITY_MOVE) M.visible_message(SPAN_WARNING("[M] suddenly gets out of [src]!"), SPAN_WARNING("You get out of [src] and get your bearings!")) + // closets can't go atop vehicles, but this sets behavior for bodybags, which can. + if(tank) + tank.mark_on_top(living_M) + else + var/obj/vehicle/multitile/tank/mob_tank = living_M.get_tank_on_top_of() + if(mob_tank) + mob_tank.clear_on_top(living_M) /// Attempts to open this closet by user, skipping checks that prevent opening if forced /obj/structure/closet/proc/open(mob/user, force) diff --git a/code/game/objects/structures/multiz_stairs.dm b/code/game/objects/structures/multiz_stairs.dm index 4728182bd3ab..8fc009af29c8 100644 --- a/code/game/objects/structures/multiz_stairs.dm +++ b/code/game/objects/structures/multiz_stairs.dm @@ -165,13 +165,14 @@ RegisterSignal(new_turf, COMSIG_TURF_ENTERED, PROC_REF(handle_entered)) RegisterSignal(new_turf, COMSIG_PRE_TURF_CHANGE, PROC_REF(handle_pre_turf_change)) -/// Handles COMSIG_TURF_ENTERED for mobs that enters a from_turf to register handle_movement and handle_deleted -/datum/staircase/proc/handle_entered(turf/originator, atom/what_did_it) +/// Handles COMSIG_TURF_ENTERED for mobs/vehicles that enter a from_turf to register handle_movement and handle_deleted +/datum/staircase/proc/handle_entered(turf/originator, atom/movable/what_did_it) SIGNAL_HANDLER - var/mob/mover = what_did_it - if(!istype(mover)) + // A multitile vehicle only fires this on its own anchor tile, same as any mob walking through. + if(!ismob(what_did_it) && !istype(what_did_it, /obj/vehicle/multitile)) return + var/atom/movable/mover = what_did_it if(mover in in_range_mob) return @@ -182,26 +183,38 @@ in_range_mob += mover -/// Handles COMSIG_MOVABLE_MOVED for mobs that were in a from_turf -/datum/staircase/proc/handle_movement(mob/mover, old_loc, direction) +/// Handles COMSIG_MOVABLE_MOVED for mobs/vehicles that were in a from_turf +/datum/staircase/proc/handle_movement(atom/movable/mover, old_loc, direction) SIGNAL_HANDLER + // A plain mob shows this to itself; a vehicle shows it to its seated driver/gunner instead. + // An empty list just makes the loops below no-ops. + var/list/client/viewers = mover.get_staircase_vision_clients() + var/turf/where_from = get_turf(old_loc) if(length(from_turf_to_images[where_from])) - for(var/image in from_turf_to_images[where_from]) - mover.client?.images -= image + for(var/client/viewer as anything in viewers) + for(var/image in from_turf_to_images[where_from]) + viewer.images -= image var/turf/where_to = get_turf(mover) if(length(from_turf_to_images[where_to])) - for(var/image in from_turf_to_images[where_to]) - mover.client?.images += image + for(var/client/viewer as anything in viewers) + for(var/image in from_turf_to_images[where_to]) + viewer.images += image return in_range_mob -= mover UnregisterSignal(mover, list(COMSIG_MOVABLE_MOVED, COMSIG_PARENT_QDELETING)) -/// Handles COMSIG_PARENT_QDELETING for mobs that were in a from_turf +/atom/movable/proc/get_staircase_vision_clients() + return list() + +/mob/get_staircase_vision_clients() + return client ? list(client) : list() + +/// Handles COMSIG_PARENT_QDELETING for mobs/vehicles that were in a from_turf /datum/staircase/proc/handle_deleted(atom/updater) SIGNAL_HANDLER diff --git a/code/game/objects/structures/stool_bed_chair_nest/bed.dm b/code/game/objects/structures/stool_bed_chair_nest/bed.dm index ce3f9c6993a6..e3d68055e101 100644 --- a/code/game/objects/structures/stool_bed_chair_nest/bed.dm +++ b/code/game/objects/structures/stool_bed_chair_nest/bed.dm @@ -60,14 +60,20 @@ /obj/structure/bed/afterbuckle(mob/M) . = ..() if(. && buckled_mob == M) - M.pixel_y = buckling_y + if(is_atop_vehicle()) + M.pixel_y = buckling_y + 12 // Magic number bad. TODO: Make tank pixel offset its own variable. + else + M.pixel_y = buckling_y M.old_y = buckling_y M.pixel_x = buckling_x M.old_x = buckling_x if(base_bed_icon) density = TRUE else - M.pixel_y = initial(buckled_mob.pixel_y) + if(is_atop_vehicle()) + M.pixel_y = initial(buckled_mob.pixel_y) + 12 // Magic number bad. TODO: Make tank pixel offset its own variable. + else + M.pixel_y = initial(buckled_mob.pixel_y) M.old_y = initial(buckled_mob.pixel_y) M.pixel_x = initial(buckled_mob.pixel_x) M.old_x = initial(buckled_mob.pixel_x) @@ -85,7 +91,11 @@ buckled_bodybag = B density = TRUE update_icon() - if(buckling_y) + var/obj/vehicle/multitile/tank/tank = get_tank_on_top_of() + if(tank) + tank.obj_mark_on_top(buckled_bodybag) + buckled_bodybag.pixel_y = buckled_bodybag.buckle_offset + 12 // Magic number bad. TODO: Make tank pixel offset its own variable. + else if(buckling_y) buckled_bodybag.pixel_y = buckled_bodybag.buckle_offset + buckling_y add_fingerprint(user) var/mob/living/carbon/human/contained_mob = locate() in B.contents @@ -94,7 +104,10 @@ /obj/structure/bed/unbuckle() if(buckled_bodybag) - buckled_bodybag.pixel_y = initial(buckled_bodybag.pixel_y) + if(is_atop_vehicle()) + buckled_bodybag.pixel_y = initial(buckled_bodybag.pixel_y) + 12 // Magic number bad. TODO: Make tank pixel offset its own variable. + else + buckled_bodybag.pixel_y = initial(buckled_bodybag.pixel_y) buckled_bodybag.roller_buckled = null buckled_bodybag = null density = FALSE @@ -214,6 +227,7 @@ foldabletype = /obj/item/roller accepts_bodybag = TRUE base_bed_icon = "roller" + is_allowed_atop_vehicle = TRUE // Allows roller beds, exceptionally, as structures, to be used atop vehicles such as the tank. /obj/structure/bed/roller/Initialize(mapload, ...) . = ..() @@ -311,6 +325,14 @@ roller.add_fingerprint(user) user.temp_drop_inv_item(src) forceMove(roller) + if(isliving(user)) + var/mob/living/living_mob = user + var/obj/vehicle/multitile/tank/tank = living_mob.get_tank_on_top_of() + var/obj/vehicle/multitile/tank/roller_tank = roller.get_tank_on_top_of() + if(tank && !roller_tank) // tank exists, our user is atop the tank, we are not marked as atop the tank yet. + tank.obj_mark_on_top(roller) + else if (!tank && roller_tank) // only remove from vehicle if it is atop a vehicle to begin with + roller_tank.obj_clear_on_top(roller) SEND_SIGNAL(user, COMSIG_MOB_ITEM_ROLLER_DEPLOYED, roller) if(target_mob) roller.buckle_mob(target_mob, user) @@ -467,6 +489,11 @@ GLOBAL_LIST_EMPTY(activated_medevac_stretchers) matter = list("plastic" = 5000, "metal" = 5000) /obj/item/roller/medevac/deploy_roller(mob/user, atom/location) + if(isliving(user)) + var/mob/living/L = user + if(L.is_on_tank_hull()) + to_chat(user, SPAN_WARNING("You can't set up \the [src] here.")) + return var/obj/structure/bed/medevac_stretcher/medevac_stretcher = new rollertype(location) medevac_stretcher.add_fingerprint(user) user.temp_drop_inv_item(src) diff --git a/code/game/objects/structures/tables_racks.dm b/code/game/objects/structures/tables_racks.dm index a387a20b141b..671177bda687 100644 --- a/code/game/objects/structures/tables_racks.dm +++ b/code/game/objects/structures/tables_racks.dm @@ -235,12 +235,22 @@ setDir(SOUTH) /obj/structure/surface/table/BlockedPassDirs(atom/movable/mover, target_dir) + // Same vehicle exemption as barricade and window. Only meaningful once flipped. + if(istype(mover, /obj/vehicle/multitile) && flipped) + return NO_BLOCKED_MOVEMENT + for(var/obj/structure/S in get_turf(mover)) if(S && S.climbable && !(S.flags_atom & ON_BORDER) && climbable && isliving(mover)) //Climbable non-border objects allow you to universally climb over others return NO_BLOCKED_MOVEMENT return ..() +/// Same vehicle exemption as BlockedPassDirs() above. +/obj/structure/surface/table/BlockedExitDirs(atom/movable/mover, target_dir) + if(istype(mover, /obj/vehicle/multitile) && flipped) + return NO_BLOCKED_MOVEMENT + return ..() + //Flipping tables, nothing more, nothing less /obj/structure/surface/table/MouseDrop(over_object, src_location, over_location) . = ..() diff --git a/code/game/objects/structures/window.dm b/code/game/objects/structures/window.dm index 88065349090b..23cfbad00907 100644 --- a/code/game/objects/structures/window.dm +++ b/code/game/objects/structures/window.dm @@ -51,6 +51,21 @@ if (PF) PF.flags_can_pass_all = PASS_HIGH_OVER_ONLY|PASS_GLASS +/** + * A multi-tile vehicle resolves directional windows through its own travel-direction scan instead. + * Full-tile windows aren't exempted. + */ +/obj/structure/window/BlockedExitDirs(atom/movable/mover, target_dir) + if(istype(mover, /obj/vehicle/multitile) && !is_full_window()) + return NO_BLOCKED_MOVEMENT + return ..() + +/// Same reasoning as BlockedExitDirs() above. +/obj/structure/window/BlockedPassDirs(atom/movable/mover, target_dir) + if(istype(mover, /obj/vehicle/multitile) && !is_full_window()) + return NO_BLOCKED_MOVEMENT + return ..() + /obj/structure/window/proc/set_constructed_window(start_dir) state = 0 anchored = FALSE diff --git a/code/game/supplyshuttle.dm b/code/game/supplyshuttle.dm index e65b7bdf5beb..8262e81f6c60 100644 --- a/code/game/supplyshuttle.dm +++ b/code/game/supplyshuttle.dm @@ -775,6 +775,7 @@ GLOBAL_DATUM_INIT(supply_controller, /datum/controller/supply, new()) "Weapons", "Vehicle Ammo", "Vehicle Equipment", + "Vehicle Spare Parts", "Attachments", "Ammo", "Weapons Specialist Ammo", diff --git a/code/game/turfs/auto_turf.dm b/code/game/turfs/auto_turf.dm index 9e5d256861b0..9b229212bd20 100644 --- a/code/game/turfs/auto_turf.dm +++ b/code/game/turfs/auto_turf.dm @@ -99,6 +99,7 @@ //Kutjevo & Trijent sand /turf/open/auto_turf/sand layer_name = list("red dirt", "sand", "rocky sand", "this layer does not exist", "call a coder") + traction = 0.8 /turf/open/auto_turf/sand/insert_self_into_baseturfs() baseturfs += /turf/open/auto_turf/sand/layer0 @@ -143,6 +144,7 @@ icon_state = "ice_0" icon_prefix = "ice" layer_name = list("cracked permafrost","permafrost","glacial permafrost","warn a coder","warn a coder") + traction = 0.75 /turf/open/auto_turf/ice/insert_self_into_baseturfs() baseturfs += /turf/open/auto_turf/ice/layer0 @@ -259,22 +261,27 @@ /turf/open/auto_turf/snow/layer0 //still have to manually define the layers for the editor icon_state = "snow_0" bleed_layer = 0 + traction = 0.8 /turf/open/auto_turf/snow/layer1 icon_state = "snow_1" bleed_layer = 1 + traction = 0.7 /turf/open/auto_turf/snow/layer2 icon_state = "snow_2" bleed_layer = 2 + traction = 0.6 /turf/open/auto_turf/snow/layer3 icon_state = "snow_3" bleed_layer = 3 + traction = 0.55 /turf/open/auto_turf/snow/layer4 icon_state = "snow_4" //Add sorokyne rock decals to this one bleed_layer = 4 + traction = 0.5 //new strata turfs @@ -289,22 +296,27 @@ /turf/open/auto_turf/snow/brown_base/layer0 //still have to manually define the layers for the editor icon_state = "snow_b_0" bleed_layer = 0 + traction = 0.8 /turf/open/auto_turf/snow/brown_base/layer1 icon_state = "snow_b_1" bleed_layer = 1 + traction = 0.7 /turf/open/auto_turf/snow/brown_base/layer2 icon_state = "snow_b_2" bleed_layer = 2 + traction = 0.6 /turf/open/auto_turf/snow/brown_base/layer3 icon_state = "snow_b_3" bleed_layer = 3 + traction = 0.55 /turf/open/auto_turf/snow/brown_base/layer4 icon_state = "snow_b_4" //Add sorokyne rock decals to this one bleed_layer = 4 + traction = 0.5 /turf/open/auto_turf/strata_grass name = "matted grass" @@ -313,6 +325,7 @@ icon_prefix = "grass" layer_name = list("ground","lush thick grass") desc = "Grass, dirt, mud, and other assorted high moisture cave flooring." + traction = 0.8 /turf/open/auto_turf/strata_grass/insert_self_into_baseturfs() baseturfs += /turf/open/auto_turf/strata_grass/layer0 @@ -345,6 +358,7 @@ icon_prefix = "grass" layer_name = list("ground","lush thick grass") desc = "grass, dirt, mud, and other assorted high moisture cave flooring." + traction = 0.8 /turf/open/auto_turf/tyrargo_grass/insert_self_into_baseturfs() baseturfs += /turf/open/auto_turf/tyrargo_grass/layer0 @@ -376,6 +390,7 @@ layer_name = list("wind blown dirt", "volcanic plate rock", "volcanic plate and rock", "this layer does not exist") icon = 'icons/turf/floors/auto_shale.dmi' icon_prefix = "shale" + traction = 0.8 /turf/open/auto_turf/shale/get_dirt_type() return DIRT_TYPE_SHALE diff --git a/code/game/turfs/floor.dm b/code/game/turfs/floor.dm index a695c9e44a71..bde8bbc756b5 100644 --- a/code/game/turfs/floor.dm +++ b/code/game/turfs/floor.dm @@ -7,6 +7,7 @@ turf_flags = TURF_BURNABLE|TURF_BREAKABLE var/mineral = "metal" var/image/wet_overlay + traction = 1 var/tile_type = /obj/item/stack/tile/plasteel var/plating_type = /turf/open/floor/plating diff --git a/code/game/turfs/floor_types.dm b/code/game/turfs/floor_types.dm index b3eec820b688..e5c06df9f876 100644 --- a/code/game/turfs/floor_types.dm +++ b/code/game/turfs/floor_types.dm @@ -9,6 +9,7 @@ icon_state = "plating" intact_tile = FALSE tool_flags = NO_FLAGS + traction = 1.1 /turf/open/floor/plating/is_plating() return TRUE @@ -207,6 +208,7 @@ /turf/open/floor/plating/almayer icon = 'icons/turf/almayer.dmi' + traction = 1.2 /turf/open/floor/plating/almayer/no_build allow_construction = FALSE @@ -363,6 +365,7 @@ icon = 'icons/turf/almayer.dmi' icon_state = "default" plating_type = /turf/open/floor/plating/almayer + traction = 1.2 /turf/open/floor/almayer/pod_landing_floor desc = "There's a hatch above it, presumably to allow pods to drop in." @@ -2442,6 +2445,7 @@ icon_state = "wood" tile_type = /obj/item/stack/tile/wood tool_flags = BREAK_CROWBAR|REMOVE_SCREWDRIVER + traction = 1 /turf/open/floor/wood/is_wood_floor() return TRUE @@ -2576,6 +2580,7 @@ icon_state = "grass1" tile_type = /obj/item/stack/tile/grass tool_flags = null + traction = 0.8 /turf/open/floor/grass/Initialize(mapload, ...) . = ..() @@ -2608,6 +2613,7 @@ icon_state = "carpet" tile_type = /obj/item/stack/tile/carpet tool_flags = REMOVE_SCREWDRIVER + traction = 0.9 // unlikely that a vehicle will ever step in a carpet tile, but HEY!! you never know!!! /turf/open/floor/carpet/Initialize(mapload, ...) . = ..() @@ -3478,6 +3484,7 @@ icon_state = "oldwood1" tile_type = /obj/item/stack/tile/wood turf_flags = NO_FLAGS // platingdmg && panelscorched icon_state does not exist in this icon + traction = 1 /turf/open/floor/interior/wood/is_wood_floor() return TRUE diff --git a/code/game/turfs/open.dm b/code/game/turfs/open.dm index 9cbfae93835e..80bd8f13eccb 100644 --- a/code/game/turfs/open.dm +++ b/code/game/turfs/open.dm @@ -259,6 +259,7 @@ is_groundmap_turf = TRUE minimap_color = MINIMAP_MARS_DIRT is_weedable = SEMI_WEEDABLE + traction = 0.8 /turf/open/mars_cave @@ -266,6 +267,7 @@ icon = 'icons/turf/floors/bigred.dmi' icon_state = "mars_cave_1" is_groundmap_turf = TRUE + traction = 0.9 /turf/open/mars_cave/Initialize(mapload, ...) . = ..() @@ -355,6 +357,7 @@ icon = 'icons/turf/floors/bigred.dmi' icon_state = "mars_dirt_1" minimap_color = MINIMAP_DIRT + traction = 0.85 /turf/open/mars_dirt/Initialize(mapload, ...) . = ..() @@ -432,6 +435,7 @@ name = "Beach" icon = 'icons/turf/floors/beach.dmi' supports_surgery = FALSE + traction = 0.6 /turf/open/beach/Entered(atom/movable/AM) ..() @@ -448,16 +452,19 @@ name = "Sand" icon_state = "sand" supports_surgery = TRUE + traction = 0.8 /turf/open/beach/coastline name = "Coastline" icon = 'icons/turf/beach2.dmi' icon_state = "sandwater" + traction = 0.75 /turf/open/beach/water name = "Water" icon_state = "water" can_bloody = FALSE + traction = 0.4 /turf/open/beach/water/Initialize(mapload, ...) . = ..() @@ -467,6 +474,7 @@ name = "Water" icon_state = "water" can_bloody = FALSE + traction = 0.4 /turf/open/beach/water2/Initialize(mapload, ...) . = ..() @@ -484,6 +492,7 @@ icon = 'icons/turf/ground_map.dmi' icon_state = "desert" is_groundmap_turf = TRUE + traction = 0.85 /turf/open/gm/attackby(obj/item/I, mob/user) @@ -520,6 +529,7 @@ icon_state = "desert" baseturfs = /turf/open/gm/dirt minimap_color = MINIMAP_DIRT + traction = 0.8 /turf/open/gm/dirt/beach icon_state = "beach" @@ -728,6 +738,7 @@ supports_surgery = FALSE minimap_color = MINIMAP_WATER is_weedable = NOT_WEEDABLE + traction = 0.4 /turf/open/gm/river/Initialize(mapload, ...) . = ..() @@ -884,6 +895,7 @@ baseturfs = /turf/open/gm/coast supports_surgery = FALSE is_weedable = NOT_WEEDABLE + traction = 0.75 /turf/open/gm/coast/north @@ -939,6 +951,7 @@ minimap_color = MINIMAP_WATER is_groundmap_turf = FALSE // Not real ground fishing_allowed = TRUE + traction = 0.4 /turf/open/gm/riverdeep/Initialize(mapload, ...) @@ -980,6 +993,7 @@ icon = 'icons/turf/ice.dmi' icon_state = "ice_floor" baseturfs = /turf/open/ice + traction = 0.7 //Randomize ice floor sprite @@ -995,6 +1009,7 @@ name = "asphalt" icon = 'icons/turf/floors/asphalt.dmi' icon_state = "sunbleached_asphalt" + traction = 1.2 baseturfs = /turf/open/asphalt /turf/open/asphalt/tile @@ -1150,6 +1165,7 @@ name = "wet grass" desc = "Thick, long, wet grass." icon = 'icons/turf/floors/jungle.dmi' + traction = 0.8 icon_state = "grass1" var/icon_spawn_state = "grass1" baseturfs = /turf/open/jungle @@ -1253,6 +1269,7 @@ icon_spawn_state = "water" can_bloody = FALSE supports_surgery = FALSE + traction = 0.4 /turf/open/jungle/water/Initialize(mapload, ...) diff --git a/code/game/turfs/open_space.dm b/code/game/turfs/open_space.dm index 9a71ef77368c..fa53d0b1f3eb 100644 --- a/code/game/turfs/open_space.dm +++ b/code/game/turfs/open_space.dm @@ -124,7 +124,18 @@ GLOBAL_DATUM_INIT(openspace_backdrop_one_for_all, /atom/movable/openspace_backdr user.visible_message(SPAN_WARNING("[user] climbs down."), SPAN_WARNING("You climb down.")) + var/obj/vehicle/multitile/tank/tank_at_destination = null + for(var/obj/vehicle/multitile/tank/T in below.contents) + if(below in T.locs) + tank_at_destination = T + break + user.forceMove(below) + + if(tank_at_destination && isliving(user)) + var/mob/living/L = user + tank_at_destination.mark_on_top(L) + for(var/atom/movable/thing as anything in grabbed_things) // grabbed things aren't moved to the tile immediately to: make the animation better, preserve the grab thing.forceMove(below) below.on_climb_down(user) @@ -155,6 +166,16 @@ GLOBAL_DATUM_INIT(openspace_backdrop_one_for_all, /atom/movable/openspace_backdr var/mob/living/living_mob = movable living_mob.death(create_cause_data("falling from a high place")) + var/obj/vehicle/multitile/tank/tank_at_destination = null + for(var/obj/vehicle/multitile/tank/T in below.contents) + if(below in T.locs) + tank_at_destination = T + break + + if(tank_at_destination && isliving(movable)) + var/mob/living/L = movable + tank_at_destination.mark_on_top(L) + /// Returns a boolean whether the turf is closed and has no opening in any cardinal direction /turf/proc/check_blocked() return FALSE diff --git a/code/game/turfs/shale.dm b/code/game/turfs/shale.dm index 9a11ede5de59..ae06dcbae4ed 100644 --- a/code/game/turfs/shale.dm +++ b/code/game/turfs/shale.dm @@ -3,6 +3,7 @@ icon = 'icons/turf/floors/auto_shale.dmi' icon_state = "shale_0" is_weedable = SEMI_WEEDABLE + traction = 0.8 /turf/open/shale/get_dirt_type() return DIRT_TYPE_SHALE diff --git a/code/game/turfs/shiva.dm b/code/game/turfs/shiva.dm index b155c46d60d2..9ae25caf2285 100644 --- a/code/game/turfs/shiva.dm +++ b/code/game/turfs/shiva.dm @@ -2,6 +2,7 @@ /turf/open/floor/shiva //Instance me! icon = 'icons/turf/floors/ice_colony/shiva_floor.dmi' icon_state = "floor" + traction = 1.1 // reasoning being that all of those floors LOOK like platings, which have a traction of 1.1 on other files. consistency! /turf/open/floor/shiva/blue icon_state = "blue" diff --git a/code/game/turfs/soro.dm b/code/game/turfs/soro.dm index 6db3721df229..e6617b22c2b2 100644 --- a/code/game/turfs/soro.dm +++ b/code/game/turfs/soro.dm @@ -68,6 +68,7 @@ icon_state = "browndirt_road" baseturfs = /turf/open/gm/road minimap_color = MINIMAP_DIRT + traction = 1.0 /turf/open/gm/grass/dirt name = "grass" @@ -113,6 +114,7 @@ icon_state = "beach" baseturfs = /turf/open/gm/coast supports_surgery = FALSE + traction = 0.75 /turf/open/gm/coast/dirt/north diff --git a/code/game/turfs/turf.dm b/code/game/turfs/turf.dm index 330fa9c30a37..7101279a6d47 100644 --- a/code/game/turfs/turf.dm +++ b/code/game/turfs/turf.dm @@ -30,6 +30,8 @@ ///Used by floors to indicate the floor is a tile (otherwise its plating) var/intact_tile = TRUE + /// How good this turf is for vehicle acceleration. 1 is neutral. + var/traction = 1.0 ///Can blood spawn on this turf? var/can_bloody = TRUE var/list/linked_pylons @@ -72,6 +74,39 @@ /// Can xenomorph weeds grow on the tile var/is_weedable = FULLY_WEEDABLE +/** + * How good this turf is for vehicle acceleration right now. + * + * Weeds and xeno resin drag traction down further than the turf's own base value. + * Hazards cap traction independently rather than stacking. + * + * Returns: + * * traction, capped further by whichever weed/resin hazard on this turf caps it hardest. + */ +/turf/proc/get_traction() + var/result = traction + if(weeds) + result = min(result, get_weed_traction_cap(weeds.weed_strength)) + for(var/obj/effect/alien/resin/R in src) + if(istype(R, /obj/effect/alien/resin/sticky/fast)) + continue + else if(istype(R, /obj/effect/alien/resin/sticky/thin/weak)) + result = min(result, STICKY_RESIN_WEAK_TRACTION_CAP) + else if(istype(R, /obj/effect/alien/resin/sticky)) + result = min(result, STICKY_RESIN_TRACTION_CAP) + else if(istype(R, /obj/effect/alien/resin/spike)) + result = min(result, RESIN_SPIKE_TRACTION_CAP) + return result + +/turf/proc/get_weed_traction_cap(strength) + if(strength >= WEED_LEVEL_HIVE) + return WEED_TRACTION_CAP_HIVE + if(strength == WEED_LEVEL_HARDY) + return WEED_TRACTION_CAP_HARDY + if(strength == WEED_LEVEL_STANDARD) + return WEED_TRACTION_CAP_STANDARD + return WEED_TRACTION_CAP_WEAK + /turf/Initialize(mapload) SHOULD_CALL_PARENT(FALSE) // this doesn't parent call for optimisation reasons if(flags_atom & INITIALIZED) diff --git a/code/modules/animations/animation_library.dm b/code/modules/animations/animation_library.dm index 5a176eaf8b64..db3ddd8b3720 100644 --- a/code/modules/animations/animation_library.dm +++ b/code/modules/animations/animation_library.dm @@ -207,7 +207,10 @@ Can look good elsewhere as well.*/ pixel_x_diff = -pixel_offset pixel_y_diff = -pixel_offset animate(src, pixel_x = pixel_x + pixel_x_diff, pixel_y = pixel_y + pixel_y_diff, time = 2, flags = ANIMATION_PARALLEL) - animate(pixel_x = initial(pixel_x), pixel_y = initial(pixel_y), time = 2) + if(isliving(src) && src:is_on_tank_hull()) + animate(pixel_x = initial(pixel_x), pixel_y = initial(pixel_y) + 12, time = 2) + else + animate(pixel_x = initial(pixel_x), pixel_y = initial(pixel_y), time = 2) /atom/proc/animation_spin(speed = 5, loop_amount = -1, clockwise = TRUE, sections = 3, angular_offset = 0, pixel_fuzz = 0) if(!sections) diff --git a/code/modules/client/preferences.dm b/code/modules/client/preferences.dm index 342fd31686ff..b5538dce7742 100644 --- a/code/modules/client/preferences.dm +++ b/code/modules/client/preferences.dm @@ -6,6 +6,7 @@ #define MENU_MENTOR "mentor" #define MENU_SETTINGS "settings" #define MENU_SPECIAL "special" +#define MENU_VEHICLE "vehicle" GLOBAL_LIST_EMPTY(preferences_datums) @@ -81,6 +82,7 @@ GLOBAL_LIST_INIT(be_special_flags, list( var/toggles_survivor = TOGGLES_SURVIVOR_DEFAULT var/toggles_insert = TOGGLES_INSERT_DEFAULT var/toggles_ert_pred = TOGGLES_ERT_GROUNDS + var/toggles_vehicle = TOGGLES_VEHICLE_DEFAULT var/list/volume_preferences = list(1, 0.5, 1, 0.6) // Game, music, admin midis, lobby music (this is also set in sanitize_volume_preferences() call) var/chat_display_preferences = CHAT_TYPE_ALL var/item_animation_pref_level = SHOW_ITEM_ANIMATIONS_ALL @@ -418,7 +420,8 @@ GLOBAL_LIST_INIT(be_special_flags, list( if(owner.check_whitelist_status(WHITELIST_MENTOR)) dat += "Mentor - " dat += "Settings - " - dat += "Special Roles" + dat += "Special Roles - " + dat += "Vehicle" dat += "" dat += "
" @@ -773,6 +776,15 @@ GLOBAL_LIST_INIT(be_special_flags, list( dat += "Spawn as Insert CO: [toggles_insert & PLAY_INSERT_CO ? "Yes" : "No"]
" dat += "" + if(MENU_VEHICLE) + dat += "" + dat += "" winshow(user, "preferencewindow", TRUE) @@ -2010,6 +2022,13 @@ GLOBAL_LIST_INIT(be_special_flags, list( else toggles_survivor ^= PLAY_SURVIVOR_NON_HOSTILE + if("toggles_vehicle") + var/flag = text2num(href_list["flag"]) + toggles_vehicle ^= flag + save_preferences() + if(flag == VEHICLE_SIMPLE_ACCELERATION && user) + SEND_SIGNAL(user, COMSIG_MOB_VEHICLE_PREFS_CHANGED) + if("ambientocclusion") toggle_prefs ^= TOGGLE_AMBIENT_OCCLUSION var/atom/movable/screen/plane_master/game_world/plane_master = locate() in user?.client.screen @@ -2472,6 +2491,7 @@ GLOBAL_LIST_INIT(be_special_flags, list( #undef MENU_MENTOR #undef MENU_SETTINGS #undef MENU_SPECIAL +#undef MENU_VEHICLE /datum/preferences/proc/generate_name(faction = FACTION_MARINE) var/female = prob(50) diff --git a/code/modules/client/preferences_savefile.dm b/code/modules/client/preferences_savefile.dm index ef67b758bd8f..3b8d297624b5 100644 --- a/code/modules/client/preferences_savefile.dm +++ b/code/modules/client/preferences_savefile.dm @@ -334,6 +334,7 @@ S["toggles_insert"] >> toggles_insert S["toggles_ert_pred"] >> toggles_ert_pred S["toggles_admin"] >> toggles_admin + S["toggles_vehicle"] >> toggles_vehicle S["UI_style"] >> UI_style S["tgui_say"] >> tgui_say S["UI_style_color"] >> UI_style_color @@ -498,6 +499,7 @@ toggles_insert = sanitize_integer(toggles_insert, 0, SHORT_REAL_LIMIT, initial(toggles_insert)) toggles_ert_pred = sanitize_integer(toggles_ert_pred, 0, SHORT_REAL_LIMIT, initial(toggles_ert_pred)) toggles_admin = sanitize_integer(toggles_admin, 0, SHORT_REAL_LIMIT, initial(toggles_admin)) + toggles_vehicle = sanitize_integer(toggles_vehicle, 0, SHORT_REAL_LIMIT, initial(toggles_vehicle)) UI_style_color = sanitize_hexcolor(UI_style_color, initial(UI_style_color)) UI_style_alpha = sanitize_integer(UI_style_alpha, 0, 255, initial(UI_style_alpha)) item_animation_pref_level = sanitize_integer(item_animation_pref_level, SHOW_ITEM_ANIMATIONS_NONE, SHOW_ITEM_ANIMATIONS_ALL, SHOW_ITEM_ANIMATIONS_ALL) @@ -633,6 +635,7 @@ S["toggles_insert"] << toggles_insert S["toggles_ert_pred"] << toggles_ert_pred S["toggles_admin"] << toggles_admin + S["toggles_vehicle"] << toggles_vehicle S["window_skin"] << window_skin S["fps"] << fps S["ghost_vision_pref"] << ghost_vision_pref diff --git a/code/modules/client/preferences_toggles.dm b/code/modules/client/preferences_toggles.dm index 28c2f917fb40..5a9c6727bfe2 100644 --- a/code/modules/client/preferences_toggles.dm +++ b/code/modules/client/preferences_toggles.dm @@ -238,6 +238,44 @@ CLIENT_VERB(toggle_ambient_occlusion) return plane_master.backdrop(src.mob) +CLIENT_VERB(toggle_vehicle_simple_transmission) + set name = "Toggle Vehicle Simple Transmission" + set category = "Preferences.Vehicle" + set desc = "Toggles between manually shifting gears and letting the vehicle pick its own gear." + + prefs.toggles_vehicle ^= VEHICLE_SIMPLE_TRANSMISSION + prefs.save_preferences() + to_chat(src, SPAN_BOLDNOTICE("Vehicles will [(prefs.toggles_vehicle & VEHICLE_SIMPLE_TRANSMISSION) ? "now shift gears automatically" : "no longer shift gears automatically"] for you.")) + +CLIENT_VERB(toggle_vehicle_simple_controls) + set name = "Toggle Vehicle Simple Controls" + set category = "Preferences.Vehicle" + set desc = "Toggles between tank-relative gas/brake/turn controls and legacy absolute-direction WASD controls." + + prefs.toggles_vehicle ^= VEHICLE_SIMPLE_CONTROLS + prefs.save_preferences() + to_chat(src, SPAN_BOLDNOTICE("You will [(prefs.toggles_vehicle & VEHICLE_SIMPLE_CONTROLS) ? "now use simple directional vehicle controls" : "no longer use simple directional vehicle controls"].")) + +CLIENT_VERB(toggle_vehicle_simple_acceleration) + set name = "Toggle Vehicle Simple Acceleration" + set category = "Preferences.Vehicle" + set desc = "Toggles between the continuous cruise-loop acceleration model and discrete keypress-driven momentum." + + prefs.toggles_vehicle ^= VEHICLE_SIMPLE_ACCELERATION + prefs.save_preferences() + to_chat(src, SPAN_BOLDNOTICE("You will [(prefs.toggles_vehicle & VEHICLE_SIMPLE_ACCELERATION) ? "now use simple vehicle acceleration" : "no longer use simple vehicle acceleration"].")) + if(mob) + SEND_SIGNAL(mob, COMSIG_MOB_VEHICLE_PREFS_CHANGED) + +CLIENT_VERB(toggle_vehicle_units_mph) + set name = "Toggle Vehicle Speed Units" + set category = "Preferences.Vehicle" + set desc = "Toggles the driver HUD's speed/acceleration readout between km/h and mph." + + prefs.toggles_vehicle ^= VEHICLE_UNITS_MPH + prefs.save_preferences() + to_chat(src, SPAN_BOLDNOTICE("Vehicle HUDs will now show speed in [(prefs.toggles_vehicle & VEHICLE_UNITS_MPH) ? "mph" : "km/h"] for you.")) + CLIENT_VERB(toggle_member_publicity) set name = "Toggle Membership Publicity" set category = "Preferences" diff --git a/code/modules/clothing/head/helmet.dm b/code/modules/clothing/head/helmet.dm index 5ba22b7274c1..4bd507ea8078 100644 --- a/code/modules/clothing/head/helmet.dm +++ b/code/modules/clothing/head/helmet.dm @@ -302,6 +302,7 @@ GLOBAL_LIST_INIT(allowed_helmet_items, list( /obj/item/device/cassette_tape/hiphop = NO_GARB_OVERRIDE, /obj/item/device/cassette_tape/nam = NO_GARB_OVERRIDE, /obj/item/device/cassette_tape/ocean = NO_GARB_OVERRIDE, + /obj/item/device/cassette_tape/liberation = NO_GARB_OVERRIDE, /obj/item/storage/pouch/cassette = NO_GARB_OVERRIDE, // PREFERENCES GEAR @@ -840,6 +841,7 @@ GLOBAL_LIST_INIT(allowed_helmet_items, list( "nvg_sight_down", "med_sight_down", "sec_sight_down", + "veh_sight_down", "blank_hud_sight_down" ) diff --git a/code/modules/cm_aliens/XenoStructures.dm b/code/modules/cm_aliens/XenoStructures.dm index 5078b77277c0..25852f9b5025 100644 --- a/code/modules/cm_aliens/XenoStructures.dm +++ b/code/modules/cm_aliens/XenoStructures.dm @@ -228,6 +228,10 @@ if(HAS_TRAIT(H, TRAIT_HAULED)) return + // mobs atop the tank shouldn't get foot-stabbed + if(H.is_on_tank_hull()) + return + H.apply_armoured_damage(damage, penetration = penetration, def_zone = pick(target_limbs)) H.last_damage_data = construction_data @@ -406,7 +410,7 @@ return 1 /obj/structure/mineral_door/resin/proc/take_damage(dam, mob/mob) - health -= dam + update_health(dam) healthcheck() /obj/structure/mineral_door/resin/attackby(obj/item/W, mob/living/user) diff --git a/code/modules/cm_aliens/structures/trap.dm b/code/modules/cm_aliens/structures/trap.dm index 0e9e69f72ca4..2a4884ebcc48 100644 --- a/code/modules/cm_aliens/structures/trap.dm +++ b/code/modules/cm_aliens/structures/trap.dm @@ -94,6 +94,10 @@ if(RESIN_TRAP_HUGGER) if(can_hug(victim, hivenumber) && !isyautja(victim) && !issynth(victim) && !isthrall(victim)) var/mob/living/victim_mob = victim + // tank treads block the hugger's hole. This behavior can be also added to normal acid traps. + var/obj/vehicle/multitile/tank/victim_tank = victim_mob.get_tank_on_top_of() + if(victim_tank && (get_turf(victim_mob) in victim_tank.locs)) + return victim_mob.visible_message(SPAN_WARNING("[victim_mob] trips on [src]!")) to_chat(victim_mob, SPAN_DANGER("You trip on [src]!")) victim_mob.apply_effect(1, WEAKEN) @@ -109,6 +113,10 @@ if(isxeno(victim)) var/mob/living/carbon/xenomorph/victim_xeno = victim if(victim_xeno.hivenumber != hivenumber) + // just in case a Greeno decides to ride on the tank. + var/obj/vehicle/multitile/tank/victim_tank = victim_xeno.get_tank_on_top_of() + if(victim_tank && (get_turf(victim_xeno) in victim_tank.locs)) + return trigger_trap() if(isVehicleMultitile(victim) && trap_type != RESIN_TRAP_GAS) trigger_trap() diff --git a/code/modules/cm_aliens/structures/xeno_structures_boilertrap.dm b/code/modules/cm_aliens/structures/xeno_structures_boilertrap.dm index 577bb962349b..cfe57316ee45 100644 --- a/code/modules/cm_aliens/structures/xeno_structures_boilertrap.dm +++ b/code/modules/cm_aliens/structures/xeno_structures_boilertrap.dm @@ -58,6 +58,21 @@ to_chat(M, SPAN_XENOHIGHDANGER("You are caught by a trap made of foul resin!")) qdel(src) +/** + * A vehicle can't be rooted like a mob. Instead, driving over the spike costs it speed and momentum. + */ +/obj/effect/alien/resin/boilertrap/proc/trigger_vehicle_collision(obj/vehicle/multitile/vehicle) + if(vehicle.uses_gear_transmission) + vehicle.current_speed *= BOILER_TRAP_VEHICLE_MOMENTUM_RETAINED + vehicle.move_momentum = vehicle.current_speed + else + vehicle.move_momentum *= BOILER_TRAP_VEHICLE_MOMENTUM_RETAINED + + playsound(src, "alien_resin_break", 25) + if(bound_xeno) + to_chat(bound_xeno, SPAN_XENOWARNING("[vehicle] crushes through one of our traps!")) + qdel(src) + /obj/effect/alien/resin/boilertrap/attack_alien(mob/living/carbon/xenomorph/X) to_chat(X, SPAN_XENOWARNING("Best not to meddle with that trap.")) return XENO_NO_DELAY_ACTION @@ -70,7 +85,14 @@ if (X.hivenumber != hivenumber) trigger_trap(A) else if(ishuman(A)) + // boiler's foul resin 'spikes' do not stab you through the tank + var/mob/living/M = A + if(M && M.is_on_tank_hull()) + return trigger_trap(A) + else if(isVehicleMultitile(A)) + var/obj/vehicle/multitile/vehicle = A + trigger_vehicle_collision(vehicle) /obj/effect/hole_tripwire_boiler name = "hole tripwire" @@ -92,4 +114,11 @@ return if(ishuman(A)) + // mobs atop the tank shouldn't trigger tripwires + var/mob/living/M = A + if(M && M.is_on_tank_hull()) + return linked_trap.trigger_trap(A) + else if(isVehicleMultitile(A)) + var/obj/vehicle/multitile/vehicle = A + linked_trap.trigger_vehicle_collision(vehicle) diff --git a/code/modules/cm_marines/equipment/kit_boxes.dm b/code/modules/cm_marines/equipment/kit_boxes.dm index 5093c68a490f..6459bed1d559 100644 --- a/code/modules/cm_marines/equipment/kit_boxes.dm +++ b/code/modules/cm_marines/equipment/kit_boxes.dm @@ -685,3 +685,15 @@ new /obj/item/attachable/extended_barrel(src) new /obj/item/ammo_magazine/rifle/type71/ap(src) new /obj/item/ammo_magazine/rifle/type71/ap(src) + +// Intended to be handed to Crewmen when buying more than one Humvee. +// Should allow operation of a Humvee's turrets as well as driving it, but not interacting with the tank or the APC. +/obj/item/storage/box/kit/mini_vehicle + name = "\improper Vehicle Crewman Support Kit" + pro_case_overlay = "jtac" + +/obj/item/storage/box/kit/mini_vehicle/fill_preset_inventory() + new /obj/item/clothing/head/helmet/marine/tech/tanker(src) + new /obj/item/device/helmet_visor/vehicle(src) + new /obj/item/pamphlet/upgradeable/vehicle(src) + new /obj/item/storage/pouch/tools/full (src) diff --git a/code/modules/cm_marines/equipment/mortar/mortars.dm b/code/modules/cm_marines/equipment/mortar/mortars.dm index badb9cb5df49..567c180120b9 100644 --- a/code/modules/cm_marines/equipment/mortar/mortars.dm +++ b/code/modules/cm_marines/equipment/mortar/mortars.dm @@ -624,6 +624,12 @@ if(CEILING_IS_PROTECTED(area.ceiling, CEILING_PROTECTION_TIER_1) && is_ground_level(deploy_turf.z)) to_chat(user, SPAN_WARNING("You probably shouldn't deploy [src] indoors.")) return + if(isliving(user)) + var/mob/living/L = user + if(L.is_on_tank_hull()) + to_chat(user, SPAN_WARNING("You can't deploy [src] here!")) + return + user.visible_message(SPAN_NOTICE("[user] starts deploying [src]."), SPAN_NOTICE("You start deploying [src].")) playsound(deploy_turf, 'sound/items/Deconstruct.ogg', 25, 1) diff --git a/code/modules/cm_marines/m2c.dm b/code/modules/cm_marines/m2c.dm index 176b8f150a70..680694d5d8bd 100644 --- a/code/modules/cm_marines/m2c.dm +++ b/code/modules/cm_marines/m2c.dm @@ -112,6 +112,11 @@ if(OT.density || !isturf(OT) || !OT.allow_construction) to_chat(user, SPAN_WARNING("You can't set up \the [src] here.")) return FALSE + if(isliving(user)) + var/mob/living/L = user + if(L.is_on_tank_hull()) + to_chat(user, SPAN_WARNING("You can't set up \the [src] here.")) + return FALSE if(rotate_check.density) to_chat(user, SPAN_WARNING("You can't set up \the [src] that way, there's a wall behind you!")) return FALSE diff --git a/code/modules/cm_marines/overwatch.dm b/code/modules/cm_marines/overwatch.dm index 916492a6bd59..4a28ab00fb15 100644 --- a/code/modules/cm_marines/overwatch.dm +++ b/code/modules/cm_marines/overwatch.dm @@ -123,6 +123,33 @@ GLOBAL_LIST_EMPTY_TYPED(active_overwatch_consoles, /obj/structure/machinery/comp UnregisterSignal(camera_holder, COMSIG_BROADCAST_SEE_EMOTE) camera_holder = null +/** + * Fully releases whatever this console is currently watching. + * Stops every concurrent user's view and tears down the camera_holder signal hookup. + */ +/obj/structure/machinery/computer/overwatch/proc/release_camera() + if(!cam) + return + for(var/datum/weakref/user_ref in concurrent_users) + var/mob/concurrent = user_ref.resolve() + if(!concurrent) + continue + stop_watching_camera(concurrent, cam) + concurrent.UnregisterSignal(cam, COMSIG_PARENT_QDELETING) + if(camera_holder) + disconnect_holder() + cam = null + +/** + * Forces this console off a camera it's currently watching, if that camera is the one given. + * Used when a vehicle's overwatch gets reassigned to a different squad. + */ +/obj/structure/machinery/computer/overwatch/proc/force_release_if_watching(obj/structure/machinery/camera/target_cam) + if(!target_cam || cam != target_cam) + return + visible_message("[icon2html(src, viewers(src))] [SPAN_WARNING("Camera feed lost - the tracked vehicle has been reassigned to a different squad's command network.")]") + release_camera() + /obj/structure/machinery/computer/overwatch/attackby(obj/I as obj, mob/user as mob) //Can't break or disassemble. return @@ -360,6 +387,29 @@ GLOBAL_LIST_EMPTY_TYPED(active_overwatch_consoles, /obj/structure/machinery/comp leader_count++ marine_count-- + // Vehicles whose overwatch is assigned to this squad (tank, APC, ...). + // Listed separately from the marine loop above, a vehicle isn't a marine role. + // Always shown once assigned, regardless of IFF state. + for(var/obj/vehicle/multitile/watched_vehicle in current_squad.overwatch_vehicles) + if(!watched_vehicle) + continue + var/turf/vehicle_turf = get_turf(watched_vehicle) + if(!vehicle_turf) + continue + switch(z_hidden) + if(HIDE_ALMAYER) + if(is_mainship_level(vehicle_turf.z)) + continue + if(HIDE_GROUND) + if(is_ground_level(vehicle_turf.z)) + continue + var/area/vehicle_area = get_area(watched_vehicle) + var/obj/structure/machinery/camera/vehicle_cam = watched_vehicle.get_camera() + var/vehicle_has_camera = vehicle_cam && vehicle_cam.can_use() + // Vehicle-appropriate labels instead of the human defaults. + var/vehicle_data = list(list("name" = watched_vehicle.name, "state" = vehicle_has_camera ? "Operational" : "Unmanned", "has_helmet" = vehicle_has_camera, "role" = "Vehicle", "acting_sl" = "", "fteam" = "", "distance" = "N/A", "area_name" = vehicle_area ? sanitize_area(vehicle_area.name) : "???", "ref" = REF(watched_vehicle), "rank" = "", "no_camera_label" = "IFF OFFLINE")) + data["marines"] += vehicle_data + for(var/obj/structure/overwatch_camera_tripod/tripod_camera as anything in GLOB.deployed_tripod_cameras) // add cameras to list o' marines if(current_squad && current_squad.name != "Root") if(!tripod_camera.squad || tripod_camera.squad != current_squad) // tldr: show cameras in root squad if placed by non-squad marines @@ -675,17 +725,7 @@ GLOBAL_LIST_EMPTY_TYPED(active_overwatch_consoles, /obj/structure/machinery/comp visible_message("[icon2html(src, viewers(src))] [SPAN_BOLDNOTICE("Overwatch systems deactivated. Goodbye, [ID ? "[ID.rank] ":""][operator ? "[operator.name]":"sysadmin"].")]") operator = null current_squad = null - if(cam) - for(var/datum/weakref/user_ref in concurrent_users) - var/mob/concurrent = user_ref.resolve() - if(!concurrent) - continue - stop_watching_camera(concurrent) - concurrent.UnregisterSignal(cam, COMSIG_PARENT_QDELETING) - cam = null - if(camera_holder) - disconnect_holder() - camera_holder = null + release_camera() ui.close() return TRUE @@ -835,6 +875,11 @@ GLOBAL_LIST_EMPTY_TYPED(active_overwatch_consoles, /obj/structure/machinery/comp new_holder = Human.get_camera_holder() if(new_holder) new_cam = new_holder.get_camera() + else if(istype(target_ref, /obj/vehicle/multitile)) + var/obj/vehicle/multitile/vehicle_target = target_ref + cam_target = vehicle_target + new_cam = vehicle_target.get_camera() + new_holder = new_cam else if(istype(target_ref, /obj/structure/overwatch_camera_tripod)) var/obj/structure/overwatch_camera_tripod/tripod_camera = target_ref if(tripod_camera.camera) @@ -849,18 +894,15 @@ GLOBAL_LIST_EMPTY_TYPED(active_overwatch_consoles, /obj/structure/machinery/comp if(cam == new_cam) //if we switch to a console that is already watching this cam return if(!new_cam || !new_cam.can_use()) - to_chat(user, "[icon2html(src, user)] [SPAN_WARNING("Searching for camera. No camera found for this target!")]") + if(istype(cam_target, /obj/vehicle/multitile)) + to_chat(user, "[icon2html(src, user)] [SPAN_WARNING("Searching for camera. This vehicle's IFF module is offline or unavailable!")]") + else + to_chat(user, "[icon2html(src, user)] [SPAN_WARNING("Searching for camera. No camera found for this target!")]") else if(cam && cam == new_cam)//click the camera you're watching a second time to stop watching. - visible_message("[icon2html(src, viewers(src))] [SPAN_BOLDNOTICE("Stopping camera view.")]") - for(var/datum/weakref/user_ref in concurrent_users) - var/mob/concurrent = user_ref.resolve() - if(!concurrent) - continue - stop_watching_camera(concurrent) - concurrent.UnregisterSignal(cam, COMSIG_PARENT_QDELETING) - disconnect_holder() - cam = null - else if(user.client.view != GLOB.world_view_size) + visible_message("[icon2html(src, viewers(src))] [SPAN_BOLDNOTICE("Stopping camera view of [cam_target].")]") + release_camera() + else if(!cam && user.client.view != GLOB.world_view_size) + // Only a concern when this console isn't already watching something itself. to_chat(user, SPAN_WARNING("You're too busy peering through binoculars.")) else for(var/datum/weakref/user_ref in concurrent_users) @@ -869,6 +911,9 @@ GLOBAL_LIST_EMPTY_TYPED(active_overwatch_consoles, /obj/structure/machinery/comp continue if(cam) concurrent.UnregisterSignal(cam, COMSIG_PARENT_QDELETING) + // Clear the old camera's view_change_sources entry before registering the new one, + // or its widened view_range would linger alongside the new camera's. + concurrent.client?.change_view(GLOB.world_view_size, cam) start_watching_camera(concurrent, new_cam) if(cam_target) set_onscreen_text(concurrent, cam_target) @@ -1219,16 +1264,7 @@ GLOBAL_LIST_EMPTY_TYPED(active_overwatch_consoles, /obj/structure/machinery/comp if(user.is_mob_incapacitated(TRUE) || ui_status(user) == UI_CLOSE || user.blinded) //user can't see - not sure why canmove is here. user.unset_interaction() else if(!cam || !cam.can_use()) //camera doesn't work, is no longer selected or is gone - for(var/datum/weakref/user_ref in concurrent_users) - var/mob/concurrent = user_ref.resolve() - if(!concurrent) - continue - if(cam) - concurrent.UnregisterSignal(cam, COMSIG_PARENT_QDELETING) - stop_watching_camera(concurrent) - if(camera_holder) - disconnect_holder() - cam = null + release_camera() /obj/structure/machinery/computer/overwatch/on_set_interaction(mob/user) if(user.interactee != src) @@ -1249,7 +1285,7 @@ GLOBAL_LIST_EMPTY_TYPED(active_overwatch_consoles, /obj/structure/machinery/comp if(!isRemoteControlling(user) && concurrent_users) if(cam) user.UnregisterSignal(cam, COMSIG_PARENT_QDELETING) - stop_watching_camera(user) + stop_watching_camera(user, cam) concurrent_users -= WEAKREF(user) /obj/structure/machinery/computer/overwatch/ui_close(mob/user) @@ -1548,6 +1584,11 @@ GLOBAL_LIST_EMPTY_TYPED(active_overwatch_consoles, /obj/structure/machinery/comp /obj/structure/machinery/computer/overwatch/proc/start_watching_camera(mob/watcher, atom/target) watcher.reset_view(target) + // reset_view() only moves the eye, it never touches the client's view size. + // Reset symmetrically in stop_watching_camera() below. + if(istype(target, /obj/structure/machinery/camera)) + var/obj/structure/machinery/camera/watched_cam = target + watcher.client?.change_view(watched_cam.view_range, target) var/atom/movable/plane_master_controller/non_master/plane_controller = watcher.hud_used.plane_master_controllers[PLANE_MASTERS_NON_MASTER] if(!plane_controller) @@ -1569,6 +1610,8 @@ GLOBAL_LIST_EMPTY_TYPED(active_overwatch_consoles, /obj/structure/machinery/comp /obj/structure/machinery/computer/overwatch/proc/stop_watching_camera(mob/watcher, atom/target) watcher.reset_view(null) // This will call the below proc via the above registered signal //Why so complicated? Many things may reset our view (resisting being the most common one) + // `target` must match `source` from start_watching_camera(), or the widened view stays stuck. + watcher.client?.change_view(GLOB.world_view_size, target) // undoes any view_range widening from start_watching_camera() above /obj/structure/machinery/computer/overwatch/proc/clear_overwatch_overlay(client/watcher) SIGNAL_HANDLER @@ -1628,6 +1671,16 @@ GLOBAL_LIST_EMPTY_TYPED(active_overwatch_consoles, /obj/structure/machinery/comp watcher.hud_used.overwatch_text.maptext = name_part + location_part + job_part + living_part + else if(istype(target, /obj/vehicle/multitile)) + var/obj/vehicle/multitile/watched_vehicle = target + var/area/current_area = get_area(watched_vehicle) + + var/name_part = "[watched_vehicle.name]
" + var/location_part = "[current_area ? sanitize_area(current_area.name) : "Unknown"]
" + var/job_part = "Vehicle - " + var/living_part = "[(watched_vehicle.health > 0) ? "Operational" : "Destroyed"]" + + watcher.hud_used.overwatch_text.maptext = name_part + location_part + job_part + living_part else if(istype(target, /obj/structure/overwatch_camera_tripod)) // on-screen text - in theory you can't click on a downed camera var/obj/structure/overwatch_camera_tripod/tripod = target var/area/current_area = get_area(tripod) diff --git a/code/modules/cm_tech/droppod/wreck.dm b/code/modules/cm_tech/droppod/wreck.dm new file mode 100644 index 000000000000..21348eb37916 --- /dev/null +++ b/code/modules/cm_tech/droppod/wreck.dm @@ -0,0 +1,59 @@ +/// A generic piece of wreckage falling nearby. Wraps a movable payload instead of a supply crate. +/obj/structure/droppod/wreck + name = "\improper wreckage" + desc = "A twisted piece of vehicle wreckage." + drop_time = 0 + dropping_time = 1.5 SECONDS + open_time = 0 + landing_sound = 'sound/vehicles/metalimpact1.ogg' + land_sound = 'sound/effects/metal_crash.ogg' + /// The real object this pod is posing as while it falls. + var/atom/movable/payload + +/obj/structure/droppod/wreck/Initialize(mapload, atom/movable/payload_atom) + . = ..() + if(!payload_atom) + return INITIALIZE_HINT_QDEL + payload_atom.forceMove(src) + payload = payload_atom + +/obj/structure/droppod/wreck/Destroy(force) + payload = null + return ..() + +/** + * Poses as the wrapped payload so it visually reads as the actual wreckage falling. + * Doesn't call the base droppod's update_icon(), since wreckage doesn't parachute down. + * Also copies the payload's pixel_x so it doesn't render offset from its real position. + */ +/obj/structure/droppod/wreck/update_icon() + overlays.Cut() + if(!payload) + return + icon = payload.icon + icon_state = payload.icon_state + overlays += payload.overlays + pixel_x = payload.pixel_x + +/// Same fall animation as the base droppod, but lands at the payload's own resting pixel_y. +/obj/structure/droppod/wreck/drop_on_target(turf/T) + droppod_flags |= DROPPOD_DROPPING + invisibility = 0 + var/resting_pixel_y = payload ? payload.pixel_y : 0 + pixel_y = 32*tiles_to_take + resting_pixel_y + playsound(loc, landing_sound, 100, TRUE, 15) + animate(src, pixel_y = resting_pixel_y, time = dropping_time, easing = LINEAR_EASING) + addtimer(CALLBACK(src, PROC_REF(land), T), dropping_time) + +/// Post effects +/obj/structure/droppod/wreck/post_land() + . = ..() + var/datum/effect_system/spark_spread/land_sparks = new + land_sparks.set_up(5, 0, loc) + land_sparks.start() + +/obj/structure/droppod/wreck/open() + for(var/atom/movable/content as anything in contents) + content.forceMove(loc) + payload = null + qdel(src) diff --git a/code/modules/cm_tech/hologram.dm b/code/modules/cm_tech/hologram.dm index 86056465531a..5f11dc1c6967 100644 --- a/code/modules/cm_tech/hologram.dm +++ b/code/modules/cm_tech/hologram.dm @@ -94,7 +94,7 @@ GLOBAL_LIST_EMPTY_TYPED(hologram_list, /mob/hologram) /mob/hologram/Destroy() if(linked_mob) UnregisterSignal(linked_mob, COMSIG_MOB_RESET_VIEW) - linked_mob.reset_view() + restore_viewer_view() linked_mob = null if(!QDESTROYING(leave_button)) @@ -106,6 +106,9 @@ GLOBAL_LIST_EMPTY_TYPED(hologram_list, /mob/hologram) return ..() +/mob/hologram/proc/restore_viewer_view() + linked_mob?.reset_view() + /datum/action/leave_hologram name = "Leave" action_icon_state = "hologram_exit" @@ -134,19 +137,24 @@ GLOBAL_LIST_EMPTY_TYPED(hologram_list, /mob/hologram) flags_atom = NO_ZFALL var/view_registered = TRUE var/list/view_blocker_images = list() + /// If set, this hologram follows this vehicle instead of the viewer, and restores the camera to it. + var/obj/vehicle/multitile/tracked_vehicle + var/tracked_vehicle_seat -/mob/hologram/look_up/Initialize(mapload, mob/viewer) +/mob/hologram/look_up/Initialize(mapload, mob/viewer, obj/vehicle/multitile/vehicle, seat) . = ..() if(viewer) + tracked_vehicle = vehicle + tracked_vehicle_seat = seat UnregisterSignal(viewer, COMSIG_CLIENT_MOB_MOVE) - RegisterSignal(viewer, COMSIG_MOVABLE_MOVED, PROC_REF(handle_move)) + RegisterSignal(tracked_vehicle || viewer, COMSIG_MOVABLE_MOVED, PROC_REF(handle_move)) RegisterSignal(viewer, COMSIG_MOB_GHOSTIZE, PROC_REF(end_lookup)) update_view_blockers(viewer) /mob/hologram/look_up/Destroy() if(linked_mob) - UnregisterSignal(linked_mob, COMSIG_MOVABLE_MOVED) + UnregisterSignal(tracked_vehicle || linked_mob, COMSIG_MOVABLE_MOVED) if(istype(linked_mob, /mob/living)) var/mob/living/linked_living = linked_mob linked_living.observed_atom = null @@ -156,6 +164,13 @@ GLOBAL_LIST_EMPTY_TYPED(hologram_list, /mob/hologram) view_blocker_images.Cut() . = ..() +/mob/hologram/look_up/restore_viewer_view() + if(tracked_vehicle && linked_mob?.client) + linked_mob.client.change_view(VEHICLE_SEAT_VIEW_RADIUS, tracked_vehicle) + tracked_vehicle.set_seated_mob(tracked_vehicle_seat, linked_mob) + return + return ..() + /mob/hologram/look_up/proc/update_view_blockers(mob/user) if(!user || !user.client) return @@ -164,15 +179,18 @@ GLOBAL_LIST_EMPTY_TYPED(hologram_list, /mob/hologram) view_blocker_images.Cut() var/list/turf/visible_turfs = alist() - for(var/turf/cur_turf in view(world.view + 1, user)) + // Uses the tracked vehicle's real position for a seated crewman, not their disconnected interior loc. + var/atom/movable/reference = tracked_vehicle || user + + for(var/turf/cur_turf in view(world.view + 1, reference)) visible_turfs["[cur_turf.x]-[cur_turf.y]"] = TRUE - for(var/x in user.x - world.view - 1 to user.x + world.view + 1) - for(var/y in user.y - world.view - 1 to user.y + world.view + 1) + for(var/x in reference.x - world.view - 1 to reference.x + world.view + 1) + for(var/y in reference.y - world.view - 1 to reference.y + world.view + 1) if(visible_turfs["[x]-[y]"]) continue - var/turf/cur_turf = locate(x, y, user.z + 1) + var/turf/cur_turf = locate(x, y, reference.z + 1) if(istransparentturf(cur_turf)) var/image/view_blocker = image('icons/turf/floors/floors.dmi', cur_turf, "full_black", 100000) @@ -180,7 +198,7 @@ GLOBAL_LIST_EMPTY_TYPED(hologram_list, /mob/hologram) view_blocker_images += view_blocker user.client.images += view_blocker -/mob/hologram/look_up/handle_move(mob/M, oldLoc, direct) +/mob/hologram/look_up/handle_move(atom/movable/M, oldLoc, direct) if(!isturf(M.loc) || HAS_TRAIT(src, TRAIT_ABILITY_BURROWED)) qdel(src) return @@ -200,11 +218,11 @@ GLOBAL_LIST_EMPTY_TYPED(hologram_list, /mob/hologram) if(!istransparentturf(new_turf)) UnregisterSignal(linked_mob, COMSIG_MOB_RESET_VIEW) view_registered = FALSE - linked_mob.reset_view() + restore_viewer_view() else if (!view_registered) RegisterSignal(linked_mob, COMSIG_MOB_RESET_VIEW, PROC_REF(end_lookup)) view_registered = TRUE - linked_mob.reset_view() + restore_viewer_view() if(linked_mob) update_view_blockers(linked_mob) diff --git a/code/modules/cm_tech/techs/marine/tier1/arc.dm b/code/modules/cm_tech/techs/marine/tier1/arc.dm index 5a7b84a9aa0a..20497b7d431a 100644 --- a/code/modules/cm_tech/techs/marine/tier1/arc.dm +++ b/code/modules/cm_tech/techs/marine/tier1/arc.dm @@ -16,11 +16,14 @@ /datum/tech/arc/can_unlock(mob/unlocking_mob) . = ..() - var/obj/structure/machinery/cm_vending/gear/vehicle_crew/gearcomp = GLOB.VehicleGearConsole - - if(gearcomp.selected_vehicle == "TANK") - to_chat(unlocking_mob, SPAN_WARNING ("A vehicle has already been selected for this operation.")) - return FALSE + // ARC can be bought even after the Tank has been selected. + // With all of the TANK's gear being taken at roundstart (hopefully), this means that the ARC ... + // ... can overwrite the vehicle ASRS' contents without much of an issue. + + // var/obj/structure/machinery/cm_vending/gear/vehicle_crew/gearcomp = GLOB.VehicleGearConsole + // if(gearcomp.selected_vehicle == "TANK") + // to_chat(unlocking_mob, SPAN_WARNING ("A vehicle has already been selected for this operation.")) + // return FALSE return TRUE diff --git a/code/modules/gear_presets/other.dm b/code/modules/gear_presets/other.dm index fee113575903..a7b7191e8e3c 100644 --- a/code/modules/gear_presets/other.dm +++ b/code/modules/gear_presets/other.dm @@ -907,7 +907,7 @@ new_human.equip_to_slot_or_del(new /obj/item/clothing/under/marine/officer/tanker(new_human), WEAR_BODY) new_human.equip_to_slot_or_del(new /obj/item/clothing/shoes/marine/knife(new_human), WEAR_FEET) - new_human.equip_to_slot_or_del(new /obj/item/device/radio/headset/almayer/mcom/vc(new_human), WEAR_L_EAR) + new_human.equip_to_slot_or_del(new /obj/item/device/radio/headset/almayer/marine/vc(new_human), WEAR_L_EAR) new_human.equip_to_slot_or_del(new /obj/item/clothing/gloves/yellow(new_human), WEAR_HANDS) new_human.equip_to_slot_or_del(new /obj/item/storage/belt/gun/m4a3/full(new_human), WEAR_WAIST) new_human.equip_to_slot_or_del(new /obj/item/clothing/suit/storage/marine/tanker(new_human), WEAR_JACKET) diff --git a/code/modules/gear_presets/uscm.dm b/code/modules/gear_presets/uscm.dm index 47dcbbc975d9..971b2e12bf2b 100644 --- a/code/modules/gear_presets/uscm.dm +++ b/code/modules/gear_presets/uscm.dm @@ -162,7 +162,7 @@ /datum/equipment_preset/uscm/tank/load_gear(mob/living/carbon/human/new_human) var/back_item = get_backpack_item(new_human) - new_human.equip_to_slot_or_del(new /obj/item/device/radio/headset/almayer/mcom/vc(new_human), WEAR_L_EAR) + new_human.equip_to_slot_or_del(new /obj/item/device/radio/headset/almayer/marine/vc(new_human), WEAR_L_EAR) new_human.equip_to_slot_or_del(new back_item(new_human), WEAR_BACK) new_human.equip_to_slot_or_del(new /obj/item/clothing/under/marine/officer/tanker(new_human), WEAR_BODY) new_human.equip_to_slot_or_del(new /obj/item/clothing/shoes/marine/knife(new_human), WEAR_FEET) @@ -180,7 +180,7 @@ utility_under = list(/obj/item/clothing/under/marine/officer/tanker) /datum/equipment_preset/uscm/tank/full/load_gear(mob/living/carbon/human/new_human) - new_human.equip_to_slot_or_del(new /obj/item/device/radio/headset/almayer/mcom/vc(new_human), WEAR_L_EAR) + new_human.equip_to_slot_or_del(new /obj/item/device/radio/headset/almayer/marine/vc(new_human), WEAR_L_EAR) new_human.equip_to_slot_or_del(new /obj/item/clothing/under/marine/officer/tanker(new_human), WEAR_BODY) new_human.equip_to_slot_or_del(new /obj/item/clothing/shoes/marine/knife(new_human), WEAR_FEET) new_human.equip_to_slot_or_del(new /obj/item/clothing/gloves/yellow(new_human), WEAR_HANDS) diff --git a/code/modules/mob/living/carbon/carbon.dm b/code/modules/mob/living/carbon/carbon.dm index 7ff77d825c5c..ebb4f7acebbe 100644 --- a/code/modules/mob/living/carbon/carbon.dm +++ b/code/modules/mob/living/carbon/carbon.dm @@ -29,6 +29,15 @@ halimage = null halbody = null +// Same as the parent proc, but adds 1 additional check for climb_down +/mob/living/carbon/_handle_tank_edge_move(NewLoc, direct) + var/obj/vehicle/multitile/tank = get_tank_on_top_of() + var/result = ..() + if(result) + var/turf/edge = get_step(get_turf(src), direct) + if(tank.climb_down(src, edge)) + return TRUE + return result /mob/living/carbon/Move(NewLoc, direct) . = ..() @@ -539,6 +548,9 @@ var/location = get_turf(loc) remove_traits(list(TRAIT_HAULED, TRAIT_NO_STRAY, TRAIT_FLOORED, TRAIT_IMMOBILIZED), TRAIT_SOURCE_XENO_HAUL) pixel_y = 0 + var/obj/vehicle/multitile/tank/TANK = null + if(hauling_xeno.is_on_tank_hull()) + TANK = hauling_xeno.get_tank_on_top_of() UnregisterSignal(src, list(COMSIG_ATTEMPT_MOB_PULL, COMSIG_LIVING_PREIGNITION, COMSIG_LIVING_FLAMER_CROSSED, COMSIG_LIVING_FLAMER_FLAMED)) UnregisterSignal(hauling_xeno, COMSIG_MOB_DEATH) hauling_xeno = null @@ -552,7 +564,8 @@ object.Crossed(src) next_haul_resist = 0 SEND_SIGNAL(src, COMSIG_MOB_UNHAULED) - + if(TANK) + TANK.mark_on_top(src) /mob/living/carbon/proc/extinguish_mob(mob/living/carbon/C) adjust_fire_stacks(-5, min_stacks = 0) diff --git a/code/modules/mob/living/carbon/carbon_debug_verbs.dm b/code/modules/mob/living/carbon/carbon_debug_verbs.dm new file mode 100644 index 000000000000..d46782638b27 --- /dev/null +++ b/code/modules/mob/living/carbon/carbon_debug_verbs.dm @@ -0,0 +1,495 @@ +/// Finds the vehicle this mob is seated in, if any. Reports why to src on failure. +/mob/living/carbon/proc/get_seated_vehicle() + if(!istype(buckled, /obj/structure/bed/chair/comfy/vehicle)) + to_chat(src, SPAN_WARNING("You're not seated in a vehicle.")) + return null + + var/obj/structure/bed/chair/comfy/vehicle/seat_chair = buckled + var/obj/vehicle/multitile/seated_vehicle = seat_chair.vehicle + if(!istype(seated_vehicle)) + to_chat(src, SPAN_WARNING("You're not seated in a vehicle.")) + return null + + return seated_vehicle + +/// Finds the tank this mob is seated in, if any. Reports why to src on failure. +/mob/living/carbon/proc/get_seated_tank_vehicle() + if(!istype(buckled, /obj/structure/bed/chair/comfy/vehicle)) + to_chat(src, SPAN_WARNING("You're not seated in a vehicle.")) + return null + + var/obj/structure/bed/chair/comfy/vehicle/seat_chair = buckled + var/obj/vehicle/multitile/tank/tank_vehicle = seat_chair.vehicle + if(!istype(tank_vehicle)) + to_chat(src, SPAN_WARNING("You're not seated in a tank.")) + return null + + return tank_vehicle + +/// Finds the tank_turret this mob is seated behind, if any. Reports why to src on failure. +/mob/living/carbon/proc/get_seated_tank_turret() + var/obj/vehicle/multitile/tank/tank_vehicle = get_seated_tank_vehicle() + if(!tank_vehicle) + return null + + var/obj/item/hardpoint/holder/tank_turret/turret + for(var/obj/item/hardpoint/holder/tank_turret/TT in tank_vehicle.hardpoints) + turret = TT + break + if(!turret) + to_chat(src, SPAN_WARNING("This tank has no turret installed.")) + return null + + return turret + +/* +/// Debug tool for live-tuning weapon sprite alignment on the vehicle you're seated in. +/mob/living/carbon/verb/set_weapon_rotation_pivot() + set name = "Set Weapon Rotation Pivot" + set category = "Debug" + set desc = "Live-tunes the rotation_pivot of a weapon mounted on the vehicle you're seated in." + + if(!check_rights(R_DEBUG)) + return + + var/obj/vehicle/multitile/seated_vehicle = get_seated_vehicle() + if(!seated_vehicle) + return + + var/datum/tank_pivot_tuner/tuner = new + tuner.vehicle = seated_vehicle + var/list/candidates = tuner.get_weapon_list() + if(!length(candidates)) + to_chat(src, SPAN_WARNING("This vehicle has no rotating weapons mounted.")) + qdel(tuner) + return + + tuner.weapon = candidates[1] + tuner.tgui_interact(src) + +*/ + +/datum/tank_pivot_tuner + var/obj/vehicle/multitile/vehicle + var/obj/item/hardpoint/weapon + +/datum/tank_pivot_tuner/ui_state(mob/user) + return GLOB.always_state + +/datum/tank_pivot_tuner/ui_close(mob/user) + . = ..() + qdel(src) + +/datum/tank_pivot_tuner/tgui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "TankPivotTuner") + ui.open() + ui.set_autoupdate(TRUE) + +/// The tank_turret holder mounted on this tuner's vehicle, if any. +/datum/tank_pivot_tuner/proc/get_turret() + return locate(/obj/item/hardpoint/holder/tank_turret) in vehicle.hardpoints + +/// Every weapon this tuner can select: a turret's own nested hardpoints, or the vehicle's own rotating top-level hardpoints if it has no turret. +/datum/tank_pivot_tuner/proc/get_weapon_list() + var/obj/item/hardpoint/holder/tank_turret/turret = get_turret() + if(turret) + return turret.hardpoints.Copy() + + var/list/candidates = list() + for(var/obj/item/hardpoint/H in vehicle.hardpoints) + if(H.traverse_arc) + candidates += H + return candidates + +/// The nearest of the 8 cardinal/diagonal dir constants to whatever drives the selected weapon's rotation_pivot/gimbal_pivot. +/datum/tank_pivot_tuner/proc/get_rotation_octant() + var/obj/item/hardpoint/holder/tank_turret/turret = get_turret() + if(turret) + return angle_to_dir(turret.current_angle) + if(QDELETED(weapon)) + return SOUTH + return angle_to_dir(weapon.get_rotation_owner().current_angle) + +/// The dir key px_offsets is looked up under for the selected weapon: its own loc's dir. +/datum/tank_pivot_tuner/proc/get_offset_dir() + if(QDELETED(weapon) || !weapon.loc) + return SOUTH + var/atom/container = weapon.loc + return container.dir + +/datum/tank_pivot_tuner/ui_data(mob/user) + . = list() + if(QDELETED(vehicle)) + return + + var/rotation_octant = get_rotation_octant() + .["dir_name"] = dir2text(rotation_octant) + + var/rotation_dir_key = "[rotation_octant]" + var/list/weapon_list = list() + for(var/obj/item/hardpoint/H in get_weapon_list()) + var/list/pivot = LAZYACCESS(H.rotation_pivot, rotation_dir_key) || list(0, 0) + weapon_list += list(list( + "ref" = "\ref[H]", + "name" = H.name, + "pivot_x" = pivot[1], + "pivot_y" = pivot[2], + "selected" = (H == weapon), + )) + .["weapons"] = weapon_list + + if(QDELETED(weapon)) + return + var/list/selected_pivot = LAZYACCESS(weapon.rotation_pivot, rotation_dir_key) || list(0, 0) + .["tuning_label"] = "facing: [dir2text(rotation_octant)]" + .["selected_name"] = weapon.name + .["pivot_x"] = selected_pivot[1] + .["pivot_y"] = selected_pivot[2] + + var/offset_dir_key = "[get_offset_dir()]" + var/list/selected_offset = LAZYACCESS(weapon.px_offsets, offset_dir_key) || list(0, 0) + .["offset_x"] = selected_offset[1] + .["offset_y"] = selected_offset[2] + + .["is_gimballed"] = weapon.self_gimballed + if(weapon.self_gimballed) + var/list/selected_gimbal_pivot = LAZYACCESS(weapon.gimbal_pivot, rotation_dir_key) || list(0, 0) + .["gimbal_pivot_x"] = selected_gimbal_pivot[1] + .["gimbal_pivot_y"] = selected_gimbal_pivot[2] + +/datum/tank_pivot_tuner/ui_act(action, list/params, datum/tgui/ui) + . = ..() + if(.) + return + if(QDELETED(vehicle)) + return + + switch(action) + if("select_weapon") + var/obj/item/hardpoint/H = locate(params["ref"]) in get_weapon_list() + if(!H) + return + weapon = H + . = TRUE + if("adjust") + if(QDELETED(weapon)) + return + var/axis = params["axis"] + var/delta = params["delta"] + var/target = params["target"] + if(!delta || !(axis == "x" || axis == "y") || !(target == "rotation" || target == "gimbal" || target == "offset")) + return + if(target == "gimbal" && !weapon.self_gimballed) + return + + var/list/pivot_list + var/dir_key + switch(target) + if("gimbal") + pivot_list = weapon.gimbal_pivot + dir_key = "[get_rotation_octant()]" + if("offset") + pivot_list = weapon.px_offsets + dir_key = "[get_offset_dir()]" + else + pivot_list = weapon.rotation_pivot + dir_key = "[get_rotation_octant()]" + var/list/pivot = LAZYACCESS(pivot_list, dir_key) || list(0, 0) + var/list/new_pivot = list(pivot[1], pivot[2]) + if(axis == "x") + new_pivot[1] = clamp(pivot[1] + delta, -200, 200) + else + new_pivot[2] = clamp(pivot[2] + delta, -200, 200) + LAZYSET(pivot_list, dir_key, new_pivot) + switch(target) + if("gimbal") + weapon.gimbal_pivot = pivot_list + if("offset") + weapon.px_offsets = pivot_list + else + weapon.rotation_pivot = pivot_list + + vehicle.update_icon() + . = TRUE + +// instantly swaps whichever primary/secondary weapon is mounted on the tank turret + +/* +/mob/living/carbon/verb/cycle_tank_weapon_hardpoint() + set name = "Cycle Tank Weapon Hardpoint" + set category = "Debug" + set desc = "Swaps the turret's primary/secondary weapon (whichever slot the next type in the cycle belongs to) to the next available type." + + if(!check_rights(R_DEBUG)) + return + + var/obj/item/hardpoint/holder/tank_turret/turret = get_seated_tank_turret() + if(!turret) + return + + if(!LAZYLEN(turret.accepted_hardpoints)) + to_chat(src, SPAN_WARNING("This turret doesn't accept any hardpoints.")) + return + + turret.debug_cycle_index = (turret.debug_cycle_index % length(turret.accepted_hardpoints)) + 1 + var/next_type = turret.accepted_hardpoints[turret.debug_cycle_index] + + var/obj/item/hardpoint/next_hardpoint = new next_type() + + var/obj/item/hardpoint/existing + for(var/obj/item/hardpoint/H in turret.hardpoints) + if(H.slot == next_hardpoint.slot) + existing = H + break + if(existing) + turret.remove_hardpoint(existing, get_turf(turret)) + qdel(existing) + + turret.add_hardpoint(next_hardpoint) + turret.owner.update_icon() + + to_chat(src, SPAN_NOTICE("Installed [next_hardpoint.name] on the turret ([turret.debug_cycle_index]/[length(turret.accepted_hardpoints)]).")) +*/ + +/* +// Full visual hardpoint editor. Swap/remove hardpoints and set health/wound tiers directly. +/mob/living/carbon/verb/debug_edit_tank_hardpoints() + set name = "Edit Tank Hardpoints" + set category = "Debug" + set desc = "Opens a visual editor for every hardpoint slot on the tank you're seated in - install/remove any hardpoint, and set the selected one's health and wounds directly." + + if(!check_rights(R_DEBUG)) + return + + var/obj/vehicle/multitile/tank/tank_vehicle = get_seated_tank_vehicle() + if(!tank_vehicle) + return + + var/datum/tank_hardpoint_debugger/debugger = new + debugger.vehicle = tank_vehicle + debugger.tgui_interact(src) +*/ +/datum/tank_hardpoint_debugger + var/obj/vehicle/multitile/tank/vehicle + var/obj/item/hardpoint/selected + +/datum/tank_hardpoint_debugger/ui_state(mob/user) + return GLOB.always_state + +/datum/tank_hardpoint_debugger/ui_close(mob/user) + . = ..() + qdel(src) + +/datum/tank_hardpoint_debugger/tgui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "TankHardpointDebugger") + ui.open() + ui.set_autoupdate(TRUE) + +/// The turret holder currently installed on this vehicle, if any. +/datum/tank_hardpoint_debugger/proc/get_turret() + RETURN_TYPE(/obj/item/hardpoint/holder/tank_turret) + for(var/obj/item/hardpoint/holder/tank_turret/turret in vehicle.hardpoints) + return turret + return null + +/** + * Builds one row per possible hardpoint slot, filled or not. Each row carries a "location" so + * ui_act() knows which object to call. + */ +/datum/tank_hardpoint_debugger/proc/build_slot_data() + var/list/slots_by_id = list() // slot string -> row list, dedups multiple candidate types sharing a slot + var/obj/item/hardpoint/holder/tank_turret/turret = get_turret() + + add_slot_candidates(slots_by_id, vehicle.hardpoints_allowed, vehicle.hardpoints, "vehicle") + if(turret) + add_slot_candidates(slots_by_id, turret.accepted_hardpoints, turret.hardpoints, "turret") + + // Peel off just the row values, in insertion order, for the frontend's .map() to consume. + var/list/rows = list() + for(var/slot_key in slots_by_id) + rows += list(slots_by_id[slot_key]) + return rows + +/// Populates slots_by_id in place from one candidate_types list and its installed hardpoints. +/datum/tank_hardpoint_debugger/proc/add_slot_candidates(list/slots_by_id, list/candidate_types, list/installed_hardpoints, location) + for(var/candidate_type in candidate_types) + var/candidate_slot = initial(candidate_type:slot) + var/list/row = slots_by_id[candidate_slot] + if(!row) + row = list("slot" = candidate_slot, "location" = location, "installed" = null, "candidates" = list()) + slots_by_id[candidate_slot] = row + row["candidates"] += list(list("type" = "[candidate_type]", "name" = initial(candidate_type:name))) + + for(var/obj/item/hardpoint/H in installed_hardpoints) + var/list/row = slots_by_id[H.slot] + if(!row) + continue + row["installed"] = list( + "ref" = "\ref[H]", + "name" = H.name, + "health_pct" = H.health <= 0 ? 0 : round(H.get_integrity_percent()), + "selected" = (H == selected), + ) + +/datum/tank_hardpoint_debugger/ui_data(mob/user) + . = list() + if(QDELETED(vehicle)) + return + + .["slots"] = build_slot_data() + + .["hull"] = list( + "health" = vehicle.health, + "max_health" = initial(vehicle.health), + "wounds" = build_wound_rows(GLOB.hardpoint_wound_families_by_slot[WOUND_SLOT_HULL], vehicle.hull_wound_tiers), + ) + + if(QDELETED(selected)) + return + + .["selected"] = list( + "ref" = "\ref[selected]", + "name" = selected.name, + "slot" = selected.slot, + "health" = selected.health, + "max_health" = initial(selected.health), + "wounds" = build_wound_rows(GLOB.hardpoint_wound_families_by_slot[selected.get_wound_family_slot()], selected.wound_tiers), + ) + +/// Builds the wound family rows ui_data() sends for one slot's tiers. +/datum/tank_hardpoint_debugger/proc/build_wound_rows(list/candidate_families, list/current_tiers) + var/list/wound_list = list() + for(var/datum/hardpoint_wound_family/family in candidate_families) + var/list/tier_names = list() + for(var/list/tier_data in family.tiers) + tier_names += tier_data["wound_name"] + wound_list += list(list( + "family_type" = "[family.type]", + "family_label" = family.get_display_label(), + "current_tier" = LAZYACCESS(current_tiers, family.type) || 0, + "max_tier" = length(family.tiers), + "tier_names" = tier_names, + )) + return wound_list + +/datum/tank_hardpoint_debugger/ui_act(action, list/params, datum/tgui/ui) + . = ..() + if(.) + return + if(QDELETED(vehicle)) + return + + switch(action) + if("select_hardpoint") + var/obj/item/hardpoint/H = locate(params["ref"]) in vehicle.get_hardpoints_copy() + selected = H + . = TRUE + + if("install") + var/hardpoint_type = text2path(params["type"]) + if(!ispath(hardpoint_type, /obj/item/hardpoint)) + return + var/target_slot = params["slot"] + var/new_hardpoint = new hardpoint_type() + if(params["location"] == "turret") + var/obj/item/hardpoint/holder/tank_turret/turret = get_turret() + if(!turret) + qdel(new_hardpoint) + return + remove_existing_in_slot(turret.hardpoints, target_slot, turret) + turret.add_hardpoint(new_hardpoint) + else + remove_existing_in_slot(vehicle.hardpoints, target_slot, vehicle) + vehicle.add_hardpoint(new_hardpoint) + selected = new_hardpoint + . = TRUE + + if("remove") + var/obj/item/hardpoint/H = locate(params["ref"]) in vehicle.get_hardpoints_copy() + if(!H) + return + if(H == selected) + selected = null + if(H in vehicle.hardpoints) + vehicle.remove_hardpoint(H) + else + var/obj/item/hardpoint/holder/tank_turret/turret = get_turret() + turret?.remove_hardpoint(H) + qdel(H) + . = TRUE + + if("set_health") + if(QDELETED(selected) || !isnum(params["value"])) + return + var/new_health = clamp(round(params["value"]), 0, initial(selected.health)) + var/was_destroyed = selected.health <= 0 + selected.health = new_health + if(new_health <= 0 && !was_destroyed) + selected.on_destroy() + . = TRUE + + if("set_wound_tier") + if(QDELETED(selected) || !isnum(params["tier"])) + return + var/family_type = text2path(params["family_type"]) + var/datum/hardpoint_wound_family/family = GLOB.hardpoint_wound_families_by_type[family_type] + // Validate against get_wound_family_slot(), not the raw install slot, since some hardpoints + // override it to roll their own dedicated wound families. + if(!family || family.part_slot != selected.get_wound_family_slot()) + return + var/current_tier = LAZYACCESS(selected.wound_tiers, family_type) || 0 + var/new_tier = clamp(round(params["tier"]), 0, length(family.tiers)) + if(new_tier <= 0) + LAZYREMOVE(selected.wound_tiers, family_type) + else + LAZYSET(selected.wound_tiers, family_type, new_tier) + // Bypasses try_advance_wound_family(), so play the wound-gain cue manually on an increase. + // No attacker to aim shrapnel toward, so it just bursts in every direction. + if(new_tier > current_tier) + play_wound_gain_effects(selected, family.damage_type, null) + selected.recalculate_wound_effects() + . = TRUE + + if("set_hull_health") + if(!isnum(params["value"])) + return + vehicle.health = clamp(round(params["value"]), 0, initial(vehicle.health)) + . = TRUE + + if("set_hull_wound_tier") + if(!isnum(params["tier"])) + return + var/family_type = text2path(params["family_type"]) + var/datum/hardpoint_wound_family/family = GLOB.hardpoint_wound_families_by_type[family_type] + if(!family || family.part_slot != WOUND_SLOT_HULL) + return + var/current_tier = LAZYACCESS(vehicle.hull_wound_tiers, family_type) || 0 + var/new_tier = clamp(round(params["tier"]), 0, length(family.tiers)) + if(new_tier <= 0) + LAZYREMOVE(vehicle.hull_wound_tiers, family_type) + else + LAZYSET(vehicle.hull_wound_tiers, family_type, new_tier) + // Same reasoning as set_wound_tier above. + if(new_tier > current_tier) + play_wound_gain_effects(vehicle, family.damage_type, null) + . = TRUE + +/** + * Removes and qdels whatever hardpoint currently occupies target_slot, if any. + * + * Arguments: + * * holder = Either the vehicle or turret holder. Untyped so DM resolves remove_hardpoint() at runtime. + */ +/datum/tank_hardpoint_debugger/proc/remove_existing_in_slot(list/hardpoint_list, target_slot, holder) + for(var/obj/item/hardpoint/H in hardpoint_list) + if(H.slot != target_slot) + continue + if(H == selected) + selected = null + holder:remove_hardpoint(H) + qdel(H) + return diff --git a/code/modules/mob/living/carbon/human/human.dm b/code/modules/mob/living/carbon/human/human.dm index cb521766644b..b23e424302a0 100644 --- a/code/modules/mob/living/carbon/human/human.dm +++ b/code/modules/mob/living/carbon/human/human.dm @@ -1527,20 +1527,28 @@ return var/atom/tracking_atom = H - if(tracking_atom.z != z && SSinterior.in_interior(tracking_atom)) + if(SSinterior.in_interior(tracking_atom)) var/datum/interior/interior = SSinterior.get_interior_by_coords(tracking_atom.x, tracking_atom.y, tracking_atom.z) - var/atom/exterior = interior.exterior + var/atom/exterior = interior?.exterior if(exterior) tracking_atom = exterior - if(!SSmapping.same_z_map(z, tracking_atom.z) || get_dist(src, tracking_atom) < 1 || src == tracking_atom) + // Same redirect, but for the observer wearing this tracker. + var/atom/observer_atom = src + if(SSinterior.in_interior(observer_atom)) + var/datum/interior/observer_interior = SSinterior.get_interior_by_coords(observer_atom.x, observer_atom.y, observer_atom.z) + var/atom/observer_exterior = observer_interior?.exterior + if(observer_exterior) + observer_atom = observer_exterior + + if(!SSmapping.same_z_map(observer_atom.z, tracking_atom.z) || get_dist(observer_atom, tracking_atom) < 1 || observer_atom == tracking_atom) hud_used.locate_leader.icon_state = "trackondirect[tracking_suffix]" else - hud_used.locate_leader.setDir(Get_Compass_Dir(src, tracking_atom)) + hud_used.locate_leader.setDir(Get_Compass_Dir(observer_atom, tracking_atom)) hud_used.locate_leader.icon_state = "trackon[tracking_suffix]" - if(tracking_atom.z > z) + if(tracking_atom.z > observer_atom.z) hud_used.locate_leader.overlays |= image('icons/mob/hud/human_bronze.dmi', "up") - if(tracking_atom.z < z) + if(tracking_atom.z < observer_atom.z) hud_used.locate_leader.overlays |= image('icons/mob/hud/human_bronze.dmi', "down") /mob/living/carbon/proc/locate_nearest_nuke() diff --git a/code/modules/mob/living/carbon/xenomorph/Powers.dm b/code/modules/mob/living/carbon/xenomorph/Powers.dm index c4041c56226f..b1289c54cbc6 100644 --- a/code/modules/mob/living/carbon/xenomorph/Powers.dm +++ b/code/modules/mob/living/carbon/xenomorph/Powers.dm @@ -138,6 +138,9 @@ if(!succeeded) return SECRETE_RESIN_INTERRUPT + if(!resin_construct.can_build_here(current_turf, src)) + return SECRETE_RESIN_FAIL + if(maybe_convert_to_weedbound(current_turf, resin_construct, thick)) if(use_plasma) use_plasma(total_resin_cost) @@ -147,9 +150,6 @@ playsound(loc, "alien_resin_build", 25) return SECRETE_RESIN_SUCCESS - if(!resin_construct.can_build_here(current_turf, src)) - return SECRETE_RESIN_FAIL - if(use_plasma) use_plasma(total_resin_cost) if(message) diff --git a/code/modules/mob/living/carbon/xenomorph/XenoProcs.dm b/code/modules/mob/living/carbon/xenomorph/XenoProcs.dm index a8d19062ff71..fd5fd294750a 100644 --- a/code/modules/mob/living/carbon/xenomorph/XenoProcs.dm +++ b/code/modules/mob/living/carbon/xenomorph/XenoProcs.dm @@ -233,11 +233,24 @@ return . = ..() +// this proc only serves to fix a visual bug where hauled mobs get layered below the vehicle. +// this is a rare edge case, because xenos won't usually be hauling mobs atop a vehicle, but just for completeness... +/mob/living/carbon/xenomorph/proc/check_on_tank_hauled_mob(mob/victim) + var/obj/vehicle/multitile/vehicle = null + if(src.is_on_tank_hull()) + vehicle = src.get_tank_on_top_of() + vehicle._apply_rider_visuals(victim) + else + victim.layer = initial(victim.layer) + victim.plane = initial(victim.plane) + victim.pixel_y = initial(victim.pixel_y) + /mob/living/carbon/xenomorph/Move(NewLoc, direct) . = ..() var/mob/user = hauled_mob?.resolve() if(user) user.forceMove(loc) + check_on_tank_hauled_mob(user) /mob/living/carbon/xenomorph/forceMove(atom/destination) . = ..() @@ -245,8 +258,10 @@ if(user) if(!isturf(destination)) user.forceMove(src) + check_on_tank_hauled_mob(user) else user.forceMove(loc) + check_on_tank_hauled_mob(user) /mob/living/carbon/xenomorph/relaymove(mob/user, direction) . = ..() @@ -354,6 +369,14 @@ ADD_TRAIT(src, TRAIT_IMMOBILIZED, TRAIT_SOURCE_ABILITY("Pounce")) pounceAction.freeze_timer_id = addtimer(CALLBACK(src, PROC_REF(unfreeze_pounce)), pounceAction.freeze_time, TIMER_STOPPABLE) pounceAction.additional_effects(carbon_mob) + var/obj/vehicle/multitile/pounced_vehicle = pounced_mob.get_tank_on_top_of() + if(pounced_vehicle) + src.forceMove(get_turf(pounced_mob)) + pounced_vehicle.mark_on_top(src) + else + var/obj/vehicle/multitile/vehicle = src.get_tank_on_top_of() + if(vehicle) + vehicle.clear_on_top(src) if(pounceAction.slash) carbon_mob.attack_alien(src, pounceAction.slash_bonus_damage) @@ -374,6 +397,21 @@ obj_launch_collision(O) return + // Castes that can pounce can use this ability to instantly climb ontop or down a vehicle with no delay. + var/obj/vehicle/multitile/V = null + if(istype(O, /obj/vehicle/multitile)) + V = O + if(V) + var/turf/current = get_turf(src) + var/turf/facing = get_step(current, dir) + if(facing && (facing in V.locs)) + forceMove(facing) + V.mark_on_top(src) + else + var/obj/vehicle/multitile/current_vehicle = src.get_tank_on_top_of() + if(current_vehicle && !(locate(/obj/vehicle/multitile) in get_turf(src))) + current_vehicle.clear_on_top(src) // if we're not atop the vehicle still, clear us from it. + if (pounceAction.should_destroy_objects) if(istype(O, /obj/structure/surface/table) || istype(O, /obj/structure/surface/rack) || istype(O, /obj/structure/window_frame)) var/obj/structure/S = O diff --git a/code/modules/mob/living/carbon/xenomorph/Xenomorph.dm b/code/modules/mob/living/carbon/xenomorph/Xenomorph.dm index 7fdd61485214..b719d23a18d5 100644 --- a/code/modules/mob/living/carbon/xenomorph/Xenomorph.dm +++ b/code/modules/mob/living/carbon/xenomorph/Xenomorph.dm @@ -207,6 +207,8 @@ // ////////////////////////////////////////////////////////////////// var/damage_modifier = 0 + /// Unlike damage_modifier, this only adds onto melee_vehicle_damage, for strains with anti-vehicle capability. + var/vehicle_damage_modifier = 0 var/health_modifier = 0 var/armor_modifier = 0 var/explosivearmor_modifier = 0 @@ -1020,7 +1022,7 @@ /mob/living/carbon/xenomorph/proc/recalculate_damage() melee_damage_lower = damage_modifier melee_damage_upper = damage_modifier - melee_vehicle_damage = damage_modifier + melee_vehicle_damage = damage_modifier + vehicle_damage_modifier if(caste) melee_damage_lower += caste.melee_damage_lower melee_damage_upper += caste.melee_damage_upper diff --git a/code/modules/mob/living/carbon/xenomorph/abilities/ability_helper_procs.dm b/code/modules/mob/living/carbon/xenomorph/abilities/ability_helper_procs.dm index a7cc78e7e7f6..e3fd3452d2a5 100644 --- a/code/modules/mob/living/carbon/xenomorph/abilities/ability_helper_procs.dm +++ b/code/modules/mob/living/carbon/xenomorph/abilities/ability_helper_procs.dm @@ -301,7 +301,8 @@ var/atom/movable/temp = new spray_path() var/atom/movable/blocker = LinkBlocked(temp, prev_turf, turf) qdel(temp) - if(blocker) + // despite a vehicle being a blocker, acid sprays should still go through it to hit marines ontop. + if(blocker && !locate(/obj/vehicle/multitile) in turf.contents) blocker.acid_spray_act(src) break diff --git a/code/modules/mob/living/carbon/xenomorph/abilities/crusher/crusher_abilities.dm b/code/modules/mob/living/carbon/xenomorph/abilities/crusher/crusher_abilities.dm index eae9b09a1855..657fc40d1df7 100644 --- a/code/modules/mob/living/carbon/xenomorph/abilities/crusher/crusher_abilities.dm +++ b/code/modules/mob/living/carbon/xenomorph/abilities/crusher/crusher_abilities.dm @@ -85,6 +85,41 @@ weaken_power = 0 slowdown = 8 +/** + * Headbutt's vehicle resolution. Weaker than charges and rams, and doesn't joust riders ontop. + * Falls through to the inherited behavior for any non-vehicle target. + */ +/datum/action/xeno_action/activable/fling/charger/use_ability(atom/affected_atom) + if(istype(affected_atom, /obj/vehicle/multitile)) + var/mob/living/carbon/xenomorph/fling_user = owner + var/obj/vehicle/multitile/vehicle = affected_atom + + if(!action_cooldown_check()) + return + if(!fling_user.check_state() || fling_user.agility) + return + if(!is_adjacent_to_multitile_vehicle(fling_user, vehicle)) + return + if(!check_and_use_plasma_owner()) + return + + var/atop_vehicle = (get_multitile_vehicle_at(get_turf(fling_user)) == vehicle) + + fling_user.visible_message(SPAN_XENOWARNING("[fling_user] headbutts [vehicle]!"), SPAN_XENOWARNING("We headbutt [vehicle.get_attack_desc(fling_user)]!")) + playsound(vehicle, 'sound/weapons/alien_claw_block.ogg', 75, TRUE) + playsound(vehicle, 'sound/effects/metal_crash.ogg', 35) + play_wound_gain_effects(vehicle, WOUND_DAMTYPE_BRUTE, fling_user) + fling_user.face_atom(vehicle) + fling_user.animation_attack_on(vehicle) + + // Standing atop the vehicle when headbutting it. What are you going to push it off? Off of itself? + vehicle.take_damage_type(CRUSHER_HEADBUTT_TANK_DAMAGE * vehicle.get_crusher_ram_scale(), "blunt", fling_user, unmitigated = atop_vehicle) + if(!atop_vehicle) + vehicle.knockback(get_cardinal_dir(fling_user, vehicle), 1, jostle_riders = FALSE, is_crusher_source = TRUE) + + apply_cooldown() + return + return ..() /datum/action/xeno_action/onclick/charger_charge name = "Toggle Charging" diff --git a/code/modules/mob/living/carbon/xenomorph/abilities/general_abilities.dm b/code/modules/mob/living/carbon/xenomorph/abilities/general_abilities.dm index 4a2d8b6713e8..9dd9a44fdd97 100644 --- a/code/modules/mob/living/carbon/xenomorph/abilities/general_abilities.dm +++ b/code/modules/mob/living/carbon/xenomorph/abilities/general_abilities.dm @@ -410,6 +410,8 @@ var/mob/living/carbon/xenomorph/xeno = owner if(!xeno) return FALSE + if(xeno.get_tank_on_top_of()) // Prevents cheesy layering underneath the tank. + return FALSE if(xeno.on_fire) return FALSE if(!xeno.buckled && !xeno.is_mob_incapacitated() && !LAZYLEN(xeno.buckled_mobs)) @@ -418,6 +420,10 @@ /// remove hide and apply modified attack cooldown /datum/action/xeno_action/onclick/xenohide/proc/post_attack() + remove_hide_status() + apply_cooldown(4) //2 second cooldown after attacking + +/datum/action/xeno_action/onclick/xenohide/proc/remove_hide_status() var/mob/living/carbon/xenomorph/xeno = owner UnregisterSignal(xeno, COMSIG_MOB_STATCHANGE) if(xeno.layer == XENO_HIDING_LAYER) @@ -425,7 +431,6 @@ button.icon_state = "template_xeno" xeno.update_wounds() xeno.update_layer() - apply_cooldown(4) //2 second cooldown after attacking /datum/action/xeno_action/onclick/xenohide/give_to(mob/living/living_mob) . = ..() diff --git a/code/modules/mob/living/carbon/xenomorph/abilities/general_powers.dm b/code/modules/mob/living/carbon/xenomorph/abilities/general_powers.dm index ce73fa67c851..5f505bd44c40 100644 --- a/code/modules/mob/living/carbon/xenomorph/abilities/general_powers.dm +++ b/code/modules/mob/living/carbon/xenomorph/abilities/general_powers.dm @@ -956,7 +956,7 @@ xeno.visible_message(SPAN_XENOWARNING("[xeno] spits at [atom]!"), - SPAN_XENOWARNING("We spit [xeno.ammo.name] at [atom]!") ) + SPAN_XENOWARNING("We spit [xeno.ammo.name] at [atom.get_attack_desc(xeno)]!") ) playsound(xeno.loc, sound_to_play, 25, 1) var/obj/projectile/proj = new (current_turf, create_cause_data(xeno.ammo.name, xeno)) @@ -1120,25 +1120,43 @@ if(xeno_turf_above?.z != targetted_atom.z && xeno_turf_below?.z != targetted_atom.z) return - var/distance = get_dist(stabbing_xeno, targetted_atom) + // A multitile vehicle's real .loc is only its center tile, so measure to its footprint instead. + var/distance + if(isVehicleMultitile(targetted_atom)) + var/obj/vehicle/multitile/vehicle_target = targetted_atom + distance = get_dist_to_multitile_vehicle(stabbing_xeno, vehicle_target) + else + distance = get_dist(stabbing_xeno, targetted_atom) if(stabbing_xeno.z != targetted_atom.z) distance++ if(distance > stab_range) return FALSE + // allows tailstabs into mobs riding atop a vehicle + var/obj/vehicle/multitile/skip_vehicle = null + if(ismob(targetted_atom)) + var/mob/living/target_mob = targetted_atom + if(istype(target_mob)) + skip_vehicle = target_mob.get_tank_on_top_of() + var/list/turf/path = get_line(stabbing_xeno, targetted_atom, include_start_atom = FALSE) for(var/turf/path_turf as anything in path) if(path_turf.density) to_chat(stabbing_xeno, SPAN_WARNING("There's something blocking our strike!")) return FALSE for(var/obj/path_contents in path_turf.contents) + if(skip_vehicle && path_contents == skip_vehicle) + continue if(path_contents != targetted_atom && path_contents.density && !path_contents.throwpass) to_chat(stabbing_xeno, SPAN_WARNING("There's something blocking our strike!")) return FALSE var/atom/barrier = path_turf.handle_barriers(stabbing_xeno, null, (PASS_MOB_THRU_XENO|PASS_OVER_THROW_MOB|PASS_TYPE_CRAWLER)) if(barrier != path_turf) + if(skip_vehicle && barrier == skip_vehicle) + continue var/tail_stab_cooldown_multiplier = barrier.handle_tail_stab(stabbing_xeno, blunt_stab) + if(!tail_stab_cooldown_multiplier) to_chat(stabbing_xeno, SPAN_WARNING("There's something blocking our strike!")) else diff --git a/code/modules/mob/living/carbon/xenomorph/abilities/praetorian/praetorian_abilities.dm b/code/modules/mob/living/carbon/xenomorph/abilities/praetorian/praetorian_abilities.dm index fab3afbc8fc9..43d4120b24ef 100644 --- a/code/modules/mob/living/carbon/xenomorph/abilities/praetorian/praetorian_abilities.dm +++ b/code/modules/mob/living/carbon/xenomorph/abilities/praetorian/praetorian_abilities.dm @@ -63,6 +63,9 @@ var/root_toggle = TRUE var/buffed = FALSE // Are we buffed + /// Plain damage against a tank, since neither root nor fling can grab onto a vehicle. Just figured it'd be neat as a combo or something. + var/tank_damage = 40 + /datum/action/xeno_action/onclick/toggle_cleave name = "Toggle Cleave Type" action_icon_state = "prae_cleave_root" diff --git a/code/modules/mob/living/carbon/xenomorph/abilities/queen/queen_powers.dm b/code/modules/mob/living/carbon/xenomorph/abilities/queen/queen_powers.dm index d6600a7f3cc9..48b4afb8d6a9 100644 --- a/code/modules/mob/living/carbon/xenomorph/abilities/queen/queen_powers.dm +++ b/code/modules/mob/living/carbon/xenomorph/abilities/queen/queen_powers.dm @@ -858,6 +858,25 @@ continue M.handle_queen_screech(xeno, mobs_in_view) + // Seated vehicle crew are sealed in a separate interior, so key off distance to the vehicle instead... + for(var/obj/vehicle/multitile/vehicle in orange(10, xeno)) + var/dist = get_dist(xeno, vehicle) + var/stun_amount + if(dist <= 4) + stun_amount = 4 * QUEEN_SCREECH_CREW_STUN_FRACTION + else if(dist < 8) + stun_amount = 3 * QUEEN_SCREECH_CREW_STUN_FRACTION + else + continue + + for(var/seat in list(VEHICLE_DRIVER, VEHICLE_GUNNER)) + var/mob/living/crew = vehicle.get_seat_mob(seat) + crew?.AdjustStun(stun_amount) + + var/obj/item/hardpoint/visual_sensors/sensors = locate() in vehicle.get_hardpoints_copy() + if(sensors) + sensors.deal_unmitigated_damage(initial(sensors.health) * QUEEN_SCREECH_CAMERA_DAMAGE_PCT) + apply_cooldown() return ..() diff --git a/code/modules/mob/living/carbon/xenomorph/castes/Burrower.dm b/code/modules/mob/living/carbon/xenomorph/castes/Burrower.dm index f3837947db9f..0a4bf47aed6a 100644 --- a/code/modules/mob/living/carbon/xenomorph/castes/Burrower.dm +++ b/code/modules/mob/living/carbon/xenomorph/castes/Burrower.dm @@ -334,6 +334,8 @@ carbon_target.apply_effect(1, WEAKEN) to_chat(carbon_target, SPAN_WARNING("The violent tremors make you lose your footing!")) + apply_atop_tank_turret_damage(burrower_tremor, 65) + apply_cooldown() return ..() diff --git a/code/modules/mob/living/carbon/xenomorph/castes/Crusher.dm b/code/modules/mob/living/carbon/xenomorph/castes/Crusher.dm index a78ad82586b8..5b41feca1cd6 100644 --- a/code/modules/mob/living/carbon/xenomorph/castes/Crusher.dm +++ b/code/modules/mob/living/carbon/xenomorph/castes/Crusher.dm @@ -90,8 +90,10 @@ else if (istype(target, /obj/vehicle/multitile)) var/obj/vehicle/multitile/vehicle_in_path = target - visible_message(SPAN_DANGER("[src] rams into [vehicle_in_path] and skids to a halt!"), SPAN_XENOWARNING("We ram into [vehicle_in_path] and skid to a halt!")) + visible_message(SPAN_DANGER("[src] rams into [vehicle_in_path], knocking it back!"), SPAN_XENOWARNING("We ram into [vehicle_in_path], knocking it back!")) + // Uses get_cardinal_dir(), not the crusher's own dir, since a vehicle only moves cardinally. + vehicle_in_path.resolve_crusher_charge_hit(src, get_cardinal_dir(src, vehicle_in_path), CRUSHER_CHARGE_TANK_KNOCKBACK_TILES, CRUSHER_CHARGE_TANK_DAMAGE) vehicle_in_path.Collided(src) . = FALSE @@ -385,6 +387,12 @@ pounced_turf.ex_act(EXPLOSION_THRESHOLD_VLOW, , create_cause_data(caste_type, src)) ..(pounced_turf) +/** + * If standing on a tank, also deals Stomp's damage to every turret hardpoint. + */ +/datum/action/xeno_action/onclick/crusher_stomp/proc/apply_tank_turret_bonus_damage(mob/living/carbon/xenomorph/xeno) + apply_atop_tank_turret_damage(xeno, damage) + /datum/action/xeno_action/onclick/crusher_stomp/use_ability(atom/Atom) var/mob/living/carbon/xenomorph/xeno = owner @@ -417,6 +425,7 @@ targets.apply_effect(get_xeno_stun_duration(targets, 0.2), WEAKEN) to_chat(targets, SPAN_XENOHIGHDANGER("You are slowed as [xeno] knocks you off balance!")) + apply_tank_turret_bonus_damage(xeno) apply_cooldown() return ..() @@ -464,6 +473,7 @@ to_chat(targets_to_get, SPAN_XENOHIGHDANGER("You watch as [stomped_carbon] gets crushed by [xeno]!")) to_chat(targets_to_get, SPAN_XENOHIGHDANGER("You are shaken as [xeno] quakes the earth!")) + apply_tank_turret_bonus_damage(xeno) apply_cooldown() return ..() diff --git a/code/modules/mob/living/carbon/xenomorph/castes/Defender.dm b/code/modules/mob/living/carbon/xenomorph/castes/Defender.dm index c14045216b4e..da01146836b6 100644 --- a/code/modules/mob/living/carbon/xenomorph/castes/Defender.dm +++ b/code/modules/mob/living/carbon/xenomorph/castes/Defender.dm @@ -157,6 +157,45 @@ if(!istype(fendy)) return + //fendys can fendy-bendy now + if(istype(target_atom, /obj/vehicle/multitile)) + if(!fendy.check_state()) + return + if(!action_cooldown_check()) + return + if(!is_adjacent_to_multitile_vehicle(fendy, target_atom)) + return + if(!check_and_use_plasma_owner()) + return + if(fendy.fortify && !usable_while_fortified) + to_chat(fendy, SPAN_XENOWARNING("We cannot use headbutt while fortified.")) + return + + var/obj/vehicle/multitile/vehicle = target_atom + var/damage = base_damage - (fendy.crest_defense * 10) + var/atop_vehicle = (get_multitile_vehicle_at(get_turf(fendy)) == vehicle) + + fendy.visible_message(SPAN_XENOWARNING("[fendy] rams [vehicle] with its armored crest!"), SPAN_XENOWARNING("We ram [vehicle.get_attack_desc(fendy)] with our armored crest!")) + fendy.face_atom(vehicle) + fendy.animation_attack_on(vehicle) + fendy.flick_attack_overlay(vehicle, "punch") + playsound(vehicle, 'sound/weapons/alien_claw_block.ogg', 50, TRUE) + playsound(vehicle, 'sound/effects/metal_crash.ogg', 35) + play_wound_gain_effects(vehicle, WOUND_DAMTYPE_BRUTE, fendy) + + // Standing atop the vehicle means no push regardless of crest state, but bypasses armor entirely. + vehicle.take_damage_type(damage, "blunt", fendy, unmitigated = atop_vehicle) + + // A lowered-crest hit pushes the vehicle back a tile + // + // Fortify and crest_defense are normally mutually exclusive. Steelcrest's headbutt override sets + // usable_while_fortified = TRUE, the only way to get here fortified, and gets the same push. + if((fendy.crest_defense || (fendy.fortify && usable_while_fortified)) && !atop_vehicle) + vehicle.knockback(get_cardinal_dir(fendy, vehicle), 1, jostle_riders = FALSE) + + apply_cooldown() + return + if(!isxeno_human(target_atom) || fendy.can_not_harm(target_atom)) return diff --git a/code/modules/mob/living/carbon/xenomorph/castes/Despoiler.dm b/code/modules/mob/living/carbon/xenomorph/castes/Despoiler.dm index 8f5f78f49312..478220b51a35 100644 --- a/code/modules/mob/living/carbon/xenomorph/castes/Despoiler.dm +++ b/code/modules/mob/living/carbon/xenomorph/castes/Despoiler.dm @@ -128,7 +128,12 @@ ..() last_combat_time = world.time -/datum/behavior_delegate/despoiler_base/melee_attack_modify_burn_damage(original_damage, mob/living/carbon/target_carbon) +/datum/behavior_delegate/despoiler_base/melee_attack_modify_burn_damage(original_damage, atom/target_carbon) + // A vehicle has no effects_list DoT to stack acid onto, so just build hypertension at half rate. + if(istype(target_carbon, /obj/vehicle/multitile)) + increase_hypertension(50) + return original_damage + if (!isxeno_human(target_carbon)) return original_damage @@ -263,6 +268,7 @@ return // Handled in additional_effects() var/list/turfs = orange(1, get_turf(xeno)) - get_step(xeno.loc, REVERSE_DIR(xeno.dir)) + var/hit_vehicle = FALSE for(var/turf/turf in turfs) for(var/mob/living/carbon/human/target in turf) playsound(target.loc, "acid_strike", 25, 1) @@ -277,6 +283,14 @@ new /obj/effect/lingering_acid(turf, xeno.hivenumber) new /obj/effect/xenomorph/xeno_telegraph/yellow(turf, 2) + // Once per use, not once per overlapping tile. A single acid splash hit, no grab/drain mechanic. + if(!hit_vehicle) + var/obj/vehicle/multitile/vehicle = get_multitile_vehicle_at(turf) + if(vehicle) + hit_vehicle = TRUE + playsound(vehicle, "acid_strike", 25, TRUE) + vehicle.take_damage_type(damage, "acid", xeno) + /datum/action/xeno_action/activable/pounce/caustic_embrace/additional_effects(mob/living/carbon/target) var/mob/living/carbon/xenomorph/despoiler/xeno = owner var/datum/behavior_delegate/despoiler_base/delegate = xeno.behavior_delegate @@ -380,6 +394,10 @@ opacity = FALSE mouse_opacity = MOUSE_OPACITY_TRANSPARENT layer = ABOVE_OBJ_LAYER + // Lets a puddle on a vehicle's footprint ride along atop it instead of staying behind when it moves. + is_allowed_atop_vehicle = TRUE + // You ain't crashing your way out of this one, PVT Ryan 'Drive' Gosling. + immune_to_tank_crash_scatter = TRUE var/hivenumber = XENO_HIVE_NORMAL var/damage = 20 var/slow_amt = 4 @@ -400,12 +418,31 @@ animate(src, alpha = 127, time = decay_time) QDEL_IN(src, decay_time) + // A vehicle already parked on this tile when the puddle spawns never triggers Crossed(), so check directly. + var/obj/vehicle/multitile/vehicle_here = get_multitile_vehicle_at(loc) + if(vehicle_here) + vehicle_here.expose_to_lingering_acid(src) + mount_on_vehicle(vehicle_here) + +/obj/effect/lingering_acid/proc/mount_on_vehicle(obj/vehicle/multitile/vehicle) + vehicle.obj_mark_on_top(src) + /obj/effect/lingering_acid/Crossed(atom/movable/movable) . = ..() + if(isVehicleMultitile(movable)) + var/obj/vehicle/multitile/vehicle = movable + vehicle.expose_to_lingering_acid(src) + mount_on_vehicle(vehicle) + return + var/mob/living/carbon/carbon = movable if(!istype(carbon)) return + // Mounted atop a tank: only a mob riding atop that same tank can actually be "crossing" this puddle. + if(is_atop_vehicle() && carbon.get_tank_on_top_of() != get_tank_on_top_of()) + return + if(carbon.ally_of_hivenumber(hivenumber)) return diff --git a/code/modules/mob/living/carbon/xenomorph/castes/King.dm b/code/modules/mob/living/carbon/xenomorph/castes/King.dm index 641b1ccb88d1..0bd7e885617c 100644 --- a/code/modules/mob/living/carbon/xenomorph/castes/King.dm +++ b/code/modules/mob/living/carbon/xenomorph/castes/King.dm @@ -224,6 +224,22 @@ for(var/datum/reagent/x in human.reagents.reagent_list) human.reagents.remove_reagent(x.id, 100) + // Seated vehicle crew are sealed inside a separate interior instance, so this view(owner) loop + // wouldn't find them on their own. Handled here instead, keyed off the vehicle itself. + else if(istype(current_atom, /obj/vehicle/multitile)) + var/obj/vehicle/multitile/vehicle = current_atom + for(var/seat in list(VEHICLE_DRIVER, VEHICLE_GUNNER)) + var/mob/living/crew = vehicle.get_seat_mob(seat) + if(!crew || xeno.can_not_harm(crew)) + continue + crew.add_client_color_matrix("doom", 99, color_matrix_multiply(color_matrix_saturation(0), color_matrix_from_string("#eeeeee"))) + addtimer(CALLBACK(crew, TYPE_PROC_REF(/mob, remove_client_color_matrix), "doom", 1 SECONDS), 5 SECONDS) + to_chat(crew, SPAN_HIGHDANGER("[xeno]'s roar overwhelms your entire being!")) + shake_camera(crew, 6, 1) + + var/obj/item/hardpoint/visual_sensors/sensors = locate() in vehicle.get_hardpoints_copy() + if(sensors) + sensors.deal_unmitigated_damage(initial(sensors.health) * KING_DOOM_CAMERA_DAMAGE_PCT) if(!istype(current_atom, /mob/dead)) var/power = current_atom.light_power @@ -301,10 +317,6 @@ to_chat(xeno, SPAN_XENONOTICE("It would not be wise to try to leap there...")) return - if(istype(target, /obj/vehicle/multitile)) - to_chat(xeno, SPAN_XENONOTICE("It would not be wise to try to leap there...")) - return - var/area/target_area = get_area(target_turf) if(target_area.flags_area & AREA_NOBURROW) to_chat(xeno, SPAN_XENONOTICE("We cannot leap to that area!")) @@ -353,7 +365,10 @@ //Initial visual var/obj/effect/king_leap/leap_visual = new(owner.loc, negative, owner.dir) - new /obj/effect/xenomorph/xeno_telegraph/king_attack_template(template_turf, 20) + var/obj/effect/xenomorph/xeno_telegraph/king_attack_template/landing_telegraph = new(template_turf, 20) + // Bump above the vehicle so the telegraph renders on top of it. + if(locate(/obj/vehicle/multitile) in target_turf) + landing_telegraph.layer = TANK_RIDER_LAYER negative = !negative //invert it for the descent later @@ -395,10 +410,14 @@ owner.mouse_opacity = initial(owner.mouse_opacity) playsound(owner.loc, 'sound/effects/meteorimpact.ogg', 200, TRUE) - /// Effects for landing - new /obj/effect/heavy_impact(owner.loc) + /// Effects for landing. Bumped above a vehicle so impact visuals don't render underneath it. + var/list/impact_turfs = list(owner.loc) for(var/step in CARDINAL_ALL_DIRS) - new /obj/effect/heavy_impact(get_step(owner.loc, step)) + impact_turfs += get_step(owner.loc, step) + for(var/turf/impact_turf in impact_turfs) + var/obj/effect/heavy_impact/impact = new(impact_turf) + if(locate(/obj/vehicle/multitile) in impact_turf) + impact.layer = TANK_RIDER_LAYER // Actual Damaging Effects - Add stuff for cades - NEED TELEGRAPHS NEED EFFECTS @@ -425,6 +444,35 @@ for(var/mob/living in range(7, owner)) shake_camera(living, 15, 1) + // Vehicle landing damage scales with how many landing tiles overlap its footprint. + var/list/landing_tiles = (orange(1, owner) - owner) + get_turf(owner) + var/list/vehicles_hit = list() + for(var/turf/landing_turf in landing_tiles) + var/obj/vehicle/multitile/vehicle = locate() in landing_turf + if(vehicle) + vehicles_hit |= vehicle + + for(var/obj/vehicle/multitile/vehicle in vehicles_hit) + var/overlap_tiles = 0 + for(var/turf/landing_turf in landing_tiles) + if(landing_turf in vehicle.locs) + overlap_tiles++ + if(!overlap_tiles) + continue + + var/vehicle_damage = overlap_tiles * KING_JUMP_DAMAGE_PER_TILE + vehicle.apply_hull_damage(vehicle_damage, "blunt", owner, unmitigated = TRUE) + for(var/obj/item/hardpoint/H in vehicle.hardpoints) + H.take_damage(vehicle_damage, "blunt", owner, unmitigated = TRUE) + if(istype(H, /obj/item/hardpoint/holder)) + var/obj/item/hardpoint/holder/holder = H + for(var/obj/item/hardpoint/nested in holder.hardpoints) + nested.take_damage(vehicle_damage, "blunt", owner, unmitigated = TRUE) + + // Landed dead-centre on a vehicle tile, place the King as a rider directly. + if(get_turf(owner) in vehicle.locs) + vehicle.mark_on_top(owner) + REMOVE_TRAIT(owner, TRAIT_UNDENSE, "Destroy") REMOVE_TRAIT(owner, TRAIT_IMMOBILIZED, "Destroy") diff --git a/code/modules/mob/living/carbon/xenomorph/castes/Lurker.dm b/code/modules/mob/living/carbon/xenomorph/castes/Lurker.dm index 9bd5e35cb8e3..23301268e6e6 100644 --- a/code/modules/mob/living/carbon/xenomorph/castes/Lurker.dm +++ b/code/modules/mob/living/carbon/xenomorph/castes/Lurker.dm @@ -81,15 +81,27 @@ // State var/next_slash_buffed = FALSE -/datum/behavior_delegate/lurker_base/melee_attack_modify_damage(original_damage, mob/living/carbon/target_carbon) +/datum/behavior_delegate/lurker_base/melee_attack_modify_damage(original_damage, atom/target_carbon) + // Crippling Strike's buff should still consume against a vehicle + if(istype(target_carbon, /obj/vehicle/multitile) && next_slash_buffed) + to_chat(bound_xeno, SPAN_XENOHIGHDANGER("We significantly strengthen our attack!")) + original_damage *= buffed_slash_damage_ratio + next_slash_buffed = FALSE + var/datum/action/xeno_action/onclick/lurker_assassinate/tank_ability = get_action(bound_xeno, /datum/action/xeno_action/onclick/lurker_assassinate) + if(tank_ability) + tank_ability.button.icon_state = "template_xeno" + return original_damage + if (!isxeno_human(target_carbon)) return original_damage + var/mob/living/carbon/target_mob = target_carbon + if (next_slash_buffed) - to_chat(bound_xeno, SPAN_XENOHIGHDANGER("We significantly strengthen our attack, slowing [target_carbon]!")) - to_chat(target_carbon, SPAN_XENOHIGHDANGER("You feel a sharp pain as [bound_xeno] slashes you, slowing you down!")) + to_chat(bound_xeno, SPAN_XENOHIGHDANGER("We significantly strengthen our attack, slowing [target_mob]!")) + to_chat(target_mob, SPAN_XENOHIGHDANGER("You feel a sharp pain as [bound_xeno] slashes you, slowing you down!")) original_damage *= buffed_slash_damage_ratio - target_carbon.set_effect(get_xeno_stun_duration(target_carbon, 3), SUPERSLOW) + target_mob.set_effect(get_xeno_stun_duration(target_mob, 3), SUPERSLOW) next_slash_buffed = FALSE var/datum/action/xeno_action/onclick/lurker_assassinate/ability = get_action(bound_xeno, /datum/action/xeno_action/onclick/lurker_assassinate) if (ability) diff --git a/code/modules/mob/living/carbon/xenomorph/castes/Ravager.dm b/code/modules/mob/living/carbon/xenomorph/castes/Ravager.dm index 85472a71a63a..1491be6706f0 100644 --- a/code/modules/mob/living/carbon/xenomorph/castes/Ravager.dm +++ b/code/modules/mob/living/carbon/xenomorph/castes/Ravager.dm @@ -345,5 +345,11 @@ if(should_sslow) new /datum/effects/xeno_slow/superslow(carbon_target, ravager_user, ttl = superslow_duration) + // Per tile, not deduped, unlike Flurry/High Gallop which only count once per use. + var/obj/vehicle/multitile/vehicle = get_multitile_vehicle_at(target_turf) + if(vehicle) + vehicle.take_damage_type(damage, "slash", ravager_user, unmitigated = TRUE) + playsound(vehicle, "alien_claw_flesh", 30, TRUE) + apply_cooldown() return ..() diff --git a/code/modules/mob/living/carbon/xenomorph/castes/Sentinel.dm b/code/modules/mob/living/carbon/xenomorph/castes/Sentinel.dm index 64b7e3247394..cc20e6a50541 100644 --- a/code/modules/mob/living/carbon/xenomorph/castes/Sentinel.dm +++ b/code/modules/mob/living/carbon/xenomorph/castes/Sentinel.dm @@ -142,7 +142,7 @@ return slowspit_user.visible_message(SPAN_XENOWARNING("[slowspit_user] spits at [target]!"), - SPAN_XENOWARNING("You spit at [target]!") ) + SPAN_XENOWARNING("You spit at [target.get_attack_desc(slowspit_user)]!") ) var/sound_to_play = pick(1, 2) == 1 ? 'sound/voice/alien_spitacid.ogg' : 'sound/voice/alien_spitacid2.ogg' playsound(slowspit_user.loc, sound_to_play, 25, 1) @@ -174,7 +174,7 @@ return scatterspit_user.visible_message(SPAN_XENOWARNING("[scatterspit_user] spits at [target]!"), - SPAN_XENOWARNING("You spit at [target]!") ) + SPAN_XENOWARNING("You spit at [target.get_attack_desc(scatterspit_user)]!") ) var/sound_to_play = pick(1, 2) == 1 ? 'sound/voice/alien_spitacid.ogg' : 'sound/voice/alien_spitacid2.ogg' playsound(scatterspit_user.loc, sound_to_play, 25, 1) diff --git a/code/modules/mob/living/carbon/xenomorph/castes/Warrior.dm b/code/modules/mob/living/carbon/xenomorph/castes/Warrior.dm index 0a79fbf32e9a..5a1e059cf622 100644 --- a/code/modules/mob/living/carbon/xenomorph/castes/Warrior.dm +++ b/code/modules/mob/living/carbon/xenomorph/castes/Warrior.dm @@ -297,6 +297,17 @@ if(!action_cooldown_check()) return + if(istype(affected_atom, /obj/vehicle/multitile)) + if(!punch_user.check_state() || punch_user.agility) + return + if(get_dist(punch_user, affected_atom) > 2) // matches the human-target branch's own tolerance below + return + if(!check_and_use_plasma_owner()) + return + punch_vehicle(punch_user, affected_atom) + apply_cooldown() + return ..() + if(!isxeno_human(affected_atom) || punch_user.can_not_harm(affected_atom)) return @@ -364,3 +375,15 @@ warrior.flick_attack_overlay(carbon, "punch") shake_camera(carbon, 2, 1) step_away(carbon, warrior, 2) + +/** + * Warrior Punch's vehicle resolution. Wrenches the turret aside if aiming at the gun or + * turret itself and one exists, otherwise just deals plain damage. + */ +/datum/action/xeno_action/activable/warrior_punch/proc/punch_vehicle(mob/living/carbon/xenomorph/warrior, obj/vehicle/multitile/vehicle) + var/turned_turret = resolve_turret_wrench_damage(vehicle, warrior, rand(base_damage, base_damage + damage_variance) * WARRIOR_PUNCH_TANK_DAMAGE_MULT, "blunt", ring_damage_mult = WARRIOR_PUNCH_TURRET_RING_DAMAGE_MULT) + if(turned_turret) + warrior.visible_message(SPAN_XENOWARNING("[warrior] slams into [vehicle]'s turret, wrenching it aside!"), SPAN_XENOWARNING("We slam into [vehicle.get_attack_desc(warrior)], wrenching the turret aside!")) + else + warrior.visible_message(SPAN_XENOWARNING("[warrior] slams into [vehicle]!"), SPAN_XENOWARNING("We slam into [vehicle.get_attack_desc(warrior)]!")) + playsound(vehicle, "punch", 25, TRUE) // force_turret_wrench() already plays its own punch/turretdamaged sounds when it fires - this covers the case where it doesn't diff --git a/code/modules/mob/living/carbon/xenomorph/resin_constructions.dm b/code/modules/mob/living/carbon/xenomorph/resin_constructions.dm index 8298f818408b..0696be9efdb1 100644 --- a/code/modules/mob/living/carbon/xenomorph/resin_constructions.dm +++ b/code/modules/mob/living/carbon/xenomorph/resin_constructions.dm @@ -69,6 +69,11 @@ to_chat(X, SPAN_WARNING("You cannot build under \the [V]!")) return FALSE + var/obj/vehicle/multitile/multitile_vehicle = get_multitile_vehicle_at(T) + if(multitile_vehicle) + to_chat(X, SPAN_WARNING("You cannot build under \the [multitile_vehicle]!")) + return FALSE + if(alien_weeds.linked_hive.hivenumber != X.hivenumber) to_chat(X, SPAN_WARNING("These weeds do not belong to your hive!")) return FALSE diff --git a/code/modules/mob/living/carbon/xenomorph/strains/castes/crusher/charger.dm b/code/modules/mob/living/carbon/xenomorph/strains/castes/crusher/charger.dm index 71f840a8c55e..ee42e6e461a0 100644 --- a/code/modules/mob/living/carbon/xenomorph/strains/castes/crusher/charger.dm +++ b/code/modules/mob/living/carbon/xenomorph/strains/castes/crusher/charger.dm @@ -481,6 +481,23 @@ charger_ability.stop_momentum() +// Vehicles + +/obj/vehicle/multitile/handle_charge_collision(mob/living/carbon/xenomorph/xeno, datum/action/xeno_action/onclick/charger_charge/charger_ability) + if(!charger_ability.momentum) + charger_ability.stop_momentum() + return + playsound(loc, "punch", 25, TRUE) + xeno.visible_message( + SPAN_DANGER("[xeno] rams [src], knocking it back!"), + SPAN_XENODANGER("You ram [src], knocking it back!") + ) + var/momentum_mult = (charger_ability.momentum == charger_ability.max_momentum) ? 8 : 5 + // Uses get_cardinal_dir(), not the charger's own dir, since a vehicle only moves cardinally. + resolve_crusher_charge_hit(xeno, get_cardinal_dir(xeno, src), CRUSHER_CHARGER_RAM_TANK_KNOCKBACK_TILES, charger_ability.momentum * momentum_mult) + charger_ability.lose_momentum(CCA_MOMENTUM_LOSS_MIN) + return XENO_CHARGE_TRY_MOVE + // Sentry /obj/structure/machinery/defenses/sentry/handle_charge_collision(mob/living/carbon/xenomorph/xeno, datum/action/xeno_action/onclick/charger_charge/charger_ability) diff --git a/code/modules/mob/living/carbon/xenomorph/strains/castes/lurker/vampire.dm b/code/modules/mob/living/carbon/xenomorph/strains/castes/lurker/vampire.dm index 1d5c0931116e..65849393fc86 100644 --- a/code/modules/mob/living/carbon/xenomorph/strains/castes/lurker/vampire.dm +++ b/code/modules/mob/living/carbon/xenomorph/strains/castes/lurker/vampire.dm @@ -86,6 +86,7 @@ target_turfs += current_turfs telegraph_atom_list += new /obj/effect/xenomorph/xeno_telegraph/red(current_turfs, 2) + var/hit_vehicle = FALSE for(var/turf/current_turfs in target_turfs) for(var/mob/living/carbon/target in current_turfs) if(target.stat == DEAD) @@ -109,6 +110,21 @@ xeno.gain_health(30) xeno.animation_attack_on(target) + // Once per use, not once per overlapping tile, since a vehicle's footprint can span multiple targets. + if(!hit_vehicle) + var/obj/vehicle/multitile/vehicle = get_multitile_vehicle_at(current_turfs) + if(vehicle) + hit_vehicle = TRUE + xeno.visible_message(SPAN_DANGER("[xeno] slashes [vehicle]!"), SPAN_XENOWARNING("We slash [vehicle.get_attack_desc(xeno)] multiple times!")) + xeno.flick_attack_overlay(vehicle, "slash") + log_attack("[key_name(xeno)] attacked [vehicle] with Flurry") + playsound(vehicle, 'sound/weapons/alien_claw_metal1.ogg', 30, TRUE) + vehicle.take_damage_type(xeno.caste.melee_damage_upper, "slash", xeno, unmitigated = TRUE) + if(!xeno.on_fire) + xeno.flick_heal_overlay(1 SECONDS, "#00B800") + xeno.gain_health(30) + xeno.animation_attack_on(vehicle) + xeno.emote("roar") return ..() @@ -149,6 +165,17 @@ if(current_structure.density && !current_structure.throwpass) to_chat(xeno, SPAN_WARNING("There's something blocking us from striking!")) return + + if(istype(targeted_atom, /obj/vehicle/multitile)) + var/obj/vehicle/multitile/vehicle = targeted_atom + to_chat(xeno, SPAN_XENOHIGHDANGER("We attack [vehicle.get_attack_desc(xeno)] with our tail, piercing it!")) + playsound(vehicle, 'sound/weapons/alien_tail_attack.ogg', 50, TRUE) + xeno.flick_attack_overlay(vehicle, "tail") + xeno.animation_attack_on(vehicle) + vehicle.take_damage_type(15, "slash", xeno, unmitigated = TRUE) + apply_cooldown() + return + // find a target in the target turf if(!iscarbon(targeted_atom) || hit_target.stat == DEAD) for(var/mob/living/carbon/carbonara in get_turf(targeted_atom)) diff --git a/code/modules/mob/living/carbon/xenomorph/strains/castes/praetorian/dancer.dm b/code/modules/mob/living/carbon/xenomorph/strains/castes/praetorian/dancer.dm index 616418396fad..88882f9f2d4c 100644 --- a/code/modules/mob/living/carbon/xenomorph/strains/castes/praetorian/dancer.dm +++ b/code/modules/mob/living/carbon/xenomorph/strains/castes/praetorian/dancer.dm @@ -208,6 +208,32 @@ if(!dancer_user.check_state()) return + if(istype(target_atom, /obj/vehicle/multitile)) + var/obj/vehicle/multitile/vehicle = target_atom + if(get_dist(dancer_user, vehicle) > range) + to_chat(dancer_user, SPAN_WARNING("[vehicle] is too far away!")) + return + if(!check_and_use_plasma_owner()) + return + apply_cooldown() + + dancer_user.face_atom(vehicle) + dancer_user.animation_attack_on(vehicle) + dancer_user.flick_attack_overlay(vehicle, "tail") + playsound(vehicle, 'sound/weapons/alien_tail_attack.ogg', 30, TRUE) + dancer_user.visible_message(SPAN_DANGER("\The [dancer_user] violently impales [vehicle] with its tail!"), SPAN_DANGER("We impale [vehicle] with our tail!")) + + // Guaranteed unmitigated hit to a random internal module when aiming at Hull/Turret, otherwise a + // bigger unmitigated hit straight to whatever external module is being aimed at. + var/damage = rand(dancer_user.melee_damage_lower, dancer_user.melee_damage_upper) + var/target_slot = vehicle.get_attack_target_slot(dancer_user) + if(target_slot == WOUND_SLOT_HULL || target_slot == HDPT_TURRET) + vehicle.force_internal_module_damage(target_slot, damage, "slash", dancer_user) + else + var/obj/item/hardpoint/target_hardpoint = vehicle.resolve_targeted_hardpoint(target_slot) + target_hardpoint?.take_damage(damage * GUARANTEED_EXTERNAL_HIT_DAMAGE_MULT, "slash", dancer_user, unmitigated = TRUE) + return + if(!ismob(target_atom)) apply_cooldown_override(impale_click_miss_cooldown) update_button_icon() diff --git a/code/modules/mob/living/carbon/xenomorph/strains/castes/praetorian/oppressor.dm b/code/modules/mob/living/carbon/xenomorph/strains/castes/praetorian/oppressor.dm index fc04821b63e7..5fc53c4478f6 100644 --- a/code/modules/mob/living/carbon/xenomorph/strains/castes/praetorian/oppressor.dm +++ b/code/modules/mob/living/carbon/xenomorph/strains/castes/praetorian/oppressor.dm @@ -38,6 +38,9 @@ var/tearing_damage = 15 /datum/behavior_delegate/oppressor_praetorian/melee_attack_additional_effects_target(mob/living/carbon/target_carbon) + if(!isxeno_human(target_carbon)) + return + if(target_carbon.stat == DEAD) return @@ -70,7 +73,7 @@ var/datum/ammo/ammoDatum = GLOB.ammo_list[/datum/ammo/xeno/oppressor_tail] hook_projectile.generate_bullet(ammoDatum, bullet_generator = stabbing_xeno) - hook_projectile.bound_beam = hook_projectile.beam(stabbing_xeno, "oppressor_tail", 'icons/effects/beam.dmi', 1 SECONDS, 5) + hook_projectile.bound_beam = hook_projectile.beam(stabbing_xeno, "oppressor_tail", 'icons/effects/beam.dmi', 1 SECONDS, 5, /obj/effect/ebeam/above_tank) hook_projectile.fire_at(targetted_atom, stabbing_xeno, stabbing_xeno, ammoDatum.max_range, ammoDatum.shell_speed) playsound(stabbing_xeno, 'sound/effects/oppressor_tail.ogg', 40, FALSE) @@ -254,6 +257,28 @@ if (!action_cooldown_check()) return + // Dislocate's vehicle resolution, same aim-dependent split Warrior Punch uses. + if(istype(target_atom, /obj/vehicle/multitile)) + if(!oppressor_user.check_state() || oppressor_user.agility) + return + if(!is_adjacent_to_multitile_vehicle(oppressor_user, target_atom)) + return + if(!check_and_use_plasma_owner()) + return + + var/obj/vehicle/multitile/vehicle = target_atom + oppressor_user.face_atom(vehicle) + oppressor_user.animation_attack_on(vehicle) + oppressor_user.flick_attack_overlay(vehicle, "punch") + var/turned_turret = resolve_turret_wrench_damage(vehicle, oppressor_user, damage * OPPRESSOR_DISLOCATE_TANK_DAMAGE_MULT, "blunt") + if(turned_turret) + oppressor_user.visible_message(SPAN_XENOWARNING("[oppressor_user] dislocates [vehicle]'s turret with a crushing blow!"), SPAN_XENOWARNING("We dislocate [vehicle.get_attack_desc(oppressor_user)] with a crushing blow!")) + else + oppressor_user.visible_message(SPAN_XENOWARNING("[oppressor_user] crushes into [vehicle]!"), SPAN_XENOWARNING("We crush into [vehicle.get_attack_desc(oppressor_user)]!")) + playsound(vehicle, "punch", 25, TRUE) // force_turret_wrench() already plays its own punch/turretdamaged sounds when it fires - this covers the case where it doesn't + apply_cooldown() + return + if (!isxeno_human(target_atom) || oppressor_user.can_not_harm(target_atom)) return diff --git a/code/modules/mob/living/carbon/xenomorph/strains/castes/praetorian/valkyrie.dm b/code/modules/mob/living/carbon/xenomorph/strains/castes/praetorian/valkyrie.dm index 06af5312f487..26f07da59e32 100644 --- a/code/modules/mob/living/carbon/xenomorph/strains/castes/praetorian/valkyrie.dm +++ b/code/modules/mob/living/carbon/xenomorph/strains/castes/praetorian/valkyrie.dm @@ -325,6 +325,7 @@ valkyrie.emote("roar") playsound(valkyrie, 'sound/effects/alien_footstep_charge3.ogg', 35, 0) + var/hit_vehicle = FALSE for (var/turf/range in target_turfs) for (var/mob/living/carbon/target in range) if (target.stat == DEAD) @@ -345,6 +346,13 @@ grenades.throw_atom(get_step_towards(target_destination, grenades), 3, SPEED_FAST, grenades) + // Minor momentum loss if it hits a vehicle at all, once per use, not once per overlapping tile. + if(!hit_vehicle) + var/obj/vehicle/multitile/vehicle = get_multitile_vehicle_at(range) + if(vehicle) + hit_vehicle = TRUE + vehicle.move_momentum *= HIGH_GALLOP_MOMENTUM_LOSS_MULT + return ..() diff --git a/code/modules/mob/living/carbon/xenomorph/strains/castes/praetorian/vanguard.dm b/code/modules/mob/living/carbon/xenomorph/strains/castes/praetorian/vanguard.dm index 2f259dc39e2e..117dac7497c0 100644 --- a/code/modules/mob/living/carbon/xenomorph/strains/castes/praetorian/vanguard.dm +++ b/code/modules/mob/living/carbon/xenomorph/strains/castes/praetorian/vanguard.dm @@ -108,13 +108,19 @@ return //X = xeno user, A = target atom - var/list/turf/target_turfs = get_line(pierce_user, targetted_atom, include_start_atom = FALSE) + // Aim at the closest tile of the tank's footprint instead of its center tile. + var/atom/line_target = targetted_atom + if(istype(targetted_atom, /obj/vehicle/multitile)) + var/obj/vehicle/multitile/vehicle_target = targetted_atom + line_target = get_closest_turf_of_multitile_vehicle(pierce_user, vehicle_target) || targetted_atom + var/list/turf/target_turfs = get_line(pierce_user, line_target, include_start_atom = FALSE) var/length_of_line = LAZYLEN(target_turfs) if(length_of_line > 3) target_turfs = target_turfs.Copy(1, 4) // Get list of target mobs var/list/target_mobs = list() + var/obj/vehicle/multitile/hit_vehicle = null var/blocked = FALSE for(var/turf/path_turf as anything in target_turfs) @@ -124,6 +130,11 @@ if(path_turf.density) break + if(!hit_vehicle) + var/obj/vehicle/multitile/candidate = get_multitile_vehicle_at(path_turf) + if(candidate) + hit_vehicle = candidate + //Check for structures such as doors for(var/atom/path_content as anything in path_turf.contents) if(isobj(path_content)) @@ -167,6 +178,20 @@ current_mob.apply_armoured_damage(get_xeno_damage_slash(current_mob, damage), ARMOR_MELEE, BRUTE, null, 20) playsound(current_mob, pierce_sounds, 30, 1) + // Once per use, guaranteed hit to a random internal module at Hull/Turret, otherwise external. + // + // Gives the vehicle its own clear hit message instead of the generic "slashes its claws" line. + if(hit_vehicle) + playsound(hit_vehicle, pierce_sounds, 30, TRUE) + var/target_slot = hit_vehicle.get_attack_target_slot(pierce_user) + if(target_slot == WOUND_SLOT_HULL || target_slot == HDPT_TURRET) + pierce_user.visible_message(SPAN_DANGER("\The [pierce_user] pierces \the [hit_vehicle], puncturing straight through to its internals!"), SPAN_DANGER("We pierce \the [hit_vehicle], puncturing straight through to its internals!")) + hit_vehicle.force_internal_module_damage(target_slot, damage, "slash", pierce_user) + else + var/obj/item/hardpoint/target_hardpoint = hit_vehicle.resolve_targeted_hardpoint(target_slot) + pierce_user.visible_message(SPAN_DANGER("\The [pierce_user] pierces \the [hit_vehicle]!"), SPAN_DANGER("We pierce [hit_vehicle.get_attack_desc(pierce_user)]!")) + target_hardpoint?.take_damage(damage * GUARANTEED_EXTERNAL_HIT_DAMAGE_MULT, "slash", pierce_user, unmitigated = TRUE) + if (length(target_mobs) >= shield_regen_threshold) var/datum/behavior_delegate/praetorian_vanguard/behavior = pierce_user.behavior_delegate if (istype(behavior)) @@ -247,6 +272,31 @@ if (!check_and_use_plasma_owner()) return + // Neither Cleave mode can grab a vehicle. Root halves its momentum instead, fling deals tank_damage. + if(istype(target_atom, /obj/vehicle/multitile)) + if(!is_adjacent_to_multitile_vehicle(cleave_user, target_atom)) + to_chat(cleave_user, SPAN_XENOWARNING("We must be adjacent to our target!")) + return + + var/obj/vehicle/multitile/vehicle = target_atom + cleave_user.face_atom(vehicle) + cleave_user.animation_attack_on(vehicle, 10) + var/tank_hitsound = pick('sound/weapons/punch1.ogg','sound/weapons/punch2.ogg','sound/weapons/punch3.ogg','sound/weapons/punch4.ogg') + playsound(vehicle, tank_hitsound, 75, TRUE) + play_wound_gain_effects(vehicle, WOUND_DAMTYPE_BRUTE, cleave_user) + + if(root_toggle) + vehicle.halve_speed() + cleave_user.visible_message(SPAN_XENODANGER("[cleave_user] slams into [vehicle], slowing it to a crawl!"), SPAN_XENOHIGHDANGER("We slam into [vehicle.get_attack_desc(cleave_user)], slowing it to a crawl!")) + cleave_user.flick_attack_overlay(vehicle, "punch") + else + cleave_user.visible_message(SPAN_XENODANGER("[cleave_user] deals [vehicle] a massive blow!"), SPAN_XENOHIGHDANGER("We deal [vehicle.get_attack_desc(cleave_user)] a massive blow!")) + cleave_user.flick_attack_overlay(vehicle, "slam") + vehicle.take_damage_type(tank_damage, "slash", cleave_user) + + apply_cooldown() + return ..() + if (!isxeno_human(target_atom) || cleave_user.can_not_harm(target_atom)) to_chat(cleave_user, SPAN_XENODANGER("We must target a hostile!")) return diff --git a/code/modules/mob/living/carbon/xenomorph/strains/castes/runner/acider.dm b/code/modules/mob/living/carbon/xenomorph/strains/castes/runner/acider.dm index d6bcb4cbfb5b..eb4afbe9c722 100644 --- a/code/modules/mob/living/carbon/xenomorph/strains/castes/runner/acider.dm +++ b/code/modules/mob/living/carbon/xenomorph/strains/castes/runner/acider.dm @@ -20,6 +20,8 @@ runner.speed_modifier += XENO_SPEED_SLOWMOD_TIER_5 runner.armor_modifier += XENO_ARMOR_MOD_MED runner.health_modifier += XENO_HEALTH_MOD_ACIDER + // Uses vehicle_damage_modifier specifically, not damage_modifier which would also buff mob damage. + runner.vehicle_damage_modifier += XENO_DAMAGE_TIER_2 runner.recalculate_everything() @@ -62,6 +64,9 @@ var/mutable_appearance/drool_applied_icon var/mutable_appearance/acid_overlays_icon + /// The acid half of the current strike against a tank, applied as a separate hit right after. + var/pending_tank_acid_damage = 0 + /datum/behavior_delegate/runner_acider/New() . = ..() drool_applied_icon = mutable_appearance('icons/mob/xenos/castes/tier_1/runner_strain_overlays.dmi', "Acider Runner Walking") @@ -84,25 +89,48 @@ if(caboom_trigger) . += "FOR THE HIVE!: in [caboom_left] seconds" -/datum/behavior_delegate/runner_acider/melee_attack_additional_effects_target(mob/living/carbon/target_mob) - if(ishuman(target_mob)) //Will acid be applied to the mob - var/mob/living/carbon/human/target_human = target_mob +/** + * Splits this strike's damage into a brute half and an acid half against a vehicle, rolling both wound pools. + */ +/datum/behavior_delegate/runner_acider/melee_attack_modify_damage(original_damage, atom/target) + if(!istype(target, /obj/vehicle/multitile)) + return ..() + pending_tank_acid_damage = round(original_damage / 2) + return original_damage - pending_tank_acid_damage + +/datum/behavior_delegate/runner_acider/melee_attack_additional_effects_target(atom/target_mob) + // A vehicle tops up acid glands directly, at half the standing-marine rate. + if(istype(target_mob, /obj/vehicle/multitile)) + modify_acid(acid_slash_regen_standing / 2) + addtimer(CALLBACK(src, PROC_REF(combat_gen_end)), combat_gen_timer, TIMER_UNIQUE|TIMER_OVERRIDE|TIMER_STOPPABLE) + combat_gen_active = TRUE + drool_overlay_active = TRUE + if(pending_tank_acid_damage > 0) + var/obj/vehicle/multitile/vehicle = target_mob + vehicle.take_damage_type(pending_tank_acid_damage, "acid", bound_xeno) + pending_tank_acid_damage = 0 + return + + var/mob/living/carbon/target = target_mob + + if(ishuman(target)) //Will acid be applied to the mob + var/mob/living/carbon/human/target_human = target if(target_human.buckled && istype(target_human.buckled, /obj/structure/bed/nest)) return if(target_human.stat == DEAD) return - var/datum/effects/acid/acid_effect = locate() in target_mob.effects_list + var/datum/effects/acid/acid_effect = locate() in target.effects_list if(acid_effect) acid_effect.prolong_duration() else - new /datum/effects/acid(target_mob, bound_xeno, initial(bound_xeno.caste_type)) - if(isxeno_human(target_mob)) //Will the runner get acid stacks - var/obj/item/alien_embryo/embryo = locate(/obj/item/alien_embryo) in target_mob.contents + new /datum/effects/acid(target, bound_xeno, initial(bound_xeno.caste_type)) + if(isxeno_human(target)) //Will the runner get acid stacks + var/obj/item/alien_embryo/embryo = locate(/obj/item/alien_embryo) in target.contents if(embryo?.stage >= 4) //very late stage hugged in case the runner unnests them return - if(target_mob.body_position == LYING_DOWN) + if(target.body_position == LYING_DOWN) modify_acid(acid_slash_regen_lying) return modify_acid(acid_slash_regen_standing) diff --git a/code/modules/mob/living/carbon/xenomorph/strains/castes/warrior/bulwark.dm b/code/modules/mob/living/carbon/xenomorph/strains/castes/warrior/bulwark.dm index 6bbecc671e41..c484dfce7b97 100644 --- a/code/modules/mob/living/carbon/xenomorph/strains/castes/warrior/bulwark.dm +++ b/code/modules/mob/living/carbon/xenomorph/strains/castes/warrior/bulwark.dm @@ -170,6 +170,32 @@ /datum/action/xeno_action/activable/plate_bash/use_ability(atom/target_atom) var/mob/living/carbon/xenomorph/xeno_player = owner + // Plain hit against a vehicle, unlike Punch. Otherwise plays out like a Defender's lowered-crest + // Headbutt, only pushing the vehicle while plates are actually down. + if(istype(target_atom, /obj/vehicle/multitile)) + if(!is_adjacent_to_multitile_vehicle(xeno_player, target_atom)) + return + XENO_ACTION_CHECK_USE_PLASMA(xeno_player) + + var/obj/vehicle/multitile/vehicle = target_atom + var/atop_vehicle = (get_multitile_vehicle_at(get_turf(xeno_player)) == vehicle) + + xeno_player.visible_message(SPAN_XENOWARNING("[xeno_player] bashes [vehicle] with its armored plates!"), SPAN_XENOWARNING("We bash [vehicle.get_attack_desc(xeno_player)] with our armored plates!")) + xeno_player.face_atom(vehicle) + xeno_player.animation_attack_on(vehicle) + playsound(vehicle, 'sound/weapons/alien_claw_block.ogg', 50, TRUE) + playsound(vehicle, 'sound/effects/metal_crash.ogg', 35) + play_wound_gain_effects(vehicle, WOUND_DAMTYPE_BRUTE, xeno_player) + + vehicle.take_damage_type(base_damage, "blunt", xeno_player, unmitigated = TRUE) + + // Never while atop the vehicle, mirrors Headbutt's own atop_vehicle exemption. + if(!atop_vehicle && HAS_TRAIT(xeno_player, TRAIT_ABILITY_ENCLOSED_PLATES)) + vehicle.knockback(get_cardinal_dir(xeno_player, vehicle), 1, jostle_riders = FALSE) + + apply_custom_cooldown() + return + if(!iscarbon(target_atom)) return diff --git a/code/modules/mob/living/living.dm b/code/modules/mob/living/living.dm index 97595a01e1b2..18f92ff7883c 100644 --- a/code/modules/mob/living/living.dm +++ b/code/modules/mob/living/living.dm @@ -156,6 +156,76 @@ return +/** + * _handle_tank_edge_move blocks consensual movement outside of tank tiles, unless you invoke climb_down() + * + * This proc makes it so that you cannot instantly dismount the tank, nor accidentally walk out of it. + * Marines and xenos alike become unable to dismount unless: + * - They are dismounted forcefully (e.g, by an explosion, grab, et-cetera) + * > Marines eat a 3 second stun if this happens. Xenos currently do not get punished. + * - They use some kind of skill to dismount (e.g pouncing outside) + * - They wait 1 second until climb_down finishes. + * + * OOPS! As a fix for LINTERS, climb_down() is called on the proc in carbon.dm + * + * Arguments: + * * NewLoc - Wherever we're trying to step down towards. + * * Direct - Direction we're facing + * + * Returns: + * * TRUE - When dismounting is impossible (blocked turf) or consensual (climb_down) + * * FALSE - When the mob dismounts improperly, and must be punished. + */ +/mob/living/proc/_handle_tank_edge_move(NewLoc, direct) + var/obj/vehicle/multitile/tank = get_tank_on_top_of() + + if(!tank) + return FALSE + + var/turf/here = get_turf(src) + if(!(here in tank.locs)) + tank.clear_on_top(src) + return FALSE + + // only care about steps that would leave the hull. + if(isturf(NewLoc) && !(NewLoc in tank.locs)) + if(!(flags_atom & DIRLOCK)) + setDir(direct) + SEND_SIGNAL(src, COMSIG_MOB_MOVE_OR_LOOK, TRUE, direct, direct) + if(pulledby || throwing) + return FALSE + var/turf/edge = get_step(here, direct) + if(!edge || edge.z != z || (edge in tank.locs)) + return FALSE + if(tank._blocked_except_mobs(edge, src, here)) + return TRUE + // special interaction for simple_animals and (edge case) silicons... + // ...to prevent SpacemanDMM from sleeping and still allow dismounts. + if(!iscarbon(src)) + tank.clear_on_top(src) + src.forceMove(NewLoc) + return TRUE + return TRUE + + return FALSE + +/** + * is_on_tank_hull checks if a mob has a valid tank rider component. + * + * This proc checks the tank this mob's vehicle_rider component (if any) points to. + * If there is a valid tank, it means this mob is atop a tank tile. + * + * Returns: + * * TRUE - If there is a valid tile under the tank, and if mob and tank are on the same Z-level. + * * FALSE - If the mob has no vehicle_rider component, if the Z-levels of the mob and Tank mistmach, if the tile Tu is invalid, + * * ...Or if the tile Tu is not, in fact, underneath a tank. + */ +/mob/living/proc/is_on_tank_hull() + var/obj/vehicle/multitile/tank = get_tank_on_top_of() + if(!tank || z != tank.z) return FALSE + var/turf/Tu = get_turf(src) + return Tu && (Tu in tank.locs) + /mob/living/Move(NewLoc, direct) if(lying_angle != 0) lying_angle_on_movement(direct) @@ -165,6 +235,21 @@ else return FALSE + if(is_on_tank_hull()) + if(_handle_tank_edge_move(NewLoc, direct)) + return FALSE + if(isturf(NewLoc)) + var/obj/vehicle/multitile/tank = get_tank_on_top_of() + if(tank && !(NewLoc in tank.locs)) + // clear_on_top() runs for every mob forced off the hull. Humans additionally get punished. + if(ishuman(src)) + var/mob/living/carbon/human/H = src + H.apply_effect(1.5, WEAKEN) + H.apply_effect(3, SUPERSLOW) + H.apply_effect(5, SLOW) + playsound(src, "punch", 25, TRUE) + tank.clear_on_top(src) + var/atom/movable/pullee = pulling if(pullee && get_dist(src, pullee) > 1) //Is the pullee adjacent? if(!pullee.clone || (pullee.clone && get_dist(src, pullee.clone) > 2)) //Be lenient with the close @@ -389,6 +474,30 @@ now_pushing = FALSE return + // tank-related collision rules + var/src_on_tank = is_on_tank_hull() + var/target_on_tank = living_mob.is_on_tank_hull() + + if(src_on_tank != target_on_tank) + if(!src_on_tank && target_on_tank) + // Reroute to the tank's own Collided() so src can climb up and share the tile with living_mob. + var/obj/vehicle/multitile/tank = living_mob.get_tank_on_top_of() + if(tank) + now_pushing = FALSE + tank.Collided(src) + return + now_pushing = FALSE + return + + if(src_on_tank && target_on_tank) + var/obj/vehicle/multitile/tank = get_tank_on_top_of() + if(tank == living_mob.get_tank_on_top_of()) + var/turf/destination = get_step(living_mob.loc, dir) + if(destination && !(destination in tank.locs)) + if(src.a_intent != INTENT_HELP || living_mob.a_intent != INTENT_HELP) + now_pushing = FALSE + return + if(!living_mob.buckled && !living_mob.anchored) var/mob_swap //the puller can always swap with its victim if on grab intent @@ -632,11 +741,23 @@ /mob/living/proc/update_layer() //so mob lying always appear behind standing mobs, but dead ones appear behind living ones if(pulledby && pulledby.grab_level == GRAB_CARRY) - layer = ABOVE_MOB_LAYER + if(is_on_tank_hull()) + layer = TANK_ABOVE_RIDER_LAYER + else + layer = ABOVE_MOB_LAYER else if (body_position == LYING_DOWN && stat == DEAD) - layer = LYING_DEAD_MOB_LAYER // Dead mobs should layer under living ones + if(is_on_tank_hull()) + layer = TANK_BELOW_RIDER_LAYER + else + layer = LYING_DEAD_MOB_LAYER // Dead mobs should layer under living ones else if(body_position == LYING_DOWN && layer == initial(layer)) //to avoid things like hiding larvas. //i have no idea what this means - layer = LYING_LIVING_MOB_LAYER + if(is_on_tank_hull()) + layer = TANK_LYING_RIDER_LAYER + else + layer = LYING_LIVING_MOB_LAYER + else if(is_on_tank_hull()) + // Standing, not being carried, on the tank hull. + layer = TANK_RIDER_LAYER /// Called when mob changes from a standing position into a prone while lacking the ability to stand up at the moment. /mob/living/proc/on_fall() diff --git a/code/modules/mob/living/living_defense.dm b/code/modules/mob/living/living_defense.dm index 4f8c1eccbbf9..1c434cf6d36a 100644 --- a/code/modules/mob/living/living_defense.dm +++ b/code/modules/mob/living/living_defense.dm @@ -230,10 +230,16 @@ else clear_fullscreen("weather") +// Riding a tank hull shields from ground fire elsewhere, but not from fire mounted on that same hull. /mob/living/handle_flamer_fire(obj/flamer_fire/fire, damage, delta_time) + if(is_on_tank_hull() && fire.get_tank_on_top_of() != get_tank_on_top_of()) + return . = ..() fire.set_on_fire(src) /mob/living/handle_flamer_fire_crossed(obj/flamer_fire/fire) + if(is_on_tank_hull() && fire.get_tank_on_top_of() != get_tank_on_top_of()) + return . = ..() fire.set_on_fire(src) + diff --git a/code/modules/mob/mob_defines.dm b/code/modules/mob/mob_defines.dm index 4d387bf6f6f6..36cbbec7aad3 100644 --- a/code/modules/mob/mob_defines.dm +++ b/code/modules/mob/mob_defines.dm @@ -32,6 +32,8 @@ /// The list of people observing this mob. var/list/mob/dead/observer/observers var/zone_selected = "chest" + /// Vehicle hardpoint slot mirrored from zone_selected via GLOB.human_zone_to_vehicle_part. + var/vehicle_zone_selected = WOUND_SLOT_HULL var/use_me = 1 //Allows all mobs to use the me verb by default, will have to manually specify they cannot var/damageoverlaytemp = 0 diff --git a/code/modules/mob/mob_grab.dm b/code/modules/mob/mob_grab.dm index 097fba18a8a7..872d8aa12c22 100644 --- a/code/modules/mob/mob_grab.dm +++ b/code/modules/mob/mob_grab.dm @@ -116,6 +116,25 @@ playsound(loc, 'sound/weapons/thudswoosh.ogg', 25, 1, 7) user.visible_message(SPAN_WARNING("[user] holds [victim] by the neck and starts choking them!"), null, null, 5) msg_admin_attack("[key_name(user)] started to choke [key_name(victim)] at [get_area_name(victim)]", victim.loc.x, victim.loc.y, victim.loc.z) + + // allows pulling ppl onto the tank if you are already ontop of it.. + // the idea is to allow the tank to charge into an incend OB and for corpsmen ontop to recover people. + var/obj/vehicle/multitile/tank/tank = user.get_tank_on_top_of() + if(tank && !victim.get_tank_on_top_of()) + if(istype(tank)) + var/turf/victim_turf = get_turf(victim) + var/adjacent_to_tank = FALSE + for(var/turf/tank_turf in tank.locs) + if(get_dist(victim_turf, tank_turf) <= 1 && victim_turf != tank_turf) + adjacent_to_tank = TRUE + break + + if(adjacent_to_tank) + victim.forceMove(user.loc) + tank.mark_on_top(victim) + victim.update_transform(TRUE) + return + victim.Move(user.loc, get_dir(victim.loc, user.loc)) victim.update_transform(TRUE) diff --git a/code/modules/mob/mob_helpers.dm b/code/modules/mob/mob_helpers.dm index f258e8a0a26c..fe8749683cf0 100644 --- a/code/modules/mob/mob_helpers.dm +++ b/code/modules/mob/mob_helpers.dm @@ -383,6 +383,11 @@ GLOBAL_LIST_INIT(limb_types_by_name, list( hud_used.zone_sel.selecting = target_zone hud_used.zone_sel.update_icon() + if(hud_used.vehicle_zone_sel) + vehicle_zone_selected = GLOB.human_zone_to_vehicle_part[target_zone] || WOUND_SLOT_HULL + hud_used.vehicle_zone_sel.selecting = target_zone + hud_used.vehicle_zone_sel.update_icon() + #define DURATION_MULTIPLIER_TIER_1 0.75 #define DURATION_MULTIPLIER_TIER_2 0.5 #define DURATION_MULTIPLIER_TIER_3 0.25 diff --git a/code/modules/mob/mob_verbs.dm b/code/modules/mob/mob_verbs.dm index c42b79562c6a..07b10440ae6d 100644 --- a/code/modules/mob/mob_verbs.dm +++ b/code/modules/mob/mob_verbs.dm @@ -251,8 +251,17 @@ set name = "Look Up" set category = "IC" + var/obj/structure/bed/chair/comfy/vehicle/vehicle_seat + if(buckled && istype(buckled, /obj/structure/bed/chair/comfy/vehicle)) + vehicle_seat = buckled + // Only the driver/gunner seats get a personal look up/down. + var/is_vehicle_crew = vehicle_seat?.vehicle && (vehicle_seat.seat == VEHICLE_DRIVER || vehicle_seat.seat == VEHICLE_GUNNER) + if(observed_atom) QDEL_NULL(observed_atom) + if(is_vehicle_crew && client) + client.change_view(VEHICLE_SEAT_VIEW_RADIUS, vehicle_seat.vehicle) + vehicle_seat.vehicle.set_seated_mob(vehicle_seat.seat, src) return if(!client) @@ -263,7 +272,12 @@ eye.change_level() return - if(client.view != world.view) + // Tank crew get a wider view even at rest, exempt their baseline seat view from the zoom check. + if(is_vehicle_crew) + if(client.view != VEHICLE_SEAT_VIEW_RADIUS) + to_chat(src, SPAN_WARNING("You cannot look up while zoomed!")) + return + else if(client.view != world.view) to_chat(src, SPAN_WARNING("You cannot look up while zoomed!")) return @@ -271,11 +285,24 @@ to_chat(src, SPAN_WARNING("We cannot look up here, we are burrowed!")) return - if(!isturf(loc)) - to_chat(src, SPAN_WARNING("You cannot look up here.")) - return + var/turf/vehicle_turf = null + if(vehicle_seat) + if(is_vehicle_crew) + vehicle_turf = get_turf(vehicle_seat.vehicle) + else + to_chat(src, SPAN_WARNING("You cannot look up from this position.")) + return + + var/turf/above + if(vehicle_turf) + // A seated crewman's loc sits in the vehicle's own interior, so use its real exterior position instead. + above = locate(vehicle_turf.x, vehicle_turf.y, vehicle_turf.z + 1) + else + if(!isturf(loc)) + to_chat(src, SPAN_WARNING("You cannot look up here.")) + return + above = SSmapping.get_turf_above(loc) - var/turf/above = SSmapping.get_turf_above(loc) if(!isturf(above)) to_chat(src, SPAN_WARNING("You cannot look up here.")) return @@ -284,6 +311,6 @@ to_chat(src, SPAN_WARNING("You cannot look up here.")) return - var/mob/hologram/look_up/observed_hologram = new(above, src) + var/mob/hologram/look_up/observed_hologram = new(above, src, vehicle_seat?.vehicle, vehicle_seat?.seat) observed_atom = observed_hologram diff --git a/code/modules/movement/launching/launching.dm b/code/modules/movement/launching/launching.dm index b5c177371b9e..a1ceb2a6b80f 100644 --- a/code/modules/movement/launching/launching.dm +++ b/code/modules/movement/launching/launching.dm @@ -211,6 +211,8 @@ break if (LM.dist >= LM.range) break + if(locate(/obj/vehicle/multitile/tank) in T) + layer = TANK_RIDER_OBJ_LAYER // prevents thrown items from being layered under a vehicle during throw. if (!Move(T)) // If this returns FALSE, then a collision happened break last_loc = loc @@ -219,10 +221,23 @@ sleep(delay) //done throwing, either because it hit something or it finished moving + // prevents xeno pounces from layering under the tank, as well as other species with pounces. + if (isliving(src)) + var/mob/living/living_mob = src + living_mob.update_layer() + var/obj/vehicle/multitile/tank/rider_tank = living_mob.get_tank_on_top_of() + if(rider_tank) + if(!(locate(/obj/vehicle/multitile/tank) in get_turf(living_mob))) + rider_tank.clear_on_top(living_mob) // if we're not atop the tank still, clear us from it. + else + src.layer = initial(src.layer) if ((isobj(src) || ismob(src)) && throwing && !early_exit) var/turf/T = get_turf(src) if(!istype(T)) return + var/obj/vehicle/multitile/tank/tank = locate(/obj/vehicle/multitile/tank) + if(tank && (tank in T)) + tank.obj_mark_on_top(src) var/atom/hit_atom = ismob(LM.target) ? null : T // TODO, just check for LM.target, the ismob is to prevent funky behavior with grenades 'n crates if(!hit_atom) for(var/atom/A in T) diff --git a/code/modules/projectiles/gun.dm b/code/modules/projectiles/gun.dm index 594ca227a9ac..1f5571b084d8 100644 --- a/code/modules/projectiles/gun.dm +++ b/code/modules/projectiles/gun.dm @@ -1280,6 +1280,8 @@ and you're good to go. var/atom/original_target = target //This is for burst mode, in case the target changes per scatter chance in between fired bullets. + var/obj/vehicle/multitile/tank/rider_tank = user.get_tank_on_top_of() // Used to calculate inaccuracy when firing atop a moving tank. + if(loc != user || (flags_gun_features & GUN_WIELDED_FIRING_ONLY && !(flags_item & WIELDED))) return TRUE @@ -1292,7 +1294,7 @@ and you're good to go. var/original_scatter = projectile_to_fire.scatter var/original_accuracy = projectile_to_fire.accuracy - apply_bullet_scatter(projectile_to_fire, user, reflex, dual_wield) //User can be passed as null. + apply_bullet_scatter(projectile_to_fire, user, reflex, dual_wield, rider_tank) //User can be passed as null. curloc = get_turf(user) if(QDELETED(original_target)) //If the target's destroyed, shoot at where it was last. @@ -1918,13 +1920,23 @@ not all weapons use normal magazines etc. load_into_chamber() itself is designed return TRUE //This proc calculates scatter and accuracy -/obj/item/weapon/gun/proc/apply_bullet_scatter(obj/projectile/projectile_to_fire, mob/user, reflex = 0, dual_wield = 0) +/obj/item/weapon/gun/proc/apply_bullet_scatter(obj/projectile/projectile_to_fire, mob/user, reflex = 0, dual_wield = 0, obj/vehicle/multitile/tank/rider_tank = null) var/gun_accuracy_mult = accuracy_mult_unwielded var/gun_scatter = scatter_unwielded if(flags_item & WIELDED || flags_gun_features & GUN_ONE_HAND_WIELDED) gun_accuracy_mult = accuracy_mult gun_scatter = scatter + // increases scatter to penalize marines firing atop a moving tank instead of outright restricting it + // only for wielded firearms. + if(rider_tank) + var/obj/vehicle/multitile/tank/TANK = rider_tank + if(world.time < TANK.on_top_mobs_shooting_inaccuracy_time) + if(world.time % 3) + to_chat(gun_user, SPAN_DANGER("You struggle to keep your aim centered as the [TANK] moves!")) + gun_accuracy_mult = max(0.1, gun_accuracy_mult * 0.4) // 60% accuracy loss + gun_scatter += SCATTER_AMOUNT_TIER_4 + else if(user && world.time - user.l_move_time < 5) //moved during the last half second //accuracy and scatter penalty if the user fires unwielded right after moving gun_accuracy_mult = max(0.1, gun_accuracy_mult - max(0,movement_onehanded_acc_penalty_mult * HIT_ACCURACY_MULT_TIER_3)) diff --git a/code/modules/projectiles/gun_attachables.dm b/code/modules/projectiles/gun_attachables.dm index ca0e97b5e80a..cffee99efae2 100644 --- a/code/modules/projectiles/gun_attachables.dm +++ b/code/modules/projectiles/gun_attachables.dm @@ -1247,6 +1247,10 @@ Defined in conflicts.dm of the #defines folder. return TRUE if(!G.zoom) + if(is_in_tank_interior(user)) + if(user) + to_chat(user, SPAN_WARNING("There's not enough room to use [src] properly in here.")) + return FALSE if(!(G.flags_item & WIELDED)) if(user) to_chat(user, SPAN_WARNING("You must hold [G] with two hands to use [src].")) diff --git a/code/modules/projectiles/gun_helpers.dm b/code/modules/projectiles/gun_helpers.dm index 22d679c2247a..f3477f12af95 100644 --- a/code/modules/projectiles/gun_helpers.dm +++ b/code/modules/projectiles/gun_helpers.dm @@ -1016,7 +1016,10 @@ DEFINES in setup.dm, referenced here. return target if(!istype(target, /atom/movable/screen/click_catcher)) return null - return params2turf(modifiers[SCREEN_LOC], get_turf(user), user.client) + // params2turf() treats its "origin" turf as the center of the client's viewport, which breaks for a + // seated vehicle crewman whose eye isn't their own body. + var/turf/anchor = get_turf(user.client?.get_eye()) || get_turf(user) + return params2turf(modifiers[SCREEN_LOC], anchor, user.client) /// check if the gun contains any light source that is currently turned on. /obj/item/weapon/gun/proc/light_sources() diff --git a/code/modules/projectiles/guns/flamer/flamer.dm b/code/modules/projectiles/guns/flamer/flamer.dm index d4ddc673fb6e..ce46ed2bba4a 100644 --- a/code/modules/projectiles/guns/flamer/flamer.dm +++ b/code/modules/projectiles/guns/flamer/flamer.dm @@ -267,7 +267,10 @@ if(blocked) if(blocked.flags_atom & ON_BORDER) return - stop_at_turf = TRUE + var/datum/flameshape/FS = GLOB.flameshapes[flameshape] + var/tank_obstacle = FS && FS.is_tank_obstacle(blocked) + if(!tank_obstacle) + stop_at_turf = TRUE if(stop_at_turf) flame_adjacent(current_turf, user, chem) @@ -280,7 +283,9 @@ playsound(current_turf, src.get_fire_sound(), 50, TRUE) - new /obj/flamer_fire(current_turf, create_cause_data(initial(name), user), chem, max_range, current_mag.reagents, flameshape, target, CALLBACK(src, PROC_REF(show_percentage), user), fuel_pressure, fire_type) + // The shooter's own tile stands in for "whatever preceded this flame". + var/mount_override = resolve_flame_mount(current_turf, get_turf(user), user.get_tank_on_top_of()) + new /obj/flamer_fire(current_turf, create_cause_data(initial(name), user), chem, max_range, current_mag.reagents, flameshape, target, CALLBACK(src, PROC_REF(show_percentage), user), fuel_pressure, fire_type, mount_override) /obj/item/weapon/gun/flamer/proc/flame_adjacent(turf/turfed, mob/living/user, datum/reagent/chem) if(!istype(turfed)) @@ -577,6 +582,10 @@ GLOBAL_LIST_EMPTY(flamer_particles) icon = 'icons/effects/fire.dmi' icon_state = "dynamic_2" layer = BELOW_OBJ_LAYER + // Lets a flame ride along on a tank's footprint instead of staying behind on the map. + is_allowed_atop_vehicle = TRUE + // A mounted flame shouldn't get flung off by a hard crash. same behavior as acid tiles. Intent is forcing crewmen to pulling back and waiting it off. + immune_to_tank_crash_scatter = TRUE light_system = STATIC_LIGHT light_on = TRUE @@ -603,7 +612,7 @@ GLOBAL_LIST_EMPTY(flamer_particles) var/weather_smothering_strength = 0 -/obj/flamer_fire/Initialize(mapload, datum/cause_data/cause_data, datum/reagent/R, fire_spread_amount = 0, datum/reagents/obj_reagents = null, new_flameshape = FLAMESHAPE_DEFAULT, atom/target = null, datum/callback/C, fuel_pressure = 1, fire_type = FIRE_VARIANT_DEFAULT) +/obj/flamer_fire/Initialize(mapload, datum/cause_data/cause_data, datum/reagent/R, fire_spread_amount = 0, datum/reagents/obj_reagents = null, new_flameshape = FLAMESHAPE_DEFAULT, atom/target = null, datum/callback/C, fuel_pressure = 1, fire_type = FIRE_VARIANT_DEFAULT, mount_override = FLAME_MOUNT_AUTO) . = ..() if(!R) R = new /datum/reagent/napalm/ut() @@ -668,6 +677,11 @@ GLOBAL_LIST_EMPTY(flamer_particles) qdel(src) return + // Mounts atop a tank's footprint so it rides along instead of being left behind. + var/obj/vehicle/multitile/tank/mount_tank = (mount_override == FLAME_MOUNT_AUTO) ? get_multitile_vehicle_at(loc) : mount_override + if(istype(mount_tank)) + mount_tank.obj_mark_on_top(src) + if(fire_spread_amount > 0) var/datum/flameshape/FS = GLOB.flameshapes[flameshape] if(!FS) @@ -692,6 +706,9 @@ GLOBAL_LIST_EMPTY(flamer_particles) S.changing_layer(new_layer) for(var/mob/living/ignited_morb in loc) //Deal bonus damage if someone's caught directly in initial stream + // Only a mob actually riding atop the same tank gets caught by a mounted flame. + if(is_atop_vehicle() && ignited_morb.get_tank_on_top_of() != get_tank_on_top_of()) + continue if(ignited_morb.stat == DEAD) continue @@ -760,6 +777,10 @@ GLOBAL_LIST_EMPTY(flamer_particles) current_open_turf.check_fall(src) /obj/flamer_fire/Destroy() + // Unmounts from the tank if mounted, otherwise a burned-out flame stays counted. + var/obj/vehicle/multitile/tank/mount_tank = get_tank_on_top_of() + if(istype(mount_tank)) + mount_tank.obj_clear_on_top(src) STOP_PROCESSING(SSobj, src) to_call = null tied_reagent = null @@ -773,6 +794,12 @@ GLOBAL_LIST_EMPTY(flamer_particles) /obj/flamer_fire/Crossed(atom/movable/atom_movable) ..() + // Mounting is only decided at creation, not when a tank drives onto existing fire. + if(is_atop_vehicle() && isliving(atom_movable)) + // Only a mob riding atop that same tank can be "crossing" it for real. + var/mob/living/mob_crossed = atom_movable + if(mob_crossed.get_tank_on_top_of() != get_tank_on_top_of()) + return atom_movable.handle_flamer_fire_crossed(src) /obj/flamer_fire/proc/type_b_debuff_xeno_armor(mob/living/carbon/xenomorph/X) @@ -882,7 +909,12 @@ GLOBAL_LIST_EMPTY(flamer_particles) update_flame() + var/obj/vehicle/multitile/tank/mounted_on = is_atop_vehicle() ? get_tank_on_top_of() : null for(var/atom/thing in loc) + if(mounted_on && isliving(thing)) + var/mob/living/living_thing = thing + if(living_thing.get_tank_on_top_of() != mounted_on) + continue thing.handle_flamer_fire(src, damage, delta_time) //This has been made a simple loop, for the most part flamer_fire_act() just does return, but for specific items it'll cause other effects. diff --git a/code/modules/projectiles/guns/flamer/flameshape.dm b/code/modules/projectiles/guns/flamer/flameshape.dm index fffcf1a53ee3..8aff36680e1a 100644 --- a/code/modules/projectiles/guns/flamer/flameshape.dm +++ b/code/modules/projectiles/guns/flamer/flameshape.dm @@ -5,7 +5,32 @@ /datum/flameshape/proc/handle_fire_spread(obj/flamer_fire/F, fire_spread_amount, burn_dam, fuel_pressure = 1) return -/datum/flameshape/proc/generate_fire(turf/T, obj/flamer_fire/F2, new_spread_amt, fs, should_call, skip_flame = FALSE, fuel_pressure = 1) +/datum/flameshape/proc/is_tank_obstacle(atom/obstacle) + return istype(obstacle, /obj/vehicle/multitile/tank) + +/** + * Decides which tank (if any) a newly-created flame at turf T should mount atop. + * Mounts if continuing from something already mounted, or arriving from outside the tank's footprint. + * + * Arguments: + * * turf/T = The turf a new flame is about to be created on. + * * turf/prev_T = The immediately preceding turf in this fire's propagation chain. + * * prev_mount_tank = Whichever tank the thing at prev_T was already mounted atop. + * + * Returns: + * * The tank to mount atop, or FALSE to stay grounded. + */ +/proc/resolve_flame_mount(turf/T, turf/prev_T, obj/vehicle/multitile/tank/prev_mount_tank) + var/obj/vehicle/multitile/tank/here_tank = get_multitile_vehicle_at(T) + if(!here_tank) + return FALSE + if(prev_mount_tank == here_tank) + return here_tank + if(!prev_T || get_multitile_vehicle_at(prev_T) != here_tank) + return here_tank + return FALSE + +/datum/flameshape/proc/generate_fire(turf/T, obj/flamer_fire/F2, new_spread_amt, fs, should_call, skip_flame = FALSE, fuel_pressure = 1, mount_override = FLAME_MOUNT_AUTO) var/obj/flamer_fire/foundflame = locate() in T if(foundflame && foundflame.tied_reagents == F2.tied_reagents && !skip_flame) // From the same flames return @@ -18,7 +43,7 @@ if(!should_call) to_call = null - new /obj/flamer_fire(T, F2.weapon_cause_data, F2.tied_reagent, new_spread_amt, F2.tied_reagents, fs, F2.target_clicked, to_call, fuel_pressure, F2.fire_variant) + new /obj/flamer_fire(T, F2.weapon_cause_data, F2.tied_reagent, new_spread_amt, F2.tied_reagents, fs, F2.target_clicked, to_call, fuel_pressure, F2.fire_variant, mount_override) return TRUE /datum/flameshape/default @@ -50,9 +75,11 @@ A.flamer_fire_act(burn_dam, F.weapon_cause_data) if (A.flags_atom & ON_BORDER) break - new_spread_amt = 0 + if(!is_tank_obstacle(A)) + new_spread_amt = 0 - addtimer(CALLBACK(src, PROC_REF(generate_fire), T, F, new_spread_amt, F.flameshape, null, FALSE, fuel_pressure), 0) + var/mount_override = resolve_flame_mount(T, source_turf, F.get_tank_on_top_of()) + addtimer(CALLBACK(src, PROC_REF(generate_fire), T, F, new_spread_amt, F.flameshape, null, FALSE, fuel_pressure, mount_override), 0) /datum/flameshape/default/irregular @@ -115,6 +142,7 @@ var/turf/source_turf = get_turf(F.loc) var/turf/prev_T + var/obj/vehicle/multitile/tank/prev_mount_tank = F.get_tank_on_top_of() var/distance = 1 var/stop_at_turf = FALSE @@ -138,7 +166,8 @@ A.flamer_fire_act(burn_dam, F.weapon_cause_data) if (A.flags_atom & ON_BORDER) break - stop_at_turf = TRUE + if(!is_tank_obstacle(A)) + stop_at_turf = TRUE if(T == F.loc) if(stop_at_turf) @@ -146,12 +175,16 @@ prev_T = T continue - addtimer(CALLBACK(src, PROC_REF(generate_fire), T, F, 0, F.flameshape, null, TRUE, fuel_pressure), distance) + var/mount_override = FALSE + if(!stop_at_turf) + mount_override = resolve_flame_mount(T, prev_T, prev_mount_tank) + addtimer(CALLBACK(src, PROC_REF(generate_fire), T, F, 0, F.flameshape, null, TRUE, fuel_pressure, mount_override), distance) if(stop_at_turf) break distance++ prev_T = T + prev_mount_tank = mount_override if(F.to_call) addtimer(F.to_call, distance + 1) @@ -168,6 +201,7 @@ var/distance = 1 var/hit_dense_atom_mid = FALSE var/turf/prev_T + var/obj/vehicle/multitile/tank/prev_mount_tank_mid = F.get_tank_on_top_of() for(var/turf/T in turfs) if(distance > fire_spread_amount) @@ -184,7 +218,8 @@ AM.flamer_fire_act(burn_dam, F.weapon_cause_data) if (AM.flags_atom & ON_BORDER) break - hit_dense_atom_mid = TRUE + if(!is_tank_obstacle(AM)) + hit_dense_atom_mid = TRUE if(T == F.loc) if (hit_dense_atom_mid) @@ -193,8 +228,12 @@ prev_T = T continue - addtimer(CALLBACK(src, PROC_REF(generate_fire), T, F, 0, FLAMESHAPE_TRIANGLE, null, FALSE, fuel_pressure), 0) + var/mid_mount_override = FALSE + if(!hit_dense_atom_mid) + mid_mount_override = resolve_flame_mount(T, prev_T, prev_mount_tank_mid) + addtimer(CALLBACK(src, PROC_REF(generate_fire), T, F, 0, FLAMESHAPE_TRIANGLE, null, FALSE, fuel_pressure, mid_mount_override), 0) prev_T = T + prev_mount_tank_mid = mid_mount_override sleep(1) var/list/turf/right = list() @@ -212,6 +251,7 @@ var/hit_dense_atom_side = FALSE var/turf/prev_R = T + var/obj/vehicle/multitile/tank/prev_mount_tank_right = mid_mount_override for (var/turf/R in right) if(prev_R) var/atom/movable/temp = new/obj/flamer_fire() @@ -221,16 +261,20 @@ AM.flamer_fire_act(burn_dam, F.weapon_cause_data) if (AM.flags_atom & ON_BORDER) break - hit_dense_atom_side = TRUE + if(!is_tank_obstacle(AM)) + hit_dense_atom_side = TRUE else if (hit_dense_atom_mid) break - generate_fire(R, F, 0, FLAMESHAPE_TRIANGLE, FALSE, FALSE, fuel_pressure) + var/right_mount_override = resolve_flame_mount(R, prev_R, prev_mount_tank_right) + generate_fire(R, F, 0, FLAMESHAPE_TRIANGLE, FALSE, FALSE, fuel_pressure, right_mount_override) if (!hit_dense_atom_mid && hit_dense_atom_side) break prev_R = R + prev_mount_tank_right = right_mount_override sleep(1) var/turf/prev_L = T + var/obj/vehicle/multitile/tank/prev_mount_tank_left = mid_mount_override for (var/turf/L in left) if(prev_L) var/atom/movable/temp = new/obj/flamer_fire() @@ -240,13 +284,16 @@ AM.flamer_fire_act(burn_dam, F.weapon_cause_data) if (AM.flags_atom & ON_BORDER) break - hit_dense_atom_side = TRUE + if(!is_tank_obstacle(AM)) + hit_dense_atom_side = TRUE else if (hit_dense_atom_mid) break - generate_fire(L, F, 0, FLAMESHAPE_TRIANGLE, FALSE, FALSE, fuel_pressure) + var/left_mount_override = resolve_flame_mount(L, prev_L, prev_mount_tank_left) + generate_fire(L, F, 0, FLAMESHAPE_TRIANGLE, FALSE, FALSE, fuel_pressure, left_mount_override) if (!hit_dense_atom_mid && hit_dense_atom_side) break prev_L = L + prev_mount_tank_left = left_mount_override sleep(1) if (hit_dense_atom_mid) diff --git a/code/modules/projectiles/homing_projectile_component.dm b/code/modules/projectiles/homing_projectile_component.dm index 8fd36fd67f31..903bff54deab 100644 --- a/code/modules/projectiles/homing_projectile_component.dm +++ b/code/modules/projectiles/homing_projectile_component.dm @@ -22,17 +22,30 @@ src.homing_target = homing_target /datum/component/homing_projectile/RegisterWithParent() + RegisterSignal(parent, COMSIG_BULLET_STEP, PROC_REF(step_retarget)) RegisterSignal(parent, COMSIG_BULLET_TERMINAL, PROC_REF(terminal_retarget)) /datum/component/homing_projectile/UnregisterFromParent() - UnregisterSignal(parent, COMSIG_BULLET_TERMINAL) + UnregisterSignal(parent, list(COMSIG_BULLET_STEP, COMSIG_BULLET_TERMINAL)) homing_target = null /datum/component/homing_projectile/Destroy() homing_target = null return ..() +// Continuously re-aims toward the target's live position every tick of flight +/datum/component/homing_projectile/proc/step_retarget() + SIGNAL_HANDLER + if(QDELETED(homing_target)) + return + var/obj/projectile/projectile = parent + var/turf/homing_turf = get_turf(homing_target) + if(!homing_turf || homing_turf == get_turf(projectile)) + return + projectile.retarget(homing_turf, keep_angle = FALSE) + /datum/component/homing_projectile/proc/terminal_retarget() + SIGNAL_HANDLER var/obj/projectile/projectile = parent var/turf/homing_turf = get_turf(homing_target) projectile.speed *= 2 // Double speed to ensure hitting next tick despite eventual movement diff --git a/code/modules/projectiles/magazines/flamer.dm b/code/modules/projectiles/magazines/flamer.dm index 92be5cd0937a..bd3201bbb309 100644 --- a/code/modules/projectiles/magazines/flamer.dm +++ b/code/modules/projectiles/magazines/flamer.dm @@ -79,10 +79,14 @@ if(G.current_mag == src) G.update_icon() +/** + * Refuels this tank from a fuel source. Also accepts the M34A2 tank's own Primary/Secondary flamer fuel tanks as a source/destination. + * Still no fuel mixing either way, the flamer_chem/reagent_list check below blocks it unless one side is empty or already the same chem. + */ /obj/item/ammo_magazine/flamer_tank/afterattack(obj/target, mob/user , flag) //refuel at fueltanks when we run out of ammo. if(get_dist(user,target) > 1) return ..() - if(!istype(target, /obj/structure/reagent_dispensers/tank/fuel) && !istype(target, /obj/item/tool/weldpack) && !istype(target, /obj/item/storage/backpack/marine/engineerpack) && !istype(target, /obj/item/ammo_magazine/flamer_tank)) + if(!istype(target, /obj/structure/reagent_dispensers/tank/fuel) && !istype(target, /obj/item/tool/weldpack) && !istype(target, /obj/item/storage/backpack/marine/engineerpack) && !istype(target, /obj/item/ammo_magazine/flamer_tank) && !istype(target, /obj/item/ammo_magazine/hardpoint/primary_flamer) && !istype(target, /obj/item/ammo_magazine/hardpoint/secondary_flamer)) return ..() if(!target.reagents || length(target.reagents.reagent_list) < 1) diff --git a/code/modules/projectiles/projectile.dm b/code/modules/projectiles/projectile.dm index 16ab9997cbf0..8c5c0e4bcd79 100644 --- a/code/modules/projectiles/projectile.dm +++ b/code/modules/projectiles/projectile.dm @@ -91,6 +91,9 @@ var/damage_boosted = 0 var/last_damage_mult = 1 + /// Tracks if the projectile is inside obj/vehicle/multitile/tank + var/obj/vehicle/multitile/tank/inside_tank = null + /obj/projectile/Initialize(mapload, datum/cause_data/cause_data) . = ..() path = list() @@ -206,7 +209,7 @@ forceMove(starting) //Put us on the turf, if we're not. target_turf = get_turf(target) - if(!target_turf || !starting || target_turf == starting) //This shouldn't happen, but it can. + if(!target_turf || !starting) qdel(src) return src.firer = firer @@ -219,6 +222,14 @@ permutated |= src //Don't try to hit self. src.shot_from = shot_from + // Point-blank: target_turf == starting can happen for real now. Resolve the hit right here via + // scan_a_turf() instead of silently vanishing the shot. + if(target_turf == starting) + src.speed = speed + scan_a_turf(starting) + qdel(src) + return + setDir(get_dir(loc, target_turf)) var/ammo_flags = ammo.flags_ammo_behavior | projectile_override_flags @@ -359,6 +370,11 @@ SHOULD_NOT_SLEEP(TRUE) PRIVATE_PROC(TRUE) var/turf/current_turf = get_turf(src) + + // Lets homing-style components continuously re-aim toward their live ttarget + // keeps the shot actually curving to follow a moving target over the whole flight. + SEND_SIGNAL(src, COMSIG_BULLET_STEP) + var/turf/next_turf = popleft(path) // Terminal projectiles (about to hit) are handled firer for retarget logic @@ -389,7 +405,14 @@ if(distance_travelled == floor(ammo.max_range / 2)) ammo.do_at_half_range(src) if(distance_travelled >= ammo.max_range) - ammo.do_at_max_range(src) + // A short-range shot can run out of range while still over a tank's footprint. + // Hit the tank here instead of quietly vanishing. AMMO_PASSES_OVER_VEHICLES skips this entirely. + var/obj/vehicle/multitile/tank/tank_here = (ammo.flags_ammo_behavior & AMMO_PASSES_OVER_VEHICLES) ? null : (inside_tank || get_multitile_vehicle_at(next_turf)) + if(tank_here) + ammo.on_hit_obj(tank_here, src) + tank_here.bullet_act(src) + else + ammo.do_at_max_range(src) speed = 0 return TRUE @@ -409,6 +432,7 @@ path.Cut(1, 2) // remove the turf we're already on var/atom/source = keep_angle ? original : current_turf update_angle(source, new_target) + target_turf = get_turf(new_target) /obj/projectile/proc/scan_a_turf(turf/turf, proj_dir) . = TRUE // Sleep safeguard: stop the bullet @@ -420,6 +444,31 @@ if(!istype(turf)) return FALSE + // Checkc if we're inside a tank and trying to exit + if(inside_tank) + // Checks the ttank's real, multi-tile locs list instead of just its one contents-holding turf. + var/tank_found = (get_multitile_vehicle_at(turf) == inside_tank) + if(!tank_found) + for(var/obj/obj in turf) + var/obj/vehicle/multitile/tank/M = _owning_tank_of(obj) + if(M == inside_tank || obj == inside_tank) + tank_found = TRUE + break + + // hits the tank if there are no tank parts inside the turf + if(!tank_found) + // unless it was shot by a mob atop the tank... + if(isliving(firer)) + var/mob/living/M = firer + if(M.is_on_tank_hull()) + return FALSE + // ...or this ammo is flagged to always fly over vehicles entirely (AMMO_PASSES_OVER_VEHICLES). + if(ammo.flags_ammo_behavior & AMMO_PASSES_OVER_VEHICLES) + return FALSE + ammo.on_hit_obj(inside_tank, src) + inside_tank.bullet_act(src) + return TRUE + if(turf.density) // Handle wall hit if(turf in permutated) return FALSE @@ -471,6 +520,13 @@ if(turf && turf.loc) turf.bullet_act(src) return TRUE + + // A tank's non-center footprint tiles have no real object in turf.contents, so catch it here via + // get_multitile_vehicle_at() so entering any of the tank's tiles marks inside_tank. + if(!inside_tank) + var/obj/vehicle/multitile/tank/tank_here = get_multitile_vehicle_at(turf) + if(tank_here) + inside_tank = tank_here return FALSE /obj/projectile/proc/handle_object(obj/obj) @@ -479,6 +535,21 @@ return FALSE permutated |= obj + var/obj/vehicle/multitile/tank/M = _owning_tank_of(obj) + if(!M) + if(istype(obj, /obj/vehicle/multitile/tank)) + M = obj + + // this block allows projectiles fired from outside the tank to travel inside it. + if(M) + if(!inside_tank) + inside_tank = M + return FALSE + else if(inside_tank == M) + return FALSE + else // if inside_tank exists but is not M, it means we're firing on another tank, so, we return true. + return TRUE + var/hit_chance = obj.get_projectile_hit_boolean(src) if(hit_chance) // Calculated from combination of both ammo accuracy and gun accuracy SEND_SIGNAL(src, COMSIG_BULLET_PRE_HANDLE_OBJ, obj) @@ -627,7 +698,9 @@ /obj/projectile/proc/check_canhit(turf/current_turf, turf/next_turf, list/ignore_list) var/proj_dir = get_dir(current_turf, next_turf) - if((proj_dir & (proj_dir - 1)) && !current_turf.Adjacent(next_turf, ignore_list = ignore_list) && current_turf.z == next_turf.z) + // this would otherwise block diagonal shots at the tank + var/next_turf_is_vehicle = get_multitile_vehicle_at(next_turf) + if((proj_dir & (proj_dir - 1)) && !next_turf_is_vehicle && !current_turf.Adjacent(next_turf, ignore_list = ignore_list) && current_turf.z == next_turf.z) ammo.on_hit_turf(current_turf, src) current_turf.bullet_act(src) return TRUE @@ -1350,6 +1423,12 @@ if(dx == -1 || dx == 1) return TRUE +// helper proc to see if we are about to hit a tank +/obj/projectile/proc/_owning_tank_of(atom/A) + if(istype(A, /obj/vehicle/multitile/tank)) + return A + return null + /obj/projectile/vulture accuracy_range_falloff = 10 /// The odds of hitting a xeno in less than your gun's range. Doesn't apply to humans. diff --git a/code/modules/reagents/Chemistry-Reagents.dm b/code/modules/reagents/Chemistry-Reagents.dm index ea9ac9bf00a7..83639d44db0c 100644 --- a/code/modules/reagents/Chemistry-Reagents.dm +++ b/code/modules/reagents/Chemistry-Reagents.dm @@ -51,6 +51,12 @@ GLOBAL_LIST_INIT(name2reagent, build_name2reagent()) var/burncolormod = 1 /// Cant be used in chemical synthesis in ANY way. var/lockdown_chem = FALSE + /// Can this be poured into a vehicle fuel tank? See /obj/item/hardpoint/fuel_tank. + var/vehicle_fuel = FALSE + /// Can this be poured into a vehicle radiator as coolant? See /obj/item/hardpoint/radiator. + var/vehicle_coolant = FALSE + /// Multiplies a vehicle's top speed and acceleration while this fuel is loaded. 1 is no effect. + var/vehicle_fuel_performance_mult = 1 var/fire_type = FIRE_VARIANT_DEFAULT //Unique types of fire not modeled by chemfire (1 = Armor Shredding Greenfire). Effects in flamer.dm // Chem generator and research stuff var/chemclass = CHEM_CLASS_NONE //Decides how rare the chem in the generation process diff --git a/code/modules/reagents/chemistry_reagents/food.dm b/code/modules/reagents/chemistry_reagents/food.dm index b7d533cd09e7..45383f680b90 100644 --- a/code/modules/reagents/chemistry_reagents/food.dm +++ b/code/modules/reagents/chemistry_reagents/food.dm @@ -401,6 +401,9 @@ color = "#302000" // rgb: 48, 32, 0 chemclass = CHEM_CLASS_RARE properties = list(PROPERTY_NUTRITIOUS = 2) + vehicle_fuel = TRUE + // -10% top speed and acceleration when run neat. Also puffs grey exhaust smoke past a fuel threshold. + vehicle_fuel_performance_mult = 0.9 /datum/reagent/enzyme name = "Universal Enzyme" diff --git a/code/modules/reagents/chemistry_reagents/other.dm b/code/modules/reagents/chemistry_reagents/other.dm index 6f5169c36429..8597471ed4ff 100644 --- a/code/modules/reagents/chemistry_reagents/other.dm +++ b/code/modules/reagents/chemistry_reagents/other.dm @@ -153,6 +153,7 @@ chemclass = CHEM_CLASS_BASIC chemfiresupp = TRUE intensitymod = -3 + vehicle_coolant = TRUE /datum/reagent/water/reaction_turf(turf/T, volume) if(!istype(T)) @@ -539,9 +540,32 @@ explosive = TRUE power = 0.12 falloff_modifier = -0.1 - burncolor = "#ff9900" + burncolor = "#660000" chemclass = CHEM_CLASS_RARE properties = list(PROPERTY_FUELING = 5, PROPERTY_OXIDIZING = 3, PROPERTY_VISCOUS = 4, PROPERTY_TOXIC = 1) + vehicle_fuel = TRUE + // -20% top speed/acceleration when run neat, worse than the cooking-oil substitute. + vehicle_fuel_performance_mult = 0.8 + +/// Same burncolor-overwrite bug as napalm's own recalculate_variables() below. +/datum/reagent/fuel/recalculate_variables() + . = ..() + burncolor = initial(burncolor) + +/// standard fuel for the tank. +/datum/reagent/fuel/jp8 + name = "JP-8" + id = "jp8" + description = "Kerosene-based jet fuel" + // +20% top speed and acceleration when run neat. + vehicle_fuel_performance_mult = 1.2 + +/// Same fuel qualitty as JP-8, minus the mobility boost. Native fuel for the colony vans. +/datum/reagent/fuel/diesel + name = "Diesel" + id = "diesel" + description = "Refined diesel fuel for civillian combustion engines." + vehicle_fuel_performance_mult = 1 /datum/reagent/space_cleaner name = "Space cleaner" @@ -777,6 +801,10 @@ burn_sprite = "red" properties = list(PROPERTY_OXIDIZING = 6, PROPERTY_FUELING = 7, PROPERTY_FLOWING = 1) +/datum/reagent/napalm/recalculate_variables() + . = ..() + burncolor = initial(burncolor) + /datum/reagent/napalm/sticky name = "Sticky-Napalm" id = "stickynapalm" @@ -937,6 +965,10 @@ chemclass = CHEM_CLASS_UNCOMMON properties = list(PROPERTY_CORROSIVE = 6, PROPERTY_TOXIC = 6, PROPERTY_OXIDIZING = 9, PROPERTY_IGNITING = 1) +/datum/reagent/chlorinetrifluoride/recalculate_variables() + . = ..() + durationfire = 2 + /datum/reagent/methane name = "Methane" id = "methane" diff --git a/code/modules/vehicles/apc/apc.dm b/code/modules/vehicles/apc/apc.dm index 44e29734c7b9..8f475a93981f 100644 --- a/code/modules/vehicles/apc/apc.dm +++ b/code/modules/vehicles/apc/apc.dm @@ -32,15 +32,38 @@ GLOBAL_LIST_EMPTY(command_apc_list) required_skill = SKILL_VEHICLE_LARGE - movement_sound = 'sound/vehicles/tank_driving.ogg' + movement_sound = 'sound/vehicles/box_van_driving.ogg' + engine_soundloop_type = /datum/looping_sound/tank_engine/apc var/gunner_view_buff = 10 + engine_on = FALSE + + uses_gear_transmission = TRUE + current_gear = "P" + top_speed = 3.5 + base_acceleration = 1 + base_fuel_use = 0.268 + + hull_cookoff_exterior_power = 100 + hull_cookoff_exterior_falloff = 14 + hull_cookoff_interior_power = 200 + hull_cookoff_interior_falloff = 80 + hardpoints_allowed = list( /obj/item/hardpoint/primary/dualcannon, /obj/item/hardpoint/secondary/frontalcannon, /obj/item/hardpoint/support/flare_launcher, /obj/item/hardpoint/locomotion/apc_wheels, + /obj/item/hardpoint/engine/apc, + /obj/item/hardpoint/fuel_tank/uscm, + /obj/item/hardpoint/radiator/uscm, + /obj/item/hardpoint/battery/uscm, + /obj/item/hardpoint/iff_module/uscm, + /obj/item/hardpoint/visual_sensors/uscm, + /obj/item/hardpoint/air_filter/uscm, + /obj/item/hardpoint/turret_ring/uscm, + /obj/item/hardpoint/hatch/armored/uscm, ) seats = list( @@ -78,6 +101,20 @@ GLOBAL_LIST_EMPTY(command_apc_list) vehicle_ram_multiplier = VEHICLE_TRAMPLE_DAMAGE_APC_REDUCTION minimap_icon_state = "apc" +/obj/vehicle/multitile/apc/Initialize() + . = ..() + gear_stats = build_gear_stats() + cruise_control_granularity = gear_stats["D"]["max_speed"] * CRUISE_CONTROL_DEFAULT_GRANULARITY_FRACTION + setup_overwatch_camera() + +/// Kicks off the shared cookoff sequence the instant hull health first reaches 0. +/obj/vehicle/multitile/apc/on_hull_destroyed() + start_hull_cookoff_sequence() + +/// The APC has a Visual Sensors slot. +/obj/vehicle/multitile/apc/supports_visual_sensors() + return TRUE + /obj/vehicle/multitile/apc/load_role_reserved_slots() var/datum/role_reserved_slots/RRS = new RRS.category_name = "Crewmen" @@ -92,6 +129,12 @@ GLOBAL_LIST_EMPTY(command_apc_list) role_reserved_slots += RRS /obj/vehicle/multitile/apc/add_seated_verbs(mob/living/M, seat) + // Reassign this APC's CIC overwatch to the new driver's squad, if they're a Tank Crewman with one. + // driver.mind is required so a map-placed dummy/mannequin can never trigger an assignment just by existing pre-buckled. + if(seat == VEHICLE_DRIVER && ishuman(M)) + var/mob/living/carbon/human/driver = M + if(driver.mind && driver.job == JOB_TANK_CREW && driver.assigned_squad) + update_overwatch_squad(driver.assigned_squad) if(!M.client) return add_verb(M.client, list( @@ -102,19 +145,53 @@ GLOBAL_LIST_EMPTY(command_apc_list) add_verb(M.client, list( /obj/vehicle/multitile/proc/toggle_door_lock, /obj/vehicle/multitile/proc/activate_horn, - /obj/vehicle/multitile/proc/name_vehicle + /obj/vehicle/multitile/proc/name_vehicle, + /obj/vehicle/multitile/proc/toggle_cruise_control, + /obj/vehicle/multitile/proc/set_cruise_control_granularity, + /obj/vehicle/multitile/proc/toggle_turn_signal_north, + /obj/vehicle/multitile/proc/toggle_turn_signal_south, + /obj/vehicle/multitile/proc/toggle_turn_signal_east, + /obj/vehicle/multitile/proc/toggle_turn_signal_west, + /obj/vehicle/multitile/proc/toggle_engine, + /obj/vehicle/multitile/proc/cycle_hardpoint, )) + give_action(M, /datum/action/human_action/vehicle_action/toggle_door_lock) + give_action(M, /datum/action/human_action/vehicle_action/toggle_engine) + if(!(M.client.prefs.toggles_vehicle & VEHICLE_SIMPLE_ACCELERATION)) + give_action(M, /datum/action/human_action/vehicle_action/toggle_cruise_control) + give_action(M, /datum/action/human_action/vehicle_action/set_cruise_control_granularity) + RegisterSignal(M, COMSIG_MOB_VEHICLE_PREFS_CHANGED, PROC_REF(on_driver_prefs_changed)) + start_crew_hud(M, VEHICLE_DRIVER) else if(seat == VEHICLE_GUNNER) add_verb(M.client, list( /obj/vehicle/multitile/proc/switch_hardpoint, /obj/vehicle/multitile/proc/cycle_hardpoint, - /obj/vehicle/multitile/proc/name_vehicle + /obj/vehicle/multitile/proc/name_vehicle, + /obj/vehicle/multitile/proc/toggle_hardpoint_fire_mode, + /obj/vehicle/multitile/proc/toggle_iff_module, + /obj/vehicle/multitile/proc/toggle_turret_safety, + /obj/vehicle/multitile/proc/toggle_slave_secondary_to_driver, )) + give_action(M, /datum/action/human_action/vehicle_action/toggle_iff) + give_action(M, /datum/action/human_action/vehicle_action/toggle_gyro) + // seats[VEHICLE_GUNNER] isn't written yet at this point, so gunner_seated is passed explicitly as TRUE. + get_gyro_hardpoint()?.recalculate_gyro(TRUE) + if(get_slavable_secondary()) + give_action(M, /datum/action/human_action/vehicle_action/slave_secondary) + start_crew_hud(M, VEHICLE_GUNNER) else if(seat == VEHICLE_SUPPORT_GUNNER_ONE || seat == VEHICLE_SUPPORT_GUNNER_TWO) add_verb(M.client, list( /obj/vehicle/multitile/proc/reload_firing_port_weapon )) + if(seat == VEHICLE_DRIVER || seat == VEHICLE_GUNNER) + if(active_hp[seat]?.get_flame_mode()) + give_action(M, /datum/action/human_action/vehicle_action/toggle_hardpoint_fire_mode) + give_action(M, /datum/action/human_action/vehicle_action/use_phone) + // seats[seat] isn't written yet, so the conditional grants above use M directly instead of delegating to this. + refresh_hardpoint_actions() + // Auto-select a default weapon for this seat if it doesn't already have a valid one. + ensure_active_hardpoint(seat) /obj/vehicle/multitile/apc/remove_seated_verbs(mob/living/M, seat) if(!M.client) @@ -129,17 +206,45 @@ GLOBAL_LIST_EMPTY(command_apc_list) /obj/vehicle/multitile/proc/toggle_door_lock, /obj/vehicle/multitile/proc/activate_horn, /obj/vehicle/multitile/proc/name_vehicle, + /obj/vehicle/multitile/proc/toggle_cruise_control, + /obj/vehicle/multitile/proc/set_cruise_control_granularity, + /obj/vehicle/multitile/proc/toggle_turn_signal_north, + /obj/vehicle/multitile/proc/toggle_turn_signal_south, + /obj/vehicle/multitile/proc/toggle_turn_signal_east, + /obj/vehicle/multitile/proc/toggle_turn_signal_west, + /obj/vehicle/multitile/proc/toggle_engine, + /obj/vehicle/multitile/proc/cycle_hardpoint, )) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_door_lock) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_engine) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_cruise_control) + remove_action(M, /datum/action/human_action/vehicle_action/set_cruise_control_granularity) + UnregisterSignal(M, COMSIG_MOB_VEHICLE_PREFS_CHANGED) + stop_crew_hud(M, VEHICLE_DRIVER) else if(seat == VEHICLE_GUNNER) remove_verb(M.client, list( /obj/vehicle/multitile/proc/switch_hardpoint, /obj/vehicle/multitile/proc/cycle_hardpoint, /obj/vehicle/multitile/proc/name_vehicle, + /obj/vehicle/multitile/proc/toggle_hardpoint_fire_mode, + /obj/vehicle/multitile/proc/toggle_iff_module, + /obj/vehicle/multitile/proc/toggle_turret_safety, + /obj/vehicle/multitile/proc/toggle_slave_secondary_to_driver, )) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_iff) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_gyro) + remove_action(M, /datum/action/human_action/vehicle_action/slave_secondary) + // This seat is about to be vacated, so gunner_seated is passed explicitly as FALSE. + get_gyro_hardpoint()?.recalculate_gyro(FALSE) + stop_crew_hud(M, VEHICLE_GUNNER) else if(seat == VEHICLE_SUPPORT_GUNNER_ONE || seat == VEHICLE_SUPPORT_GUNNER_TWO) remove_verb(M.client, list( /obj/vehicle/multitile/proc/reload_firing_port_weapon )) + if(seat == VEHICLE_DRIVER || seat == VEHICLE_GUNNER) + remove_action(M, /datum/action/human_action/vehicle_action/cycle_hardpoint) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_hardpoint_fire_mode) + remove_action(M, /datum/action/human_action/vehicle_action/use_phone) /obj/vehicle/multitile/apc/initialize_cameras(change_tag = FALSE) if(!camera) @@ -209,6 +314,15 @@ GLOBAL_LIST_EMPTY(command_apc_list) //PRESET: FPWs, wheels installed /obj/effect/vehicle_spawner/apc/plain/load_hardpoints(obj/vehicle/multitile/apc/V) V.add_hardpoint(new /obj/item/hardpoint/locomotion/apc_wheels) + V.add_hardpoint(new /obj/item/hardpoint/engine/apc) + V.add_hardpoint(new /obj/item/hardpoint/fuel_tank/uscm) + V.add_hardpoint(new /obj/item/hardpoint/radiator/uscm) + V.add_hardpoint(new /obj/item/hardpoint/battery/uscm) + V.add_hardpoint(new /obj/item/hardpoint/iff_module/uscm) + V.add_hardpoint(new /obj/item/hardpoint/visual_sensors/uscm) + V.add_hardpoint(new /obj/item/hardpoint/air_filter/uscm) + V.add_hardpoint(new /obj/item/hardpoint/turret_ring/uscm) + V.add_hardpoint(new /obj/item/hardpoint/hatch/armored/uscm) //PRESET: default hardpoints, destroyed (this one spawns on VASRS elevatorfor VCs) /obj/effect/vehicle_spawner/apc/decrepit/spawn_vehicle() @@ -226,6 +340,15 @@ GLOBAL_LIST_EMPTY(command_apc_list) V.add_hardpoint(new /obj/item/hardpoint/secondary/frontalcannon) V.add_hardpoint(new /obj/item/hardpoint/support/flare_launcher) V.add_hardpoint(new /obj/item/hardpoint/locomotion/apc_wheels) + V.add_hardpoint(new /obj/item/hardpoint/engine/apc) + V.add_hardpoint(new /obj/item/hardpoint/fuel_tank/uscm) + V.add_hardpoint(new /obj/item/hardpoint/radiator/uscm) + V.add_hardpoint(new /obj/item/hardpoint/battery/uscm) + V.add_hardpoint(new /obj/item/hardpoint/iff_module/uscm) + V.add_hardpoint(new /obj/item/hardpoint/visual_sensors/uscm) + V.add_hardpoint(new /obj/item/hardpoint/air_filter/uscm) + V.add_hardpoint(new /obj/item/hardpoint/turret_ring/uscm) + V.add_hardpoint(new /obj/item/hardpoint/hatch/armored/uscm) //PRESET: FPWs, default hardpoints /obj/effect/vehicle_spawner/apc/fixed/load_hardpoints(obj/vehicle/multitile/apc/V) @@ -233,6 +356,15 @@ GLOBAL_LIST_EMPTY(command_apc_list) V.add_hardpoint(new /obj/item/hardpoint/secondary/frontalcannon) V.add_hardpoint(new /obj/item/hardpoint/support/flare_launcher) V.add_hardpoint(new /obj/item/hardpoint/locomotion/apc_wheels) + V.add_hardpoint(new /obj/item/hardpoint/engine/apc) + V.add_hardpoint(new /obj/item/hardpoint/fuel_tank/uscm) + V.add_hardpoint(new /obj/item/hardpoint/radiator/uscm) + V.add_hardpoint(new /obj/item/hardpoint/battery/uscm) + V.add_hardpoint(new /obj/item/hardpoint/iff_module/uscm) + V.add_hardpoint(new /obj/item/hardpoint/visual_sensors/uscm) + V.add_hardpoint(new /obj/item/hardpoint/air_filter/uscm) + V.add_hardpoint(new /obj/item/hardpoint/turret_ring/uscm) + V.add_hardpoint(new /obj/item/hardpoint/hatch/armored/uscm) //Transport version without FPWs @@ -273,10 +405,28 @@ GLOBAL_LIST_EMPTY(command_apc_list) V.add_hardpoint(new /obj/item/hardpoint/secondary/frontalcannon) V.add_hardpoint(new /obj/item/hardpoint/support/flare_launcher) V.add_hardpoint(new /obj/item/hardpoint/locomotion/apc_wheels) + V.add_hardpoint(new /obj/item/hardpoint/engine/apc) + V.add_hardpoint(new /obj/item/hardpoint/fuel_tank/uscm) + V.add_hardpoint(new /obj/item/hardpoint/radiator/uscm) + V.add_hardpoint(new /obj/item/hardpoint/battery/uscm) + V.add_hardpoint(new /obj/item/hardpoint/iff_module/uscm) + V.add_hardpoint(new /obj/item/hardpoint/visual_sensors/uscm) + V.add_hardpoint(new /obj/item/hardpoint/air_filter/uscm) + V.add_hardpoint(new /obj/item/hardpoint/turret_ring/uscm) + V.add_hardpoint(new /obj/item/hardpoint/hatch/armored/uscm) //PRESET: no FPWs, wheels installed /obj/effect/vehicle_spawner/apc/unarmed/plain/load_hardpoints(obj/vehicle/multitile/apc/unarmed/V) V.add_hardpoint(new /obj/item/hardpoint/locomotion/apc_wheels) + V.add_hardpoint(new /obj/item/hardpoint/engine/apc) + V.add_hardpoint(new /obj/item/hardpoint/fuel_tank/uscm) + V.add_hardpoint(new /obj/item/hardpoint/radiator/uscm) + V.add_hardpoint(new /obj/item/hardpoint/battery/uscm) + V.add_hardpoint(new /obj/item/hardpoint/iff_module/uscm) + V.add_hardpoint(new /obj/item/hardpoint/visual_sensors/uscm) + V.add_hardpoint(new /obj/item/hardpoint/air_filter/uscm) + V.add_hardpoint(new /obj/item/hardpoint/turret_ring/uscm) + V.add_hardpoint(new /obj/item/hardpoint/hatch/armored/uscm) //PRESET: default hardpoints /obj/effect/vehicle_spawner/apc/unarmed/fixed/load_hardpoints(obj/vehicle/multitile/apc/unarmed/V) @@ -284,3 +434,12 @@ GLOBAL_LIST_EMPTY(command_apc_list) V.add_hardpoint(new /obj/item/hardpoint/secondary/frontalcannon) V.add_hardpoint(new /obj/item/hardpoint/support/flare_launcher) V.add_hardpoint(new /obj/item/hardpoint/locomotion/apc_wheels) + V.add_hardpoint(new /obj/item/hardpoint/engine/apc) + V.add_hardpoint(new /obj/item/hardpoint/fuel_tank/uscm) + V.add_hardpoint(new /obj/item/hardpoint/radiator/uscm) + V.add_hardpoint(new /obj/item/hardpoint/battery/uscm) + V.add_hardpoint(new /obj/item/hardpoint/iff_module/uscm) + V.add_hardpoint(new /obj/item/hardpoint/visual_sensors/uscm) + V.add_hardpoint(new /obj/item/hardpoint/air_filter/uscm) + V.add_hardpoint(new /obj/item/hardpoint/turret_ring/uscm) + V.add_hardpoint(new /obj/item/hardpoint/hatch/armored/uscm) diff --git a/code/modules/vehicles/apc/apc_command.dm b/code/modules/vehicles/apc/apc_command.dm index fb59d61af08e..24b841167f69 100644 --- a/code/modules/vehicles/apc/apc_command.dm +++ b/code/modules/vehicles/apc/apc_command.dm @@ -85,6 +85,12 @@ role_reserved_slots += RRS /obj/vehicle/multitile/apc/command/add_seated_verbs(mob/living/M, seat) + // Reassign this APC's CIC overwatch to the new driver's squad, if they're a Tank Crewman with one. + // driver.mind is required so a map-placed dummy/mannequin can never trigger an assignment just by existing pre-buckled. + if(seat == VEHICLE_DRIVER && ishuman(M)) + var/mob/living/carbon/human/driver = M + if(driver.mind && driver.job == JOB_TANK_CREW && driver.assigned_squad) + update_overwatch_squad(driver.assigned_squad) if(!M.client) return add_verb(M.client, list( @@ -96,12 +102,43 @@ add_verb(M.client, list( /obj/vehicle/multitile/proc/toggle_door_lock, /obj/vehicle/multitile/proc/activate_horn, + /obj/vehicle/multitile/proc/toggle_cruise_control, + /obj/vehicle/multitile/proc/set_cruise_control_granularity, + /obj/vehicle/multitile/proc/toggle_turn_signal_north, + /obj/vehicle/multitile/proc/toggle_turn_signal_south, + /obj/vehicle/multitile/proc/toggle_turn_signal_east, + /obj/vehicle/multitile/proc/toggle_turn_signal_west, + /obj/vehicle/multitile/proc/toggle_engine, + /obj/vehicle/multitile/proc/cycle_hardpoint, )) + give_action(M, /datum/action/human_action/vehicle_action/toggle_door_lock) + give_action(M, /datum/action/human_action/vehicle_action/toggle_engine) + if(!(M.client.prefs.toggles_vehicle & VEHICLE_SIMPLE_ACCELERATION)) + give_action(M, /datum/action/human_action/vehicle_action/toggle_cruise_control) + give_action(M, /datum/action/human_action/vehicle_action/set_cruise_control_granularity) + RegisterSignal(M, COMSIG_MOB_VEHICLE_PREFS_CHANGED, PROC_REF(on_driver_prefs_changed)) + start_crew_hud(M, VEHICLE_DRIVER) else if(seat == VEHICLE_GUNNER) add_verb(M.client, list( /obj/vehicle/multitile/proc/switch_hardpoint, /obj/vehicle/multitile/proc/cycle_hardpoint, + /obj/vehicle/multitile/proc/toggle_hardpoint_fire_mode, + /obj/vehicle/multitile/proc/toggle_iff_module, + /obj/vehicle/multitile/proc/toggle_turret_safety, + /obj/vehicle/multitile/proc/toggle_slave_secondary_to_driver, )) + give_action(M, /datum/action/human_action/vehicle_action/toggle_iff) + give_action(M, /datum/action/human_action/vehicle_action/toggle_gyro) + get_gyro_hardpoint()?.recalculate_gyro(TRUE) + if(get_slavable_secondary()) + give_action(M, /datum/action/human_action/vehicle_action/slave_secondary) + start_crew_hud(M, VEHICLE_GUNNER) + if(seat == VEHICLE_DRIVER || seat == VEHICLE_GUNNER) + if(active_hp[seat]?.get_flame_mode()) + give_action(M, /datum/action/human_action/vehicle_action/toggle_hardpoint_fire_mode) + give_action(M, /datum/action/human_action/vehicle_action/use_phone) + refresh_hardpoint_actions() + ensure_active_hardpoint(seat) /obj/vehicle/multitile/apc/command/remove_seated_verbs(mob/living/M, seat) if(!M.client) @@ -116,12 +153,39 @@ remove_verb(M.client, list( /obj/vehicle/multitile/proc/toggle_door_lock, /obj/vehicle/multitile/proc/activate_horn, + /obj/vehicle/multitile/proc/toggle_cruise_control, + /obj/vehicle/multitile/proc/set_cruise_control_granularity, + /obj/vehicle/multitile/proc/toggle_turn_signal_north, + /obj/vehicle/multitile/proc/toggle_turn_signal_south, + /obj/vehicle/multitile/proc/toggle_turn_signal_east, + /obj/vehicle/multitile/proc/toggle_turn_signal_west, + /obj/vehicle/multitile/proc/toggle_engine, + /obj/vehicle/multitile/proc/cycle_hardpoint, )) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_door_lock) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_engine) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_cruise_control) + remove_action(M, /datum/action/human_action/vehicle_action/set_cruise_control_granularity) + UnregisterSignal(M, COMSIG_MOB_VEHICLE_PREFS_CHANGED) + stop_crew_hud(M, VEHICLE_DRIVER) else if(seat == VEHICLE_GUNNER) remove_verb(M.client, list( /obj/vehicle/multitile/proc/switch_hardpoint, /obj/vehicle/multitile/proc/cycle_hardpoint, + /obj/vehicle/multitile/proc/toggle_hardpoint_fire_mode, + /obj/vehicle/multitile/proc/toggle_iff_module, + /obj/vehicle/multitile/proc/toggle_turret_safety, + /obj/vehicle/multitile/proc/toggle_slave_secondary_to_driver, )) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_iff) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_gyro) + remove_action(M, /datum/action/human_action/vehicle_action/slave_secondary) + get_gyro_hardpoint()?.recalculate_gyro(FALSE) + stop_crew_hud(M, VEHICLE_GUNNER) + if(seat == VEHICLE_DRIVER || seat == VEHICLE_GUNNER) + remove_action(M, /datum/action/human_action/vehicle_action/cycle_hardpoint) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_hardpoint_fire_mode) + remove_action(M, /datum/action/human_action/vehicle_action/use_phone) /obj/vehicle/multitile/apc/command/initialize_cameras(change_tag = FALSE) if(!camera) @@ -224,6 +288,15 @@ //PRESET: only wheels installed /obj/effect/vehicle_spawner/apc_cmd/plain/load_hardpoints(obj/vehicle/multitile/apc/command/V) V.add_hardpoint(new /obj/item/hardpoint/locomotion/apc_wheels) + V.add_hardpoint(new /obj/item/hardpoint/engine/apc) + V.add_hardpoint(new /obj/item/hardpoint/fuel_tank/uscm) + V.add_hardpoint(new /obj/item/hardpoint/radiator/uscm) + V.add_hardpoint(new /obj/item/hardpoint/battery/uscm) + V.add_hardpoint(new /obj/item/hardpoint/iff_module/uscm) + V.add_hardpoint(new /obj/item/hardpoint/visual_sensors/uscm) + V.add_hardpoint(new /obj/item/hardpoint/air_filter/uscm) + V.add_hardpoint(new /obj/item/hardpoint/turret_ring/uscm) + V.add_hardpoint(new /obj/item/hardpoint/hatch/armored/uscm) //PRESET: default hardpoints, destroyed /obj/effect/vehicle_spawner/apc_cmd/decrepit/spawn_vehicle() @@ -240,6 +313,15 @@ V.add_hardpoint(new /obj/item/hardpoint/secondary/frontalcannon) V.add_hardpoint(new /obj/item/hardpoint/support/flare_launcher) V.add_hardpoint(new /obj/item/hardpoint/locomotion/apc_wheels) + V.add_hardpoint(new /obj/item/hardpoint/engine/apc) + V.add_hardpoint(new /obj/item/hardpoint/fuel_tank/uscm) + V.add_hardpoint(new /obj/item/hardpoint/radiator/uscm) + V.add_hardpoint(new /obj/item/hardpoint/battery/uscm) + V.add_hardpoint(new /obj/item/hardpoint/iff_module/uscm) + V.add_hardpoint(new /obj/item/hardpoint/visual_sensors/uscm) + V.add_hardpoint(new /obj/item/hardpoint/air_filter/uscm) + V.add_hardpoint(new /obj/item/hardpoint/turret_ring/uscm) + V.add_hardpoint(new /obj/item/hardpoint/hatch/armored/uscm) //PRESET: default hardpoints /obj/effect/vehicle_spawner/apc_cmd/fixed/load_hardpoints(obj/vehicle/multitile/apc/command/V) @@ -247,3 +329,12 @@ V.add_hardpoint(new /obj/item/hardpoint/secondary/frontalcannon) V.add_hardpoint(new /obj/item/hardpoint/support/flare_launcher) V.add_hardpoint(new /obj/item/hardpoint/locomotion/apc_wheels) + V.add_hardpoint(new /obj/item/hardpoint/engine/apc) + V.add_hardpoint(new /obj/item/hardpoint/fuel_tank/uscm) + V.add_hardpoint(new /obj/item/hardpoint/radiator/uscm) + V.add_hardpoint(new /obj/item/hardpoint/battery/uscm) + V.add_hardpoint(new /obj/item/hardpoint/iff_module/uscm) + V.add_hardpoint(new /obj/item/hardpoint/visual_sensors/uscm) + V.add_hardpoint(new /obj/item/hardpoint/air_filter/uscm) + V.add_hardpoint(new /obj/item/hardpoint/turret_ring/uscm) + V.add_hardpoint(new /obj/item/hardpoint/hatch/armored/uscm) diff --git a/code/modules/vehicles/apc/apc_medical.dm b/code/modules/vehicles/apc/apc_medical.dm index 8274212533d8..3b9ad0dd54a7 100644 --- a/code/modules/vehicles/apc/apc_medical.dm +++ b/code/modules/vehicles/apc/apc_medical.dm @@ -43,6 +43,12 @@ role_reserved_slots += RRS /obj/vehicle/multitile/apc/medical/add_seated_verbs(mob/living/M, seat) + // Reassign this APC's CIC overwatch to the new driver's squad, if they're a Tank Crewman with one. + // driver.mind is required so a map-placed dummy/mannequin can never trigger an assignment just by existing pre-buckled. + if(seat == VEHICLE_DRIVER && ishuman(M)) + var/mob/living/carbon/human/driver = M + if(driver.mind && driver.job == JOB_TANK_CREW && driver.assigned_squad) + update_overwatch_squad(driver.assigned_squad) if(!M.client) return add_verb(M.client, list( @@ -54,12 +60,43 @@ add_verb(M.client, list( /obj/vehicle/multitile/proc/toggle_door_lock, /obj/vehicle/multitile/proc/activate_horn, + /obj/vehicle/multitile/proc/toggle_cruise_control, + /obj/vehicle/multitile/proc/set_cruise_control_granularity, + /obj/vehicle/multitile/proc/toggle_turn_signal_north, + /obj/vehicle/multitile/proc/toggle_turn_signal_south, + /obj/vehicle/multitile/proc/toggle_turn_signal_east, + /obj/vehicle/multitile/proc/toggle_turn_signal_west, + /obj/vehicle/multitile/proc/toggle_engine, + /obj/vehicle/multitile/proc/cycle_hardpoint, )) + give_action(M, /datum/action/human_action/vehicle_action/toggle_door_lock) + give_action(M, /datum/action/human_action/vehicle_action/toggle_engine) + if(!(M.client.prefs.toggles_vehicle & VEHICLE_SIMPLE_ACCELERATION)) + give_action(M, /datum/action/human_action/vehicle_action/toggle_cruise_control) + give_action(M, /datum/action/human_action/vehicle_action/set_cruise_control_granularity) + RegisterSignal(M, COMSIG_MOB_VEHICLE_PREFS_CHANGED, PROC_REF(on_driver_prefs_changed)) + start_crew_hud(M, VEHICLE_DRIVER) else if(seat == VEHICLE_GUNNER) add_verb(M.client, list( /obj/vehicle/multitile/proc/switch_hardpoint, /obj/vehicle/multitile/proc/cycle_hardpoint, + /obj/vehicle/multitile/proc/toggle_hardpoint_fire_mode, + /obj/vehicle/multitile/proc/toggle_iff_module, + /obj/vehicle/multitile/proc/toggle_turret_safety, + /obj/vehicle/multitile/proc/toggle_slave_secondary_to_driver, )) + give_action(M, /datum/action/human_action/vehicle_action/toggle_iff) + give_action(M, /datum/action/human_action/vehicle_action/toggle_gyro) + get_gyro_hardpoint()?.recalculate_gyro(TRUE) + if(get_slavable_secondary()) + give_action(M, /datum/action/human_action/vehicle_action/slave_secondary) + start_crew_hud(M, VEHICLE_GUNNER) + if(seat == VEHICLE_DRIVER || seat == VEHICLE_GUNNER) + if(active_hp[seat]?.get_flame_mode()) + give_action(M, /datum/action/human_action/vehicle_action/toggle_hardpoint_fire_mode) + give_action(M, /datum/action/human_action/vehicle_action/use_phone) + refresh_hardpoint_actions() + ensure_active_hardpoint(seat) /obj/vehicle/multitile/apc/medical/remove_seated_verbs(mob/living/M, seat) if(!M.client) @@ -74,12 +111,39 @@ remove_verb(M.client, list( /obj/vehicle/multitile/proc/toggle_door_lock, /obj/vehicle/multitile/proc/activate_horn, + /obj/vehicle/multitile/proc/toggle_cruise_control, + /obj/vehicle/multitile/proc/set_cruise_control_granularity, + /obj/vehicle/multitile/proc/toggle_turn_signal_north, + /obj/vehicle/multitile/proc/toggle_turn_signal_south, + /obj/vehicle/multitile/proc/toggle_turn_signal_east, + /obj/vehicle/multitile/proc/toggle_turn_signal_west, + /obj/vehicle/multitile/proc/toggle_engine, + /obj/vehicle/multitile/proc/cycle_hardpoint, )) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_door_lock) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_engine) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_cruise_control) + remove_action(M, /datum/action/human_action/vehicle_action/set_cruise_control_granularity) + UnregisterSignal(M, COMSIG_MOB_VEHICLE_PREFS_CHANGED) + stop_crew_hud(M, VEHICLE_DRIVER) else if(seat == VEHICLE_GUNNER) remove_verb(M.client, list( /obj/vehicle/multitile/proc/switch_hardpoint, /obj/vehicle/multitile/proc/cycle_hardpoint, + /obj/vehicle/multitile/proc/toggle_hardpoint_fire_mode, + /obj/vehicle/multitile/proc/toggle_iff_module, + /obj/vehicle/multitile/proc/toggle_turret_safety, + /obj/vehicle/multitile/proc/toggle_slave_secondary_to_driver, )) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_iff) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_gyro) + remove_action(M, /datum/action/human_action/vehicle_action/slave_secondary) + get_gyro_hardpoint()?.recalculate_gyro(FALSE) + stop_crew_hud(M, VEHICLE_GUNNER) + if(seat == VEHICLE_DRIVER || seat == VEHICLE_GUNNER) + remove_action(M, /datum/action/human_action/vehicle_action/cycle_hardpoint) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_hardpoint_fire_mode) + remove_action(M, /datum/action/human_action/vehicle_action/use_phone) /obj/vehicle/multitile/apc/medical/initialize_cameras(change_tag = FALSE) if(!camera) @@ -120,6 +184,15 @@ //PRESET: only wheels installed /obj/effect/vehicle_spawner/apc_med/plain/load_hardpoints(obj/vehicle/multitile/apc/medical/V) V.add_hardpoint(new /obj/item/hardpoint/locomotion/apc_wheels) + V.add_hardpoint(new /obj/item/hardpoint/engine/apc) + V.add_hardpoint(new /obj/item/hardpoint/fuel_tank/uscm) + V.add_hardpoint(new /obj/item/hardpoint/radiator/uscm) + V.add_hardpoint(new /obj/item/hardpoint/battery/uscm) + V.add_hardpoint(new /obj/item/hardpoint/iff_module/uscm) + V.add_hardpoint(new /obj/item/hardpoint/visual_sensors/uscm) + V.add_hardpoint(new /obj/item/hardpoint/air_filter/uscm) + V.add_hardpoint(new /obj/item/hardpoint/turret_ring/uscm) + V.add_hardpoint(new /obj/item/hardpoint/hatch/armored/uscm) //PRESET: default hardpoints, destroyed /obj/effect/vehicle_spawner/apc_med/decrepit/spawn_vehicle() @@ -136,6 +209,15 @@ V.add_hardpoint(new /obj/item/hardpoint/secondary/frontalcannon) V.add_hardpoint(new /obj/item/hardpoint/support/flare_launcher) V.add_hardpoint(new /obj/item/hardpoint/locomotion/apc_wheels) + V.add_hardpoint(new /obj/item/hardpoint/engine/apc) + V.add_hardpoint(new /obj/item/hardpoint/fuel_tank/uscm) + V.add_hardpoint(new /obj/item/hardpoint/radiator/uscm) + V.add_hardpoint(new /obj/item/hardpoint/battery/uscm) + V.add_hardpoint(new /obj/item/hardpoint/iff_module/uscm) + V.add_hardpoint(new /obj/item/hardpoint/visual_sensors/uscm) + V.add_hardpoint(new /obj/item/hardpoint/air_filter/uscm) + V.add_hardpoint(new /obj/item/hardpoint/turret_ring/uscm) + V.add_hardpoint(new /obj/item/hardpoint/hatch/armored/uscm) //PRESET: default hardpoints /obj/effect/vehicle_spawner/apc_med/fixed/load_hardpoints(obj/vehicle/multitile/apc/medical/V) @@ -143,3 +225,12 @@ V.add_hardpoint(new /obj/item/hardpoint/secondary/frontalcannon) V.add_hardpoint(new /obj/item/hardpoint/support/flare_launcher) V.add_hardpoint(new /obj/item/hardpoint/locomotion/apc_wheels) + V.add_hardpoint(new /obj/item/hardpoint/engine/apc) + V.add_hardpoint(new /obj/item/hardpoint/fuel_tank/uscm) + V.add_hardpoint(new /obj/item/hardpoint/radiator/uscm) + V.add_hardpoint(new /obj/item/hardpoint/battery/uscm) + V.add_hardpoint(new /obj/item/hardpoint/iff_module/uscm) + V.add_hardpoint(new /obj/item/hardpoint/visual_sensors/uscm) + V.add_hardpoint(new /obj/item/hardpoint/air_filter/uscm) + V.add_hardpoint(new /obj/item/hardpoint/turret_ring/uscm) + V.add_hardpoint(new /obj/item/hardpoint/hatch/armored/uscm) diff --git a/code/modules/vehicles/apc/apc_pmc.dm b/code/modules/vehicles/apc/apc_pmc.dm index f1793a6e1e48..53d81b36ae9e 100644 --- a/code/modules/vehicles/apc/apc_pmc.dm +++ b/code/modules/vehicles/apc/apc_pmc.dm @@ -30,15 +30,38 @@ required_skill = SKILL_VEHICLE_LARGE - movement_sound = 'sound/vehicles/tank_driving.ogg' + movement_sound = 'sound/vehicles/box_van_driving.ogg' + engine_soundloop_type = /datum/looping_sound/tank_engine/apc var/gunner_view_buff = 10 + engine_on = FALSE + + uses_gear_transmission = TRUE + current_gear = "P" + top_speed = 3.5 + base_acceleration = 1 + base_fuel_use = 0.268 + + hull_cookoff_exterior_power = 100 + hull_cookoff_exterior_falloff = 14 + hull_cookoff_interior_power = 200 + hull_cookoff_interior_falloff = 80 + hardpoints_allowed = list( /obj/item/hardpoint/primary/dualcannon/pmc, /obj/item/hardpoint/secondary/frontalcannon/pmc, /obj/item/hardpoint/support/flare_launcher, /obj/item/hardpoint/locomotion/apc_wheels/pmc, + /obj/item/hardpoint/engine/apc, + /obj/item/hardpoint/fuel_tank/uscm, + /obj/item/hardpoint/radiator/uscm, + /obj/item/hardpoint/battery/uscm, + /obj/item/hardpoint/iff_module/uscm, + /obj/item/hardpoint/visual_sensors/uscm, + /obj/item/hardpoint/air_filter/uscm, + /obj/item/hardpoint/turret_ring/uscm, + /obj/item/hardpoint/hatch/armored/uscm, ) seats = list( @@ -77,11 +100,22 @@ /obj/vehicle/multitile/apc_pmc/Initialize() . = ..() + gear_stats = build_gear_stats() + cruise_control_granularity = gear_stats["D"]["max_speed"] * CRUISE_CONTROL_DEFAULT_GRANULARITY_FRACTION + setup_overwatch_camera() var/turf/gotten_turf = get_turf(src) if(gotten_turf && gotten_turf.z) SSminimaps.add_marker(src, gotten_turf.z, MINIMAP_FLAG_USCM, "apc", 'icons/ui_icons/map_blips_large.dmi') +/// Kicks off the shared cookoff sequence the instant hull health first reaches 0. +/obj/vehicle/multitile/apc_pmc/on_hull_destroyed() + start_hull_cookoff_sequence() + +/// The APC has a Visual Sensors slot. +/obj/vehicle/multitile/apc_pmc/supports_visual_sensors() + return TRUE + /obj/vehicle/multitile/apc_pmc/load_role_reserved_slots() var/datum/role_reserved_slots/RRS = new RRS.category_name = "Crewmen" @@ -96,6 +130,12 @@ role_reserved_slots += RRS /obj/vehicle/multitile/apc_pmc/add_seated_verbs(mob/living/M, seat) + // Reassign this APC's CIC overwatch to the new driver's squad, if they're a Tank Crewman with one. + // driver.mind is required so a map-placed dummy/mannequin can never trigger an assignment just by existing pre-buckled. + if(seat == VEHICLE_DRIVER && ishuman(M)) + var/mob/living/carbon/human/driver = M + if(driver.mind && driver.job == JOB_TANK_CREW && driver.assigned_squad) + update_overwatch_squad(driver.assigned_squad) if(!M.client) return add_verb(M.client, list( @@ -106,19 +146,50 @@ add_verb(M.client, list( /obj/vehicle/multitile/proc/toggle_door_lock, /obj/vehicle/multitile/proc/activate_horn, - /obj/vehicle/multitile/proc/name_vehicle + /obj/vehicle/multitile/proc/name_vehicle, + /obj/vehicle/multitile/proc/toggle_cruise_control, + /obj/vehicle/multitile/proc/set_cruise_control_granularity, + /obj/vehicle/multitile/proc/toggle_turn_signal_north, + /obj/vehicle/multitile/proc/toggle_turn_signal_south, + /obj/vehicle/multitile/proc/toggle_turn_signal_east, + /obj/vehicle/multitile/proc/toggle_turn_signal_west, + /obj/vehicle/multitile/proc/toggle_engine, + /obj/vehicle/multitile/proc/cycle_hardpoint, )) + give_action(M, /datum/action/human_action/vehicle_action/toggle_door_lock) + give_action(M, /datum/action/human_action/vehicle_action/toggle_engine) + if(!(M.client.prefs.toggles_vehicle & VEHICLE_SIMPLE_ACCELERATION)) + give_action(M, /datum/action/human_action/vehicle_action/toggle_cruise_control) + give_action(M, /datum/action/human_action/vehicle_action/set_cruise_control_granularity) + RegisterSignal(M, COMSIG_MOB_VEHICLE_PREFS_CHANGED, PROC_REF(on_driver_prefs_changed)) + start_crew_hud(M, VEHICLE_DRIVER) else if(seat == VEHICLE_GUNNER) add_verb(M.client, list( /obj/vehicle/multitile/proc/switch_hardpoint, /obj/vehicle/multitile/proc/cycle_hardpoint, - /obj/vehicle/multitile/proc/name_vehicle + /obj/vehicle/multitile/proc/name_vehicle, + /obj/vehicle/multitile/proc/toggle_hardpoint_fire_mode, + /obj/vehicle/multitile/proc/toggle_iff_module, + /obj/vehicle/multitile/proc/toggle_turret_safety, + /obj/vehicle/multitile/proc/toggle_slave_secondary_to_driver, )) + give_action(M, /datum/action/human_action/vehicle_action/toggle_iff) + give_action(M, /datum/action/human_action/vehicle_action/toggle_gyro) + get_gyro_hardpoint()?.recalculate_gyro(TRUE) + if(get_slavable_secondary()) + give_action(M, /datum/action/human_action/vehicle_action/slave_secondary) + start_crew_hud(M, VEHICLE_GUNNER) else if(seat == VEHICLE_SUPPORT_GUNNER_ONE || seat == VEHICLE_SUPPORT_GUNNER_TWO) add_verb(M.client, list( /obj/vehicle/multitile/proc/reload_firing_port_weapon )) + if(seat == VEHICLE_DRIVER || seat == VEHICLE_GUNNER) + if(active_hp[seat]?.get_flame_mode()) + give_action(M, /datum/action/human_action/vehicle_action/toggle_hardpoint_fire_mode) + give_action(M, /datum/action/human_action/vehicle_action/use_phone) + refresh_hardpoint_actions() + ensure_active_hardpoint(seat) /obj/vehicle/multitile/apc_pmc/remove_seated_verbs(mob/living/M, seat) if(!M.client) @@ -133,17 +204,44 @@ /obj/vehicle/multitile/proc/toggle_door_lock, /obj/vehicle/multitile/proc/activate_horn, /obj/vehicle/multitile/proc/name_vehicle, + /obj/vehicle/multitile/proc/toggle_cruise_control, + /obj/vehicle/multitile/proc/set_cruise_control_granularity, + /obj/vehicle/multitile/proc/toggle_turn_signal_north, + /obj/vehicle/multitile/proc/toggle_turn_signal_south, + /obj/vehicle/multitile/proc/toggle_turn_signal_east, + /obj/vehicle/multitile/proc/toggle_turn_signal_west, + /obj/vehicle/multitile/proc/toggle_engine, + /obj/vehicle/multitile/proc/cycle_hardpoint, )) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_door_lock) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_engine) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_cruise_control) + remove_action(M, /datum/action/human_action/vehicle_action/set_cruise_control_granularity) + UnregisterSignal(M, COMSIG_MOB_VEHICLE_PREFS_CHANGED) + stop_crew_hud(M, VEHICLE_DRIVER) else if(seat == VEHICLE_GUNNER) remove_verb(M.client, list( /obj/vehicle/multitile/proc/switch_hardpoint, /obj/vehicle/multitile/proc/cycle_hardpoint, /obj/vehicle/multitile/proc/name_vehicle, + /obj/vehicle/multitile/proc/toggle_hardpoint_fire_mode, + /obj/vehicle/multitile/proc/toggle_iff_module, + /obj/vehicle/multitile/proc/toggle_turret_safety, + /obj/vehicle/multitile/proc/toggle_slave_secondary_to_driver, )) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_iff) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_gyro) + remove_action(M, /datum/action/human_action/vehicle_action/slave_secondary) + get_gyro_hardpoint()?.recalculate_gyro(FALSE) + stop_crew_hud(M, VEHICLE_GUNNER) else if(seat == VEHICLE_SUPPORT_GUNNER_ONE || seat == VEHICLE_SUPPORT_GUNNER_TWO) remove_verb(M.client, list( /obj/vehicle/multitile/proc/reload_firing_port_weapon )) + if(seat == VEHICLE_DRIVER || seat == VEHICLE_GUNNER) + remove_action(M, /datum/action/human_action/vehicle_action/cycle_hardpoint) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_hardpoint_fire_mode) + remove_action(M, /datum/action/human_action/vehicle_action/use_phone) /obj/vehicle/multitile/apc_pmc/initialize_cameras(change_tag = FALSE) if(!camera) @@ -213,6 +311,15 @@ //PRESET: FPWs, wheels installed /obj/effect/vehicle_spawner/apc_pmc/plain/load_hardpoints(obj/vehicle/multitile/apc/V) V.add_hardpoint(new /obj/item/hardpoint/locomotion/apc_wheels/pmc) + V.add_hardpoint(new /obj/item/hardpoint/engine/apc) + V.add_hardpoint(new /obj/item/hardpoint/fuel_tank/uscm) + V.add_hardpoint(new /obj/item/hardpoint/radiator/uscm) + V.add_hardpoint(new /obj/item/hardpoint/battery/uscm) + V.add_hardpoint(new /obj/item/hardpoint/iff_module/uscm) + V.add_hardpoint(new /obj/item/hardpoint/visual_sensors/uscm) + V.add_hardpoint(new /obj/item/hardpoint/air_filter/uscm) + V.add_hardpoint(new /obj/item/hardpoint/turret_ring/uscm) + V.add_hardpoint(new /obj/item/hardpoint/hatch/armored/uscm) //PRESET: default hardpoints, destroyed (this one spawns on VASRS elevatorfor VCs) /obj/effect/vehicle_spawner/apc_pmc/decrepit/spawn_vehicle() @@ -230,6 +337,15 @@ V.add_hardpoint(new /obj/item/hardpoint/secondary/frontalcannon/pmc) V.add_hardpoint(new /obj/item/hardpoint/support/flare_launcher) V.add_hardpoint(new /obj/item/hardpoint/locomotion/apc_wheels/pmc) + V.add_hardpoint(new /obj/item/hardpoint/engine/apc) + V.add_hardpoint(new /obj/item/hardpoint/fuel_tank/uscm) + V.add_hardpoint(new /obj/item/hardpoint/radiator/uscm) + V.add_hardpoint(new /obj/item/hardpoint/battery/uscm) + V.add_hardpoint(new /obj/item/hardpoint/iff_module/uscm) + V.add_hardpoint(new /obj/item/hardpoint/visual_sensors/uscm) + V.add_hardpoint(new /obj/item/hardpoint/air_filter/uscm) + V.add_hardpoint(new /obj/item/hardpoint/turret_ring/uscm) + V.add_hardpoint(new /obj/item/hardpoint/hatch/armored/uscm) //PRESET: FPWs, default hardpoints /obj/effect/vehicle_spawner/apc_pmc/fixed/load_hardpoints(obj/vehicle/multitile/apc/V) @@ -237,6 +353,15 @@ V.add_hardpoint(new /obj/item/hardpoint/secondary/frontalcannon/pmc) V.add_hardpoint(new /obj/item/hardpoint/support/flare_launcher) V.add_hardpoint(new /obj/item/hardpoint/locomotion/apc_wheels/pmc) + V.add_hardpoint(new /obj/item/hardpoint/engine/apc) + V.add_hardpoint(new /obj/item/hardpoint/fuel_tank/uscm) + V.add_hardpoint(new /obj/item/hardpoint/radiator/uscm) + V.add_hardpoint(new /obj/item/hardpoint/battery/uscm) + V.add_hardpoint(new /obj/item/hardpoint/iff_module/uscm) + V.add_hardpoint(new /obj/item/hardpoint/visual_sensors/uscm) + V.add_hardpoint(new /obj/item/hardpoint/air_filter/uscm) + V.add_hardpoint(new /obj/item/hardpoint/turret_ring/uscm) + V.add_hardpoint(new /obj/item/hardpoint/hatch/armored/uscm) //Transport version without FPWs @@ -277,10 +402,28 @@ V.add_hardpoint(new /obj/item/hardpoint/secondary/frontalcannon/pmc) V.add_hardpoint(new /obj/item/hardpoint/support/flare_launcher) V.add_hardpoint(new /obj/item/hardpoint/locomotion/apc_wheels/pmc) + V.add_hardpoint(new /obj/item/hardpoint/engine/apc) + V.add_hardpoint(new /obj/item/hardpoint/fuel_tank/uscm) + V.add_hardpoint(new /obj/item/hardpoint/radiator/uscm) + V.add_hardpoint(new /obj/item/hardpoint/battery/uscm) + V.add_hardpoint(new /obj/item/hardpoint/iff_module/uscm) + V.add_hardpoint(new /obj/item/hardpoint/visual_sensors/uscm) + V.add_hardpoint(new /obj/item/hardpoint/air_filter/uscm) + V.add_hardpoint(new /obj/item/hardpoint/turret_ring/uscm) + V.add_hardpoint(new /obj/item/hardpoint/hatch/armored/uscm) //PRESET: no FPWs, wheels installed /obj/effect/vehicle_spawner/apc_pmc/unarmed/plain/load_hardpoints(obj/vehicle/multitile/apc/unarmed/V) V.add_hardpoint(new /obj/item/hardpoint/locomotion/apc_wheels/pmc) + V.add_hardpoint(new /obj/item/hardpoint/engine/apc) + V.add_hardpoint(new /obj/item/hardpoint/fuel_tank/uscm) + V.add_hardpoint(new /obj/item/hardpoint/radiator/uscm) + V.add_hardpoint(new /obj/item/hardpoint/battery/uscm) + V.add_hardpoint(new /obj/item/hardpoint/iff_module/uscm) + V.add_hardpoint(new /obj/item/hardpoint/visual_sensors/uscm) + V.add_hardpoint(new /obj/item/hardpoint/air_filter/uscm) + V.add_hardpoint(new /obj/item/hardpoint/turret_ring/uscm) + V.add_hardpoint(new /obj/item/hardpoint/hatch/armored/uscm) //PRESET: default hardpoints /obj/effect/vehicle_spawner/apc_pmc/unarmed/fixed/load_hardpoints(obj/vehicle/multitile/apc/unarmed/V) @@ -288,3 +431,12 @@ V.add_hardpoint(new /obj/item/hardpoint/secondary/frontalcannon/pmc) V.add_hardpoint(new /obj/item/hardpoint/support/flare_launcher) V.add_hardpoint(new /obj/item/hardpoint/locomotion/apc_wheels/pmc) + V.add_hardpoint(new /obj/item/hardpoint/engine/apc) + V.add_hardpoint(new /obj/item/hardpoint/fuel_tank/uscm) + V.add_hardpoint(new /obj/item/hardpoint/radiator/uscm) + V.add_hardpoint(new /obj/item/hardpoint/battery/uscm) + V.add_hardpoint(new /obj/item/hardpoint/iff_module/uscm) + V.add_hardpoint(new /obj/item/hardpoint/visual_sensors/uscm) + V.add_hardpoint(new /obj/item/hardpoint/air_filter/uscm) + V.add_hardpoint(new /obj/item/hardpoint/turret_ring/uscm) + V.add_hardpoint(new /obj/item/hardpoint/hatch/armored/uscm) diff --git a/code/modules/vehicles/arc/arc.dm b/code/modules/vehicles/arc/arc.dm index 80b494672718..24813c1d2861 100644 --- a/code/modules/vehicles/arc/arc.dm +++ b/code/modules/vehicles/arc/arc.dm @@ -28,14 +28,40 @@ required_skill = SKILL_VEHICLE_LARGE - movement_sound = 'sound/vehicles/tank_driving.ogg' + movement_sound = 'sound/vehicles/box_van_driving.ogg' + engine_soundloop_type = /datum/looping_sound/tank_engine/arc luminosity = 7 + engine_on = FALSE + + uses_gear_transmission = TRUE + current_gear = "P" + top_speed = 3.5 + base_acceleration = 1.2 + base_fuel_use = 0.175 + // 80% smaller than the baseline idle rate. intent being that the ARC should be a lower-maintenance vehicle when parked with sensors on. + idle_fuel_use_mult = 0.2 + desant_momentum_cap = 0.35 + + hull_cookoff_exterior_power = 100 + hull_cookoff_exterior_falloff = 14 + hull_cookoff_interior_power = 200 + hull_cookoff_interior_falloff = 80 + hardpoints_allowed = list( /obj/item/hardpoint/locomotion/arc_wheels, /obj/item/hardpoint/primary/arc_sentry, /obj/item/hardpoint/support/arc_antenna, + /obj/item/hardpoint/engine/arc, + /obj/item/hardpoint/fuel_tank/arc, + /obj/item/hardpoint/radiator/uscm, + /obj/item/hardpoint/battery/uscm, + /obj/item/hardpoint/iff_module/uscm, + /obj/item/hardpoint/visual_sensors/uscm, + /obj/item/hardpoint/air_filter/uscm, + /obj/item/hardpoint/turret_ring/uscm, + /obj/item/hardpoint/hatch/armored/uscm, ) seats = list( @@ -79,6 +105,16 @@ /obj/vehicle/multitile/arc/Initialize() . = ..() RegisterSignal(src, COMSIG_ARC_ANTENNA_TOGGLED, PROC_REF(on_antenna_toggle)) + gear_stats = build_gear_stats() + cruise_control_granularity = gear_stats["D"]["max_speed"] * CRUISE_CONTROL_DEFAULT_GRANULARITY_FRACTION + +/// Kicks off the shared cookoff sequence the instant hull health first reaches 0. +/obj/vehicle/multitile/arc/on_hull_destroyed() + start_hull_cookoff_sequence() + +/// The ARC has a Visual Sensors slot. +/obj/vehicle/multitile/arc/supports_visual_sensors() + return TRUE /obj/vehicle/multitile/arc/crew_mousedown(datum/source, atom/object, turf/location, control, params) var/list/modifiers = params2list(params) @@ -120,7 +156,9 @@ return var/obj/item/hardpoint/support/arc_antenna/antenna = locate() in hardpoints - if(!antenna || (antenna.health <= 0)) + // Also requires vehicle power, so a deployed-but-unpowered antenna stops the sensor effects + // until power returns. Doesn't affect the deploy/retract verb itself. + if(!antenna || !antenna.is_functional()) clear_tacmap() return @@ -179,7 +217,26 @@ /obj/vehicle/multitile/proc/activate_horn, /obj/vehicle/multitile/proc/name_vehicle, /obj/vehicle/multitile/arc/proc/toggle_antenna, + /obj/vehicle/multitile/proc/toggle_cruise_control, + /obj/vehicle/multitile/proc/set_cruise_control_granularity, + /obj/vehicle/multitile/proc/toggle_turn_signal_north, + /obj/vehicle/multitile/proc/toggle_turn_signal_south, + /obj/vehicle/multitile/proc/toggle_turn_signal_east, + /obj/vehicle/multitile/proc/toggle_turn_signal_west, + /obj/vehicle/multitile/proc/toggle_engine, + /obj/vehicle/multitile/proc/toggle_iff_module, )) + give_action(M, /datum/action/human_action/vehicle_action/toggle_door_lock) + give_action(M, /datum/action/human_action/vehicle_action/toggle_engine) + give_action(M, /datum/action/human_action/vehicle_action/toggle_iff) + give_action(M, /datum/action/human_action/vehicle_action/use_phone) + if(!(M.client.prefs.toggles_vehicle & VEHICLE_SIMPLE_ACCELERATION)) + give_action(M, /datum/action/human_action/vehicle_action/toggle_cruise_control) + give_action(M, /datum/action/human_action/vehicle_action/set_cruise_control_granularity) + RegisterSignal(M, COMSIG_MOB_VEHICLE_PREFS_CHANGED, PROC_REF(on_driver_prefs_changed)) + start_crew_hud(M, VEHICLE_DRIVER) + refresh_hardpoint_actions() + ensure_active_hardpoint(seat) /obj/vehicle/multitile/arc/remove_seated_verbs(mob/living/M, seat) if(!M.client) @@ -191,7 +248,23 @@ /obj/vehicle/multitile/proc/activate_horn, /obj/vehicle/multitile/proc/name_vehicle, /obj/vehicle/multitile/arc/proc/toggle_antenna, + /obj/vehicle/multitile/proc/toggle_cruise_control, + /obj/vehicle/multitile/proc/set_cruise_control_granularity, + /obj/vehicle/multitile/proc/toggle_turn_signal_north, + /obj/vehicle/multitile/proc/toggle_turn_signal_south, + /obj/vehicle/multitile/proc/toggle_turn_signal_east, + /obj/vehicle/multitile/proc/toggle_turn_signal_west, + /obj/vehicle/multitile/proc/toggle_engine, + /obj/vehicle/multitile/proc/toggle_iff_module, )) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_door_lock) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_engine) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_iff) + remove_action(M, /datum/action/human_action/vehicle_action/use_phone) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_cruise_control) + remove_action(M, /datum/action/human_action/vehicle_action/set_cruise_control_granularity) + UnregisterSignal(M, COMSIG_MOB_VEHICLE_PREFS_CHANGED) + stop_crew_hud(M, VEHICLE_DRIVER) SStgui.close_user_uis(M, src) /obj/vehicle/multitile/arc/initialize_cameras(change_tag = FALSE) @@ -267,3 +340,12 @@ vehicle.add_hardpoint(new /obj/item/hardpoint/locomotion/arc_wheels) vehicle.add_hardpoint(new /obj/item/hardpoint/primary/arc_sentry) vehicle.add_hardpoint(new /obj/item/hardpoint/support/arc_antenna) + vehicle.add_hardpoint(new /obj/item/hardpoint/engine/arc) + vehicle.add_hardpoint(new /obj/item/hardpoint/fuel_tank/arc) + vehicle.add_hardpoint(new /obj/item/hardpoint/radiator/uscm) + vehicle.add_hardpoint(new /obj/item/hardpoint/battery/uscm) + vehicle.add_hardpoint(new /obj/item/hardpoint/iff_module/uscm) + vehicle.add_hardpoint(new /obj/item/hardpoint/visual_sensors/uscm) + vehicle.add_hardpoint(new /obj/item/hardpoint/air_filter/uscm) + vehicle.add_hardpoint(new /obj/item/hardpoint/turret_ring/uscm) + vehicle.add_hardpoint(new /obj/item/hardpoint/hatch/armored/uscm) diff --git a/code/modules/vehicles/hardpoints/air_filter/air_filter.dm b/code/modules/vehicles/hardpoints/air_filter/air_filter.dm new file mode 100644 index 000000000000..9c0628e9cd8f --- /dev/null +++ b/code/modules/vehicles/hardpoints/air_filter/air_filter.dm @@ -0,0 +1,50 @@ +/obj/item/hardpoint/air_filter + name = "\improper air filter" + desc = "Filters outside air before it reaches the crew compartment." + + slot = HDPT_AIR_FILTER + hdpt_layer = HDPT_LAYER_SUPPORT + + icon = 'icons/obj/vehicles/hardpoints/shared.dmi' + icon_state = "filter-0" + + health = 700 + damage_multiplier = 0.2 + power_draw = AIR_FILTER_POWER_DRAW + +// obs. this is an internal fighting compartment air filter, not an engine air filter. +/obj/item/hardpoint/air_filter/uscm + name = "\improper M39 air filter" + desc = "The air filtration system of a USCM ground vehicle. Standard issue across the fleet." + +/obj/item/hardpoint/air_filter/update_icon() + icon = 'icons/obj/vehicles/hardpoints/shared.dmi' + icon_state = "filter-[get_shared_damage_suffix()]" + +/obj/item/hardpoint/air_filter/on_uninstall(obj/vehicle/multitile/vehicle) + update_icon() + . = ..() + +/** + * How much of a gas cloud gets through into the crew compartment, 0 to 1. + * Scales with wound tier and integrity. Fully leaks if unpowered or destroyed. + */ +/obj/item/hardpoint/air_filter/proc/get_gas_leak_fraction() + if(health <= 0 || !owner?.has_vehicle_power()) + return 1 + + var/worst_tier = 0 + for(var/family_type in wound_tiers) + worst_tier = max(worst_tier, wound_tiers[family_type]) + + var/tier_floor = 0 + var/tier_ceiling = AIR_FILTER_LEAK_FRACTION_TIER_1 + if(worst_tier == 1) + tier_floor = AIR_FILTER_LEAK_FRACTION_TIER_1 + tier_ceiling = AIR_FILTER_LEAK_FRACTION_TIER_2 + else if(worst_tier >= 2) + tier_floor = AIR_FILTER_LEAK_FRACTION_TIER_2 + tier_ceiling = 1 + + var/degrade_ratio = CLAMP01((AIR_FILTER_LEAK_HEALTH_THRESHOLD_PCT - get_integrity_percent()) / AIR_FILTER_LEAK_HEALTH_THRESHOLD_PCT) + return tier_floor + (tier_ceiling - tier_floor) * degrade_ratio diff --git a/code/modules/vehicles/hardpoints/armor/armor.dm b/code/modules/vehicles/hardpoints/armor/armor.dm index e60bb7b7063a..9d37291361b2 100644 --- a/code/modules/vehicles/hardpoints/armor/armor.dm +++ b/code/modules/vehicles/hardpoints/armor/armor.dm @@ -8,3 +8,17 @@ damage_multiplier = 0.5 health = 1000 + + /// Whether this armor's type_multipliers also protect internal modules, not just external ones. + var/protects_internal_modules = FALSE + /// Multiplier on neurotoxin wound chance vehicle-wide. 1 for every type except Caustic. + var/neuro_wound_chance_mult = 1 + +/** + * Scales incoming damage by this panel's own current wound multiplier before applying it. + * A corroded/cracked panel gets weaker where it's already been hit, unlike the turret + * holder's cascade which buffs damage to other hardpoints. + */ +/obj/item/hardpoint/armor/take_damage(damage, type = "abstract", atom/attacker, unmitigated = FALSE, wound_chance_mult = 1) + var/scaled_damage = damage * get_incoming_damage_wound_multiplier(wound_damage_type_for(type)) + . = ..(scaled_damage, type, attacker, unmitigated, wound_chance_mult) diff --git a/code/modules/vehicles/hardpoints/armor/ballistic.dm b/code/modules/vehicles/hardpoints/armor/ballistic.dm index f090ca0c3377..8b52d05e0e0c 100644 --- a/code/modules/vehicles/hardpoints/armor/ballistic.dm +++ b/code/modules/vehicles/hardpoints/armor/ballistic.dm @@ -6,8 +6,11 @@ disp_icon = "tank" disp_icon_state = "ballistic_armor" + // anti FF armor type_multipliers = list( - "bullet" = 0.67, - "slash" = 0.67, + "bullet" = 0.2, + "explosive" = 0.8, + "slash" = 0.9, + "blunt" = 0.9, "all" = 0.9 ) diff --git a/code/modules/vehicles/hardpoints/armor/caustic.dm b/code/modules/vehicles/hardpoints/armor/caustic.dm index 9eb754bb172e..4af8a7082a3e 100644 --- a/code/modules/vehicles/hardpoints/armor/caustic.dm +++ b/code/modules/vehicles/hardpoints/armor/caustic.dm @@ -6,7 +6,10 @@ disp_icon = "tank" disp_icon_state = "caustic_armor" + // anti boiler spitter sentinel armor type_multipliers = list( "acid" = 0.67, "all" = 0.9 ) + protects_internal_modules = TRUE + neuro_wound_chance_mult = 0.67 diff --git a/code/modules/vehicles/hardpoints/armor/concussive.dm b/code/modules/vehicles/hardpoints/armor/concussive.dm index c3ce5b5646f9..cc2cec336da5 100644 --- a/code/modules/vehicles/hardpoints/armor/concussive.dm +++ b/code/modules/vehicles/hardpoints/armor/concussive.dm @@ -6,7 +6,11 @@ disp_icon = "tank" disp_icon_state = "concussive_armor" + // anti melee armor type_multipliers = list( + "slash" = 0.67, "blunt" = 0.67, + "bullet" = 0.8, + "explosive" = 0.8, "all" = 0.9 ) diff --git a/code/modules/vehicles/hardpoints/armor/paladin.dm b/code/modules/vehicles/hardpoints/armor/paladin.dm index a351ca95b24b..7029291fbecc 100644 --- a/code/modules/vehicles/hardpoints/armor/paladin.dm +++ b/code/modules/vehicles/hardpoints/armor/paladin.dm @@ -6,7 +6,11 @@ disp_icon = "tank" disp_icon_state = "paladin_armor" + // anti suicide armor type_multipliers = list( - "explosive" = 0.67, + "explosive" = 0.2, + "bullet" = 0.8, + "slash" = 0.9, + "blunt" = 0.9, "all" = 0.9 ) diff --git a/code/modules/vehicles/hardpoints/battery/battery.dm b/code/modules/vehicles/hardpoints/battery/battery.dm new file mode 100644 index 000000000000..e9acaacc556e --- /dev/null +++ b/code/modules/vehicles/hardpoints/battery/battery.dm @@ -0,0 +1,79 @@ +/obj/item/hardpoint/battery + name = "\improper battery" + desc = "Supplies electrical power for a vehicle's onboard systems. Light enough to be carried by hand." + + slot = HDPT_BATTERY + hdpt_layer = HDPT_LAYER_SUPPORT + + icon = 'icons/obj/vehicles/hardpoints/shared.dmi' + icon_state = "battery-0" + + health = 350 + damage_multiplier = 0.15 + + var/max_charge = 1000 + var/current_charge = 1000 + +/obj/item/hardpoint/battery/uscm + name = "\improper WY-24 power bank" + desc = "A Weyland-Yutani-manufactured 24V power bank, standard issue across the USCM ground vehicle fleet. Powers onboard systems such as the IFF module." + + max_charge = 2000 + current_charge = 2000 + +/obj/item/hardpoint/battery/civilian + name = "\improper 12V civilian battery" + desc = "A standard 12V civilian vehicle battery. Powers the starter and onboard electrics." + +/obj/item/hardpoint/battery/proc/get_charge_percent() + if(!max_charge) + return 0 + return 100.0 * current_charge / max_charge + +// Refreshes everything that depends on vehicle power when this battery is installed. +/obj/item/hardpoint/battery/on_install(obj/vehicle/multitile/vehicle) + . = ..() + recalculate_owner_turret(vehicle) + vehicle?.recheck_iff_module() + vehicle?.recheck_support_modules() + vehicle?.recheck_visual_sensors() + +// Forces every power-dependent recalculation to treat this departing battery as already gone. +/obj/item/hardpoint/battery/on_uninstall(obj/vehicle/multitile/vehicle) + recalculate_owner_turret(vehicle, ignore_battery = TRUE) + vehicle?.recheck_iff_module(ignore_battery = TRUE) + vehicle?.recheck_support_modules(ignore_battery = TRUE) + vehicle?.recheck_turn_signals(ignore_battery = TRUE) + vehicle?.recheck_visual_sensors(ignore_battery = TRUE) + update_icon() + . = ..() + +/// Rechecks everything that depends on vehicle power the instant this battery is destroyed. +/obj/item/hardpoint/battery/take_damage(damage, type = "abstract", atom/attacker, unmitigated = FALSE, wound_chance_mult = 1) + var/was_functional = health > 0 + . = ..() + if(was_functional && health <= 0 && owner) + recalculate_owner_turret(owner) + owner.recheck_iff_module() + owner.recheck_support_modules() + owner.recheck_turn_signals() + owner.recheck_visual_sensors() + +/obj/item/hardpoint/battery/proc/recalculate_owner_turret(obj/vehicle/multitile/vehicle = owner, ignore_battery = FALSE) + if(!vehicle) + return + var/obj/item/hardpoint/holder/tank_turret/turret = locate() in vehicle.hardpoints + if(turret) + turret.recalculate_turn_rate(ignore_battery) + +/// This module only has a loose obj sprite, no on-tank sprite. +/obj/item/hardpoint/battery/update_icon() + var/suffix = 0 + if(get_integrity_percent() < 33) + suffix = 2 + else if(get_integrity_percent() < 66) + suffix = 1 + if(LAZYLEN(wound_tiers)) + suffix = max(suffix, 1) + icon = 'icons/obj/vehicles/hardpoints/shared.dmi' + icon_state = "battery-[suffix]" diff --git a/code/modules/vehicles/hardpoints/engine/engine.dm b/code/modules/vehicles/hardpoints/engine/engine.dm new file mode 100644 index 000000000000..bdc77d77d6f3 --- /dev/null +++ b/code/modules/vehicles/hardpoints/engine/engine.dm @@ -0,0 +1,76 @@ +/obj/item/hardpoint/engine + name = "\improper Power Plant" + desc = "A vehicle's engine block. Too heavy and awkwardly shaped to be man-handled. A power loader is needed to lift it in or out." + + slot = HDPT_ENGINE + hdpt_layer = HDPT_LAYER_SUPPORT + + icon = 'icons/obj/vehicles/hardpoints/shared.dmi' + icon_state = "engine-0" + + w_class = SIZE_MASSIVE + density = TRUE + anchored = TRUE + + health = 500 + damage_multiplier = 0.15 + + var/engine_temperature = T20C + /// world.time before which the overheat wound check won't roll again. + var/next_overheat_wound_roll = 0 + +// Too heavy to man-handle. +/obj/item/hardpoint/engine/attack_hand(mob/user) + return + +/// Shuts the engine off if it's removed while still running. +/obj/item/hardpoint/engine/on_uninstall(obj/vehicle/multitile/vehicle) + if(vehicle?.engine_on) + vehicle.set_engine_on(FALSE) + update_icon() + . = ..() + +/// Shuts the engine off the moment it's destroyed by damage. +/obj/item/hardpoint/engine/take_damage(damage, type = "abstract", atom/attacker, unmitigated = FALSE, wound_chance_mult = 1) + . = ..() + if(health <= 0 && owner?.engine_on) + owner.set_engine_on(FALSE) + +/obj/item/hardpoint/engine/tank + name = "\improper Lunnar-Welsun AGT1200" + desc = "A 1200HP gas turbine power plant driving the M34A2 Longstreet. Too heavy and awkwardly shaped to be man-handled. A power loader is needed to lift it in or out." + + health = 550 + +/obj/item/hardpoint/engine/apc + name = "\improper Alphatech TD800 " + desc = "A 760HP turbocharged diesel piston engine driving the M577 Armored Personnel Carrier. Too heavy and awkwardly shaped to be man-handled. A power loader is needed to lift it in or out." + +/obj/item/hardpoint/engine/arc + name = "\improper Alphatech TD700" + desc = "A 655HP turbocharged diesel piston engine driving the M540-B Armored Recon Carrier. Too heavy and awkwardly shaped to be man-handled. A power loader is needed to lift it in or out." + +// Shared by every colony van variant. +/obj/item/hardpoint/engine/van + name = "\improper Alphatech D400" + desc = "A commercial 360HP diesel piston engine block. Too heavy and awkwardly shaped to be man-handled. A power loader is needed to lift it in or out." + +/obj/item/hardpoint/engine/update_icon() + icon = 'icons/obj/vehicles/hardpoints/shared.dmi' + icon_state = "engine-[get_shared_damage_suffix()]" + +// I wasn't going to add this initially, but allowing an engine to overheat ad infinitum doesn't seem very nice +/obj/item/hardpoint/engine/proc/is_overheating() + return engine_temperature > ENGINE_OVERHEAT_THRESHOLD + +/** + * Reports how hot this engine is, as a percent from room temperature to overheating. + * + * Returns: + * * 0 at room temperature, 100 at the overheat threshold, and beyond 100 past that. + */ +/obj/item/hardpoint/engine/proc/get_temperature_percent() + var/range = ENGINE_OVERHEAT_THRESHOLD - T20C + if(!range) + return 0 + return 100 * (engine_temperature - T20C) / range diff --git a/code/modules/vehicles/hardpoints/fuel_tank/fuel_tank.dm b/code/modules/vehicles/hardpoints/fuel_tank/fuel_tank.dm new file mode 100644 index 000000000000..08b407313765 --- /dev/null +++ b/code/modules/vehicles/hardpoints/fuel_tank/fuel_tank.dm @@ -0,0 +1,318 @@ +/obj/item/hardpoint/fuel_tank + name = "\improper fuel tank" + desc = "Holds a vehicle's fuel supply. Light enough to be carried by hand." + + slot = HDPT_FUEL_TANK + hdpt_layer = HDPT_LAYER_SUPPORT + + icon = 'icons/obj/vehicles/hardpoints/shared.dmi' + icon_state = "fuel-0" + + flags_atom = OPENCONTAINER + + health = 550 + damage_multiplier = 0.15 + + var/max_fuel = 500 + var/native_fuel_id = "jp8" + /// Vehicle moves since the last fuel leak puddle drop. + var/moves_since_puddle = 0 + /// TRUE while a gauze patch is masking the leak. + var/list/taped_wounds + var/amount_per_transfer_from_this = 25 + var/possible_transfer_amounts = list(10, 25, 50, 100, 250, 500) + +// Shared by the tank and APC. +/obj/item/hardpoint/fuel_tank/uscm + name = "\improper M39 fuel tank" + desc = "The standard-issue main fuel tank of the USCM's amored vehicles. Runs best on JP-8, but can make do with welding fuel or cooking oil in a pinch." + +/obj/item/hardpoint/fuel_tank/arc + name = "\improper M540-B fuel tank" + desc = "The compact main fuel tank of the M540-B Armored Recon Carrier. Runs best on JP-8, but can make do with welding fuel or cooking oil in a pinch." + max_fuel = 200 + +/obj/item/hardpoint/fuel_tank/van + name = "\improper diesel fuel tank" + desc = "A commercial diesel fuel tank. Runs best on Diesel, but can make do with welding fuel or cooking oil in a pinch." + max_fuel = 150 + native_fuel_id = "diesel" + +/obj/item/hardpoint/fuel_tank/Initialize(mapload) + . = ..() + create_reagents(max_fuel) + reagents.add_reagent(native_fuel_id, max_fuel) + START_PROCESSING(SSslowobj, src) + +/obj/item/hardpoint/fuel_tank/Destroy() + STOP_PROCESSING(SSslowobj, src) + return ..() + +/// Sets this tank's per-transfer amount. +/obj/item/hardpoint/fuel_tank/verb/set_APTFT() + set name = "Set Transfer Amount" + set category = "Object" + set src in view(1) + + if(!reagents) + return + + var/new_amount = tgui_input_list(usr, "Amount per transfer from this:", "[src]", possible_transfer_amounts) + if(new_amount) + amount_per_transfer_from_this = new_amount + +/** + * Drains fuel from an active leak wound. + * Drains at a flat rate once fully destroyed. + */ +/obj/item/hardpoint/fuel_tank/process(delta_time) + if(health <= 0) + consume_fuel(HARDPOINT_BUSTED_LEAK_RATE * delta_time) + return + if(LAZYACCESS(taped_wounds, /datum/hardpoint_wound_family/fuel_tank_leak)) + return + var/leak_rate = get_wound_effect_sum("passive_leak_rate") + if(leak_rate <= 0) + return + consume_fuel(leak_rate * delta_time) + +/// Drops a fuel puddle as the vehicle moves, based on leak severity. +/obj/item/hardpoint/fuel_tank/on_move(turf/old, turf/new_turf, move_dir) + . = ..() + var/leak_tier = LAZYACCESS(wound_tiers, /datum/hardpoint_wound_family/fuel_tank_leak) + if(!leak_tier || LAZYACCESS(taped_wounds, /datum/hardpoint_wound_family/fuel_tank_leak)) + moves_since_puddle = 0 + return + + var/moves_per_puddle = (leak_tier >= 2) ? 1 : 3 + moves_since_puddle++ + if(moves_since_puddle < moves_per_puddle) + return + moves_since_puddle = 0 + + var/turf/leak_turf = get_turf(owner) + if(leak_turf) + new /obj/effect/decal/cleanable/liquid_fuel(leak_turf, 1) + +/obj/item/hardpoint/fuel_tank/proc/get_fuel_percent() + if(!reagents || !max_fuel) + return 0 + return 100.0 * reagents.total_volume / max_fuel + +/obj/item/hardpoint/fuel_tank/get_examine_text(mob/user) + . = ..() + . += SPAN_NOTICE("It contains:") + if(reagents && length(reagents.reagent_list)) + for(var/datum/reagent/current_reagent in reagents.reagent_list) + . += SPAN_NOTICE(" [current_reagent.volume] units of [current_reagent.name].") + else + . += SPAN_NOTICE(" Nothing.") + +/// Pours the held item's reagents into this tank. +/obj/item/hardpoint/fuel_tank/attackby(obj/item/item, mob/user) + if(istype(item, /obj/item/stack/medical/bruise_pack)) + try_tape_leak(item, user) + return + + if(!item.reagents || iswelder(item)) + return ..() + + if(!item.reagents.total_volume) + to_chat(user, SPAN_WARNING("\The [item] is empty.")) + return ATTACKBY_HINT_NO_AFTERATTACK + + if(!can_accept_reagents(item.reagents)) + to_chat(user, SPAN_WARNING("You can't pour that into \the [src]!")) + return ATTACKBY_HINT_NO_AFTERATTACK + + var/available = max_fuel - reagents.total_volume + if(available <= 0) + to_chat(user, SPAN_WARNING("\The [src] is full!")) + return ATTACKBY_HINT_NO_AFTERATTACK + + var/transferred = item.reagents.trans_to(src, available) + to_chat(user, SPAN_NOTICE("You pour [transferred] units of fuel into \the [src].")) + return ATTACKBY_HINT_NO_AFTERATTACK + +/// Drains this tank's contents into another reagent-holding target. +/obj/item/hardpoint/fuel_tank/afterattack(atom/target, mob/user, proximity) + if(!proximity || ismob(target)) + return ..() + + if(istype(target, /obj/structure/reagent_dispensers)) + var/obj/structure/reagent_dispensers/dispenser = target + try_refill_from_dispenser(dispenser, user) + return + + if(!reagents || !reagents.total_volume) + return ..() + if(!target.reagents || !target.is_open_container()) + return ..() + + var/room = target.reagents.maximum_volume - target.reagents.total_volume + if(room <= 0) + to_chat(user, SPAN_WARNING("\The [target] is full.")) + return + + var/drained = reagents.trans_to(target, min(room, amount_per_transfer_from_this)) + to_chat(user, SPAN_NOTICE("You pour [drained] units from \the [src] into \the [target].")) + +/** + * Refills this tank from a fixed reagent dispenser. + * + * Arguments: + * * dispenser = The dispenser being drawn from. + * * user = Whoever is doing the refilling. + */ +/obj/item/hardpoint/fuel_tank/proc/try_refill_from_dispenser(obj/structure/reagent_dispensers/dispenser, mob/user) + if(!dispenser.reagents || !dispenser.dispensing) + return + if(!dispenser.reagents.total_volume) + to_chat(user, SPAN_WARNING("\The [dispenser] is empty.")) + return + if(!can_accept_reagents(dispenser.reagents)) + to_chat(user, SPAN_WARNING("\The [dispenser] doesn't contain anything suitable for \the [src].")) + return + + var/available = max_fuel - reagents.total_volume + if(available <= 0) + to_chat(user, SPAN_WARNING("\The [src] is full!")) + return + + var/transferred = dispenser.reagents.trans_to(src, min(available, amount_per_transfer_from_this)) + if(!transferred) + to_chat(user, SPAN_WARNING("You fail to draw anything from \the [dispenser].")) + return + to_chat(user, SPAN_NOTICE("You fill \the [src] with [transferred] units from \the [dispenser].")) + +/** + * Checks every reagent in a source holder is flagged as valid vehicle fuel. + * + * Arguments: + * * source = Reagent holder being poured into this tank. + * + * Returns: + * * FALSE if source holds any reagent without vehicle_fuel set, TRUE otherwise. + */ +/obj/item/hardpoint/fuel_tank/proc/can_accept_reagents(datum/reagents/source) + for(var/datum/reagent/reagent in source.reagent_list) + if(!reagent.vehicle_fuel) + return FALSE + return TRUE + +/** + * Burns fuel, removing the same fraction from every loaded reagent. + * + * Arguments: + * * amount = Units of fuel to remove. + * + * Returns: + * * Units actually removed. + */ +/obj/item/hardpoint/fuel_tank/proc/consume_fuel(amount) + if(!reagents || reagents.total_volume <= 0 || amount <= 0) + return 0 + + var/actual_amount = min(amount, reagents.total_volume) + var/fraction = actual_amount / reagents.total_volume + for(var/datum/reagent/current_reagent as anything in reagents.reagent_list.Copy()) + reagents.remove_reagent(current_reagent.id, current_reagent.volume * fraction) + return actual_amount + +/** + * Volume-weighted performance penalty from whatever fuel is loaded. + * + * Returns: + * * 1 for pure full-spec fuel (vehicle_fuel_performance_mult >= 1, e.g. JP-8 or Diesel), up to + * OFF_LABEL_FUEL_PENALTY for pure off-label fuel. + */ +/obj/item/hardpoint/fuel_tank/proc/get_fuel_blend_penalty() + if(!reagents || !reagents.total_volume) + return 1 + + var/weighted_penalty = 0 + for(var/datum/reagent/current_reagent in reagents.reagent_list) + var/reagent_penalty = (current_reagent.vehicle_fuel_performance_mult >= 1) ? 1 : OFF_LABEL_FUEL_PENALTY + weighted_penalty += reagent_penalty * (current_reagent.volume / reagents.total_volume) + return weighted_penalty + +/** + * Volume-weighted top speed/acceleration multiplier from whatever fuel is loaded. + * + * Returns: + * * 1 for an empty tank, blended otherwise. + */ +/obj/item/hardpoint/fuel_tank/proc/get_fuel_performance_mult() + if(!reagents || !reagents.total_volume) + return 1 + + var/weighted_mult = 0 + for(var/datum/reagent/current_reagent in reagents.reagent_list) + weighted_mult += current_reagent.vehicle_fuel_performance_mult * (current_reagent.volume / reagents.total_volume) + return weighted_mult + +/// Fraction (0-1) of this tank's volume made up of the given reagent id. +/obj/item/hardpoint/fuel_tank/proc/get_reagent_fraction(reagent_id) + if(!reagents || !reagents.total_volume) + return 0 + return reagents.get_reagent_amount(reagent_id) / reagents.total_volume + +/** + * Patches an active fuel leak with gauze. Damage can tear it back off. + * + * Arguments: + * * gauze = The gauze roll used. + * * user = Whoever is applying it. + */ +/obj/item/hardpoint/fuel_tank/proc/try_tape_leak(obj/item/stack/medical/bruise_pack/gauze, mob/living/user) + var/leak_tier = LAZYACCESS(wound_tiers, /datum/hardpoint_wound_family/fuel_tank_leak) + if(!leak_tier) + to_chat(user, SPAN_WARNING("\The [src] isn't leaking - there's nothing to patch.")) + return + if(LAZYACCESS(taped_wounds, /datum/hardpoint_wound_family/fuel_tank_leak)) + to_chat(user, SPAN_WARNING("\The [src]'s leak is already taped up.")) + return + + user.visible_message(SPAN_NOTICE("[user] starts wrapping gauze around \the [src]'s leak."), SPAN_NOTICE("You start wrapping gauze around \the [src]'s leak.")) + if(!do_after(user, 3 SECONDS, INTERRUPT_ALL, BUSY_ICON_FRIENDLY)) + return + if(!gauze || QDELETED(gauze) || gauze != user.get_active_hand() || LAZYACCESS(wound_tiers, /datum/hardpoint_wound_family/fuel_tank_leak) != leak_tier || LAZYACCESS(taped_wounds, /datum/hardpoint_wound_family/fuel_tank_leak)) + return + + gauze.use(1) + LAZYSET(taped_wounds, /datum/hardpoint_wound_family/fuel_tank_leak, TRUE) + owner?.update_icon() + user.visible_message(SPAN_NOTICE("[user] tapes up \the [src]'s leak with gauze."), SPAN_NOTICE("You tape up \the [src]'s leak with gauze.")) + +/// A gauze-taped leak has a chance to tear back open under continued damage. +/obj/item/hardpoint/fuel_tank/take_damage(damage, type = "abstract", atom/attacker, unmitigated = FALSE, wound_chance_mult = 1) + . = ..() + if(!damage || !LAZYACCESS(taped_wounds, /datum/hardpoint_wound_family/fuel_tank_leak)) + return + if(!prob(50 + damage * 2.5)) + return + LAZYREMOVE(taped_wounds, /datum/hardpoint_wound_family/fuel_tank_leak) + owner?.update_icon() + notify_crew_of_wound("The gauze patch on the fuel tank tears loose - the leak's back!", "The metal beast's patched wound tears open once more!", FALSE) + +/// Prunes taped_wounds once a wound is actually repaired. +/obj/item/hardpoint/fuel_tank/recalculate_wound_effects() + . = ..() + if(LAZYLEN(taped_wounds) && !LAZYACCESS(wound_tiers, /datum/hardpoint_wound_family/fuel_tank_leak)) + LAZYREMOVE(taped_wounds, /datum/hardpoint_wound_family/fuel_tank_leak) + owner?.update_icon() + +/// This module only has a loose obj sprite, no on-tank sprite. +/obj/item/hardpoint/fuel_tank/update_icon() + var/suffix + if(LAZYACCESS(taped_wounds, /datum/hardpoint_wound_family/fuel_tank_leak)) + suffix = 3 + else + suffix = (get_integrity_percent() < 33) ? 2 : ((get_integrity_percent() < 66) ? 1 : 0) + suffix = max(suffix, LAZYACCESS(wound_tiers, /datum/hardpoint_wound_family/fuel_tank_leak) || 0) + icon = 'icons/obj/vehicles/hardpoints/shared.dmi' + icon_state = "fuel-[suffix]" + +/obj/item/hardpoint/fuel_tank/on_uninstall(obj/vehicle/multitile/vehicle) + update_icon() + . = ..() diff --git a/code/modules/vehicles/hardpoints/hardpoint.dm b/code/modules/vehicles/hardpoints/hardpoint.dm index a75fb9353b43..ba257c97d695 100644 --- a/code/modules/vehicles/hardpoints/hardpoint.dm +++ b/code/modules/vehicles/hardpoints/hardpoint.dm @@ -5,6 +5,8 @@ //------MAIN VARS---------- /// Which slot is this hardpoint in. Purely to check for conflicting hardpoints. var/slot + /// Overrides which slot this hardpoint's wound families roll under. Null uses its real slot. + var/wound_family_slot /// The vehicle this hardpoint is installed on. var/obj/vehicle/multitile/owner @@ -14,6 +16,18 @@ /// Determines how much of any incoming damage is actually taken. var/damage_multiplier = 1 + /// Cumulative Acid damage taken, capped at max health. Drives acid wound rolls. + var/acid_damage_taken = 0 + /// Cumulative Brute damage taken, capped at max health. Drives brute wound rolls. + var/brute_damage_taken = 0 + /// wound_family_id -> current tier. Missing/0 means no wound of that family yet. + var/list/wound_tiers + /// wound_family_id -> list("tier", "step") tracking repair progress on the current tier. + var/list/wound_repair_progress + + /// Power (units/sec) this hardpoint draws while installed and undamaged. 0 for most hardpoints. + var/power_draw = 0 + /// Origin coords of the hardpoint relative to the vehicle. var/list/origins = list(0, 0) @@ -33,6 +47,11 @@ /// List of pixel offsets for each direction. var/list/px_offsets + // pivots used to calculate the correctt rotation both for secondary hardpoints and for primary hardpoints. + // because the secondary hardpoints are rotating WITH the turret, but also AROUND themselves, they need two pivots. + var/list/rotation_pivot + var/list/gimbal_pivot + /// Visual layer of hardpoint when on vehicle. var/hdpt_layer = HDPT_LAYER_WHEELS @@ -68,8 +87,52 @@ /// Used to prevent welder click spam. var/being_repaired = FALSE - /// The firing arc of this hardpoint. - var/firing_arc = 0 //in degrees. 0 skips whole arc of fire check + /// How fast this weapon can traverse. + var/traverse_arc = 0 //in degrees. 0 skips whole arc of fire check on fixed mounts + + /// If TRUE, this hardpoint runs its own independent mouse-aim rotation (current_angle/rotation_loop below) instead of following its holder's rotate() cascade - see /obj/item/hardpoint/secondary/proc/toggle_slaved_to_driver(). + var/self_gimballed = FALSE + + /// If TRUE, this hardpoint is mounted directly on the vehicle and gets live rotation tracking. TRUE on primary/secondary. + var/uses_live_rotation_tracking = FALSE + /// If TRUE, this hardpoint holds its own facing independent of hull rotation. + var/gyro = FALSE + /// Safety lock. While TRUE, forces gyro off and locks this hardpoint to hull facing. + var/turret_safety_on = FALSE + /// Looping rotation sound, active while this hardpoint is rotating. + var/datum/looping_sound/tank_turret/rotation_soundloop + /// If TRUE, tthis hardpoint loads star shells/star shell packets directly (attackby()) instead of swapping magazines, and fires them at a randomized point ahead of the vehicle. TRUE on the tank's turret and the APC's flare launcher. + var/uses_starshell_ammo = FALSE + /// Width, in degrees, of the arc in front of the hull a fired star shell can land within. + var/flare_spread = 45 + /// Minimum tiles in front of the hull a fired star shell can land. + var/flare_range_min = 5 + /// Maximum tiles in front of the hull a fired star shell can land. + var/flare_range_max = 9 + /// The hardpoint's live, continuously-simulated facing (0-360, north-clockwise). Distinct from dir, which stays snapped to a cardinal for sprite selection. Only meaningful for the turret itself, or a self_gimballed hardpoint. + var/current_angle = 0 + /// Where the mouse currently wants this hardpoint to face (0-360, north-clockwise). current_angle chases this. + var/desired_angle = 0 + /// Current turn speed, in degrees per tick, ramped toward max_angular_velocity. + var/angular_velocity = 0 + /// Turn speed cap for this hardpoint. + var/max_angular_velocity = TURRET_DEFAULT_ANGULAR_VELOCITY + /// How fast angular_velocity ramps up/down, in degrees per tick^2. + var/angular_accel = TURRET_ANGULAR_ACCEL + /// Guards against spawning more than one concurrent rotation_loop(). + var/rotation_active = FALSE + /// world.time of the last processed mouse-move, used to rate-limit update_desired_angle(). + var/last_desired_update_time = 0 + /// While TRUE, update_desired_angle() no-ops - set on whichever object get_rotation_owner() resolves + /// to while a track_and_charge() lock-on is in progress, so live mouse input can't fight it. + var/aim_locked = FALSE + /// Guards against spawning more than one concurrent track_and_charge() on this hardpoint. + var/charging = FALSE + /// Set by cancel_charge() (COMSIG_GUN_STOP_FIRE/COMSIG_GUN_INTERRUPT_FIRE) to abort an in-progress + /// track_and_charge() loop. Distinct from fire_wait_cancelled, which guards a different wait + /// (arc-entry, not an active charge) and could otherwise interfere if both were active at once. + var/charge_cancelled = FALSE + var/trigger_held = FALSE // Muzzleflash var/use_muzzle_flash = FALSE @@ -81,6 +144,11 @@ /// Currently loaded ammo that we shoot from. var/obj/item/ammo_magazine/hardpoint/ammo + /// The type of magazine this hardpoint accepts, cached from ammo's class-default in Initialize() + /// unlike ammo itself, this stays set even while ammo is temporary null, so + /// get_hardpoints_with_ammo()/try_add_clip()/the weapons loader can still recognize this + /// hardpoint as reloadable and know whatt magazine type to accept + var/ammo_type /// Spare magazines that we can reload from. var/list/backup_clips /// Maximum amount of spare mags. @@ -96,6 +164,8 @@ var/shots_fired = 0 /// Delay before a new firing sequence can start. COOLDOWN_DECLARE(fire_cooldown) + /// Set by cancel_firing_arc_wait() to break out of wait_for_firing_arc() early. + var/fire_wait_cancelled = FALSE // Firemodes. /// Current selected firemode of the gun. @@ -127,6 +197,8 @@ var/atom/target /// The type of projectile to fire var/projectile_type = /obj/projectile + /// How many ammo.current_rounds a single base handle_fire() shot costs. The flamer hardpoints (primary/flamer.dm, secondary/flamer.dm) set this to 0 and do their own reagent-based fuel deduction instead, since their ammo tracks real chemical volume rather than plain integer rounds. + var/ammo_cost_per_shot = 1 //----------------------------- //------GENERAL PROCS---------- @@ -141,7 +213,11 @@ /obj/item/hardpoint/Initialize() . = ..() set_bullet_traits() + if(ammo) + ammo_type = ammo.type AddComponent(/datum/component/automatedfire/autofire, fire_delay, burst_delay, burst_amount, gun_firemode, autofire_slow_mult, CALLBACK(src, PROC_REF(set_burst_firing)), CALLBACK(src, PROC_REF(reset_fire)), CALLBACK(src, PROC_REF(fire_wrapper)), callback_set_firing = CALLBACK(src, PROC_REF(set_auto_firing))) + if(uses_live_rotation_tracking) + rotation_soundloop = new(src) /obj/item/hardpoint/Destroy() if(owner) @@ -151,13 +227,14 @@ QDEL_NULL_LIST(backup_clips) QDEL_NULL(ammo) set_target(null) + QDEL_NULL(rotation_soundloop) return ..() /obj/item/hardpoint/ex_act(severity) if(owner || explo_proof) return - take_damage(severity / 2) + take_damage(severity / 2, "explosive") if(health <= 0) visible_message(SPAN_WARNING("\The [src] disintegrates into useless pile of scrap under the damage it suffered.")) deconstruct(TRUE) @@ -169,6 +246,8 @@ /obj/item/hardpoint/proc/generate_bullet(mob/user, turf/origin_turf) var/obj/projectile/P = new projectile_type(origin_turf, create_cause_data(initial(name), user)) P.generate_bullet(new ammo.default_ammo) + P.effective_range_max = P.ammo.effective_range_max + P.effective_range_min = P.ammo.effective_range_min // Apply bullet traits from gun for(var/entry in traits_to_give) var/list/L @@ -181,18 +260,60 @@ P.apply_bullet_trait(L) return P -/obj/item/hardpoint/proc/can_take_damage() - if(!damage_multiplier) - return FALSE - if(health > 0) - return TRUE - -/obj/item/hardpoint/proc/take_damage(damage) +/** + * Applies damage, tracks it into the Acid/Brute sub-pool, and rolls for a wound. + * + * Arguments: + * * damage = Raw incoming damage, pre-damage_multiplier. + * * type = Damage type used for wound rolling. Defaults to "abstract", which never rolls a wound. + * * attacker = Who dealt the hit, if known. Used to aim wound effects like shrapnel. + * * unmitigated = Skips damage_multiplier, but still rolls wounds. + * * wound_chance_mult = Multiplies the wound roll chance for this hit. + */ +/obj/item/hardpoint/proc/take_damage(damage, type = "abstract", atom/attacker, unmitigated = FALSE, wound_chance_mult = 1) if(health <= 0) return + var/applied_damage = unmitigated ? damage : damage * damage_multiplier + health = max(0, health - applied_damage) + + var/mapped_type = wound_damage_type_for(type) + if(mapped_type == WOUND_DAMTYPE_ACID) + acid_damage_taken += applied_damage + else if(mapped_type == WOUND_DAMTYPE_BRUTE) + brute_damage_taken += applied_damage + if(mapped_type) + roll_wounds(applied_damage, mapped_type, attacker, wound_chance_mult) + + if(!health) + on_destroy() + owner?.ensure_active_hardpoint(VEHICLE_DRIVER) + owner?.ensure_active_hardpoint(VEHICLE_GUNNER) + +/** + * Applies raw damage to health only. No wound tracking or rolling. + * Used for splash damage bleeding between a parent and its mounted children. + * + * Arguments: + * * damage = Raw incoming damage, pre-damage_multiplier. + */ +/obj/item/hardpoint/proc/deal_raw_damage(damage) + if(health <= 0 || damage <= 0) + return health = max(0, health - damage * damage_multiplier) if(!health) on_destroy() + owner?.ensure_active_hardpoint(VEHICLE_DRIVER) + owner?.ensure_active_hardpoint(VEHICLE_GUNNER) + +/// Same as deal_raw_damage(), but also skips damage_multiplier for a fully unblocked hit. +/obj/item/hardpoint/proc/deal_unmitigated_damage(damage) + if(health <= 0 || damage <= 0) + return + health = max(0, health - damage) + if(!health) + on_destroy() + owner?.ensure_active_hardpoint(VEHICLE_DRIVER) + owner?.ensure_active_hardpoint(VEHICLE_GUNNER) /obj/item/hardpoint/proc/on_destroy() return @@ -212,6 +333,12 @@ return RegisterSignal(vehicle, COMSIG_GUN_RECALCULATE_ATTACHMENT_BONUSES, PROC_REF(recalculate_hardpoint_bonuses)) apply_buff(vehicle) + vehicle.refresh_hardpoint_actions() + if(uses_live_rotation_tracking) + // Spawns facing dir's default (south) instead of current_angle's own default (north). + current_angle = dir2angle(dir) + desired_angle = current_angle + recalculate_own_turn_rate() /// Remove hardpoint effects from vehicle and self. /obj/item/hardpoint/proc/on_uninstall(obj/vehicle/multitile/vehicle) @@ -220,6 +347,7 @@ UnregisterSignal(vehicle, COMSIG_GUN_RECALCULATE_ATTACHMENT_BONUSES) remove_buff(vehicle) //resetting values like set_gun_config_values() would be tidy, but unnecessary as it gets recalc'd on install anyway + // Not calling refresh_hardpoint_actions() yet, still installed at this point. /// Applying passive buffs like damage type resistance, speed, accuracy, cooldowns. /obj/item/hardpoint/proc/apply_buff(obj/vehicle/multitile/vehicle) @@ -247,9 +375,13 @@ buff_applied = FALSE SEND_SIGNAL(vehicle, COMSIG_GUN_RECALCULATE_ATTACHMENT_BONUSES) -/// Recalculates hardpoint values based on vehicle modifiers. +/** + * Recalculates hardpoint values based on vehicle modifiers, wound effects, and raw integrity. + */ /obj/item/hardpoint/proc/recalculate_hardpoint_bonuses() - scatter = initial(scatter) / owner.misc_multipliers["accuracy"] + var/accuracy_mult = get_combined_weapon_mult("accuracy_mult", WEAPON_MIN_ACCURACY_MULT) + var/scatter_mult = min(get_wound_effect_multiplier("scatter_mult"), WEAPON_MAX_SCATTER_MULT) + scatter = initial(scatter) / (owner.misc_multipliers["accuracy"] * accuracy_mult) * scatter_mult var/cooldown_mult = owner.misc_multipliers["cooldown"] set_fire_delay(initial(fire_delay) * cooldown_mult) set_burst_delay(initial(burst_delay) * cooldown_mult) @@ -276,9 +408,22 @@ /obj/item/hardpoint/proc/reset_rotation() rotate(turning_angle(dir, SOUTH)) -/obj/item/hardpoint/proc/rotate(deg) +/** + * Rotates this hardpoint by deg degrees, updating its own origins, dir, and current_angle. + * + * Arguments: + * * deg = Degrees to turn by. + * * override_gyro = Bypasses the gyro lock. + * * sync_angle = Whether to drag current_angle along by deg. + * + * Returns: + * * TRUE if the rotation happened, FALSE if skipped (gyro-locked or deg was 0). + */ +/obj/item/hardpoint/proc/rotate(deg, override_gyro = FALSE, sync_angle = TRUE) + if(gyro && !override_gyro) + return FALSE if(!deg) - return + return FALSE // Update origins var/list/root_coords = get_root_origins() @@ -298,6 +443,46 @@ // Update dir setDir(turn(dir, deg)) + if(sync_angle) + // deg's rotational convention is the opposite sign of current_angle's. + current_angle = ((current_angle - deg) %% 360 + 360) %% 360 + + return TRUE + +/** + * Keeps gyro in sync with the gunner seat and turret_safety_on. Gyrostabilized while a gunner is + * seated and safety is off, otherwise follows the hull like a fixed mount. + * + * Called whenever a gunner sits/stands or turret_safety_on is toggled. + * + * Arguments: + * * gunner_seated = Pass TRUE/FALSE to skip reading the seat live. Needed by callers that run + * before the seat is actually updated. + */ +/obj/item/hardpoint/proc/recalculate_gyro(gunner_seated = null) + if(isnull(gunner_seated)) + gunner_seated = !!(owner?.get_seat_mob(VEHICLE_GUNNER)) + gyro = gunner_seated && !turret_safety_on + +/** + * Toggles this hardpoint's safety lock. Turning it ON slowly turns it back to the hull's own + * facing. Turning it OFF hands rotation control back to the gunner. + */ +/obj/item/hardpoint/proc/toggle_turret_safety(mob/user) + if(health <= 0) + to_chat(user, SPAN_WARNING("\The [src]'s stabilization systems are busted!")) + return + + turret_safety_on = !turret_safety_on + to_chat(user, SPAN_NOTICE("You toggle \the [src]'s rotation and hardpoint safety [turret_safety_on ? "ON" : "OFF"].")) + + recalculate_gyro() + aim_locked = turret_safety_on + + if(turret_safety_on && owner) + desired_angle = dir2angle(owner.dir) + start_rotation_if_needed() + //for le tgui /obj/item/hardpoint/proc/get_tgui_info() var/list/data = list() @@ -318,6 +503,8 @@ else data["uses_ammo"] = FALSE + data["wounds"] = get_wound_tgui_data() + return data //----------------------------- @@ -335,9 +522,49 @@ /obj/item/hardpoint/get_examine_text(mob/user) . = ..() if(health <= 0) - . += "It's busted!" + . += SPAN_BOLDWARNING("It's busted!") else if(isobserver(user) || (ishuman(user) && (skillcheck(user, SKILL_ENGINEER, SKILL_ENGINEER_NOVICE) || skillcheck(user, SKILL_VEHICLE, SKILL_VEHICLE_CREWMAN)))) . += "It's at [round(get_integrity_percent(), 1)]% integrity!" + . += get_wound_examine_lines(user) + +/** + * Handles clicks from get_wound_examine_lines() and the vehicle's part list: examining this + * hardpoint, or showing a wound's repair info. Falls through to ..() for anything else. + */ +/obj/item/hardpoint/Topic(href, list/href_list) + if(href_list["examine_hardpoint"]) + examine(usr) + return + if(href_list["wound_info"]) + show_wound_repair_info(usr, href_list["wound_info"]) + return + return ..() + +/** + * Builds the examine feedback lines for every active wound on this hardpoint. + * + * Arguments: + * * user = Whoever is examining this hardpoint. + * + * Returns: + * * A list of examine-text lines, one per active wound. Empty if none. + */ +/obj/item/hardpoint/proc/get_wound_examine_lines(mob/user) + . = list() + var/is_xeno_examiner = isxeno(user) + for(var/family_type in wound_tiers) + var/list/tier_data = get_wound_tier_data(family_type) + if(!tier_data) + continue + var/blurb = is_xeno_examiner ? tier_data["xeno_feedback"] : tier_data["marine_feedback_red"] + if(!blurb) + continue + if(is_xeno_examiner) + . += tier_data["bold_feedback"] ? SPAN_XENOBOLDNOTICE(blurb) : SPAN_XENOWARNING(blurb) + continue + var/line = tier_data["bold_feedback"] ? SPAN_BOLDWARNING(blurb) : SPAN_WARNING(blurb) + line += " (Repair Info)" + . += line //reloading hardpoint - take mag from backup clips and replace current ammo with it. Will change in future. Called via weapons loader /obj/item/hardpoint/proc/reload(mob/user) @@ -354,17 +581,23 @@ sleep(20) - forceMove(ammo, get_turf(src)) - ammo.update_icon() + if(ammo) + forceMove(ammo, get_turf(src)) + ammo.update_icon() ammo = A LAZYREMOVE(backup_clips, A) + owner?.update_icon() to_chat(user, SPAN_NOTICE("You reload \the [name].")) +/// Whether this hardpoint will accept a given magazine for reloading. Eg, m56d cupola accepts m56d drums. +/obj/item/hardpoint/proc/accepts_magazine(obj/item/ammo_magazine/magazine) + return istype(magazine, ammo_type) + //try adding magazine to hardpoint's backup clips. Called via weapons loader /obj/item/hardpoint/proc/try_add_clip(obj/item/ammo_magazine/A, mob/user) - if(!ammo) - to_chat(user, SPAN_WARNING("\The [name] doesn't use ammunition.")) + if(!ammo_type) + to_chat(user, SPAN_WARNING("\The [name] doesn't use ammunition.")) // UA flag return FALSE if(max_clips == 0) to_chat(user, SPAN_WARNING("\The [name] does not have room for additional ammo.")) @@ -387,10 +620,23 @@ playsound(loc, 'sound/machines/hydraulics_2.ogg', 50) LAZYADD(backup_clips, A) - to_chat(user, SPAN_NOTICE("You load \the [A] into \the [name]. Ammo: [SPAN_HELPFUL(ammo.current_rounds)]/[SPAN_HELPFUL(ammo.max_rounds)] | Mags: [SPAN_HELPFUL(LAZYLEN(backup_clips))]/[SPAN_HELPFUL(max_clips)]")) + to_chat(user, SPAN_NOTICE("You load \the [A] into \the [name]. Ammo: [SPAN_HELPFUL(ammo ? ammo.current_rounds : 0)]/[SPAN_HELPFUL(ammo ? ammo.max_rounds : 0)] | Mags: [SPAN_HELPFUL(LAZYLEN(backup_clips))]/[SPAN_HELPFUL(max_clips)]")) return TRUE +/** + * Offers wound-fix and integrity-repair together in one picker when a welder could do either. + * Only reachable while detached. + */ /obj/item/hardpoint/attackby(obj/item/O, mob/user) + if(uses_starshell_ammo) + if(istype(O, /obj/item/explosive/grenade/high_explosive/airburst/starshell)) + load_star_shell(O, user) + return TRUE + if(istype(O, /obj/item/storage/box/packet/flare)) + load_star_shell_packet(O, user) + return TRUE + if(!owner && try_fix_detached_wound_with_tool(O, user)) + return if(iswelder(O)) if(!HAS_TRAIT(O, TRAIT_TOOL_BLOWTORCH)) to_chat(user, SPAN_WARNING("You need a stronger blowtorch!")) @@ -399,6 +645,44 @@ return ..() +/// Loads a single loose star shell directly into this hardpoint's internal holders. +/obj/item/hardpoint/proc/load_star_shell(obj/item/explosive/grenade/high_explosive/airburst/starshell/shell, mob/user) + if(shell.active) + to_chat(user, SPAN_WARNING("You can't load a primed star shell into \the [src]!")) + return + if(ammo.current_rounds >= ammo.max_rounds) + to_chat(user, SPAN_WARNING("\The [src]'s shell holders are already full.")) + return + user.visible_message(SPAN_NOTICE("[user] loads \a [shell] into \the [src]."), SPAN_NOTICE("You load \a [shell] into \the [src]'s shell holders.")) + playsound(loc, 'sound/weapons/gun_shotgun_shell_insert.ogg', 25, TRUE) + ammo.current_rounds++ + qdel(shell) + +/// Loads as many star shells as fit out of a star shell packet into this hardpoint's internal holders. +/obj/item/hardpoint/proc/load_star_shell_packet(obj/item/storage/box/packet/flare/packet, mob/user) + var/space_left = ammo.max_rounds - ammo.current_rounds + if(space_left <= 0) + to_chat(user, SPAN_WARNING("\The [src]'s shell holders are already full.")) + return + + var/list/shells_to_load = list() + for(var/obj/item/explosive/grenade/high_explosive/airburst/starshell/shell in packet.contents) + shells_to_load += shell + if(length(shells_to_load) >= space_left) + break + + if(!length(shells_to_load)) + to_chat(user, SPAN_WARNING("\The [packet] has no star shells left to load.")) + return + + for(var/obj/item/explosive/grenade/high_explosive/airburst/starshell/shell as anything in shells_to_load) + qdel(shell) + + ammo.current_rounds += length(shells_to_load) + packet.update_icon() + user.visible_message(SPAN_NOTICE("[user] loads [length(shells_to_load)] star shell\s from \the [packet] into \the [src]."), SPAN_NOTICE("You load [length(shells_to_load)] star shell\s from \the [packet] into \the [src]'s shell holders.")) + playsound(loc, 'sound/weapons/gun_shotgun_shell_insert.ogg', 25, TRUE) + //repair procs /obj/item/hardpoint/proc/handle_repair(obj/item/tool/weldingtool/WT, mob/user) if(user.is_mob_incapacitated()) @@ -464,7 +748,11 @@ WT.remove_fuel(1, user) //get_skill_duration_multiplier returns a multiplier, so we delete by it - health += initial(health)/100 * (amount_fixed / amount_fixed_adjustment) + var/health_restored = initial(health)/100 * (amount_fixed / amount_fixed_adjustment) + health += health_restored + // Bleed down both sub-pools by the same amount health just recovered. + acid_damage_taken = max(0, acid_damage_taken - health_restored) + brute_damage_taken = max(0, brute_damage_taken - health_restored) if(health >= initial(health)) health = initial(health) user.visible_message(SPAN_NOTICE("[user] finishes repairing \the [name]."), SPAN_NOTICE("You finish repairing \the [name]. The integrity of the module is at [SPAN_HELPFUL(floor(get_integrity_percent()))]%.")) @@ -523,6 +811,7 @@ /// Reset variables used in firing and remove the gun from the autofire system. /obj/item/hardpoint/proc/stop_fire(datum/source, atom/object, turf/location, control, params) + trigger_held = FALSE SEND_SIGNAL(src, COMSIG_GUN_STOP_FIRE) if(auto_firing) reset_fire() //automatic fire doesn't reset itself from COMSIG_GUN_STOP_FIRE @@ -545,16 +834,20 @@ return set_target(get_turf_on_clickcatcher(object, source, params)) + trigger_held = TRUE if(gun_firemode == GUN_FIREMODE_SEMIAUTO) - var/fire_return = try_fire(object, source, params) - //end-of-fire, show ammo (if changed) - if(fire_return == AUTOFIRE_CONTINUE) - reset_fire() - display_ammo(source) + INVOKE_ASYNC(src, PROC_REF(fire_semiauto), object, source, params) else SEND_SIGNAL(src, COMSIG_GUN_FIRE) +// Fires a single semi-auto shot and handles its result so it can be invoked asynchronous for linters +/obj/item/hardpoint/proc/fire_semiauto(atom/target, mob/living/user, params) + var/fire_return = try_fire(target, user, params) + if(fire_return == AUTOFIRE_CONTINUE) + reset_fire() + display_ammo(user) + /// Wrapper proc for the autofire system to ensure the important args aren't null. /obj/item/hardpoint/proc/fire_wrapper(atom/target, mob/living/user, params) if(!target) @@ -568,27 +861,260 @@ /// Tests if firing should be interrupted, otherwise fires. /obj/item/hardpoint/proc/try_fire(atom/target, mob/living/user, params) + if(uses_starshell_ammo && owner) + var/fire_angle = dir2angle(owner.dir) + rand(-flare_spread * 0.5, flare_spread * 0.5) + var/fire_range = rand(flare_range_min, flare_range_max) + target = get_angle_target_turf(owner, fire_angle, fire_range) + if(health <= 0) to_chat(user, SPAN_WARNING("\The [name] is broken!")) return NONE - if(ammo && ammo.current_rounds <= 0) + // Turret safety locks out every hardpoint on this turret. + var/obj/item/hardpoint/holder/tank_turret/safety_check_turret = loc + if(istype(safety_check_turret) && safety_check_turret.turret_safety_on) + to_chat(user, SPAN_WARNING("Turret Safety is ON!")) + return NONE + + // A hardpoint can legitimately have no magazine installed at all. This is needed especifically so the + // DRGN flamer and the secondary version can 'eject' their magazines to be refueled from a fuel source.. + // so, other guns can now eject their full magazines and have nothing loaded. + if(!ammo) + click_empty(user) + return NONE + + if(ammo.current_rounds <= 0) click_empty(user) return NONE - if(!in_firing_arc(target)) - to_chat(user, SPAN_WARNING("The target is not within your firing arc!")) + // stops you from firing at your own hull. prevents a bug found in testing where guns shoot straight up. + if(owner && (get_turf(target) in owner.locs)) + to_chat(user, SPAN_WARNING("Invalid target!")) + return NONE + + var/atom/original_target = target + var/obj/item/hardpoint/holder/tank_turret/turret = loc + var/turret_mounted = istype(turret) + // Anything that actually gets live current_angle rotation: self-gimballed, holder-mounted, or a + // plain vehicle-mounted weapon with uses_live_rotation_tracking (e.g. the APC's dualcannon). + var/actively_tracked = self_gimballed || turret_mounted || uses_live_rotation_tracking + if(actively_tracked && gun_firemode != GUN_FIREMODE_SEMIAUTO) + // Held-trigger fire modes (burst/automatic) don't wait for the barrel to finish swinging onto target + var/obj/item/hardpoint/facing_source = get_rotation_owner() + target = facing_source.redirect_to_current_facing(get_origin_turf(), target) + else if(!in_firing_arc(target)) + // A tracked weapon (turret-mounted, gimballed, or live-rotation) waits for its own facing to swing onto the target before firing + if(!actively_tracked || !wait_for_firing_arc(target, user)) + to_chat(user, SPAN_WARNING("The target is not within your firing arc!")) + return NONE + + if(!target) return NONE - return handle_fire(target, user, params) + return handle_fire(target, user, params, original_target) + +/** + * Waits for in_firing_arc(target) to become true, instead of instantly rejecting the shot. + * + * Returns TRUE once in arc and ready to fire, FALSE if the wait was cancelled or the shot became + * impossible for some other reason while waiting (weapon/target gone, broke, ran dry, trigger let go). + */ +/obj/item/hardpoint/proc/wait_for_firing_arc(atom/target, mob/user) + fire_wait_cancelled = FALSE + RegisterSignal(src, list(COMSIG_GUN_STOP_FIRE, COMSIG_GUN_INTERRUPT_FIRE), PROC_REF(cancel_firing_arc_wait)) + + . = TRUE + while(!in_firing_arc(target)) + sleep(1) + if(fire_wait_cancelled || !trigger_held || QDELETED(src) || QDELETED(target) || health <= 0 || (ammo && ammo.current_rounds <= 0)) + . = FALSE + break + + UnregisterSignal(src, list(COMSIG_GUN_STOP_FIRE, COMSIG_GUN_INTERRUPT_FIRE)) + +/obj/item/hardpoint/proc/cancel_firing_arc_wait() + SIGNAL_HANDLER + fire_wait_cancelled = TRUE + +/// Resolves which object owns continuous rotation for this hardpoint. +/obj/item/hardpoint/proc/get_rotation_owner() + RETURN_TYPE(/obj/item/hardpoint) + if(self_gimballed) + return src + var/obj/item/hardpoint/holder/tank_turret/turret = loc + if(istype(turret)) + return turret + return src + +/// Whether this hardpoint gets independently mouse-aimed rotation, separate from the vehicle's own hull facing. +/obj/item/hardpoint/proc/has_independent_aim() + if(uses_live_rotation_tracking) + return TRUE + return self_gimballed && istype(loc, /obj/item/hardpoint/holder) + +/// Single choke point for locking/unlocking mouse-driven aim on whichever object get_rotation_owner() +/// resolves to, so ttrack_and_charge() below can lock/unlock symmetrically. +/obj/item/hardpoint/proc/lock_rotation(lock) + get_rotation_owner().aim_locked = lock + +/** + * Refreshes max_angular_velocity for a vehicle-mounted weapon that's its own rotation owner + * (e.g. the APC's dualcannon). Scaled by the weapon's own integrity and the vehicle's Turret Ring. + * No-ops for a fixed mount, a holder-nested weapon, or a self-gimballed one. + */ +/obj/item/hardpoint/proc/recalculate_own_turn_rate() + if(!traverse_arc || !owner || self_gimballed || istype(loc, /obj/item/hardpoint/holder)) + return + + var/obj/item/hardpoint/turret_ring/ring = locate() in owner.hardpoints + var/ring_scale = 0 + if(ring) + ring_scale = ring.get_own_health_scale() * ring.get_wound_effect_multiplier("turn_rate_mult") + if(!owner.has_vehicle_power()) + ring_scale *= TURRET_NO_POWER_TURN_RATE_FRACTION + + max_angular_velocity = max(TURRET_BASE_ANGULAR_VELOCITY * (traverse_arc / TURRET_ARC_NORMALIZATION), TURRET_MIN_ANGULAR_VELOCITY) * get_own_health_scale() * ring_scale + if(ring_scale > 0) + max_angular_velocity = max(max_angular_velocity, TURRET_ABSOLUTE_MIN_ANGULAR_VELOCITY) + +/** + * Locks onto target and keeps re-aiming at its live position for aim_time, cancellable if the target + * drifts further behind the current facing than this hardpoint's own traverse_arc allows, giving the + * mount a litttle bit of allowance to catch up before the shot is considered lost + * + * Recomputes the true world-space angle to the target fresh every tick from this hardpoint's current + * position, so it needs no special-casing for the vehicle itself moving/turning mid-charge + * + * + * Returns TRUE if the charge completed with the target still valid and in arc, FALSE if cancelled. + */ +/obj/item/hardpoint/proc/track_and_charge(atom/target, mob/living/user, aim_time) + if(charging) + return FALSE + charging = TRUE + + set_target(target) + lock_rotation(TRUE) + charge_cancelled = FALSE + RegisterSignal(src, list(COMSIG_GUN_STOP_FIRE, COMSIG_GUN_INTERRUPT_FIRE), PROC_REF(cancel_charge)) + start_aim_visuals(target, user) + + . = TRUE + var/elapsed = 0 + while(elapsed < aim_time) + sleep(1) + elapsed += 1 + + if(charge_cancelled || !trigger_held || QDELETED(src) || QDELETED(target) || health <= 0 || (ammo && ammo.current_rounds <= 0)) + . = FALSE + break + + if(isliving(target)) + var/mob/living/living_target = target + if(living_target.is_dead()) + . = FALSE + break + + var/turf/origin_turf = get_origin_turf() + var/turf/target_turf = get_turf(target) + if(origin_turf == target_turf) + . = FALSE + break + + try + // Uses target_turf, not the target atom, so pixel offsets don't throw the aim off. + var/angle_to_target = Get_Angle_Grounded(origin_turf, target_turf) + var/obj/item/hardpoint/rotation_owner = get_rotation_owner() + var/obj/item/hardpoint/holder/tank_turret/mount = self_gimballed ? loc : null + + if(istype(mount)) // self-gimballed, mounted on a turret + var/turret_facing = mount.current_angle + var/raw_delta = angle_delta(angle_to_target, turret_facing) + if(abs(raw_delta) > SLAVED_GIMBAL_ARC_HALF_WIDTH) + to_chat(user, SPAN_WARNING("Target moved out of the firing arc!")) + . = FALSE + else + var/swing = clamp(raw_delta, -SLAVED_GIMBAL_ARC_HALF_WIDTH, SLAVED_GIMBAL_ARC_HALF_WIDTH) + // %% (not %) since plain % breaks on fractional angles. + rotation_owner.desired_angle = ((turret_facing + swing) %% 360 + 360) %% 360 + else + rotation_owner.desired_angle = angle_to_target + if(traverse_arc && abs(angle_delta(angle_to_target, rotation_owner.current_angle)) > traverse_arc * 0.5) + to_chat(user, SPAN_WARNING("Target moved out of the firing arc!")) + . = FALSE + if(. != FALSE) + rotation_owner.start_rotation_if_needed() + on_track_tick(target) + catch(var/exception/tick_error) + log_runtime(tick_error) + . = FALSE + + if(. == FALSE) + break + + UnregisterSignal(src, list(COMSIG_GUN_STOP_FIRE, COMSIG_GUN_INTERRUPT_FIRE)) + end_aim_visuals(target, user, .) + lock_rotation(FALSE) + charging = FALSE + +/obj/item/hardpoint/proc/cancel_charge() + SIGNAL_HANDLER + charge_cancelled = TRUE + +/// No-op hook - override to spawn lock-on visuals (overlays/beams) when a track_and_charge() begins. +// currently overriden by LTB and mounted BRUTE launcher only. Same goes for the other hooks below. - BWSB +/obj/item/hardpoint/proc/start_aim_visuals(atom/target, mob/living/user) + return + +/// No-op hook - override to tear down whatever start_aim_visuals() spawned. Always called, success or not. +/obj/item/hardpoint/proc/end_aim_visuals(atom/target, mob/living/user, success) + return + +/// No-op hook - called once per tick during track_and_charge(), after desired_angle has been updated. +/// Override to keep any tick-dependent visuals (e.g. a beam anchor) following the live muzzle position. +/obj/item/hardpoint/proc/on_track_tick(atom/target) + return /// Actually fires the gun, sets up the projectile and fires it. -/obj/item/hardpoint/proc/handle_fire(atom/target, mob/living/user, params) +/obj/item/hardpoint/proc/handle_fire(atom/target, mob/living/user, params, atom/original_target) + if(isnull(original_target)) + original_target = target var/turf/origin_turf = get_origin_turf() - var/obj/projectile/projectile_to_fire = generate_bullet(user, origin_turf) - ammo.current_rounds-- + // Spawn projectile outside the vehicle hull, and never on a dense turf, so it doesn't end up + // stuck inside a wall the vehicle is pressed against. + var/turf/spawn_turf = origin_turf + if(owner && length(owner.locs) > 1) + var/turf/target_turf = get_turf(target) + // Flatten the target's z to origin's own so cross-z targets don't break the exit search. + var/turf/search_target = target_turf ? (locate(target_turf.x, target_turf.y, origin_turf.z) || target_turf) : target_turf + // Walk the line from the vehicle's center so diagonal aims exit the right hull tile. + var/list/path = get_line(get_turf(owner), search_target) + var/found_exit = FALSE + for(var/turf/T as anything in path) + // preventts a bug where the projectile disappears if it occupies the same tile the projectile spawns in + if(!(T in owner.locs) && T != search_target) + spawn_turf = T + found_exit = TRUE + break + // Point-blank range: spawn at the target's own tile instead of falling back to the hull. + if(!found_exit && search_target && !(search_target in owner.locs) && !search_target.density) + spawn_turf = search_target + if(!spawn_turf || spawn_turf.density || (owner && (spawn_turf in owner.locs))) + spawn_turf = get_turf(owner) // last-resort fallback, always valid and non-dense + + var/obj/projectile/projectile_to_fire = generate_bullet(user, spawn_turf) + ammo.current_rounds -= ammo_cost_per_shot SEND_SIGNAL(projectile_to_fire, COMSIG_BULLET_USER_EFFECTS, user) + // The tank's own IFF module overrides whatever IFF the firer's ID card set. + if(projectile_to_fire.runtime_iff_group && owner) + var/obj/item/hardpoint/iff_module/iff = locate() in owner.get_hardpoints_copy() + projectile_to_fire.runtime_iff_group = (iff && iff.is_functional()) ? iff.set_faction : null + projectile_to_fire.original = original_target + + // Mirrors every handheld gun's own def_zone assignment (Fire(), gun.dm). + if(isliving(user)) + projectile_to_fire.def_zone = user.zone_selected // turf-targeted projectiles are fired without scatter, because proc would raytrace them further away var/ammo_flags = projectile_to_fire.ammo.flags_ammo_behavior | projectile_to_fire.projectile_override_flags @@ -596,7 +1122,16 @@ projectile_to_fire.scatter = scatter target = simulate_scatter(projectile_to_fire, target, origin_turf, get_turf(target), user) - INVOKE_ASYNC(projectile_to_fire, TYPE_PROC_REF(/obj/projectile, fire_at), target, user, src, projectile_to_fire.ammo.max_range, projectile_to_fire.ammo.shell_speed) + // Damaged weapons hit softer. + projectile_to_fire.ammo.damage *= get_combined_weapon_mult("damage_mult", WEAPON_MIN_DAMAGE_MULT) + + // Primary weapons only: a slowed round also loses penetration by the same fraction. + if(istype(src, /obj/item/hardpoint/primary)) + projectile_to_fire.ammo.penetration *= get_combined_weapon_mult("projectile_speed_mult", WEAPON_MIN_PROJECTILE_SPEED_MULT) + + // projectile_speed_mult: a fouled or bent barrel slows the round leaving it. + var/wound_shell_speed = projectile_to_fire.ammo.shell_speed * get_combined_weapon_mult("projectile_speed_mult", WEAPON_MIN_PROJECTILE_SPEED_MULT) + INVOKE_ASYNC(projectile_to_fire, TYPE_PROC_REF(/obj/projectile, fire_at), target, user, src, projectile_to_fire.ammo.max_range, wound_shell_speed) projectile_to_fire = null shots_fired++ @@ -608,6 +1143,150 @@ return AUTOFIRE_CONTINUE +/** + * No-op by default - only the flamer hardpoints (primary/flamer.dm, secondary/flamer.dm) override + * this to flip between FLAME_MODE_STTREAM and FLAME_MODE_GLOB + */ +/obj/item/hardpoint/proc/toggle_fire_mode(mob/user) + return + +/** + * Null by default. Flamer hardpoints override this to return their own flame_mode, so the + * Toggle Fire Mode action button can pick an icon without knowing the specific subtype. + */ +/obj/item/hardpoint/proc/get_flame_mode() + return null + +/** + * Fires the flamer hardpoint's stream mode, drawing fuel properties from the loaded ammo's chem. + * + * Arguments: + * * target = Where the stream is aimed. + * * user = The gunner firing. + * * flameshape = Spray pattern override. Falls back to the loaded chem's own flameshape. + * * range_bonus = Flat extra tiles of range added to the loaded fuel's own reach. + */ +/obj/item/hardpoint/proc/fire_flame_stream(atom/target, mob/living/user, flameshape, range_bonus = 0) + var/datum/reagent/chem = LAZYACCESS(ammo.reagents?.reagent_list, 1) + if(!chem) + click_empty(user) + return NONE + + //step forward along path so flame starts outside hull + var/list/turfs = get_line(get_origin_turf(), get_turf(target)) + var/turf/origin_turf + for(var/turf/turf as anything in turfs) + if(turf in owner.locs) + continue + origin_turf = turf + break + + var/base_range = ((chem.rangefire == -1) ? ammo.reagents.max_fire_rad : chem.rangefire) + range_bonus + var/weapon_mult = get_combined_weapon_mult("projectile_speed_mult", WEAPON_MIN_PROJECTILE_SPEED_MULT) + var/effective_max_range = max(1, round(base_range * weapon_mult)) + var/distance = get_dist(origin_turf, get_turf(target)) + var/fire_amount = min(distance+1, effective_max_range) + + chem.intensityfire = clamp(chem.intensityfire, ammo.reagents.min_fire_int, ammo.reagents.max_fire_int) + chem.durationfire = clamp(chem.durationfire, ammo.reagents.min_fire_dur, ammo.reagents.max_fire_dur) + + new /obj/flamer_fire(origin_turf, create_cause_data(initial(name), user), chem, fire_amount, ammo.reagents, flameshape || chem.flameshape, target, CALLBACK(src, PROC_REF(sync_ammo_from_reagents)), 1, chem.fire_type) + sync_ammo_from_reagents() + + play_firing_sounds() + + COOLDOWN_START(src, fire_cooldown, fire_delay) + + return AUTOFIRE_CONTINUE + +/** + * Tank equivalent of the infantry M240's unleash_smoke(). Disperses the loaded chem as smoke + * along the aimed line instead of fire. + * + * Arguments: + * * target = Where the smoke is aimed. + * * user = The gunner firing. + * * smoke_range = Max tiles the smoke travels. + * * units_in_smoke = Reagent units per dispersed smoke puff. + */ +/obj/item/hardpoint/proc/fire_smoke_stream(atom/target, mob/living/user, smoke_range = 4, units_in_smoke = 35) + var/datum/reagent/chemical = LAZYACCESS(ammo.reagents?.reagent_list, 1) + if(!chemical) + click_empty(user) + return NONE + + //step forward along path so the smoke starts outside the hull, same as fire_flame_stream() + var/list/turfs = get_line(get_origin_turf(), get_turf(target)) + var/turf/origin_turf + for(var/turf/turf as anything in turfs) + if(turf in owner.locs) + continue + origin_turf = turf + break + + if(!origin_turf) + click_empty(user) + return NONE + + var/use_multiplier = 3 + var/distance = 0 + + var/datum/reagents/to_disperse = new() + to_disperse.add_reagent(chemical.id, units_in_smoke) + to_disperse.my_atom = src + + var/list/turf/travel_turfs = get_line(origin_turf, get_turf(target), FALSE) + var/amount_required = min(length(travel_turfs), smoke_range) * use_multiplier + for(var/turf/turf in travel_turfs) + if(chemical.volume < amount_required) + smoke_range = floor(chemical.volume / use_multiplier) + + if(distance >= smoke_range) + break + + if(turf.density) + break + else + var/obj/effect/particle_effect/smoke/chem/checker = new() + var/atom/blocked = LinkBlocked(checker, origin_turf, turf) + qdel(checker) + if(blocked) + break + + playsound(turf, 'sound/effects/smoke.ogg', 25, 1) + var/datum/effect_system/smoke_spread/chem/smoke = new() + smoke.set_up(to_disperse, 5, loca = turf) + smoke.start() + sleep(4) + + distance++ + + var/amount_used = distance * use_multiplier + chemical.volume = max(chemical.volume - amount_used, 0) + ammo.reagents.total_volume = chemical.volume + + if(chemical.volume < use_multiplier) // not enough left for even one more tile of smoke - empty the tank, same as unleash_smoke() + ammo.reagents.clear_reagents() + + sync_ammo_from_reagents() + + play_firing_sounds() + + COOLDOWN_START(src, fire_cooldown, fire_delay) + + return AUTOFIRE_CONTINUE + +/** + * Re-syncs ammo.current_rounds to match ammo.reagents.total_volume.sync_ammo_from_reagents() + * Called both right after firing and again once a since reagent consumption happens asynchronously tile by tile + * as the flame propagates, not all at once at the moment of firing. Also refreshes the mounted sprite + * immediately. + */ +/obj/item/hardpoint/proc/sync_ammo_from_reagents() + if(ammo && ammo.reagents) + ammo.current_rounds = round(ammo.reagents.total_volume) + owner?.update_icon() + /// Start cooldown to respect delay of firemode. /obj/item/hardpoint/proc/set_fire_cooldown(firemode) var/cooldown_time = 0 @@ -622,6 +1301,10 @@ /// Adjust target based on random scatter angle. /obj/item/hardpoint/proc/simulate_scatter(obj/projectile/projectile_to_fire, atom/target, turf/curloc, turf/targloc) + // without this, clicking a point-blank target that resolves to the same turf as the muzzle + if(curloc == targloc) + return target + var/fire_angle = Get_Angle(curloc, targloc) var/total_scatter_angle = projectile_to_fire.scatter @@ -630,6 +1313,9 @@ fire_angle += rand(-total_scatter_angle, total_scatter_angle) target = get_angle_target_turf(curloc, fire_angle, 30) + if(curloc.z != targloc.z) + target = locate(target.x, target.y, targloc.z) + return target /// Get turf at hardpoint origin offset, used as the muzzle. @@ -647,9 +1333,21 @@ if(LAZYLEN(activation_sounds)) playsound(get_turf(src), pick(activation_sounds), 60, 1) -/// Determines whether something is in firing arc of a hardpoint. +/** + * Determines whether something is in firing arc of a hardpoint. + * + * A hardpoint that actually rotates checks how far current_angle has caught up to the target. + * A hardpoint that never rotates checks against the vehicle's own snapped dir instead. + */ /obj/item/hardpoint/proc/in_firing_arc(atom/target) - if(!firing_arc || !ISINRANGE_EX(firing_arc, 0, 360)) + if(has_independent_aim()) + return check_gimbal_firing_arc(target) + + var/obj/item/hardpoint/holder/tank_turret/turret = loc + if(istype(turret)) + return turret.in_turret_firing_arc(src, target) + + if(!traverse_arc || !ISINRANGE_EX(traverse_arc, 0, 360)) return TRUE var/turf/muzzle_turf = get_origin_turf() @@ -665,13 +1363,301 @@ else if(angle_diff > 180) angle_diff -= 360 - return abs(angle_diff) <= (firing_arc * 0.5) + return abs(angle_diff) <= (traverse_arc * 0.5) + +/** + * Firing-arc gate for a hardpoint whose own current_angle is what actually rotates: a self-gimballed + * hardpoint (see toggle_slaved_to_driver()), or a plain vehicle-mounted weapon with + * uses_live_rotation_tracking set (e.g. the APC's dualcannon/frontalcannon). current_angle must have + * caught up to within FIRING_GATE_TOLERANCE of the angle from the weapon's own muzzle to target. Same + * shape as /obj/item/hardpoint/holder/tank_turret/proc/in_turret_firing_arc(), but measured against + * this hardpoint's own current_angle instead of a parent turret's. + */ +/obj/item/hardpoint/proc/check_gimbal_firing_arc(atom/target) + var/turf/muzzle_turf = get_origin_turf() + var/turf/target_turf = get_turf(target) + + //same tile angle is undefined for Gett_Angle, returning FALSE to match the legacy static-arc check + if(muzzle_turf == target_turf) + return FALSE + + var/target_angle = Get_Angle(muzzle_turf, target_turf) + return abs(angle_delta(target_angle, current_angle)) <= FIRING_GATE_TOLERANCE + +/** + * records where the mouse currently wants this hardpoint to face. Called on every processed + * mouse-move while a relevant crew member is seated. + * + * A self_gimballed weapon's desired_angle gets clamped to within SLAVED_GIMBAL_ARC_HALF_WIDTH + * degrees of the turret's own current continuous facing. the turret can sit anywhere within its own lean, e.g. 168 + * degrees, and the allowed swing needs to follow that actual angle, not snap to whichever cardinal it's nearest to. + */ +/obj/item/hardpoint/proc/update_desired_angle(atom/object, mob/user, params) + if(health <= 0) + return + if(aim_locked) // a track_and_charge() lock-on is in progress - mouse input can't fight it + return + if(world.time == last_desired_update_time) // collapses same-tick MouseMove spam into one update + return + last_desired_update_time = world.time + + // Aim at the hovered mob/object itself, not its bare turf + var/atom/aim_target + if(ismob(object) || isobj(object)) + aim_target = object + else + aim_target = get_turf_on_clickcatcher(object, user, params) + if(!aim_target) + return + var/turf/target_turf = get_turf(aim_target) + var/turf/origin_turf = get_origin_turf() + if(!origin_turf || target_turf == origin_turf) + return + + desired_angle = Get_Angle_Grounded(origin_turf, target_turf) + + if(self_gimballed) + var/obj/item/hardpoint/holder/tank_turret/turret = loc + if(istype(turret)) + var/turret_facing = turret.current_angle + var/swing = clamp(angle_delta(desired_angle, turret_facing), -SLAVED_GIMBAL_ARC_HALF_WIDTH, SLAVED_GIMBAL_ARC_HALF_WIDTH) + // %% (not %) since plain % breaks on fractional angles. + desired_angle = ((turret_facing + swing) %% 360 + 360) %% 360 + + start_rotation_if_needed() + +/obj/item/hardpoint/proc/start_rotation_if_needed() + if(rotation_active) + return + rotation_active = TRUE + on_rotation_started() + spawn(0) + rotation_loop() + +/** + * Ticks current_angle toward desired_angle with inertia (accelerating up to max_angular_velocity, + * decelerating on approach). Self-terminates once current_angle settles within ROTATION_SETTLE_TOLERANCE of + * desired_angle, and restarts on demand from update_desired_angle(). + */ +/obj/item/hardpoint/proc/rotation_loop() + while(TRUE) + sleep(1) + + if(QDELETED(src) || health <= 0) + angular_velocity = 0 + rotation_active = FALSE + on_rotation_stopped() + return + + var/delta = angle_delta(desired_angle, current_angle) + if(abs(delta) < ROTATION_SETTLE_TOLERANCE) + current_angle = desired_angle + angular_velocity = 0 + drag_self_gimballed_weapons(delta) + apply_current_angle() + rotation_active = FALSE + on_rotation_stopped() + return + + // Accelerate toward max_angular_velocity, decelerate once we're close enough to stop in time. + var/stopping_distance = (angular_velocity ** 2) / (2 * angular_accel) + if(abs(delta) <= stopping_distance) + angular_velocity = max(angular_velocity - angular_accel, 0) + else + angular_velocity = min(angular_velocity + angular_accel, max_angular_velocity) + + var/turn_sign = (delta > 0) - (delta < 0) + var/step = min(angular_velocity, abs(delta)) * turn_sign + // %% (not %) since plain % breaks on fractional angles. + current_angle = ((current_angle + step) %% 360 + 360) %% 360 + drag_self_gimballed_weapons(step) + apply_current_angle() + +/** + * No-op by default. /obj/item/hardpoint/holder/tank_turret overrides this to drag every + * self_gimballed mounted weapon's own current_angle/desired_angle along by the same delta this + * hardpoint's current_angle just moved by. + * + * Without this, a slaved weapon's current_angle stays a fixed absolute world angle. + * This would be fine, but, since we can slave the secondaries to the driver, then they also need to drag by themselves. + */ +/obj/item/hardpoint/proc/drag_self_gimballed_weapons(delta) + return + +/// Starts rotation_soundloop, if this hardpoint has one, and bumps the Turret Ring's power draw. +/obj/item/hardpoint/proc/on_rotation_started() + if(owner?.has_vehicle_power() && rotation_soundloop) + rotation_soundloop.mid_sounds = is_damaged() ? 'sound/vehicles/turretdamaged.ogg' : 'sound/vehicles/tankturret.ogg' + rotation_soundloop.start() + set_turret_ring_power_draw(TURRET_RING_ROTATING_POWER_DRAW) + +/// Stops rotation_soundloop and settles the Turret Ring's power draw back to idle. +/obj/item/hardpoint/proc/on_rotation_stopped() + rotation_soundloop?.stop() + set_turret_ring_power_draw(TURRET_RING_IDLE_POWER_DRAW) + +/** + * Whether this hardpoint is damaged enough for its rotation to grind instead of sounding normal. + * Based on the vehicle's installed Turret Ring, not this hardpoint's own health. + */ +/obj/item/hardpoint/proc/is_damaged() + if(!owner) + return FALSE + var/obj/item/hardpoint/turret_ring/ring = locate() in owner.hardpoints + return ring && (ring.get_integrity_percent() <= 50 || LAZYLEN(ring.wound_tiers)) + +/** + * Updates the vehicle's installed Turret Ring's power_draw. Rotating pulls more power than idling. + * + * Arguments: + * * new_draw = Power draw (units/sec) to set the ring to. + */ +/obj/item/hardpoint/proc/set_turret_ring_power_draw(new_draw) + if(!owner) + return + var/obj/item/hardpoint/turret_ring/ring = locate() in owner.hardpoints + if(ring) + ring.power_draw = new_draw + +/// Snaps a continuous angle down to the nearest 90-degree cardinal quadrant +/obj/item/hardpoint/proc/angle_to_cardinal(angle) + // %% (not %) since plain % breaks on fractional angles. + angle = ((angle %% 360) + 360) %% 360 + if(angle >= 315 || angle < 45) + return NORTH + if(angle < 135) + return EAST + if(angle < 225) + return SOUTH + return WEST + +/// Keeps dir in sync with current_angle's quadrant, then refreshes the vehicle's sprite. +/obj/item/hardpoint/proc/apply_current_angle() + if(!istype(loc, /obj/item/hardpoint/holder)) + var/target_cardinal = angle_to_cardinal(current_angle) + if(target_cardinal != dir) + rotate(turning_angle(dir, target_cardinal), override_gyro = TRUE, sync_angle = FALSE) + if(owner) + owner.update_icon() + +/** + * Builds a matrix that rotates by tilt degrees around pivot (a local x,y point relative to the + * icon's own center) instead of the icon's default center. pivot defaults to (0,0) if null/unset. + * + * (Translate(pivot), called last, applied last) - Translate(-pivot) -> Turn(tilt) -> Translate(pivot). + */ +/obj/item/hardpoint/proc/build_tilt_matrix(tilt, list/pivot) + var/matrix/tilt_matrix = matrix() + if(pivot && (pivot[1] || pivot[2])) + tilt_matrix.Translate(-pivot[1], -pivot[2]) + tilt_matrix.Turn(tilt) + tilt_matrix.Translate(pivot[1], pivot[2]) + else + tilt_matrix.Turn(tilt) + return tilt_matrix + +/** + * Like build_tilt_matrix(), but composes two nested rotations onto one matrix instead of one: + * inner_tilt around inner_pivot is applied first (closest to the raw icon), then outter_tilt around + * outer_pivot is applied on top of that, carrying the already-tilted icon along with it. Used to + * render a self_gimballed weapon, its own idependent swivel (inner, pivoting around its own local + * mount joint, gimbal_pivot) rides along with the turret's own lean (outer, pivoting around the + * turret's own rotation center, rotation_pivot) instead of replacing it - two separate pivots of + * rotation, one nested inside the other, same as a real two-stage gimbal mountted on a turret. + */ +/obj/item/hardpoint/proc/build_nested_tilt_matrix(inner_tilt, list/inner_pivot, outer_tilt, list/outer_pivot) + var/matrix/tilt_matrix = build_tilt_matrix(inner_tilt, inner_pivot) + if(outer_tilt) + if(outer_pivot && (outer_pivot[1] || outer_pivot[2])) + tilt_matrix.Translate(-outer_pivot[1], -outer_pivot[2]) + tilt_matrix.Turn(outer_tilt) + tilt_matrix.Translate(outer_pivot[1], outer_pivot[2]) + else + tilt_matrix.Turn(outer_tilt) + return tilt_matrix + +/** + * Looks up the pivot for a given angle out of up to 8 keyed keyframes (dir constants, same + * convention as dir2angle()), blending between a cardinal and an adjacent diagonal if that + * diagonal has a tuned entry - otherwise hard-snaps to the cardinal (old angle_to_cardinal() + * behavior), so untuned weapons render unchanged. Returns null if pivot_data is empty. + * + * Uses a direct equality switch rather than angle_to_dir() that proc rounds to the NEARESTT dir + * and misresolves exact cardinal angles (0/90/180/270) to an adjacent diagonal, which a multiple of 45 avoids + */ +/obj/item/hardpoint/proc/interpolate_pivot(list/pivot_data, angle) + if(!pivot_data) + return null + + // %% (not %) since plain % breaks on fractional angles. + angle = ((angle %% 360) + 360) %% 360 + var/lower_anchor = FLOOR(angle, 45) + var/upper_anchor = (lower_anchor + 45) %% 360 + + var/list/lower_pivot = LAZYACCESS(pivot_data, "[anchor_angle_to_dir(lower_anchor)]") + var/list/upper_pivot = LAZYACCESS(pivot_data, "[anchor_angle_to_dir(upper_anchor)]") + + if(!lower_pivot) + return upper_pivot + if(!upper_pivot) + return lower_pivot + + var/frac = (angle - lower_anchor) / 45 + return list( + lower_pivot[1] + (upper_pivot[1] - lower_pivot[1]) * frac, + lower_pivot[2] + (upper_pivot[2] - lower_pivot[2]) * frac, + ) + +/// Exact reverse of dir2angle() for one of the 8 cardinal/diagonal 45-degree-multiple angles - see interpolate_pivot()'s doc comment for why this can't just reuse the global angle_to_dir() nearest-neighbor helper. +/obj/item/hardpoint/proc/anchor_angle_to_dir(angle) + switch(angle) + if(0) + return NORTH + if(45) + return NORTHEAST + if(90) + return EAST + if(135) + return SOUTHEAST + if(180) + return SOUTH + if(225) + return SOUTHWEST + if(270) + return WEST + if(315) + return NORTHWEST + return NORTH + +/** + * Redirects a target to preserve its range from origin_turf but along this hardpoint's actual + * current facing (current_angle) instead of the originally-aimed direction. Used by held-ttrigger + * fire modes (see try_fire()), which don't wait for the turret (or a self-gimballed weapon's own gimbal) to finish swinging onto target. + */ +/obj/item/hardpoint/proc/redirect_to_current_facing(turf/origin_turf, atom/target) + var/turf/target_turf = get_turf(target) + if(!origin_turf || !target_turf || origin_turf == target_turf) + return target + + var/cross_z = (target_turf.z != origin_turf.z) + + // Once the barrel has fully caught up to its own tracked aim point, there's no + // rotational lag left to simulate. fire directly at the target. + if(abs(angle_delta(desired_angle, current_angle)) <= FIRING_GATE_TOLERANCE) + return target + + var/range = get_dist_euclidian(origin_turf, target_turf) + var/turf/redirected_turf = get_angle_target_turf(origin_turf, current_angle, range) + // Reapply the target's real z if it crossed levels. + if(redirected_turf && cross_z) + redirected_turf = locate(redirected_turf.x, redirected_turf.y, target_turf.z) + return redirected_turf //----------------------------- //------ICON PROCS---------- //----------------------------- -//Returns an image for the hardpoint +/// Returns an image for the hardpoint, with a tilt transform layered on for a rotation-tracked weapon. /obj/item/hardpoint/proc/get_hardpoint_image() var/offset_x = 0 var/offset_y = 0 @@ -681,12 +1667,29 @@ offset_y = px_offsets["[loc.dir]"][2] var/image/I = get_icon_image(offset_x, offset_y, dir) + + if(has_independent_aim()) + var/tilt = angle_delta(current_angle, dir2angle(dir)) + if(tilt) + I.transform = build_tilt_matrix(tilt, interpolate_pivot(rotation_pivot, current_angle)) + return I +/** + * The on-hull damage suffix used by get_icon_image(). 1 (damaged) once integrity drops below + * threshold_pct or this hardpoint carries a tier-2+ wound, 0 (healthy) otherwise. + */ +/obj/item/hardpoint/proc/get_mounted_damage_suffix(threshold_pct = 50) + if(get_integrity_percent() < threshold_pct) + return 1 + for(var/family_type in wound_tiers) + if(wound_tiers[family_type] >= 2) + return 1 + return 0 + //Returns the image object to overlay onto the root object /obj/item/hardpoint/proc/get_icon_image(x_offset, y_offset, new_dir) - var/is_broken = health <= 0 - var/image/I = image(icon = disp_icon, icon_state = "[disp_icon_state]_[is_broken ? "1" : "0"]", pixel_x = x_offset, pixel_y = y_offset, dir = new_dir) + var/image/I = image(icon = disp_icon, icon_state = "[disp_icon_state]_[get_mounted_damage_suffix()]", pixel_x = x_offset, pixel_y = y_offset, dir = new_dir) switch(floor((health / initial(health)) * 100)) if(0) I.color = "#888888" @@ -702,6 +1705,33 @@ I.color = null return I +/** + * Builds one clickable, wound-highlighted examine line for this hardpoint. + * + * Arguments: + * * user = Mob examining, passed through to icon2html(). + * * mounted_on = Optional parent holder's name, shown if set. + */ +/obj/item/hardpoint/proc/build_examine_line(mob/user, mounted_on) + var/line = mounted_on ? "There [p_are()] \a [src] module[p_s()] installed on [mounted_on]." : "There [p_are()] \a [src] module[p_s()] installed." + // Style has to live inside the tag's own text, or the link color wins over it. + // A destroyed part always gets the DESTROYED callout, even with no active wound. + var/is_destroyed = health <= 0 + var/name_text = is_destroyed ? "DESTROYED [name]" : name + var/display_name = (is_destroyed || LAZYLEN(wound_tiers)) ? SPAN_BOLDWARNING(name_text) : name_text + var/styled_name = "[display_name]" + line = replacetext(line, name, styled_name) + return "[icon2html(src, user)] [line]" + +/** + * The common healthy/damaged suffix shared by simple 2-state modules. Damaged if integrity + * drops below threshold_pct or this hardpoint has any active wound. + */ +/obj/item/hardpoint/proc/get_shared_damage_suffix(threshold_pct = 50) + if(LAZYLEN(wound_tiers) || get_integrity_percent() < threshold_pct) + return 1 + return 0 + // debug proc /obj/item/hardpoint/proc/set_offsets(dir, x, y) if(isnull(px_offsets)) diff --git a/code/modules/vehicles/hardpoints/hardpoint_ammo/dualcannon_ammo.dm b/code/modules/vehicles/hardpoints/hardpoint_ammo/dualcannon_ammo.dm index 866e386148d3..839fd29e4279 100644 --- a/code/modules/vehicles/hardpoints/hardpoint_ammo/dualcannon_ammo.dm +++ b/code/modules/vehicles/hardpoints/hardpoint_ammo/dualcannon_ammo.dm @@ -6,7 +6,8 @@ icon = 'icons/obj/items/weapons/guns/ammo_by_faction/USCM/vehicles.dmi' w_class = SIZE_LARGE default_ammo = /datum/ammo/bullet/tank/dualcannon - max_rounds = 60 + // Boyars was pretty underwhelming in testing, so, I've upped max rounds from 60 to 100 - BWSB + max_rounds = 100 gun_type = /obj/item/hardpoint/primary/dualcannon /obj/item/ammo_magazine/hardpoint/boyars_dualcannon/update_icon() diff --git a/code/modules/vehicles/hardpoints/hardpoint_ammo/flare_ammo.dm b/code/modules/vehicles/hardpoints/hardpoint_ammo/flare_ammo.dm new file mode 100644 index 000000000000..02211a3c0214 --- /dev/null +++ b/code/modules/vehicles/hardpoints/hardpoint_ammo/flare_ammo.dm @@ -0,0 +1,18 @@ +/obj/item/ammo_magazine/hardpoint/turret_flare + name = "Turret Flare Screen Magazine" + desc = "The turret's internal star shell holders. Loaded directly with individual M74 AGM-S star shells or star shell packets, not swapped as a whole magazine." + caliber = "flare" + icon = 'icons/obj/items/weapons/guns/ammo_by_faction/USCM/vehicles.dmi' + icon_state = "slauncher_1" + w_class = SIZE_LARGE + default_ammo = /datum/ammo/flare/starshell/burst + max_rounds = 6 + gun_type = /obj/item/hardpoint/holder/tank_turret + +/obj/item/ammo_magazine/hardpoint/turret_flare/update_icon() + icon_state = "slauncher_[current_rounds <= 0 ? "0" : "1"]" + +/obj/item/ammo_magazine/hardpoint/turret_flare/apc + name = "Flare Launcher Shell Holders" + desc = "The flare launcher's internal star shell holders. Loaded directly with individual M74 AGM-S star shells or star shell packets, not swapped as a whole magazine." + gun_type = /obj/item/hardpoint/support/flare_launcher diff --git a/code/modules/vehicles/hardpoints/hardpoint_ammo/ltb_ammo.dm b/code/modules/vehicles/hardpoints/hardpoint_ammo/ltb_ammo.dm index ee6de433eb36..b8b639de0ba2 100644 --- a/code/modules/vehicles/hardpoints/hardpoint_ammo/ltb_ammo.dm +++ b/code/modules/vehicles/hardpoints/hardpoint_ammo/ltb_ammo.dm @@ -8,6 +8,77 @@ default_ammo = /datum/ammo/rocket/ltb max_rounds = 4 gun_type = /obj/item/hardpoint/primary/cannon + /// Custom message engraved onto this shell, inherited by the fired projectile. + var/engraved_message /obj/item/ammo_magazine/hardpoint/ltb_cannon/update_icon() icon_state = "ltbcannon_[current_rounds]" + +/** + * Lets a marine scratch a message onto this shell with a hand labeler, a pen, or a bayonet. + * + * Bespoke flavor feature for the LTB cannon, not the generic label component other items use. + * + * Arguments: + * * obj/item/I = The item used against the shell. + * * mob/living/user = Whoever is engraving. + * * bypass_hold_check = Unused here, kept for signature parity with the base attackby(). + */ +/obj/item/ammo_magazine/hardpoint/ltb_cannon/attackby(obj/item/I, mob/living/user, bypass_hold_check = 0) + . = ..() + if(!is_engraving_tool(I)) + return + + var/new_message = copytext(reject_bad_text(tgui_input_text(user, "Engrave what onto [src]?", "Engrave Shell", engraved_message, MAX_NAME_LEN, ui_state = GLOB.not_incapacitated_state, encode = FALSE)), 1, MAX_NAME_LEN) + new_message = trim_right(replace_non_alphanumeric_plus(new_message)) + + if(QDELETED(src) || QDELETED(user) || !user.Adjacent(src) || !user.is_holding(I)) + return + + if(!new_message || !length(new_message)) + if(engraved_message) + user.visible_message(SPAN_NOTICE("[user] scratches the engraving off [src]."), SPAN_NOTICE("You scratch the engraving off [src].")) + clear_engraving() + return + + if(new_message == engraved_message) + to_chat(user, SPAN_WARNING("[src] is already engraved with \"[new_message]\".")) + return + + if(length(name) + length(new_message) > 64) + to_chat(user, SPAN_WARNING("That message is too long to fit on [src].")) + return + + user.visible_message(SPAN_NOTICE("[user] engraves \"[new_message]\" onto [src] with \a [I]."), SPAN_NOTICE("You engrave \"[new_message]\" onto [src].")) + set_engraving(new_message) + +/// Whether I can be used to engrave this shell (hand labeler, pen, or any bayonet). +/obj/item/ammo_magazine/hardpoint/ltb_cannon/proc/is_engraving_tool(obj/item/I) + if(istype(I, /obj/item/tool/hand_labeler)) + return TRUE + if(istype(I, /obj/item/tool/pen)) + return TRUE + return istype(I, /obj/item/attachable/bayonet) + +/// Sets new_message as this shell's engraving, replacing any existing one. +/obj/item/ammo_magazine/hardpoint/ltb_cannon/proc/set_engraving(new_message) + clear_engraving() + engraved_message = new_message + name += " \"[engraved_message]\"" + +/// Clears this shell's engraving, if any. +/obj/item/ammo_magazine/hardpoint/ltb_cannon/proc/clear_engraving() + if(!engraved_message) + return + name = trim(replacetext(name, " \"[engraved_message]\"", "")) + engraved_message = null + +// Single-round alternative magazine, still istype()-matched against the hardpoint's cached ammo_type +/obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell + name = "86x455mm HE Shell" + desc = "A single high-explosive 86x455mm shell for the LTB Cannon." + icon_state = "lbtshell_1" + max_rounds = 1 + +/obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell/update_icon() + icon_state = "lbtshell_[current_rounds]" diff --git a/code/modules/vehicles/hardpoints/hardpoint_ammo/primary_flamer_ammo.dm b/code/modules/vehicles/hardpoints/hardpoint_ammo/primary_flamer_ammo.dm index 8384b3f6d94e..5e3f08e291c4 100644 --- a/code/modules/vehicles/hardpoints/hardpoint_ammo/primary_flamer_ammo.dm +++ b/code/modules/vehicles/hardpoints/hardpoint_ammo/primary_flamer_ammo.dm @@ -5,12 +5,123 @@ icon = 'icons/obj/items/weapons/guns/ammo_by_faction/USCM/vehicles.dmi' icon_state = "drgn_flametank" w_class = SIZE_LARGE - max_rounds = 60 + max_rounds = 300 gun_type = /obj/item/hardpoint/primary/flamer default_ammo = /datum/ammo/flamethrower/tank_flamer + // Reagent id this tank starts filled with + var/flamer_chem = "highdamagenapalm" +/obj/item/ammo_magazine/hardpoint/primary_flamer/Initialize(mapload, ...) + . = ..() + create_reagents(max_rounds) + if(flamer_chem) + reagents.add_reagent(flamer_chem, max_rounds) + current_rounds = round(reagents.total_volume) + update_icon() + +/** + * Refuels this tank from a fuel source, mirroring the handheld flamer's own fuel type rules. + * Also accepts the infantry M240's fuel tank as a source/destination. No fuel mixing either way. + * Rejects specialist fuels, but allows research-generated/custom fuels through. + */ +/obj/item/ammo_magazine/hardpoint/primary_flamer/afterattack(obj/target, mob/user, flag) + if(get_dist(user, target) > 1) + return ..() + if(!istype(target, /obj/structure/reagent_dispensers/tank/fuel) && !istype(target, /obj/item/tool/weldpack) && !istype(target, /obj/item/storage/backpack/marine/engineerpack) && !istype(target, /obj/item/ammo_magazine/hardpoint/primary_flamer) && !istype(target, /obj/item/ammo_magazine/flamer_tank)) + return ..() + + if(!target.reagents || length(target.reagents.reagent_list) < 1) + to_chat(user, SPAN_WARNING("[target] is empty!")) + return + + if(!reagents) + create_reagents(max_rounds) + + var/datum/reagent/to_add = target.reagents.reagent_list[1] + + if(!istype(to_add) || (length(reagents.reagent_list) && flamer_chem != to_add.id) || length(target.reagents.reagent_list) > 1) + to_chat(user, SPAN_WARNING("You can't mix fuel mixtures!")) + return + + if(to_add.flags & REAGENT_TYPE_SPECIALIST) + to_chat(user, SPAN_WARNING("[src] cannot accept specialist fuels!")) + return + + if(rejects_weak_fuel(to_add)) + to_chat(user, SPAN_WARNING("This chemical is not potent enough to be used in a flamethrower!")) + return + + var/fuel_amt_to_remove = clamp(to_add.volume, 0, max_rounds - reagents.get_reagent_amount(to_add.id)) + if(!fuel_amt_to_remove) + if(!max_rounds) + to_chat(user, SPAN_WARNING("[target] is empty!")) + return + + target.reagents.remove_reagent(to_add.id, fuel_amt_to_remove) + reagents.add_reagent(to_add.id, fuel_amt_to_remove) + playsound(loc, 'sound/effects/refill.ogg', 25, 1, 3) + caliber = to_add.name + flamer_chem = to_add.id + current_rounds = round(reagents.total_volume) + + to_chat(user, SPAN_NOTICE("You refill [src] with [caliber].")) + update_icon() + +/// Whether afterattack() rejects to_add as not potent enough. /smoke overrides to accept anything. +/obj/item/ammo_magazine/hardpoint/primary_flamer/proc/rejects_weak_fuel(datum/reagent/to_add) + return !to_add.intensityfire && to_add.id != "stablefoam" + +// Colored fuel strip overlay /obj/item/ammo_magazine/hardpoint/primary_flamer/update_icon() + overlays.Cut() if(current_rounds > 0) icon_state = "drgn_flametank" + var/image/strip = image(icon, icon_state = "drgn_flametank_strip") + if(reagents) + strip.color = mix_color_from_reagents(reagents.reagent_list) + overlays += strip else icon_state = "drgn_flametank_empty" + +// Lists loaded fuel by name/volume on examine +/obj/item/ammo_magazine/hardpoint/primary_flamer/get_examine_text(mob/user) + . = ..() + . += SPAN_NOTICE("It contains:") + if(reagents && length(reagents.reagent_list)) + for(var/datum/reagent/reagent in reagents.reagent_list) + . += SPAN_NOTICE(" [reagent.volume] units of [reagent.name].") + else + . += SPAN_NOTICE(" Nothing.") + +// "Empty canister" verb, also found in the m240 flamer. +/obj/item/ammo_magazine/hardpoint/primary_flamer/verb/remove_reagents() + set name = "Empty canister" + set category = "Object" + + set src in usr + + if(usr.get_active_hand() != src) + return + + if(alert(usr, "Do you really want to empty out [src]?", "Empty canister", "Yes", "No") != "Yes") + return + + reagents.clear_reagents() + current_rounds = round(reagents.total_volume) + + playsound(loc, 'sound/effects/refill.ogg', 25, 1, 3) + to_chat(usr, SPAN_NOTICE("You empty out [src]")) + update_icon() + +/** + * DRG-N equivalent of the infantry M240's smoke tank. Starts empty and accepts any chemical. + * Disperses smoke further and longer than the LZR-N's version. + */ +/obj/item/ammo_magazine/hardpoint/primary_flamer/smoke + name = "DRG-N Offensive Flamer Unit Smoke Tank" + desc = "A tank fitted to be accepted by the DRG-N Offensive Flamer, holding powdered smoke that expands when exposed to an open flame and carries any chemicals along with it. Its larger reservoir and wider nozzle let it lay down a thicker, longer-reaching smokescreen than the LZR-N's own smoke tank." + caliber = "Powdered Smoke" + flamer_chem = null + +/obj/item/ammo_magazine/hardpoint/primary_flamer/smoke/rejects_weak_fuel(datum/reagent/to_add) + return FALSE diff --git a/code/modules/vehicles/hardpoints/hardpoint_ammo/secondary_flamer_ammo.dm b/code/modules/vehicles/hardpoints/hardpoint_ammo/secondary_flamer_ammo.dm index 4a09046c9678..9b463d2b7db1 100644 --- a/code/modules/vehicles/hardpoints/hardpoint_ammo/secondary_flamer_ammo.dm +++ b/code/modules/vehicles/hardpoints/hardpoint_ammo/secondary_flamer_ammo.dm @@ -7,3 +7,115 @@ w_class = SIZE_LARGE max_rounds = 150 gun_type = /obj/item/hardpoint/secondary/small_flamer + // Only used when the hardpoint is toggled to FLAME_MODE_GLOB + default_ammo = /datum/ammo/flamethrower/tank_flamer_secondary + // Reagent id this tank starts filled with. + var/flamer_chem = "utnapthal" + +/obj/item/ammo_magazine/hardpoint/secondary_flamer/Initialize(mapload, ...) + . = ..() + create_reagents(max_rounds) + if(flamer_chem) + reagents.add_reagent(flamer_chem, max_rounds) + current_rounds = round(reagents.total_volume) + update_icon() + +// Colored fuel strip overlay +/obj/item/ammo_magazine/hardpoint/secondary_flamer/update_icon() + overlays.Cut() + var/image/strip = image(icon, icon_state = "flametank_large_custom_strip") + if(reagents && length(reagents.reagent_list)) + strip.color = mix_color_from_reagents(reagents.reagent_list) + overlays += strip + +/** + * Refuels this tank from a fuel source, mirroring the handheld flamer's own fuel type rules. + * Also accepts the infantry M240's fuel tank as a source/destination. No fuel mixing either way. + * Rejects specialist fuels, but allows research-generated/custom fuels through. + */ +/obj/item/ammo_magazine/hardpoint/secondary_flamer/afterattack(obj/target, mob/user, flag) + if(get_dist(user, target) > 1) + return ..() + if(!istype(target, /obj/structure/reagent_dispensers/tank/fuel) && !istype(target, /obj/item/tool/weldpack) && !istype(target, /obj/item/storage/backpack/marine/engineerpack) && !istype(target, /obj/item/ammo_magazine/hardpoint/secondary_flamer) && !istype(target, /obj/item/ammo_magazine/flamer_tank)) + return ..() + + if(!target.reagents || length(target.reagents.reagent_list) < 1) + to_chat(user, SPAN_WARNING("[target] is empty!")) + return + + if(!reagents) + create_reagents(max_rounds) + + var/datum/reagent/to_add = target.reagents.reagent_list[1] + + if(!istype(to_add) || (length(reagents.reagent_list) && flamer_chem != to_add.id) || length(target.reagents.reagent_list) > 1) + to_chat(user, SPAN_WARNING("You can't mix fuel mixtures!")) + return + + if(to_add.flags & REAGENT_TYPE_SPECIALIST) + to_chat(user, SPAN_WARNING("[src] cannot accept specialist fuels!")) + return + + if(rejects_weak_fuel(to_add)) + to_chat(user, SPAN_WARNING("This chemical is not potent enough to be used in a flamethrower!")) + return + + var/fuel_amt_to_remove = clamp(to_add.volume, 0, max_rounds - reagents.get_reagent_amount(to_add.id)) + if(!fuel_amt_to_remove) + if(!max_rounds) + to_chat(user, SPAN_WARNING("[target] is empty!")) + return + + target.reagents.remove_reagent(to_add.id, fuel_amt_to_remove) + reagents.add_reagent(to_add.id, fuel_amt_to_remove) + playsound(loc, 'sound/effects/refill.ogg', 25, 1, 3) + caliber = to_add.name + flamer_chem = to_add.id + current_rounds = round(reagents.total_volume) + + to_chat(user, SPAN_NOTICE("You refill [src] with [caliber].")) + update_icon() + +/// Whether afterattack() rejects to_add as not potent enough. /smoke overrides to accept anything. +/obj/item/ammo_magazine/hardpoint/secondary_flamer/proc/rejects_weak_fuel(datum/reagent/to_add) + return !to_add.intensityfire && to_add.id != "stablefoam" + +// Lists loaded fuel by name/volume when examining +/obj/item/ammo_magazine/hardpoint/secondary_flamer/get_examine_text(mob/user) + . = ..() + . += SPAN_NOTICE("It contains:") + if(reagents && length(reagents.reagent_list)) + for(var/datum/reagent/reagent in reagents.reagent_list) + . += SPAN_NOTICE(" [reagent.volume] units of [reagent.name].") + else + . += SPAN_NOTICE(" Nothing.") + +// "Empty canister" verb, also found in the m240 flamer. +/obj/item/ammo_magazine/hardpoint/secondary_flamer/verb/remove_reagents() + set name = "Empty canister" + set category = "Object" + + set src in usr + + if(usr.get_active_hand() != src) + return + + if(alert(usr, "Do you really want to empty out [src]?", "Empty canister", "Yes", "No") != "Yes") + return + + reagents.clear_reagents() + current_rounds = round(reagents.total_volume) + + playsound(loc, 'sound/effects/refill.ogg', 25, 1, 3) + to_chat(usr, SPAN_NOTICE("You empty out [src]")) + update_icon() + +/// LZR-N equivalent of the infantry M240's smoke tank. Starts empty and accepts any chemical. +/obj/item/ammo_magazine/hardpoint/secondary_flamer/smoke + name = "LZR-N Flamer Unit Smoke Tank" + desc = "A tank fitted to be accepted by the LZR-N Flamer, holding powdered smoke that expands when exposed to an open flame and carries any chemicals along with it." + caliber = "Powdered Smoke" + flamer_chem = null + +/obj/item/ammo_magazine/hardpoint/secondary_flamer/smoke/rejects_weak_fuel(datum/reagent/to_add) + return FALSE diff --git a/code/modules/vehicles/hardpoints/hardpoint_ammo/smoke_ammo.dm b/code/modules/vehicles/hardpoints/hardpoint_ammo/smoke_ammo.dm deleted file mode 100644 index 04846d74c423..000000000000 --- a/code/modules/vehicles/hardpoints/hardpoint_ammo/smoke_ammo.dm +++ /dev/null @@ -1,13 +0,0 @@ -/obj/item/ammo_magazine/hardpoint/turret_smoke - name = "Turret Smoke Screen Magazine" - desc = "A smoke grenades magazine used by tank turret." - caliber = "grenade" - icon = 'icons/obj/items/weapons/guns/ammo_by_faction/USCM/vehicles.dmi' - icon_state = "slauncher_1" - w_class = SIZE_LARGE - default_ammo = /datum/ammo/grenade_container/smoke - max_rounds = 10 - gun_type = /obj/item/hardpoint/holder/tank_turret - -/obj/item/ammo_magazine/hardpoint/turret_smoke/update_icon() - icon_state = "slauncher_[current_rounds <= 0 ? "0" : "1"]" diff --git a/code/modules/vehicles/hardpoints/hardpoint_wound_families.dm b/code/modules/vehicles/hardpoints/hardpoint_wound_families.dm new file mode 100644 index 000000000000..3ea5e32278ed --- /dev/null +++ b/code/modules/vehicles/hardpoints/hardpoint_wound_families.dm @@ -0,0 +1,779 @@ +/** + * A catalog entry for one wound family (e.g. "primary_fouling"). Every instance is + * auto-collected by part_slot into GLOB.hardpoint_wound_families_by_slot. + */ +/datum/hardpoint_wound_family + /// HDPT_* slot this family applies to, or WOUND_SLOT_HULL for the two vehicle-frame families. + var/part_slot + /// WOUND_DAMTYPE_ACID, WOUND_DAMTYPE_BRUTE, or WOUND_DAMTYPE_UNIFIED (fuel_tank_leak only). + var/damage_type + /// TRUE for neurotoxin "_gunked" families. Only reachable via the neuro alt-trigger roll. + var/neuro_alt_trigger_only = FALSE + /** + * Ordered list of tier data, index 1 = tier 1. Each entry is an assoc list with keys: + * * wound_name: short display name. + * * marine_feedback_red, xeno_feedback: examine blurbs shown to marines and xenos. + * * bold_feedback: TRUE renders both blurbs bold, marking a clear weak point. + * * repair_method, repair_skill: fallback repair text and the skill needed. + * * unmount_required: TRUE means this tier only repairs after being removed from the vehicle. + * * repair_steps: ordered list of required steps, a tool trait, "welder", or a material step + * list("material", stack_type_path, amount). + * * repair_step_verbs: one verb phrase per repair_steps entry, used in the repair info text. + * * repair_reverts_to_tier: null clears the wound, or a lower tier number to revert to. + * * repair_required_skill: set only when repair_skill needs more than the base engineer skill. + * * min_health_threshold_pct, damage_to_chance_pct, max_roll_chance_pct: gating/roll formula. + * * neuro_alt_trigger_chance_pct: stored but unused this round. + * * Optional effect fields: accuracy_mult, scatter_mult, projectile_speed_mult, damage_mult, + * turn_rate_mult, vision_impair_add, incoming_damage_mult, incoming_damage_type, + * passive_leak_rate, aura_reduction_add, iff_disabled, module_disabled, performance_mult. + * + * Multiple active wounds setting the same numeric key compound multiplicatively. + */ + var/list/tiers + +// Feedback strings below underline and color-code the damage type word: red for +// mechanical, green for corrosive, gold for neurotoxin. + +/// Human-readable family name derived from this datum's type path. +/datum/hardpoint_wound_family/proc/get_display_label() + var/static/prefix_length = length("[/datum/hardpoint_wound_family]/") + var/family_id = copytext("[type]", prefix_length + 1) + var/list/words = splittext(family_id, "_") + var/list/capitalized_words = list() + for(var/word in words) + capitalized_words += capitalize(word) + return jointext(capitalized_words, " ") + +/datum/hardpoint_wound_family/primary_fouling + part_slot = HDPT_PRIMARY + damage_type = WOUND_DAMTYPE_ACID + tiers = list( + list("wound_name" = "Light Fouling", "marine_feedback_red" = "A light film of acid fouling coats the barrel's bore.", + "xeno_feedback" = "Acid grime dulls the insides of the boom-tube to a sickly color.", "bold_feedback" = FALSE, + "repair_method" = "Wipe down with a rag OR burn with a welder", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list("welder"), "repair_step_verbs" = list("welding the fouling away"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 90, "neuro_alt_trigger_chance_pct" = null, + "accuracy_mult" = 0.9, "scatter_mult" = 1.2, "damage_mult" = 0.95), + list("wound_name" = "Heavy Fouling", "marine_feedback_red" = "Thick, caked carbon chunks choke the chamber, built up from repeated acid exposure.", + "xeno_feedback" = "Acid buildup clogs the primary fire-spitter's throat.", "bold_feedback" = FALSE, + "repair_method" = "Pry the caked carbon chunks loose with a crowbar (Reverts to Tier 1)", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list(TRAIT_TOOL_CROWBAR), "repair_step_verbs" = list("prying the carbon chunks loose"), "repair_reverts_to_tier" = 1, + "min_health_threshold_pct" = 70, "damage_to_chance_pct" = 3, "max_roll_chance_pct" = 55, "neuro_alt_trigger_chance_pct" = null, + "accuracy_mult" = 0.75, "scatter_mult" = 1.5, "projectile_speed_mult" = 0.8, "damage_mult" = 0.85), + list("wound_name" = "Severe Fouling", "marine_feedback_red" = "Hardened, acid-eaten carbon buildup chokes the breach solid.", + "xeno_feedback" = "Partially-molten acid-steel slag fills the primary barrel solid.", "bold_feedback" = TRUE, + "repair_method" = "Unmount the barrel and fully scrape out the breach (Unmount, crowbar + welder)", "repair_skill" = "Any Marine", "unmount_required" = TRUE, + "repair_steps" = list(TRAIT_TOOL_CROWBAR, "welder"), "repair_step_verbs" = list("scraping the breach clean", "welding it smooth"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 60, "damage_to_chance_pct" = 2, "max_roll_chance_pct" = 30, "neuro_alt_trigger_chance_pct" = null, + "accuracy_mult" = 0.55, "scatter_mult" = 2.0, "projectile_speed_mult" = 0.5, "damage_mult" = 0.65), + ) + +/** + * Neurotoxin's parallel to primary_fouling. Half as severe per tier, rolls about twice as often. + */ +/datum/hardpoint_wound_family/primary_gunked + part_slot = HDPT_PRIMARY + damage_type = WOUND_DAMTYPE_ACID + neuro_alt_trigger_only = TRUE + tiers = list( + list("wound_name" = "Light Gunk", "marine_feedback_red" = "A tacky film of neurotoxin residue coats the barrel's bore.", + "xeno_feedback" = "A sticky film of neurotoxin clings to the insides of the boom-tube.", "bold_feedback" = FALSE, + "repair_method" = "Wipe down with a rag OR burn with a welder", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list("welder"), "repair_step_verbs" = list("welding the gunk away"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 90, "neuro_alt_trigger_chance_pct" = 40, + "accuracy_mult" = 0.95, "scatter_mult" = 1.1, "damage_mult" = 0.975), + list("wound_name" = "Heavy Gunk", "marine_feedback_red" = "Thick, tacky neurotoxin residue chokes the chamber.", + "xeno_feedback" = "Globs of sticky neurotoxin residue gum the primary fire-spitter's throat shut.", "bold_feedback" = FALSE, + "repair_method" = "Pry the caked residue loose with a crowbar (Reverts to Tier 1)", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list(TRAIT_TOOL_CROWBAR), "repair_step_verbs" = list("prying the caked residue loose"), "repair_reverts_to_tier" = 1, + "min_health_threshold_pct" = 70, "damage_to_chance_pct" = 3, "max_roll_chance_pct" = 55, "neuro_alt_trigger_chance_pct" = 24, + "accuracy_mult" = 0.875, "scatter_mult" = 1.25, "projectile_speed_mult" = 0.9, "damage_mult" = 0.925), + list("wound_name" = "Severe Gunk", "marine_feedback_red" = "Hardened, web-like neurotoxin residue chokes the breach solid.", + "xeno_feedback" = "A hardened web of neurotoxin residue packs the primary barrel solid.", "bold_feedback" = TRUE, + "repair_method" = "Unmount the barrel and fully scrape out the breach (Unmount, crowbar + welder)", "repair_skill" = "Any Marine", "unmount_required" = TRUE, + "repair_steps" = list(TRAIT_TOOL_CROWBAR, "welder"), "repair_step_verbs" = list("scraping the breach clean", "welding it smooth"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 60, "damage_to_chance_pct" = 2, "max_roll_chance_pct" = 30, "neuro_alt_trigger_chance_pct" = 12, + "accuracy_mult" = 0.775, "scatter_mult" = 1.5, "projectile_speed_mult" = 0.75, "damage_mult" = 0.825), + ) + +/datum/hardpoint_wound_family/primary_bent_barrel + part_slot = HDPT_PRIMARY + damage_type = WOUND_DAMTYPE_BRUTE + tiers = list( + list("wound_name" = "Minor Bend", "marine_feedback_red" = "A slight kink runs along the barrel.", + "xeno_feedback" = "A faint kink mars the barrel's line.", "bold_feedback" = FALSE, + "repair_method" = "Hammer it straight in place with a wrench", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list(TRAIT_TOOL_WRENCH), "repair_step_verbs" = list("wrenching it back into shape"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 90, "neuro_alt_trigger_chance_pct" = null, + "accuracy_mult" = 0.9, "scatter_mult" = 1.2, "damage_mult" = 0.95), + list("wound_name" = "Warped Barrel", "marine_feedback_red" = "The barrel is visibly warped out of true.", + "xeno_feedback" = "Our strikes have left the weapon's silhouette crooked.", "bold_feedback" = FALSE, + "repair_method" = "Unmount and straighten with a crowbar", "repair_skill" = "Any Marine", "unmount_required" = TRUE, + "repair_steps" = list(TRAIT_TOOL_CROWBAR), "repair_step_verbs" = list("straightening it with the crowbar"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 80, "damage_to_chance_pct" = 3, "max_roll_chance_pct" = 55, "neuro_alt_trigger_chance_pct" = null, + "accuracy_mult" = 0.75, "scatter_mult" = 1.5, "projectile_speed_mult" = 0.8, "damage_mult" = 0.85), + list("wound_name" = "Severely Bent Barrel", "marine_feedback_red" = "The barrel is bent badly enough it's a wonder it still fires straight at all.", + "xeno_feedback" = "The boom-tube is so misshapen that it points almost completely to the side.", "bold_feedback" = TRUE, + "repair_method" = "Unmount, soften metal with a welder, straighten with a crowbar, weaken again, straighten with a wrench.", "repair_skill" = "Maintenance Tech or Combat Technician", "unmount_required" = TRUE, + "repair_steps" = list("welder", TRAIT_TOOL_CROWBAR, "welder", TRAIT_TOOL_WRENCH), "repair_step_verbs" = list("softening the metal with a welder", "straightening it with a crowbar", "weakening the metal again", "straightening it true with a wrench"), "repair_reverts_to_tier" = null, "repair_required_skill" = SKILL_ENGINEER_ENGI, + "min_health_threshold_pct" = 75, "damage_to_chance_pct" = 2, "max_roll_chance_pct" = 30, "neuro_alt_trigger_chance_pct" = null, + "accuracy_mult" = 0.55, "scatter_mult" = 2.0, "projectile_speed_mult" = 0.5, "damage_mult" = 0.65), + ) + +/datum/hardpoint_wound_family/secondary_sticky_feed + part_slot = HDPT_SECONDARY + damage_type = WOUND_DAMTYPE_ACID + tiers = list( + list("wound_name" = "Light Residue", "marine_feedback_red" = "A light film of acid residue coats the feed tray.", + "xeno_feedback" = "Acid buildup clogs the secondary fire-spitter's throat.", "bold_feedback" = FALSE, + "repair_method" = "Wipe down the feed tray with a rag OR burn the residue off with a welder", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list("welder"), "repair_step_verbs" = list("welding the residue off"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 80, "neuro_alt_trigger_chance_pct" = null, + "damage_mult" = 0.95), + list("wound_name" = "Gummed Feed", "marine_feedback_red" = "The feed tray is thickly gummed, sticky with hardened acid residue.", + "xeno_feedback" = "Partially-molten acid-steel slag fills the secondary barrel solid.", "bold_feedback" = TRUE, + "repair_method" = "Pry the chunks off with a crowbar (Reverts to Tier 1)", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list(TRAIT_TOOL_CROWBAR), "repair_step_verbs" = list("prying the chunks off"), "repair_reverts_to_tier" = 1, + "min_health_threshold_pct" = 55, "damage_to_chance_pct" = 3, "max_roll_chance_pct" = 50, "neuro_alt_trigger_chance_pct" = null, + "damage_mult" = 0.8), + ) + +/// Neurotoxin's parallel to secondary_sticky_feed. Half as severe, rolls about twice as often. +/datum/hardpoint_wound_family/secondary_gunked + part_slot = HDPT_SECONDARY + damage_type = WOUND_DAMTYPE_ACID + neuro_alt_trigger_only = TRUE + tiers = list( + list("wound_name" = "Light Gunk", "marine_feedback_red" = "A tacky film of neurotoxin residue coats the feed tray.", + "xeno_feedback" = "Sticky neurotoxin residue gums the secondary fire-spitter's throat shut.", "bold_feedback" = FALSE, + "repair_method" = "Wipe down the feed tray with a rag OR burn the residue off with a welder", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list("welder"), "repair_step_verbs" = list("welding the residue off"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 80, "neuro_alt_trigger_chance_pct" = 30, + "damage_mult" = 0.975), + list("wound_name" = "Gunked Feed", "marine_feedback_red" = "The feed tray is thickly webbed with hardened neurotoxin residue.", + "xeno_feedback" = "A hardened web of neurotoxin residue packs the secondary barrel solid.", "bold_feedback" = TRUE, + "repair_method" = "Pry the chunks off with a crowbar (Reverts to Tier 1)", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list(TRAIT_TOOL_CROWBAR), "repair_step_verbs" = list("prying the chunks off"), "repair_reverts_to_tier" = 1, + "min_health_threshold_pct" = 55, "damage_to_chance_pct" = 3, "max_roll_chance_pct" = 50, "neuro_alt_trigger_chance_pct" = 16, + "damage_mult" = 0.9), + ) + +/datum/hardpoint_wound_family/secondary_cracked_mount + part_slot = HDPT_SECONDARY + damage_type = WOUND_DAMTYPE_BRUTE + tiers = list( + list("wound_name" = "Loose Mount", "marine_feedback_red" = "Our strikes have left the mount visibly loose, rattling in its bolts.", + "xeno_feedback" = "The weapon mount rattles loose every time we strike it.", "bold_feedback" = FALSE, + "repair_method" = "Re-torque the mounting bolts", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list(TRAIT_TOOL_WRENCH), "repair_step_verbs" = list("wrenching the mounting bolts tight"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 80, "neuro_alt_trigger_chance_pct" = null, + "accuracy_mult" = 0.9, "scatter_mult" = 1.2, "damage_mult" = 0.95), + list("wound_name" = "Cracked Mount", "marine_feedback_red" = "A crack runs clean through the mount housing.", + "xeno_feedback" = "The secondary weapon mount hangs cracked and barely functional.", "bold_feedback" = TRUE, + "repair_method" = "Unmount, weld the cracked housing. (reverts to t1)", "repair_skill" = "Any Marine", "unmount_required" = TRUE, + "repair_steps" = list("welder"), "repair_step_verbs" = list("welding the cracked housing shut"), "repair_reverts_to_tier" = 1, + "min_health_threshold_pct" = 55, "damage_to_chance_pct" = 3, "max_roll_chance_pct" = 50, "neuro_alt_trigger_chance_pct" = null, + "accuracy_mult" = 0.7, "scatter_mult" = 1.6, "damage_mult" = 0.8), + ) + +/datum/hardpoint_wound_family/turret_melted + part_slot = HDPT_TURRET + damage_type = WOUND_DAMTYPE_ACID + tiers = list( + list("wound_name" = "Molten Plating", "marine_feedback_red" = "Acid residue coats and weakens the turret's plating.", + "xeno_feedback" = "Our acid has left the turret's hide soft and vulnerable!", "bold_feedback" = FALSE, + "repair_method" = "Burn off the acid damage with a welder, or with a rag", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list("welder"), "repair_step_verbs" = list("welding the acid damage away"), "repair_reverts_to_tier" = null, + // Hull/Turret tiers trigger easier so they wound fast and unlock the internal cascade. + "min_health_threshold_pct" = 98, "damage_to_chance_pct" = 8, "max_roll_chance_pct" = 85, "neuro_alt_trigger_chance_pct" = null, + "incoming_damage_mult" = 1.5), + list("wound_name" = "Molten Support Beams", "marine_feedback_red" = "Acid has eaten into the turret's internal structures.", + "xeno_feedback" = "The turret melts away before us! An inviting target!", "bold_feedback" = TRUE, + "repair_method" = "Attach 10 sheets of steel. Weld it.", "repair_skill" = "Maintenance Tech or Combat Technician", "unmount_required" = TRUE, + "repair_steps" = list(list("material", /obj/item/stack/sheet/metal, 10), "welder"), "repair_step_verbs" = list("attaching sheets of steel", "welding them into place"), "repair_reverts_to_tier" = null, "repair_required_skill" = SKILL_ENGINEER_ENGI, + "min_health_threshold_pct" = 70, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 60, "neuro_alt_trigger_chance_pct" = null, + "incoming_damage_mult" = 2.5), + ) + +/datum/hardpoint_wound_family/turret_hole + part_slot = HDPT_TURRET + damage_type = WOUND_DAMTYPE_BRUTE + tiers = list( + list("wound_name" = "Dented Plating", "marine_feedback_red" = "The turret's outer plating is dented and scraped raw.", + "xeno_feedback" = "Our blows have left the turret's armor battered and weak!", "bold_feedback" = FALSE, + "repair_method" = "Repair it using a welder", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list("welder"), "repair_step_verbs" = list("welding the dents out"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 98, "damage_to_chance_pct" = 8, "max_roll_chance_pct" = 85, "neuro_alt_trigger_chance_pct" = null, + "incoming_damage_mult" = 1.5), + list("wound_name" = "Gaping Hole", "marine_feedback_red" = "A gaping hole exposes the turret's delicate internals.", + "xeno_feedback" = "A gaping wound bares the turret's guts to us! Strike true!", "bold_feedback" = TRUE, + "repair_method" = "Attach 10 sheets of steel. Weld it.", "repair_skill" = "Maintenance Tech or Combat Technician", "unmount_required" = TRUE, + "repair_steps" = list(list("material", /obj/item/stack/sheet/metal, 10), "welder"), "repair_step_verbs" = list("attaching sheets of steel", "welding them into place"), "repair_reverts_to_tier" = null, "repair_required_skill" = SKILL_ENGINEER_ENGI, + "min_health_threshold_pct" = 70, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 60, "neuro_alt_trigger_chance_pct" = null, + "incoming_damage_mult" = 2.5), + ) + +/datum/hardpoint_wound_family/hull_melted + part_slot = WOUND_SLOT_HULL + damage_type = WOUND_DAMTYPE_ACID + tiers = list( + list("wound_name" = "Molten Plating", "marine_feedback_red" = "Acid residue coats and weakens the hull's plating.", + "xeno_feedback" = "Our acid has left the hull's hide soft and vulnerable!", "bold_feedback" = FALSE, + "repair_method" = "Burn off the acid damage with a welder, or with a rag", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list("welder"), "repair_step_verbs" = list("welding the acid damage away"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 98, "damage_to_chance_pct" = 8, "max_roll_chance_pct" = 85, "neuro_alt_trigger_chance_pct" = null, + "incoming_damage_mult" = 1.5), + list("wound_name" = "Molten Support Beams", "marine_feedback_red" = "Acid has eaten deep into the hull, severely weakening it.", + "xeno_feedback" = "The hull melts and bubbles wildly. The steel parts like butter before us.", "bold_feedback" = TRUE, + "repair_method" = "Attach 10 sheets of steel. Weld it.", "repair_skill" = "Maintenance Tech or Combat Technician", "unmount_required" = FALSE, + "repair_steps" = list(list("material", /obj/item/stack/sheet/metal, 10), "welder"), "repair_step_verbs" = list("attaching sheets of steel", "welding them into place"), "repair_reverts_to_tier" = null, "repair_required_skill" = SKILL_ENGINEER_ENGI, + "min_health_threshold_pct" = 70, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 60, "neuro_alt_trigger_chance_pct" = null, + "incoming_damage_mult" = 2.5), + ) + +/datum/hardpoint_wound_family/hull_hole + part_slot = WOUND_SLOT_HULL + damage_type = WOUND_DAMTYPE_BRUTE + tiers = list( + list("wound_name" = "Dented Plating", "marine_feedback_red" = "The hull's outer plating is dented and scraped raw.", + "xeno_feedback" = "Our blows have left the hull's armor loose and dislodged!", "bold_feedback" = FALSE, + "repair_method" = "Repair it using a welder", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list("welder"), "repair_step_verbs" = list("welding the dents out"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 98, "damage_to_chance_pct" = 8, "max_roll_chance_pct" = 85, "neuro_alt_trigger_chance_pct" = null, + "incoming_damage_mult" = 1.5), + list("wound_name" = "Gaping Hole", "marine_feedback_red" = "Gaping holes tear through the hull, straight into the modules beneath.", + "xeno_feedback" = "Torn panels bare the hull's delicate innards to us. Rip away at its metal guts!", "bold_feedback" = TRUE, + "repair_method" = "Attach 10 sheets of steel. Weld it.", "repair_skill" = "Maintenance Tech or Combat Technician", "unmount_required" = FALSE, + "repair_steps" = list(list("material", /obj/item/stack/sheet/metal, 10), "welder"), "repair_step_verbs" = list("attaching sheets of steel", "welding them into place"), "repair_reverts_to_tier" = null, "repair_required_skill" = SKILL_ENGINEER_ENGI, + "min_health_threshold_pct" = 70, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 60, "neuro_alt_trigger_chance_pct" = null, + "incoming_damage_mult" = 2.5), + ) + +/datum/hardpoint_wound_family/turret_ring_gummed_bearings + part_slot = HDPT_TURRET_RING + damage_type = WOUND_DAMTYPE_ACID + tiers = list( + list("wound_name" = "Light Grime", "marine_feedback_red" = "A light film of acid grime coats the turret ring.", + "xeno_feedback" = "Acid buildup makes the turret struggle to rotate.", "bold_feedback" = FALSE, + "repair_method" = "Wipe down with a rag OR burn with a welder", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list("welder"), "repair_step_verbs" = list("welding the grime off"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 75, "neuro_alt_trigger_chance_pct" = null, + "turn_rate_mult" = 0.67), + list("wound_name" = "Gummed Bearings", "marine_feedback_red" = "The turret ring grinds and catches, its bearings gummed with hardened acid slag.", + "xeno_feedback" = "The turret's rotation visibly stutters, its guts clogged with acid slag.", "bold_feedback" = TRUE, + "repair_method" = "Unmount the turret first, then pry the caked carbon chunks loose from the ring with a crowbar (Reverts to Tier 1)", "repair_skill" = "Any Marine", "unmount_required" = TRUE, + "repair_steps" = list(TRAIT_TOOL_CROWBAR), "repair_step_verbs" = list("prying the caked carbon chunks loose"), "repair_reverts_to_tier" = 1, + "min_health_threshold_pct" = 75, "damage_to_chance_pct" = 3, "max_roll_chance_pct" = 45, "neuro_alt_trigger_chance_pct" = null, + "turn_rate_mult" = 0.33), + ) + +/datum/hardpoint_wound_family/turret_ring_warped_gear + part_slot = HDPT_TURRET_RING + damage_type = WOUND_DAMTYPE_BRUTE + tiers = list( + list("wound_name" = "Ring Chatter", "marine_feedback_red" = "The ring gear chatters faintly as it turns.", + "xeno_feedback" = "Our blows have left the turret grinding, rotating slow and rough.", "bold_feedback" = FALSE, + "repair_method" = "Adjust it with a wrench", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list(TRAIT_TOOL_WRENCH), "repair_step_verbs" = list("wrenching it back into alignment"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 75, "neuro_alt_trigger_chance_pct" = null, + "turn_rate_mult" = 0.67), + list("wound_name" = "Warped Ring Gear", "marine_feedback_red" = "The ring gear's teeth sit visibly warped where they mesh.", + "xeno_feedback" = "The turret occasionally seizes locked entirely mid-turn.", "bold_feedback" = TRUE, + "repair_method" = "Unmount the turret first, then crowbar the ring off, and wrench + weld it", "repair_skill" = "Maintenance Tech or Combat Technician", "unmount_required" = TRUE, + "repair_steps" = list(TRAIT_TOOL_WRENCH, "welder"), "repair_step_verbs" = list("wrenching it back into shape", "welding it solid"), "repair_reverts_to_tier" = null, "repair_required_skill" = SKILL_ENGINEER_ENGI, + "min_health_threshold_pct" = 75, "damage_to_chance_pct" = 3, "max_roll_chance_pct" = 45, "neuro_alt_trigger_chance_pct" = null, + "turn_rate_mult" = 0.33), + ) + +/datum/hardpoint_wound_family/armor_corroded_plating + part_slot = HDPT_ARMOR + damage_type = WOUND_DAMTYPE_ACID + tiers = list( + list("wound_name" = "Surface Corrosion", "marine_feedback_red" = "A light film of corrosion discolors the plating.", + "xeno_feedback" = "A faint acid scar discolors the armor plate.", "bold_feedback" = FALSE, + "repair_method" = "Rag Or Welder", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list("welder"), "repair_step_verbs" = list("welding the corrosion off"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 75, "neuro_alt_trigger_chance_pct" = null, + "incoming_damage_mult" = 1.25), + list("wound_name" = "Corroded Plating", "marine_feedback_red" = "The plating sits visibly pitted and thinning from corrosion.", + "xeno_feedback" = "Our acid has already eaten deep pits into this plate.", "bold_feedback" = TRUE, + "repair_method" = "Crowbar out. Fix with 5 sheets of plasteel.", "repair_skill" = "Any Marine", "unmount_required" = TRUE, + "repair_steps" = list(list("material", /obj/item/stack/sheet/plasteel, 5)), "repair_step_verbs" = list("patching it with plasteel sheets"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 55, "damage_to_chance_pct" = 3, "max_roll_chance_pct" = 45, "neuro_alt_trigger_chance_pct" = null, + "incoming_damage_mult" = 2.0), + ) + +/datum/hardpoint_wound_family/armor_cracked_plating + part_slot = HDPT_ARMOR + damage_type = WOUND_DAMTYPE_BRUTE + tiers = list( + list("wound_name" = "Spalling", "marine_feedback_red" = "Our blows have flaked chunks off the armor's inner face.", + "xeno_feedback" = "Small gaps open as the armor plates work loose under our strikes.", "bold_feedback" = FALSE, + "repair_method" = "Welder and Wrench", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list("welder", TRAIT_TOOL_WRENCH), "repair_step_verbs" = list("welding the flaking plate back", "wrenching it tight"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 75, "neuro_alt_trigger_chance_pct" = null, + "incoming_damage_mult" = 1.25), + // This tier's incoming multiplier also covers Acid hits, not just Brute, on the same panel. + list("wound_name" = "Cracked Plating", "marine_feedback_red" = "A structural crack runs clean through the plating.", + "xeno_feedback" = "Massive cracks split the external armor wide open.", "bold_feedback" = TRUE, + "repair_method" = "Crowbar out. Fix with 5 sheets of plasteel.", "repair_skill" = "Maintenance Tech or Combat Technician", "unmount_required" = TRUE, + "repair_steps" = list(list("material", /obj/item/stack/sheet/plasteel, 5)), "repair_step_verbs" = list("patching it with plasteel sheets"), "repair_reverts_to_tier" = null, "repair_required_skill" = SKILL_ENGINEER_ENGI, + "min_health_threshold_pct" = 50, "damage_to_chance_pct" = 3, "max_roll_chance_pct" = 45, "neuro_alt_trigger_chance_pct" = null, + "incoming_damage_mult" = 2.0, "incoming_damage_type" = "all"), + ) + +/datum/hardpoint_wound_family/treads_acid_fouled_pins + part_slot = HDPT_TREADS + damage_type = WOUND_DAMTYPE_ACID + tiers = list( + list("wound_name" = "Gritty Pins", "marine_feedback_red" = "The track pins feel gritty and dry, etched by acid grit.", + "xeno_feedback" = "Acid grit grinds faintly between the treads with every turn.", "bold_feedback" = FALSE, + "repair_method" = "Rag Or Welder", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list("welder"), "repair_step_verbs" = list("welding the grit away"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 75, "neuro_alt_trigger_chance_pct" = null, + "performance_mult" = 0.9), + list("wound_name" = "Acid-Fouled Pins", "marine_feedback_red" = "The track pins sit crusted in hardened acid buildup and seizing up.", + "xeno_feedback" = "The treads visibly grind and hitch, acid-fouled with every rotation.", "bold_feedback" = TRUE, + "repair_method" = "Unmount, Wrench, Welder.", "repair_skill" = "Any Marine", "unmount_required" = TRUE, + "repair_steps" = list(TRAIT_TOOL_WRENCH, "welder"), "repair_step_verbs" = list("wrenching the pins loose", "welding them clean"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 55, "damage_to_chance_pct" = 3, "max_roll_chance_pct" = 45, "neuro_alt_trigger_chance_pct" = null, + "performance_mult" = 0.6), + ) + +/// Neurotoxin's parallel to treads_acid_fouled_pins. Half as severe, rolls about twice as often. +/datum/hardpoint_wound_family/treads_neuro_gunked_pins + part_slot = HDPT_TREADS + damage_type = WOUND_DAMTYPE_ACID + neuro_alt_trigger_only = TRUE + tiers = list( + list("wound_name" = "Tacky Pins", "marine_feedback_red" = "The track pins feel tacky, coated in a thin film of neurotoxin residue.", + "xeno_feedback" = "Neurotoxin residue clings faintly between the treads with every turn.", "bold_feedback" = FALSE, + "repair_method" = "Rag Or Welder", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list("welder"), "repair_step_verbs" = list("welding the residue away"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 75, "neuro_alt_trigger_chance_pct" = 30, + "performance_mult" = 0.95), + list("wound_name" = "Neuro-Gunked Pins", "marine_feedback_red" = "The track pins sit webbed in hardened, sticky neurotoxin residue and seizing up.", + "xeno_feedback" = "The treads visibly grind and hitch, neuro-gunked with every rotation.", "bold_feedback" = TRUE, + "repair_method" = "Unmount, Wrench, Welder.", "repair_skill" = "Any Marine", "unmount_required" = TRUE, + "repair_steps" = list(TRAIT_TOOL_WRENCH, "welder"), "repair_step_verbs" = list("wrenching the pins loose", "welding them clean"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 55, "damage_to_chance_pct" = 3, "max_roll_chance_pct" = 45, "neuro_alt_trigger_chance_pct" = 16, + "performance_mult" = 0.8), + ) + +/datum/hardpoint_wound_family/treads_jammed_sprocket + part_slot = HDPT_TREADS + damage_type = WOUND_DAMTYPE_BRUTE + tiers = list( + list("wound_name" = "Jammed Sprocket", "marine_feedback_red" = "The drive sprocket jams and grinds with every rotation.", + "xeno_feedback" = "Our strikes have left the tank lurching, its stride broken.", "bold_feedback" = FALSE, + "repair_method" = "Free the sprocket with a wrench", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list(TRAIT_TOOL_WRENCH), "repair_step_verbs" = list("wrenching the sprocket free"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 75, "neuro_alt_trigger_chance_pct" = null, + "performance_mult" = 0.85), + list("wound_name" = "Thrown Track", "marine_feedback_red" = "The track has slipped thrown partway off its guide wheels.", + "xeno_feedback" = "One of its treads hangs almost completely thrown loose!", "bold_feedback" = TRUE, + "repair_method" = "Unmount, Crowbar, Wrench", "repair_skill" = "Any Marine", "unmount_required" = TRUE, + "repair_steps" = list(TRAIT_TOOL_CROWBAR, TRAIT_TOOL_WRENCH), "repair_step_verbs" = list("prying the track back into its guide", "wrenching it taut"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 45, "damage_to_chance_pct" = 3, "max_roll_chance_pct" = 40, "neuro_alt_trigger_chance_pct" = null, + "performance_mult" = 0.5), + ) + +/datum/hardpoint_wound_family/engine_fouled_injector + part_slot = HDPT_ENGINE + damage_type = WOUND_DAMTYPE_ACID + tiers = list( + list("wound_name" = "Fouled Carburator", "marine_feedback_red" = "Acid fouling in the injector makes the engine sputter unevenly under load.", + "xeno_feedback" = "The engine's note stutters, acid-fouled from within.", "bold_feedback" = FALSE, + "repair_method" = "Crowbar, Wrench, Wrench", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list(TRAIT_TOOL_CROWBAR, TRAIT_TOOL_WRENCH), "repair_step_verbs" = list("prying the injector loose", "wrenching it back into place"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 75, "neuro_alt_trigger_chance_pct" = null, + "performance_mult" = 0.85), + list("wound_name" = "Acid Intake", "marine_feedback_red" = "The engine bogs down and runs hot, its intake choked with acid residue.", + "xeno_feedback" = "Thick exhaust smoke pours out under acceleration. Our acid has corroded this metal beast from the inside.", "bold_feedback" = TRUE, + "repair_method" = "Engine must be removed. Screwdriver, Crowbar, Wrench, Crowbar, Welding Tool, Wrench, Screwdriver", "repair_skill" = "Maintenance Tech or Chief Engineer", "unmount_required" = TRUE, + "repair_steps" = list(TRAIT_TOOL_SCREWDRIVER, TRAIT_TOOL_CROWBAR, TRAIT_TOOL_WRENCH, TRAIT_TOOL_CROWBAR, "welder", TRAIT_TOOL_WRENCH, TRAIT_TOOL_SCREWDRIVER), + "repair_step_verbs" = list("unscrewing the housing", "prying the injector free", "wrenching the manifold loose", "prying the seal away", "welding the injector body", "wrenching it back into place", "screwing the housing shut"), + "repair_reverts_to_tier" = null, "repair_required_skill" = SKILL_ENGINEER_MASTER, + "min_health_threshold_pct" = 55, "damage_to_chance_pct" = 3, "max_roll_chance_pct" = 45, "neuro_alt_trigger_chance_pct" = null, + "performance_mult" = 0.6), + ) + +/datum/hardpoint_wound_family/engine_cracked_block + part_slot = HDPT_ENGINE + damage_type = WOUND_DAMTYPE_BRUTE + tiers = list( + // Overheat trigger lives separately in check_overheat_wound_trigger(). + list("wound_name" = "Blown Gasket", "marine_feedback_red" = "A blown gasket leaves milky sludge on the dipstick and white smoke pouring from the exhaust.", + "xeno_feedback" = "It exhales white smoke through its exhaust, wounded and moving with lethargy.", "bold_feedback" = FALSE, + "repair_method" = "Crowbar, Wrench, Welder, Wrench", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list(TRAIT_TOOL_CROWBAR, TRAIT_TOOL_WRENCH, "welder", TRAIT_TOOL_WRENCH), "repair_step_verbs" = list("prying the block open", "wrenching it steady", "welding the crack shut", "wrenching it back together"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 75, "neuro_alt_trigger_chance_pct" = null, + "performance_mult" = 0.85), + list("wound_name" = "Cracked Block", "marine_feedback_red" = "A hairline crack spreads clean through the block casing.", + "xeno_feedback" = "Fluid pools visibly beneath the engine compartment, cracked open. This metal beast, too, can bleed.", "bold_feedback" = TRUE, + "repair_method" = "Engine must be removed. Screwdriver, Crowbar, Wrench, Crowbar, Welding Tool, Welding Tool, Wrench, Screwdriver", "repair_skill" = "Maintenance Tech or Chief Engineer", "unmount_required" = TRUE, + "repair_steps" = list(TRAIT_TOOL_SCREWDRIVER, TRAIT_TOOL_CROWBAR, TRAIT_TOOL_WRENCH, TRAIT_TOOL_CROWBAR, "welder", TRAIT_TOOL_WRENCH, TRAIT_TOOL_SCREWDRIVER), + "repair_step_verbs" = list("unscrewing the housing", "prying the block open", "wrenching it steady", "prying the crack wider to clean it", "welding the crack shut", "wrenching it back together", "screwing the housing shut"), + "repair_reverts_to_tier" = null, "repair_required_skill" = SKILL_ENGINEER_MASTER, + "min_health_threshold_pct" = 45, "damage_to_chance_pct" = 3, "max_roll_chance_pct" = 40, "neuro_alt_trigger_chance_pct" = null, + "performance_mult" = 0.55), + ) + +/datum/hardpoint_wound_family/fuel_tank_leak + part_slot = HDPT_FUEL_TANK + damage_type = WOUND_DAMTYPE_UNIFIED + tiers = list( + // Responds to both acid and brute damage cumulatively, so flavor text stays purely mechanical. + list("wound_name" = "Slow Leak", "marine_feedback_red" = "Fuel visibly weeps from a seam torn in the tank.", + "xeno_feedback" = "A faint fuel smell seeps from a fresh wound in the tank's hull.", "bold_feedback" = FALSE, + "repair_method" = "Crowbar, Weld", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list(TRAIT_TOOL_CROWBAR, "welder"), "repair_step_verbs" = list("prying the hole closed", "welding the crack"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 75, "neuro_alt_trigger_chance_pct" = null, + "passive_leak_rate" = 0.5), + list("wound_name" = "Cracked Manifold", "marine_feedback_red" = "The fuel manifold has split open, weeping fuel in a steady stream.", + "xeno_feedback" = "A steady stream of fuel pools beneath the tank, bleeding out from within.", "bold_feedback" = TRUE, + "repair_method" = "Crowbar, Wrench, Weld", "repair_skill" = "Any Marine", "unmount_required" = TRUE, + "repair_steps" = list(TRAIT_TOOL_CROWBAR, TRAIT_TOOL_WRENCH, "welder"), "repair_step_verbs" = list("prying the manifold open", "wrenching the fitting tight", "welding the crack shut"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 40, "damage_to_chance_pct" = 3, "max_roll_chance_pct" = 40, "neuro_alt_trigger_chance_pct" = null, + "passive_leak_rate" = 3), + ) + +/datum/hardpoint_wound_family/radiator_clogged_fins + part_slot = HDPT_RADIATOR + damage_type = WOUND_DAMTYPE_ACID + tiers = list( + list("wound_name" = "Dusty Fins", "marine_feedback_red" = "A light layer of acid grime coats the radiator fins.", + "xeno_feedback" = "A faint film of acid residue dusts its metal gills.", "bold_feedback" = FALSE, + "repair_method" = "Rag Or Welder", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list("welder"), "repair_step_verbs" = list("welding the grime off"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 75, "neuro_alt_trigger_chance_pct" = null), + list("wound_name" = "Clogged Fins", "marine_feedback_red" = "The radiator fins sit caked solid with acid grime.", + "xeno_feedback" = "Its metal gills are caked with acid residue. It will soon suffocate.", "bold_feedback" = TRUE, + "repair_method" = "Crowbar, Welder", "repair_skill" = "Any Marine", "unmount_required" = TRUE, + "repair_steps" = list(TRAIT_TOOL_CROWBAR, "welder"), "repair_step_verbs" = list("prying the fins clear", "welding the residue away"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 55, "damage_to_chance_pct" = 3, "max_roll_chance_pct" = 45, "neuro_alt_trigger_chance_pct" = null), + ) + +/datum/hardpoint_wound_family/radiator_cracked_core + part_slot = HDPT_RADIATOR + damage_type = WOUND_DAMTYPE_BRUTE + tiers = list( + list("wound_name" = "Hairline Crack", "marine_feedback_red" = "A hairline crack runs across the radiator core.", + "xeno_feedback" = "A faint crack hisses softly where we struck its gills.", "bold_feedback" = FALSE, + "repair_method" = "Welder and Wrench", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list("welder", TRAIT_TOOL_WRENCH), "repair_step_verbs" = list("welding the crack shut", "wrenching the housing tight"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 75, "neuro_alt_trigger_chance_pct" = null, + "passive_leak_rate" = 0.3), + list("wound_name" = "Cracked Core", "marine_feedback_red" = "The radiator core sits visibly cracked, weeping coolant.", + "xeno_feedback" = "A hiss of steam escapes its cracked vents. It cooks itself from the inside.", "bold_feedback" = TRUE, + "repair_method" = "Welder, Wrench, Welder", "repair_skill" = "Any Marine", "unmount_required" = TRUE, + "repair_steps" = list("welder", TRAIT_TOOL_WRENCH, "welder"), "repair_step_verbs" = list("welding the crack shut", "wrenching the housing tight", "welding the seam smooth"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 45, "damage_to_chance_pct" = 3, "max_roll_chance_pct" = 40, "neuro_alt_trigger_chance_pct" = null, + "passive_leak_rate" = 1.8), + ) + +/datum/hardpoint_wound_family/battery_corroded_terminals + part_slot = HDPT_BATTERY + damage_type = WOUND_DAMTYPE_ACID + tiers = list( + list("wound_name" = "Corroded Terminals", "marine_feedback_red" = "The battery terminals sit crusted with corrosion.", + "xeno_feedback" = "A faint chemical tang seeps from somewhere deep in its guts, acid-eaten from within.", "bold_feedback" = FALSE, + "repair_method" = "Welder to burn the film", "repair_skill" = "Any Marine", "unmount_required" = TRUE, + "repair_steps" = list("welder"), "repair_step_verbs" = list("welding the corrosion off"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 80, "damage_to_chance_pct" = 3, "max_roll_chance_pct" = 45, "neuro_alt_trigger_chance_pct" = null), + ) + +/datum/hardpoint_wound_family/battery_cracked_cell + part_slot = HDPT_BATTERY + damage_type = WOUND_DAMTYPE_BRUTE + tiers = list( + list("wound_name" = "Cracked Casing", "marine_feedback_red" = "The battery casing sits cracked open, its internals bared.", + "xeno_feedback" = "A dull rattle answers our blows - something cracked loose deep inside it.", "bold_feedback" = FALSE, + "repair_method" = "Welder to fix the casing", "repair_skill" = "Any Marine", "unmount_required" = TRUE, + "repair_steps" = list("welder"), "repair_step_verbs" = list("welding the casing shut"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 65, "damage_to_chance_pct" = 3, "max_roll_chance_pct" = 40, "neuro_alt_trigger_chance_pct" = null), + ) + +/datum/hardpoint_wound_family/hatch_sticky_latch + part_slot = HDPT_HATCH + damage_type = WOUND_DAMTYPE_ACID + tiers = list( + list("wound_name" = "Sticky Latch", "marine_feedback_red" = "The hatch latch sticks, gummed with acid residue, and needs a hard shove to move.", + "xeno_feedback" = "Somewhere beneath its shell, acid has gummed a seam shut.", "bold_feedback" = FALSE, + "repair_method" = "Welder to burn the sticky film", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list("welder"), "repair_step_verbs" = list("welding the sticky film off"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 90, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 60, "neuro_alt_trigger_chance_pct" = null), + ) + +/datum/hardpoint_wound_family/hatch_bent_frame + part_slot = HDPT_HATCH + damage_type = WOUND_DAMTYPE_BRUTE + tiers = list( + list("wound_name" = "Bent Frame", "marine_feedback_red" = "The hatch frame sits bent, no longer flush.", + "xeno_feedback" = "A dull clunk answers our blows - the hatch frame has bent out of true beneath its shell.", "bold_feedback" = FALSE, + "repair_method" = "Weld it, then hammer it with a wrench", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list("welder", TRAIT_TOOL_WRENCH), "repair_step_verbs" = list("welding it straight", "wrenching it back into shape"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 90, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 70, "neuro_alt_trigger_chance_pct" = null), + ) + +/datum/hardpoint_wound_family/iff_static_interference + part_slot = HDPT_IFF_MODULE + damage_type = WOUND_DAMTYPE_ACID + tiers = list( + list("wound_name" = "Molten Antenna", "marine_feedback_red" = "The IFF antennae have melted down to useless slag.", + "xeno_feedback" = "A thin wisp of smoke curls from a melted antenna stub, hidden beneath its shell.", "bold_feedback" = FALSE, + "repair_method" = "Welding tool", "repair_skill" = "Any Marine", "unmount_required" = TRUE, + "repair_steps" = list("welder"), "repair_step_verbs" = list("welding the antenna back into shape"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 55, "neuro_alt_trigger_chance_pct" = null, + "iff_disabled" = TRUE), + ) + +/datum/hardpoint_wound_family/iff_corrupted_transponder + part_slot = HDPT_IFF_MODULE + damage_type = WOUND_DAMTYPE_BRUTE + tiers = list( + list("wound_name" = "Cracked Transponder", "marine_feedback_red" = "The transponder's cracked casing reports garbled, inconsistent IDs.", + "xeno_feedback" = "A faint electronic whine leaks from a cracked housing, hidden beneath its shell.", "bold_feedback" = FALSE, + "repair_method" = "Screwdriver, wirecutter, welder", "repair_skill" = "Any Marine", "unmount_required" = TRUE, + "repair_steps" = list(TRAIT_TOOL_SCREWDRIVER, TRAIT_TOOL_WIRECUTTERS, "welder"), "repair_step_verbs" = list("unscrewing the housing", "cutting away the frayed wiring", "welding the transponder casing shut"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 55, "neuro_alt_trigger_chance_pct" = null, + "iff_disabled" = TRUE), + ) + +/datum/hardpoint_wound_family/visual_sensors_obscured + part_slot = HDPT_VISUAL_SENSORS + damage_type = WOUND_DAMTYPE_ACID + tiers = list( + list("wound_name" = "Light Smear", "marine_feedback_red" = "A light smear of acid residue streaks the external camera lens.", + "xeno_feedback" = "The metal beast struggles to aim up far. Acid covers its eyes.", "bold_feedback" = FALSE, + "repair_method" = "Quick wipe with a rag or welding tool", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list("welder"), "repair_step_verbs" = list("welding the smear off"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 8, "max_roll_chance_pct" = 90, "neuro_alt_trigger_chance_pct" = null, + "vision_impair_add" = 1), + list("wound_name" = "Heavy Smear", "marine_feedback_red" = "The external camera lens sits smeared thick with acid residue, hard to see through.", + "xeno_feedback" = "Gunks of acid slag clog up the metal turtle's eyes.", "bold_feedback" = FALSE, + "repair_method" = "Longer wipe with a rag or welding tool.", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list("welder"), "repair_step_verbs" = list("welding the smear off thoroughly"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 90, "damage_to_chance_pct" = 6, "max_roll_chance_pct" = 70, "neuro_alt_trigger_chance_pct" = null, + "vision_impair_add" = 2), + list("wound_name" = "Fully Obscured", "marine_feedback_red" = "The lens sits caked thick with acid slag. It obstructs your vision almost entirely.", + "xeno_feedback" = "A buildup of acid slag completely covers its lenses. It is effectively blind!", "bold_feedback" = TRUE, + "repair_method" = "Unmount. Remove caked chunks with a crowbar. Reverts to stage 2 where it can be cleaned with a rag/welding tool", "repair_skill" = "Any Marine", "unmount_required" = TRUE, + "repair_steps" = list(TRAIT_TOOL_CROWBAR), "repair_step_verbs" = list("prying the caked chunks free"), "repair_reverts_to_tier" = 2, + "min_health_threshold_pct" = 85, "damage_to_chance_pct" = 4, "max_roll_chance_pct" = 50, "neuro_alt_trigger_chance_pct" = null, + "vision_impair_add" = 4), + ) + +/// Neurotoxin's parallel to visual_sensors_obscured. Both can be active at once. +/datum/hardpoint_wound_family/visual_sensors_gunked + part_slot = HDPT_VISUAL_SENSORS + damage_type = WOUND_DAMTYPE_ACID + neuro_alt_trigger_only = TRUE + tiers = list( + list("wound_name" = "Light Gunk", "marine_feedback_red" = "A tacky film of neurotoxin residue streaks the external camera lens.", + "xeno_feedback" = "The metal beast struggles to aim up far. Sticky neurotoxin clings to its eyes.", "bold_feedback" = FALSE, + "repair_method" = "Quick wipe with a rag or welding tool", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list("welder"), "repair_step_verbs" = list("welding the gunk off"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 8, "max_roll_chance_pct" = 90, "neuro_alt_trigger_chance_pct" = 40, + "vision_impair_add" = 1), + list("wound_name" = "Heavy Gunk", "marine_feedback_red" = "The external camera lens sits thickly gummed with neurotoxin residue, hard to see through.", + "xeno_feedback" = "Globs of sticky neurotoxin residue clog up the metal turtle's eyes.", "bold_feedback" = FALSE, + "repair_method" = "Longer wipe with a rag or welding tool.", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list("welder"), "repair_step_verbs" = list("welding the gunk off thoroughly"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 90, "damage_to_chance_pct" = 6, "max_roll_chance_pct" = 70, "neuro_alt_trigger_chance_pct" = 24, + "vision_impair_add" = 2), + list("wound_name" = "Fully Gunked", "marine_feedback_red" = "The lens sits sealed thick with hardened neurotoxin residue. You can barely make out shapes through it.", + "xeno_feedback" = "A web of hardened neurotoxin residue completely seals its lenses shut. It is effectively blind!", "bold_feedback" = TRUE, + "repair_method" = "Unmount. Remove caked chunks with a crowbar. Reverts to stage 2 where it can be cleaned with a rag/welding tool", "repair_skill" = "Any Marine", "unmount_required" = TRUE, + "repair_steps" = list(TRAIT_TOOL_CROWBAR), "repair_step_verbs" = list("prying the caked chunks free"), "repair_reverts_to_tier" = 2, + "min_health_threshold_pct" = 85, "damage_to_chance_pct" = 4, "max_roll_chance_pct" = 50, "neuro_alt_trigger_chance_pct" = 12, + "vision_impair_add" = 4), + ) + +/datum/hardpoint_wound_family/visual_sensors_cracked_lens + part_slot = HDPT_VISUAL_SENSORS + damage_type = WOUND_DAMTYPE_BRUTE + tiers = list( + list("wound_name" = "Scratched Lens", "marine_feedback_red" = "Small scratches runs across the surface of the lenses.", + "xeno_feedback" = "Its eyes have gained scars from our repeated strikes.", "bold_feedback" = FALSE, + "repair_method" = "Welder", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list("welder"), "repair_step_verbs" = list("welding the crack shut"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 90, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 75, "neuro_alt_trigger_chance_pct" = null, + "vision_impair_add" = 1), + list("wound_name" = "Cracked Lens", "marine_feedback_red" = "The camera lensses have been cracked, and will require a replacement.", + "xeno_feedback" = "Its camera lenses lie shattered. It struggles to see!", "bold_feedback" = TRUE, + "repair_method" = "Replace the lenses with 5 units of glass.", "repair_skill" = "Any Marine", "unmount_required" = TRUE, + "repair_steps" = list(list("material", /obj/item/stack/sheet/glass, 5)), "repair_step_verbs" = list("fitting the new glass lenses"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 55, "damage_to_chance_pct" = 3, "max_roll_chance_pct" = 45, "neuro_alt_trigger_chance_pct" = null, + "vision_impair_add" = 2), + ) + +/datum/hardpoint_wound_family/air_filter_clogged + part_slot = HDPT_AIR_FILTER + damage_type = WOUND_DAMTYPE_ACID + tiers = list( + list("wound_name" = "Dusty Filter", "marine_feedback_red" = "A light layer of acid dust coats the air filter cartridge.", + "xeno_feedback" = "Acid globs are drawn in through the filter with every breath it takes.", "bold_feedback" = FALSE, + "repair_method" = "Unmount and dust off the filter by using it in hand", "repair_skill" = "Any Marine", "unmount_required" = TRUE, + "repair_steps" = list("welder"), "repair_step_verbs" = list("dusting off the filter"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 75, "neuro_alt_trigger_chance_pct" = null), + list("wound_name" = "Clogged Filter", "marine_feedback_red" = "The air filter cartridge sits visibly caked with acid particulate.", + "xeno_feedback" = "Acid pours freely in through the clogged, ruined air filter!", "bold_feedback" = TRUE, + "repair_method" = "Unmount and apply welding tool to the filter to burn the residue off", "repair_skill" = "Any Marine", "unmount_required" = TRUE, + "repair_steps" = list("welder"), "repair_step_verbs" = list("welding the residue away"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 55, "damage_to_chance_pct" = 3, "max_roll_chance_pct" = 45, "neuro_alt_trigger_chance_pct" = null), + ) + +/// Neurotoxin's parallel to air_filter_clogged. No severity to halve, only trigger chance doubled. +/datum/hardpoint_wound_family/air_filter_gunked + part_slot = HDPT_AIR_FILTER + damage_type = WOUND_DAMTYPE_ACID + neuro_alt_trigger_only = TRUE + tiers = list( + list("wound_name" = "Tacky Filter", "marine_feedback_red" = "A light, tacky film of neurotoxin residue coats the air filter cartridge.", + "xeno_feedback" = "Sticky neurotoxin globs are drawn in through the filter with every breath it takes.", "bold_feedback" = FALSE, + "repair_method" = "Unmount and dust off the filter by using it in hand", "repair_skill" = "Any Marine", "unmount_required" = TRUE, + "repair_steps" = list("welder"), "repair_step_verbs" = list("dusting off the filter"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 75, "neuro_alt_trigger_chance_pct" = 30), + list("wound_name" = "Gunked Filter", "marine_feedback_red" = "The air filter cartridge sits visibly webbed with hardened neurotoxin residue.", + "xeno_feedback" = "Thick, sticky neurotoxin residue pours in through the gunked-up air filter!", "bold_feedback" = TRUE, + "repair_method" = "Unmount and apply welding tool to the filter to burn the residue off", "repair_skill" = "Any Marine", "unmount_required" = TRUE, + "repair_steps" = list("welder"), "repair_step_verbs" = list("welding the residue away"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 55, "damage_to_chance_pct" = 3, "max_roll_chance_pct" = 45, "neuro_alt_trigger_chance_pct" = 16), + ) + +/datum/hardpoint_wound_family/air_filter_cracked_housing + part_slot = HDPT_AIR_FILTER + damage_type = WOUND_DAMTYPE_BRUTE + tiers = list( + list("wound_name" = "Hairline Crack", "marine_feedback_red" = "A hairline crack runs along the filter housing seam.", + "xeno_feedback" = "A cracked seam lets acid and neurotoxin globs alike seep into the fighting compartment.", "bold_feedback" = FALSE, + "repair_method" = "Unmount and apply wrench to the filter.", "repair_skill" = "Any Marine", "unmount_required" = TRUE, + "repair_steps" = list(TRAIT_TOOL_WRENCH), "repair_step_verbs" = list("wrenching the housing back together"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 75, "neuro_alt_trigger_chance_pct" = null), + list("wound_name" = "Cracked Housing", "marine_feedback_red" = "The filter housing sits cracked wide open at the seam.", + "xeno_feedback" = "The air filter hangs shattered. Acid and neurotoxic smoke pour freely in through the gaps!", "bold_feedback" = TRUE, + "repair_method" = "Unmount, weld, wrench.", "repair_skill" = "Any Marine", "unmount_required" = TRUE, + "repair_steps" = list("welder", TRAIT_TOOL_WRENCH), "repair_step_verbs" = list("welding the crack shut", "wrenching the housing tight"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 40, "damage_to_chance_pct" = 3, "max_roll_chance_pct" = 40, "neuro_alt_trigger_chance_pct" = null), + ) + +/** + * Shared by every Support-slot module. A single tier that just sets module_disabled = TRUE, which + * is_functional() reads to gate whether the module's own effect is currently active. + */ +/datum/hardpoint_wound_family/support_module_disabled + part_slot = HDPT_SUPPORT + damage_type = WOUND_DAMTYPE_UNIFIED + tiers = list( + list("wound_name" = "Malfunctioning", "marine_feedback_red" = "Something inside the module sparks and seizes - it's gone completely dead.", + "xeno_feedback" = "A sharp crackle bursts from the small metal box. It has gone dark and still.", "bold_feedback" = TRUE, + "repair_method" = "Wrench the housing back into place, then weld the internals shut.", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list(TRAIT_TOOL_WRENCH, "welder"), "repair_step_verbs" = list("wrenching the housing back into place", "welding the internals shut"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 80, "damage_to_chance_pct" = 5, "max_roll_chance_pct" = 70, "neuro_alt_trigger_chance_pct" = null, + "module_disabled" = TRUE), + ) + +/** + * Physical tearing of the mounted UA flag's fabric. Keyed under HDPT_UA_FLAG, not + * HDPT_SECONDARY, so its wounds reference the banner itself. + * + * aura_reduction_add scales with tier and stacks additively with other active wounds. + */ +/datum/hardpoint_wound_family/flag_ripped_fabric + part_slot = HDPT_UA_FLAG + damage_type = WOUND_DAMTYPE_BRUTE + tiers = list( + list("wound_name" = "Torn Seam", "marine_feedback_red" = "A seam along the flag's edge has torn loose, fluttering free.", + "xeno_feedback" = "The banner hangs torn at its edge, snapping weakly.", "bold_feedback" = FALSE, + "repair_method" = "Wirecutters - snip the frayed threads and stitch the tear shut", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list(TRAIT_TOOL_WIRECUTTERS), "repair_step_verbs" = list("cutting away the frayed threads and stitching the tear shut"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 8, "max_roll_chance_pct" = 90, "neuro_alt_trigger_chance_pct" = null, + "aura_reduction_add" = 1), + list("wound_name" = "Ragged Tear", "marine_feedback_red" = "A ragged tear runs halfway across the flag's face.", + "xeno_feedback" = "The banner flaps in torn ribbons, half-shredded.", "bold_feedback" = FALSE, + "repair_method" = "Wirecutters - snip the frayed threads and stitch the tear shut", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list(TRAIT_TOOL_WIRECUTTERS), "repair_step_verbs" = list("cutting away the frayed threads and stitching the tear shut"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 90, "damage_to_chance_pct" = 6, "max_roll_chance_pct" = 70, "neuro_alt_trigger_chance_pct" = null, + "aura_reduction_add" = 2), + list("wound_name" = "Shredded Banner", "marine_feedback_red" = "The flag hangs in shredded tatters, barely recognizable.", + "xeno_feedback" = "Only shredded scraps of the banner remain, snapping uselessly.", "bold_feedback" = TRUE, + "repair_method" = "Wirecutters - snip the frayed threads and stitch the tear shut", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list(TRAIT_TOOL_WIRECUTTERS), "repair_step_verbs" = list("cutting away the frayed threads and stitching the tear shut"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 85, "damage_to_chance_pct" = 4, "max_roll_chance_pct" = 50, "neuro_alt_trigger_chance_pct" = null, + "aura_reduction_add" = 3), + ) + +/// Chemical/acid damage to the flag's fabric. Mirrors flag_ripped_fabric, just Acid-typed. +/datum/hardpoint_wound_family/flag_melted_fabric + part_slot = HDPT_UA_FLAG + damage_type = WOUND_DAMTYPE_ACID + tiers = list( + list("wound_name" = "Scorched Threads", "marine_feedback_red" = "A patch of acid has scorched and stiffened the fabric.", + "xeno_feedback" = "Acid has scorched a stiff, blackened patch into the banner.", "bold_feedback" = FALSE, + "repair_method" = "Wirecutters - snip away the scorched fabric and patch it over", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list(TRAIT_TOOL_WIRECUTTERS), "repair_step_verbs" = list("cutting away the scorched fabric and patching it over"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 8, "max_roll_chance_pct" = 90, "neuro_alt_trigger_chance_pct" = null, + "aura_reduction_add" = 1), + list("wound_name" = "Melted Patch", "marine_feedback_red" = "Acid has melted clean through a section of the flag.", + "xeno_feedback" = "A section of the banner has melted away into a dripping, ragged hole.", "bold_feedback" = FALSE, + "repair_method" = "Wirecutters - snip away the scorched fabric and patch it over", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list(TRAIT_TOOL_WIRECUTTERS), "repair_step_verbs" = list("cutting away the scorched fabric and patching it over"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 90, "damage_to_chance_pct" = 6, "max_roll_chance_pct" = 70, "neuro_alt_trigger_chance_pct" = null, + "aura_reduction_add" = 2), + list("wound_name" = "Dissolving Fabric", "marine_feedback_red" = "The flag's acid-eaten fabric is actively dissolving, more hole than banner.", + "xeno_feedback" = "Acid has all but dissolved the banner into dripping scraps.", "bold_feedback" = TRUE, + "repair_method" = "Wirecutters - snip away the scorched fabric and patch it over", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list(TRAIT_TOOL_WIRECUTTERS), "repair_step_verbs" = list("cutting away the scorched fabric and patching it over"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 85, "damage_to_chance_pct" = 4, "max_roll_chance_pct" = 50, "neuro_alt_trigger_chance_pct" = null, + "aura_reduction_add" = 3), + ) + +/// Neurotoxin's parallel to flag_melted_fabric. Both can be active at once. +/datum/hardpoint_wound_family/flag_neurotoxin_stained + part_slot = HDPT_UA_FLAG + damage_type = WOUND_DAMTYPE_ACID + tiers = list( + list("wound_name" = "Faint Stain", "marine_feedback_red" = "A faint neurotoxin stain discolors part of the flag.", + "xeno_feedback" = "A faint neurotoxin residue clings to the banner's fabric.", "bold_feedback" = FALSE, + "repair_method" = "Wirecutters - snip away the stained fabric and patch it over", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list(TRAIT_TOOL_WIRECUTTERS), "repair_step_verbs" = list("cutting away the stained fabric and patching it over"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 95, "damage_to_chance_pct" = 8, "max_roll_chance_pct" = 90, "neuro_alt_trigger_chance_pct" = 40, + "aura_reduction_add" = 1), + list("wound_name" = "Spreading Stain", "marine_feedback_red" = "A spreading neurotoxin stain has soaked deep into the fabric.", + "xeno_feedback" = "Neurotoxin residue has soaked deep into the banner, spreading further.", "bold_feedback" = FALSE, + "repair_method" = "Wirecutters - snip away the stained fabric and patch it over", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list(TRAIT_TOOL_WIRECUTTERS), "repair_step_verbs" = list("cutting away the stained fabric and patching it over"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 90, "damage_to_chance_pct" = 6, "max_roll_chance_pct" = 70, "neuro_alt_trigger_chance_pct" = 24, + "aura_reduction_add" = 2), + list("wound_name" = "Saturated Fabric", "marine_feedback_red" = "The flag's fabric is fully neurotoxin-saturated, stiff and reeking.", + "xeno_feedback" = "Neurotoxin has fully saturated the banner's fabric, stiff and foul.", "bold_feedback" = TRUE, + "repair_method" = "Wirecutters - snip away the stained fabric and patch it over", "repair_skill" = "Any Marine", "unmount_required" = FALSE, + "repair_steps" = list(TRAIT_TOOL_WIRECUTTERS), "repair_step_verbs" = list("cutting away the stained fabric and patching it over"), "repair_reverts_to_tier" = null, + "min_health_threshold_pct" = 85, "damage_to_chance_pct" = 4, "max_roll_chance_pct" = 50, "neuro_alt_trigger_chance_pct" = 12, + "aura_reduction_add" = 3), + ) + +/// part_slot -> list of every wound family instance for that slot. +GLOBAL_LIST_INIT(hardpoint_wound_families_by_slot, build_hardpoint_wound_family_index()) + +/// Family type path -> the singleton instance, for O(1) reverse lookup. +GLOBAL_LIST_INIT(hardpoint_wound_families_by_type, build_hardpoint_wound_family_type_index()) + +/// Instantiates every wound family subtype once and groups the instances by part_slot. +/proc/build_hardpoint_wound_family_index() + . = list() + for(var/family_type in subtypesof(/datum/hardpoint_wound_family)) + var/datum/hardpoint_wound_family/family = new family_type() + var/list/slot_families = LAZYACCESS(., family.part_slot) + if(!slot_families) + slot_families = list() + .[family.part_slot] = slot_families + slot_families += family + +/// Flattens GLOB.hardpoint_wound_families_by_slot into a type path -> instance map. +/proc/build_hardpoint_wound_family_type_index() + . = list() + for(var/part_slot in GLOB.hardpoint_wound_families_by_slot) + for(var/datum/hardpoint_wound_family/family in GLOB.hardpoint_wound_families_by_slot[part_slot]) + .[family.type] = family diff --git a/code/modules/vehicles/hardpoints/hardpoint_wounds.dm b/code/modules/vehicles/hardpoints/hardpoint_wounds.dm new file mode 100644 index 000000000000..ca6fca99415c --- /dev/null +++ b/code/modules/vehicles/hardpoints/hardpoint_wounds.dm @@ -0,0 +1,1393 @@ +/** + * Maps a vehicle-level damage-type literal down to the coarser Acid/Brute wound split. + * + * Arguments: + * * type = One of "acid"/"slash"/"bullet"/"explosive"/"blunt"/"abstract". + * + * Returns: + * * WOUND_DAMTYPE_ACID for "acid", WOUND_DAMTYPE_BRUTE for other kinetic types, or null. + */ +/proc/wound_damage_type_for(type) + switch(type) + if("acid") + return WOUND_DAMTYPE_ACID + if("slash", "bullet", "explosive", "blunt") + return WOUND_DAMTYPE_BRUTE + return null + +// The screeches ended up receiving negative feedback, so, now you get thuds and impacts +/proc/play_metal_screech(atom/source) + if(prob(1)) + // hell yeah + playsound(source, 'sound/effects/slam_rare_1.ogg', 65, TRUE) + return + playsound(source, pick(list( + 'sound/vehicles/metalimpact1.ogg', + 'sound/vehicles/metalimpact2.ogg', + 'sound/vehicles/metalimpact3.ogg', + 'sound/vehicles/metalimpact4.ogg', + 'sound/vehicles/metalimpact5.ogg', + 'sound/vehicles/metalimpact6.ogg', + )), 65, TRUE) + +/// Plays a random acid-sizzle one-shot at `source`, the audible cue for gaining an Acid-type wound. +/proc/play_acid_sizzle(atom/source) + playsound(source, pick(list( + 'sound/vehicles/acidsizzle1.ogg', + 'sound/vehicles/acidsizzle2.ogg', + 'sound/vehicles/acidsizzle3.ogg', + 'sound/vehicles/acidsizzle4.ogg', + 'sound/vehicles/acidsizzle5.ogg', + 'sound/vehicles/acidsizzle6.ogg', + 'sound/vehicles/acidsizzle7.ogg', + )), 45, TRUE) + +/** + * Audio and visual cue for a part gaining a wound tier. Brute wounds get a metal-screech, + * shrapnel, and sparks. Acid wounds get a sizzle and heavier sparks, no shrapnel. + * + * Arguments: + * * epicenter_atom = Where to center the effects. + * * damage_type = WOUND_DAMTYPE_ACID or WOUND_DAMTYPE_BRUTE. + * * attacker = Whoever dealt the hit, if known. Null bursts shrapnel in every direction. + */ +/proc/play_wound_gain_effects(atom/epicenter_atom, damage_type, atom/attacker) + var/turf/epicenter = get_turf(epicenter_atom) + if(!epicenter) + return + + if(damage_type == WOUND_DAMTYPE_BRUTE) + play_metal_screech(epicenter_atom) + + var/shrapnel_dir + var/turf/attacker_turf = get_turf(attacker) + if(attacker_turf && attacker_turf != epicenter) + shrapnel_dir = get_dir(epicenter, attacker_turf) + var/turf/shrapnel_origin = get_shrapnel_spawn_turf(epicenter_atom, epicenter, shrapnel_dir) + create_shrapnel(shrapnel_origin, 4, shrapnel_dir, 40, /datum/ammo/bullet/shrapnel/tank_wound, null, TRUE, 0) + + var/datum/effect_system/spark_spread/sparks = new + sparks.set_up(3, 0, epicenter) + sparks.start() + + else if(damage_type == WOUND_DAMTYPE_ACID) + play_acid_sizzle(epicenter_atom) + + var/datum/effect_system/spark_spread/sparks = new + sparks.set_up(8, 0, epicenter) + sparks.start() + +/** + * Finds a turf just outside the vehicle's own footprint to spawn wound-gain shrapnel from, so it + * doesn't immediately collide with the vehicle's own dense hitbox. + * + * Arguments: + * * epicenter_atom = The wounded hardpoint, or the vehicle itself for Hull. + * * epicenter = get_turf(epicenter_atom), passed in since the caller already computed it. + * * shrapnel_dir = Direction shrapnel should cone toward, or null for an omnidirectional burst. + */ +/proc/get_shrapnel_spawn_turf(atom/epicenter_atom, turf/epicenter, shrapnel_dir) + var/obj/vehicle/multitile/vehicle + if(istype(epicenter_atom, /obj/vehicle/multitile)) + vehicle = epicenter_atom + else if(istype(epicenter_atom, /obj/item/hardpoint)) + var/obj/item/hardpoint/hardpoint = epicenter_atom + vehicle = hardpoint.owner + + if(!vehicle || length(vehicle.locs) <= 1) + return epicenter + + var/walk_dir = shrapnel_dir || vehicle.dir + var/turf/walking = epicenter + for(var/i in 1 to 5) + if(!(walking in vehicle.locs)) + return walking + var/turf/next_turf = get_step(walking, walk_dir) + if(!next_turf) + break + walking = next_turf + return walking + +/** + * Rolls every candidate wound family for this hardpoint's slot against one hit. + * Advances at most one tier per family per hit. + * + * Arguments: + * * hit_damage = Post-damage_multiplier damage this specific hit dealt. + * * damage_type = WOUND_DAMTYPE_ACID or WOUND_DAMTYPE_BRUTE. + * * attacker = Whoever dealt this hit, if known. Used for the shrapnel cone on gaining a wound. + * * chance_mult = Multiplies the wound-roll chance computed off this hit. 1 (no change) by default. + */ +/// The slot key to look up this hardpoint's own candidate wound families under. +/obj/item/hardpoint/proc/get_wound_family_slot() + return wound_family_slot || slot + +/obj/item/hardpoint/proc/roll_wounds(hit_damage, damage_type, atom/attacker, chance_mult = 1) + var/effective_slot = get_wound_family_slot() + if(!effective_slot || hit_damage <= 0) + return + var/list/candidate_families = GLOB.hardpoint_wound_families_by_slot[effective_slot] + if(!candidate_families) + return + for(var/datum/hardpoint_wound_family/family in candidate_families) + if(family.neuro_alt_trigger_only) + continue + if(family.damage_type != damage_type && family.damage_type != WOUND_DAMTYPE_UNIFIED) + continue + try_advance_wound_family(family, hit_damage, attacker, chance_mult) + +/** + * Gates and rolls one wound family's next tier, advancing if it passes. + * No-ops at max tier, below the health gate, or on a failed roll. + * + * Arguments: + * * family = The wound family to try advancing. + * * hit_damage = Post-multiplier damage from the hit that triggered this roll. + * * attacker = Whoever dealt this hit, if known. + * * chance_mult = Multiplies the roll chance before the tier's own max_roll_chance_pct cap applies. + */ +/obj/item/hardpoint/proc/try_advance_wound_family(datum/hardpoint_wound_family/family, hit_damage, atom/attacker, chance_mult = 1) + var/current_tier = LAZYACCESS(wound_tiers, family.type) || 0 + var/next_tier = current_tier + 1 + if(next_tier > length(family.tiers)) + return + + var/list/tier_data = family.tiers[next_tier] + var/gate_pct = get_wound_gate_health_pct(family.damage_type) + if(gate_pct > tier_data["min_health_threshold_pct"]) + return + + var/chance = min(tier_data["max_roll_chance_pct"], hit_damage * tier_data["damage_to_chance_pct"] * chance_mult) + if(!prob(chance)) + return + + LAZYSET(wound_tiers, family.type, next_tier) + notify_crew_of_wound(tier_data["marine_feedback_red"], tier_data["xeno_feedback"], tier_data["bold_feedback"]) + play_wound_gain_effects(src, family.damage_type, attacker) + recalculate_wound_effects() + +/** + * Alternate trigger for neurotoxin "_gunked" wound families. Advances a tier on its own + * roll, skipping the health-threshold gate. + * + * Arguments: + * * family = The wound family to try advancing. + * * chance_scale = Multiplies the tier's neuro trigger chance before rolling. + */ +/obj/item/hardpoint/proc/try_advance_wound_family_via_neuro(datum/hardpoint_wound_family/family, chance_scale = 1) + var/current_tier = LAZYACCESS(wound_tiers, family.type) || 0 + var/next_tier = current_tier + 1 + if(next_tier > length(family.tiers)) + return + + var/list/tier_data = family.tiers[next_tier] + var/base_chance = tier_data["neuro_alt_trigger_chance_pct"] + if(!base_chance) + return + var/chance = min(TANK_GLOB_NEURO_CHANCE_CAP_PCT, base_chance * chance_scale) + if(!prob(chance)) + return + + LAZYSET(wound_tiers, family.type, next_tier) + notify_crew_of_wound(tier_data["marine_feedback_red"], tier_data["xeno_feedback"], tier_data["bold_feedback"]) + play_wound_gain_effects(src, family.damage_type, null) + recalculate_wound_effects() + +/** + * The health% a wound family's gate is checked against: the matching Acid/Brute + * sub-pool's health%, or combined integrity% for a WOUND_DAMTYPE_UNIFIED family. + * + * Arguments: + * * family_damage_type = The wound family's own damage_type. + * + * Returns: + * * A percentage, 100 (undamaged) down to 0 (destroyed) or below. + */ +/obj/item/hardpoint/proc/get_wound_gate_health_pct(family_damage_type) + if(family_damage_type == WOUND_DAMTYPE_UNIFIED) + return get_integrity_percent() + if(!initial(health)) + return 100 + var/relevant_damage = (family_damage_type == WOUND_DAMTYPE_ACID) ? acid_damage_taken : brute_damage_taken + return 100 - (relevant_damage / initial(health) * 100) + +/// This hardpoint's active wound tier data for one family, or null if unwounded. +/obj/item/hardpoint/proc/get_wound_tier_data(family_type) + var/tier = LAZYACCESS(wound_tiers, family_type) + if(!tier) + return null + var/datum/hardpoint_wound_family/family = GLOB.hardpoint_wound_families_by_type[family_type] + if(!family || tier > length(family.tiers)) + return null + return family.tiers[tier] + +/** + * Combines one named effect field (e.g. "accuracy_mult") across every active wound + * family by multiplying them together. + * + * Arguments: + * * key = The tier-data field to combine (e.g. "accuracy_mult", "scatter_mult"). + */ +/obj/item/hardpoint/proc/get_wound_effect_multiplier(key) + . = 1 + for(var/family_type in wound_tiers) + var/list/tier_data = get_wound_tier_data(family_type) + var/value = tier_data?[key] + if(isnull(value)) + continue + . *= value + +/** + * How much of this hardpoint's normal performance is retained purely from its raw integrity%. Full + * performance above WEAPON_DEGRADE_GRACE_THRESHOLD_PCT, ramping down toward 0 below it. + * + * Returns: + * * 0-1 scale, or 0 if uninstalled. + */ +/obj/item/hardpoint/proc/get_own_health_scale() + if(!owner) + return 0 + return owner.get_health_scale_with_grace(get_integrity_percent(), WEAPON_DEGRADE_GRACE_THRESHOLD_PCT) + +/// Combines wound compounding with raw-health degradation, floored at `floor_value`. +/obj/item/hardpoint/proc/get_combined_weapon_mult(key, floor_value = 0) + return max(get_wound_effect_multiplier(key) * get_own_health_scale(), floor_value) + +/// Whether any active wound family sets a truthy value for a boolean effect field. +/obj/item/hardpoint/proc/get_wound_effect_flag(key) + for(var/family_type in wound_tiers) + var/list/tier_data = get_wound_tier_data(family_type) + if(tier_data?[key]) + return TRUE + return FALSE + +/// Additively combines one named effect field across every active wound family. +/obj/item/hardpoint/proc/get_wound_effect_sum(key) + . = 0 + for(var/family_type in wound_tiers) + var/list/tier_data = get_wound_tier_data(family_type) + var/value = tier_data?[key] + if(!isnull(value)) + . += value + +/** + * How much extra damage this hardpoint should take of a given type right now, from any active + * Turret/Hull/Armor "melted"/"hole"/"plating" wound. Takes the single worst active multiplier + * rather than compounding multiple wound families together. + * + * Arguments: + * * type = WOUND_DAMTYPE_ACID or WOUND_DAMTYPE_BRUTE, the incoming hit's mapped type. + * + * Returns: + * * A multiplier, 1 (no active wound affecting this type) or higher. + */ +/obj/item/hardpoint/proc/get_incoming_damage_wound_multiplier(type) + . = 1 + for(var/family_type in wound_tiers) + var/list/tier_data = get_wound_tier_data(family_type) + var/mult = tier_data?["incoming_damage_mult"] + if(isnull(mult)) + continue + var/datum/hardpoint_wound_family/family = GLOB.hardpoint_wound_families_by_type[family_type] + var/applies_to = tier_data["incoming_damage_type"] || family.damage_type + if(applies_to == "all" || applies_to == type) + . = max(., mult) + +/** + * How much of this hardpoint's normal performance is retained, given its active wound tiers. + * Only used by engine/locomotion hardpoints. Multiplies every active tier's + * "performance_mult" together. + * + * Returns: + * * A multiplier, 1 (no active wound affecting performance) or lower. + */ +/obj/item/hardpoint/proc/get_wound_performance_multiplier() + . = 1 + for(var/family_type in wound_tiers) + var/list/tier_data = get_wound_tier_data(family_type) + var/mult = tier_data?["performance_mult"] + if(isnull(mult)) + continue + . *= mult + +/// List of {"name": wound_name, "tier": current_tier} for every active wound. Empty if none. +/obj/item/hardpoint/proc/get_wound_tgui_data() + var/list/data = list() + for(var/family_type in wound_tiers) + var/list/tier_data = get_wound_tier_data(family_type) + if(tier_data) + data += list(list("name" = tier_data["wound_name"], "tier" = wound_tiers[family_type])) + return data + +/// Whether `tool` satisfies a wound tier's repair_tool_trait. +/proc/hardpoint_tool_matches_trait(obj/item/tool, required_trait) + if(!tool || !required_trait) + return FALSE + if(required_trait == "welder") + return iswelder(tool) + if(required_trait == TRAIT_TOOL_WIRECUTTERS && istype(tool, /obj/item/attachable/bayonet)) + return TRUE + return HAS_TRAIT(tool, required_trait) + +/// Whether `tool` satisfies one repair_steps entry: a trait/tool marker or a material step. +/proc/wound_repair_step_matches_tool(step, obj/item/tool) + if(islist(step)) + var/list/material_step = step + if(length(material_step) < 3 || material_step[1] != "material" || !istype(tool, /obj/item/stack)) + return FALSE + var/obj/item/stack/stack = tool + return istype(stack, material_step[2]) && stack.amount >= material_step[3] + return hardpoint_tool_matches_trait(tool, step) + +/// Player-facing tool name for one repair_steps entry. +/proc/get_wound_repair_step_tool_name(step) + if(islist(step)) + var/list/material_step = step + var/obj/item/stack/stack_type = material_step[2] + return "[material_step[3]]x [initial(stack_type.name)]" + if(step == "welder") + return "Welding Tool" + if(step == TRAIT_TOOL_WRENCH) + return "Wrench" + if(step == TRAIT_TOOL_CROWBAR) + return "Crowbar" + if(step == TRAIT_TOOL_SCREWDRIVER) + return "Screwdriver" + if(step == TRAIT_TOOL_WIRECUTTERS) + return "Wirecutters" + return "Unknown Tool" + +/// Consumes a material step's required stack amount. No-op for trait/tool steps. +/proc/consume_wound_repair_step_material(step, obj/item/tool) + if(!islist(step)) + return TRUE + var/list/material_step = step + var/obj/item/stack/stack = tool + return stack.use(material_step[3]) + +/** + * Pre-flight check and tool sound for one wound-repair step, called before its do_after() starts. + * + * Arguments: + * * step = The repair_steps entry this attempt is for. + * * tool = The tool being used. + * * user = Whoever's using it, shown the failure message if any. + * + * Returns: + * * FALSE if a welder step's lit/fuelled pre-check failed. TRUE otherwise. + */ +/proc/precheck_wound_repair_step_tool(step, obj/item/tool, mob/user) + if(step == "welder") + var/obj/item/tool/weldingtool/WT = tool + if(!WT.isOn()) + to_chat(user, SPAN_WARNING("You need to light \the [WT] first.")) + return FALSE + if(WT.get_fuel() < 1) + to_chat(user, SPAN_WARNING("You need to refuel \the [WT] first.")) + return FALSE + playsound(get_turf(user), 'sound/items/weldingtool_weld.ogg', 25) + else if(step == TRAIT_TOOL_WRENCH) + playsound(get_turf(user), 'sound/items/Ratchet.ogg', 25) + else if(step == TRAIT_TOOL_CROWBAR) + playsound(get_turf(user), 'sound/items/Crowbar.ogg', 25) + else if(step == TRAIT_TOOL_WIRECUTTERS) + playsound(get_turf(user), 'sound/items/Wirecutter.ogg', 25) + else if(step == TRAIT_TOOL_SCREWDRIVER) + playsound(get_turf(user), 'sound/items/Screwdriver.ogg', 25) + return TRUE + +/** + * Applies a welder step's fuel cost and eye-protection check right as it completes. No-op for every + * other tool. + * + * Returns: + * * FALSE if a welder went out or ran dry between the precheck and now. TRUE otherwise. + */ +/proc/apply_wound_repair_step_welder_cost(step, obj/item/tool, mob/user) + if(step != "welder") + return TRUE + var/obj/item/tool/weldingtool/WT = tool + return WT.isOn() && WT.remove_fuel(1, user) + +/** + * Turns a tier's repair_step_verbs into one sentence, each verb bolded with its tool named + * after. Falls back to raw repair_method text if there are no structured steps. + * + * Adds a callout for unmount_required tiers and for a stricter repair_required_skill. + */ +/proc/format_repair_info_sentence(list/tier_data) + var/unmount_notice = tier_data["unmount_required"] ? "[SPAN_BOLD("This part must be unmounted (crowbar) first.")] " : "" + + var/skill_notice = "" + if(tier_data["repair_required_skill"]) + skill_notice = "[SPAN_BOLD("Requires:")] [tier_data["repair_skill"]]. " + + var/list/verbs = tier_data["repair_step_verbs"] + if(!length(verbs)) + return "[unmount_notice][skill_notice][tier_data["repair_method"]]" + + var/list/steps = tier_data["repair_steps"] + var/list/formatted = list() + for(var/i in 1 to length(verbs)) + var/phrase = verbs[i] + var/space_pos = findtext(phrase, " ") + var/bolded_phrase = space_pos ? "[SPAN_BOLD(copytext(phrase, 1, space_pos))][copytext(phrase, space_pos)]" : SPAN_BOLD(phrase) + var/tool_name = (length(steps) >= i) ? get_wound_repair_step_tool_name(steps[i]) : null + formatted += tool_name ? "[bolded_phrase] ([tool_name])" : bolded_phrase + return "[unmount_notice][skill_notice]This can be fixed by [jointext(formatted, ", then ")]." + +/** + * Sends a wound tier's repair instructions to `user` alone, resolving the "(Repair Info)" link. + * + * Arguments: + * * user = Whoever clicked the link, the only one who sees the output. + * * family_type_text = A wound family's type path, as text. + */ +/obj/item/hardpoint/proc/show_wound_repair_info(mob/user, family_type_text) + var/list/tier_data = get_wound_tier_data(text2path(family_type_text)) + if(!tier_data) + return + to_chat(user, SPAN_NOTICE("[SPAN_BOLD(tier_data["wound_name"])]: [format_repair_info_sentence(tier_data)]")) + +/// This hardpoint's progress on one wound family's current tier, or 0 if none. +/obj/item/hardpoint/proc/get_wound_repair_step(family_type) + var/list/progress = LAZYACCESS(wound_repair_progress, family_type) + if(!progress || progress["tier"] != LAZYACCESS(wound_tiers, family_type)) + return 0 + return progress["step"] + +/** + * Attempts one step of a wound tier's repair_steps sequence. Clears the wound once every + * step is done. Only for unmount_required = FALSE tiers, still mounted. + * + * Arguments: + * * user = Whoever is attempting the repair. + * * tool = The tool used. Must match the next unfinished step. + * * family_type = A wound family's type path. + */ +/obj/item/hardpoint/proc/fix_wound(mob/living/user, obj/item/tool, family_type) + if(!ishuman(user) || user.is_mob_incapacitated()) + return + if(!owner || get_dist(owner, user) > 2) // vehicle is 3x3, Adjacent() alone forces standing on its unreachable centre tile + return + + var/starting_tier = LAZYACCESS(wound_tiers, family_type) + if(!starting_tier) + return + var/datum/hardpoint_wound_family/family = GLOB.hardpoint_wound_families_by_type[family_type] + if(!family || starting_tier > length(family.tiers)) + return + var/list/tier_data = family.tiers[starting_tier] + if(tier_data["unmount_required"]) + return + var/list/steps = tier_data["repair_steps"] + if(!length(steps)) + return + var/starting_step = get_wound_repair_step(family_type) + if(starting_step >= length(steps)) + return + var/required_step = steps[starting_step + 1] + if(!wound_repair_step_matches_tool(required_step, tool)) + return + if(required_step == "welder") + var/obj/item/tool/weldingtool/WT = tool + if(!HAS_TRAIT(WT, TRAIT_TOOL_BLOWTORCH)) + to_chat(user, SPAN_WARNING("You need a stronger blowtorch!")) + return + if(!skillcheck(user, SKILL_ENGINEER, tier_data["repair_required_skill"] || SKILL_ENGINEER_NOVICE)) + to_chat(user, SPAN_WARNING("This task is beyond you!")) + return + if(!precheck_wound_repair_step_tool(required_step, tool, user)) + return + + var/is_final_step = (starting_step + 1) >= length(steps) + user.visible_message(SPAN_NOTICE("[user] starts working on \the [src]'s [tier_data["wound_name"]]."), SPAN_NOTICE("You start fixing \the [src]'s [tier_data["wound_name"]].")) + if(!do_after(user, 5 SECONDS, INTERRUPT_ALL, BUSY_ICON_FRIENDLY)) + return + if(!owner || get_dist(owner, user) > 2 || tool != user.get_active_hand() || get_wound_repair_step(family_type) != starting_step || LAZYACCESS(wound_tiers, family_type) != starting_tier || !wound_repair_step_matches_tool(required_step, tool)) + return + if(!apply_wound_repair_step_welder_cost(required_step, tool, user)) + to_chat(user, SPAN_WARNING("You need to keep \the [tool] lit and fuelled to finish this.")) + return + consume_wound_repair_step_material(required_step, tool) + + if(!is_final_step) + LAZYSET(wound_repair_progress, family_type, list("tier" = starting_tier, "step" = starting_step + 1)) + user.visible_message(SPAN_NOTICE("[user] finishes a step on \the [src]'s [tier_data["wound_name"]]."), SPAN_NOTICE("You finish a step. [length(steps) - starting_step - 1] more to go.")) + return + + var/revert_tier = tier_data["repair_reverts_to_tier"] + if(revert_tier) + LAZYSET(wound_tiers, family_type, revert_tier) + else + LAZYREMOVE(wound_tiers, family_type) + LAZYREMOVE(wound_repair_progress, family_type) + recalculate_wound_effects() + user.visible_message(SPAN_NOTICE("[user] fixes \the [src]'s [tier_data["wound_name"]]."), SPAN_NOTICE("You fix \the [src]'s [tier_data["wound_name"]].")) + +/** + * The detached counterpart to fix_wound(), attempted while this hardpoint is crowbarred + * off the vehicle. + * + * Arguments: + * * user = Whoever is attempting the repair. + * * tool = The tool used. Must match the next unfinished step. + * * family_type = A wound family's type path. + */ +/obj/item/hardpoint/proc/fix_detached_wound(mob/living/user, obj/item/tool, family_type) + if(!ishuman(user) || user.is_mob_incapacitated()) + return + if(owner || !Adjacent(user)) + return + + var/starting_tier = LAZYACCESS(wound_tiers, family_type) + if(!starting_tier) + return + var/datum/hardpoint_wound_family/family = GLOB.hardpoint_wound_families_by_type[family_type] + if(!family || starting_tier > length(family.tiers)) + return + var/list/tier_data = family.tiers[starting_tier] + var/list/steps = tier_data["repair_steps"] + if(!length(steps)) + return + var/starting_step = get_wound_repair_step(family_type) + if(starting_step >= length(steps)) + return + var/required_step = steps[starting_step + 1] + if(!wound_repair_step_matches_tool(required_step, tool)) + return + if(required_step == "welder") + var/obj/item/tool/weldingtool/WT = tool + if(!HAS_TRAIT(WT, TRAIT_TOOL_BLOWTORCH)) + to_chat(user, SPAN_WARNING("You need a stronger blowtorch!")) + return + if(!skillcheck(user, SKILL_ENGINEER, tier_data["repair_required_skill"] || SKILL_ENGINEER_NOVICE)) + to_chat(user, SPAN_WARNING("This task is beyond you!")) + return + if(!precheck_wound_repair_step_tool(required_step, tool, user)) + return + + var/is_final_step = (starting_step + 1) >= length(steps) + user.visible_message(SPAN_NOTICE("[user] starts working on \the [src]'s [tier_data["wound_name"]]."), SPAN_NOTICE("You start fixing \the [src]'s [tier_data["wound_name"]].")) + if(!do_after(user, 5 SECONDS, INTERRUPT_ALL, BUSY_ICON_FRIENDLY)) + return + if(owner || !Adjacent(user) || tool != user.get_active_hand() || get_wound_repair_step(family_type) != starting_step || LAZYACCESS(wound_tiers, family_type) != starting_tier || !wound_repair_step_matches_tool(required_step, tool)) + return + if(!apply_wound_repair_step_welder_cost(required_step, tool, user)) + to_chat(user, SPAN_WARNING("You need to keep \the [tool] lit and fuelled to finish this.")) + return + consume_wound_repair_step_material(required_step, tool) + + if(!is_final_step) + LAZYSET(wound_repair_progress, family_type, list("tier" = starting_tier, "step" = starting_step + 1)) + user.visible_message(SPAN_NOTICE("[user] finishes a step on \the [src]'s [tier_data["wound_name"]]."), SPAN_NOTICE("You finish a step. [length(steps) - starting_step - 1] more to go.")) + return + + var/revert_tier2 = tier_data["repair_reverts_to_tier"] + if(revert_tier2) + LAZYSET(wound_tiers, family_type, revert_tier2) + else + LAZYREMOVE(wound_tiers, family_type) + LAZYREMOVE(wound_repair_progress, family_type) + recalculate_wound_effects() + user.visible_message(SPAN_NOTICE("[user] fixes \the [src]'s [tier_data["wound_name"]]."), SPAN_NOTICE("You fix \the [src]'s [tier_data["wound_name"]].")) + +/** + * Every active wound whose next unfinished repair_steps entry matches `tool`. + * + * Returns: + * * An assoc list, "Fix " -> a family_type path. Empty if nothing matches. + */ +/obj/item/hardpoint/proc/get_detached_wound_fix_candidates(obj/item/tool) + . = list() + for(var/family_type in wound_tiers) + var/list/tier_data = get_wound_tier_data(family_type) + if(!tier_data) + continue + var/list/steps = tier_data["repair_steps"] + if(!length(steps)) + continue + var/current_step = get_wound_repair_step(family_type) + if(current_step >= length(steps)) + continue + if(!wound_repair_step_matches_tool(steps[current_step + 1], tool)) + continue + .["Fix [tier_data["wound_name"]]"] = family_type + +/** + * Wound-fix / integrity-repair dispatch for this hardpoint's attackby() while detached. + * Offers a combined picker since a welder can do either. + * + * Returns: + * * TRUE if anything was offered/attempted, caller should skip its own fallback. + */ +/obj/item/hardpoint/proc/try_fix_detached_wound_with_tool(obj/item/tool, mob/living/user) + var/list/wound_candidates = get_detached_wound_fix_candidates(tool) + var/can_repair_integrity = iswelder(tool) && HAS_TRAIT(tool, TRAIT_TOOL_BLOWTORCH) && health > 0 && health < initial(health) + if(!length(wound_candidates) && !can_repair_integrity) + return FALSE + + var/list/labels = list() + for(var/label in wound_candidates) + labels += label + if(can_repair_integrity) + labels += "Repair integrity" + + var/chosen_label = length(labels) == 1 ? labels[1] : tgui_input_list(user, "What would you like to fix?", "Part Repair", labels + list("Cancel")) + if(!chosen_label || chosen_label == "Cancel") + return TRUE + + if(QDELETED(user) || user.is_mob_incapacitated() || !Adjacent(user) || tool != user.get_active_hand()) + return TRUE + + if(chosen_label == "Repair integrity") + handle_repair(tool, user) + return TRUE + + var/family_type = wound_candidates[chosen_label] + if(!family_type) + return TRUE + fix_detached_wound(user, tool, family_type) + return TRUE + +/// Broadcasts a wound-gain notice. No-op if this hardpoint isn't installed on a vehicle. +/obj/item/hardpoint/proc/notify_crew_of_wound(marine_text, xeno_text, bold) + if(!owner) + return + owner.broadcast_wound_notice(marine_text, xeno_text, bold) + +/** + * Called once right after a wound tier changes. Recalculates hardpoint bonuses and refreshes + * the vehicle's sprite. + */ +/obj/item/hardpoint/proc/recalculate_wound_effects() + if(!owner) + return + recalculate_hardpoint_bonuses() + owner.update_icon() + +/** + * Rolls the engine_cracked_block family's overheat wound progression, throttled to once per + * ENGINE_OVERHEAT_WOUND_ROLL_INTERVAL. Never fires while the engine is off. + * + * Arguments: + * * vehicle = The vehicle this engine is installed on. + */ +/obj/item/hardpoint/engine/proc/check_overheat_wound_trigger(obj/vehicle/multitile/vehicle) + if(!vehicle.engine_on) + return + if(world.time < next_overheat_wound_roll) + return + next_overheat_wound_roll = world.time + ENGINE_OVERHEAT_WOUND_ROLL_INTERVAL + + var/current_temp_pct = get_temperature_percent() + if(current_temp_pct < ENGINE_OVERHEAT_WOUND_TIER1_THRESHOLD_PCT) + return + + var/datum/hardpoint_wound_family/engine_cracked_block/family = GLOB.hardpoint_wound_families_by_type[/datum/hardpoint_wound_family/engine_cracked_block] + if(!family) + return + + var/current_tier = LAZYACCESS(wound_tiers, family.type) || 0 + var/severe = current_temp_pct >= ENGINE_OVERHEAT_WOUND_TIER2_THRESHOLD_PCT + + var/next_tier + if(current_tier == 0 && prob(severe ? ENGINE_OVERHEAT_WOUND_TIER1_CHANCE_PCT_HIGH : ENGINE_OVERHEAT_WOUND_TIER1_CHANCE_PCT)) + next_tier = 1 + else if(severe && current_tier == 1 && prob(ENGINE_OVERHEAT_WOUND_TIER2_ADVANCE_CHANCE_PCT)) + next_tier = 2 + + if(!next_tier || next_tier > length(family.tiers)) + return + + var/list/tier_data = family.tiers[next_tier] + LAZYSET(wound_tiers, family.type, next_tier) + notify_crew_of_wound(tier_data["marine_feedback_red"], tier_data["xeno_feedback"], tier_data["bold_feedback"]) + play_wound_gain_effects(src, WOUND_DAMTYPE_BRUTE, null) // engine_cracked_block is always Brute; overheat has no directional attacker + recalculate_wound_effects() + +/// Every hardpoint slot with a dedicated neurotoxin "_gunked" wound family. +GLOBAL_LIST_INIT(neuro_foulable_slots, list(HDPT_VISUAL_SENSORS, HDPT_AIR_FILTER, HDPT_PRIMARY, HDPT_SECONDARY, HDPT_TREADS)) + +/// Multiplier applied to every neurotoxin wound-chance roll on this vehicle. 1 by default. +/obj/vehicle/multitile/proc/get_neuro_wound_chance_scale() + return 1 + +/** + * Rolls the neuro alt-trigger for whichever hardpoint currently occupies `slot`, if it has an + * Acid-type wound family with that trigger set. No-op for a slot with nothing installed. + * + * Arguments: + * * chance_scale = Passed straight through to try_advance_wound_family_via_neuro(). + */ +/obj/vehicle/multitile/proc/roll_neuro_wound_at_slot(slot, chance_scale = 1) + var/obj/item/hardpoint/H = get_hardpoint_by_slot(slot) + if(!H) + return + var/list/candidate_families = GLOB.hardpoint_wound_families_by_slot[H.get_wound_family_slot()] + if(!candidate_families) + return + for(var/datum/hardpoint_wound_family/family in candidate_families) + if(family.damage_type != WOUND_DAMTYPE_ACID) + continue + H.try_advance_wound_family_via_neuro(family, chance_scale) + +/// Rolls the neuro alt-trigger for every neuro-foulable hardpoint. Used for ambient gas exposure. +/obj/vehicle/multitile/proc/expose_to_boiler_gas() + var/chance_scale = get_neuro_wound_chance_scale() + for(var/slot in GLOB.neuro_foulable_slots) + roll_neuro_wound_at_slot(slot, chance_scale) + +/** + * A neurotoxin spit hitting this vehicle. Rolls only the slot the attacker aimed at. A + * turret-aimed spit instead gets a chance to bleed into the Air Filter's neuro family. + * + * Arguments: + * * attacker = Whoever fired the spit, read for aim. + * * ignore_aim = TRUE for scattered ammo, picks a random neuro-foulable slot instead. + */ +/obj/vehicle/multitile/proc/expose_to_neurotoxin_spit(atom/attacker, ignore_aim = FALSE) + if(ignore_aim) + roll_neuro_wound_at_slot(pick(GLOB.neuro_foulable_slots), get_neuro_wound_chance_scale()) + return + + var/aimed_slot = get_attack_target_slot(attacker) + if(aimed_slot == HDPT_TURRET) + if(prob(TURRET_NEURO_SPIT_AIR_FILTER_BLEED_CHANCE_PCT)) + roll_neuro_wound_at_slot(HDPT_AIR_FILTER, get_neuro_wound_chance_scale()) + return + + roll_neuro_wound_at_slot(aimed_slot, get_neuro_wound_chance_scale()) + +//----------------------------- +// Vehicle-frame ("Hull") wounds. hull_melted/hull_hole track the vehicle's health directly. +//----------------------------- + +/** + * Rolls hull_melted/hull_hole against one hit to the vehicle's own frame. + * + * Arguments: + * * hit_damage = Damage that was just subtracted from this vehicle's own health. + * * damage_type = WOUND_DAMTYPE_ACID or WOUND_DAMTYPE_BRUTE. + * * attacker = Whoever dealt this hit, if known. + * * chance_mult = Multiplies the roll chance before the tier's own max_roll_chance_pct cap applies. + */ +/obj/vehicle/multitile/proc/roll_hull_wounds(hit_damage, damage_type, atom/attacker, chance_mult = 1) + if(hit_damage <= 0) + return + var/list/candidate_families = GLOB.hardpoint_wound_families_by_slot[WOUND_SLOT_HULL] + if(!candidate_families) + return + for(var/datum/hardpoint_wound_family/family in candidate_families) + if(family.damage_type != damage_type) + continue + var/current_tier = LAZYACCESS(hull_wound_tiers, family.type) || 0 + var/next_tier = current_tier + 1 + if(next_tier > length(family.tiers)) + continue + + var/list/tier_data = family.tiers[next_tier] + var/relevant_damage = (damage_type == WOUND_DAMTYPE_ACID) ? hull_acid_damage_taken : hull_brute_damage_taken + var/health_pct = initial(health) ? 100 - (relevant_damage / initial(health) * 100) : 100 + if(health_pct > tier_data["min_health_threshold_pct"]) + continue + + var/chance = min(tier_data["max_roll_chance_pct"], hit_damage * tier_data["damage_to_chance_pct"] * chance_mult) + if(!prob(chance)) + continue + + LAZYSET(hull_wound_tiers, family.type, next_tier) + notify_crew_of_hull_wound(tier_data["marine_feedback_red"], tier_data["xeno_feedback"], tier_data["bold_feedback"]) + play_wound_gain_effects(src, family.damage_type, attacker) + +/// How much extra damage the vehicle's frame takes of a given type from an active hull wound. +/obj/vehicle/multitile/proc/get_hull_incoming_damage_wound_multiplier(type) + . = 1 + for(var/family_type in hull_wound_tiers) + var/tier = hull_wound_tiers[family_type] + var/datum/hardpoint_wound_family/family = GLOB.hardpoint_wound_families_by_type[family_type] + if(!family || tier > length(family.tiers)) + continue + var/list/tier_data = family.tiers[tier] + var/mult = tier_data["incoming_damage_mult"] + if(isnull(mult)) + continue + if(tier_data["incoming_damage_type"] == "all" || family.damage_type == type) + . = max(., mult) + +/// Broadcasts a hull-wound-gain notice. +/obj/vehicle/multitile/proc/notify_crew_of_hull_wound(marine_text, xeno_text, bold) + broadcast_wound_notice(marine_text, xeno_text, bold) + +//----------------------------- +// Single-target damage resolution. Every vehicle type routes take_damage_type() through here. +//----------------------------- + +/// Names whichever module `attacker` is currently aimed at, for use in attack flavor messages. +/obj/vehicle/multitile/get_attack_desc(atom/attacker) + var/target_slot = get_attack_target_slot(attacker) + var/obj/item/hardpoint/target = resolve_targeted_hardpoint(target_slot) + return "\the [src]'s [target ? target.name : "hull"]" + +/** + * Which of HIT_ZONE_FRONT/HIT_ZONE_SIDE/HIT_ZONE_REAR a hit from `attacker` counts as, relative to the hull's own facing. + * Front and rear are each a 90 degree cone, the rest counts as side. Defaults to HIT_ZONE_FRONT if there's no meaningful bearing to compute. + */ +/obj/vehicle/multitile/proc/get_hit_direction_zone(atom/attacker) + var/turf/attacker_turf = get_turf(attacker) + var/turf/own_turf = get_turf(src) + if(!attacker_turf || !own_turf || attacker_turf == own_turf) + return HIT_ZONE_FRONT + + var/bearing_to_attacker = Get_Angle(src, attacker) + var/relative_angle = abs(angle_delta(bearing_to_attacker, dir2angle(dir))) + if(relative_angle <= 45) + return HIT_ZONE_FRONT + if(relative_angle >= 135) + return HIT_ZONE_REAR + return HIT_ZONE_SIDE + +/// Fraction of Armor's own damage reduction a hit from this hit_zone ignores outright. 0 for HIT_ZONE_FRONT. +/obj/vehicle/multitile/proc/get_armor_bypass_fraction(hit_zone) + switch(hit_zone) + if(HIT_ZONE_SIDE) + return ARMOR_BYPASS_FRACTION_SIDE + if(HIT_ZONE_REAR) + return ARMOR_BYPASS_FRACTION_REAR + return 0 + +/// Percent chance bonus to internal module damage for a hit from this hit_zone. 0 for HIT_ZONE_FRONT. +/obj/vehicle/multitile/proc/get_internal_leak_chance_bonus(hit_zone) + switch(hit_zone) + if(HIT_ZONE_SIDE) + return ARMOR_BYPASS_INTERNAL_CHANCE_BONUS_SIDE + if(HIT_ZONE_REAR) + return ARMOR_BYPASS_INTERNAL_CHANCE_BONUS_REAR + return 0 + +/** + * Directional counterpart to get_dmg_multi(). Isolates Armor's own contribution and bypasses a fraction of it. + * The hull's baseline toughness stays untouched by hit direction. + * + * Arguments: + * * type = One of "acid"/"slash"/"bullet"/"explosive"/"blunt"/"abstract". + * * hit_zone = HIT_ZONE_FRONT/HIT_ZONE_SIDE/HIT_ZONE_REAR. + */ +/obj/vehicle/multitile/proc/get_directional_dmg_multi(type, hit_zone) + var/base_mult = get_dmg_multi(type) + var/bypass_fraction = get_armor_bypass_fraction(hit_zone) + if(!bypass_fraction) + return base_mult + + var/obj/item/hardpoint/armor/installed_armor = get_hardpoint_by_slot(HDPT_ARMOR) + if(!installed_armor) + return base_mult + + var/armor_type_mult = LAZYACCESS(installed_armor.type_multipliers, type) + if(isnull(armor_type_mult)) + armor_type_mult = 1 + var/armor_all_mult = LAZYACCESS(installed_armor.type_multipliers, "all") + if(isnull(armor_all_mult)) + armor_all_mult = 1 + var/armor_contribution = max(armor_type_mult * armor_all_mult, 0.0001) // guards the division below; armor mults are never actually 0 today + + var/other_mult = base_mult / armor_contribution + var/bypassed_armor_contribution = armor_contribution + bypass_fraction * (1 - armor_contribution) + return other_mult * bypassed_armor_contribution + +/** + * Directional counterpart to get_internal_dmg_multi(). Lerps that value toward 1 (no protection) by `bypass_fraction`. + * + * Arguments: + * * type = One of "acid"/"slash"/"bullet"/"explosive"/"blunt"/"abstract". + * * hit_zone = HIT_ZONE_FRONT/HIT_ZONE_SIDE/HIT_ZONE_REAR. + */ +/obj/vehicle/multitile/proc/get_directional_internal_dmg_multi(type, hit_zone) + var/base_mult = get_internal_dmg_multi(type) + var/bypass_fraction = get_armor_bypass_fraction(hit_zone) + if(!bypass_fraction) + return base_mult + return base_mult + bypass_fraction * (1 - base_mult) + +/** + * The internal-module counterpart to get_dmg_multi(). What fraction of incoming damage should reach an internal module. + * Only reduces damage if the installed Armor hardpoint opts into protecting internal modules. + * + * Arguments: + * * type = One of "acid"/"slash"/"bullet"/"explosive"/"blunt"/"abstract". + * + * Returns: + * * A multiplier, same shape as get_dmg_multi(). 1 means no reduction. + */ +/obj/vehicle/multitile/proc/get_internal_dmg_multi(type) + var/obj/item/hardpoint/armor/installed_armor = get_hardpoint_by_slot(HDPT_ARMOR) + if(!installed_armor || !installed_armor.protects_internal_modules || !LAZYLEN(installed_armor.type_multipliers)) + return 1 + var/list/mults = installed_armor.type_multipliers + var/type_mult = LAZYACCESS(mults, type) + if(isnull(type_mult)) + type_mult = 1 + var/all_mult = LAZYACCESS(mults, "all") + if(isnull(all_mult)) + all_mult = 1 + return type_mult * all_mult + +/** + * Armor takes a splash of damage from any hit that lands on an external module, since every armor type protects every external module. + * This is Armor's only route to taking damage, uses ARMOR_SPLASH_FRACTION, and rolls real wounds (Corroded Plating/Cracked Plating) over time. + * + * Arguments: + * * external_scaled_damage = The hit's damage after external mitigation, or raw if unmitigated. + * * type = One of "acid"/"slash"/"bullet"/"explosive"/"blunt"/"abstract". + * * attacker = Whoever dealt this hit, if known. + * * unmitigated = Passed straight through to Armor's own take_damage(). + */ +/obj/vehicle/multitile/proc/apply_armor_splash_damage(external_scaled_damage, type, atom/attacker, unmitigated) + var/obj/item/hardpoint/armor/installed_armor = get_hardpoint_by_slot(HDPT_ARMOR) + if(!installed_armor) + return + installed_armor.take_damage(floor(external_scaled_damage * ARMOR_SPLASH_FRACTION), type, attacker, unmitigated = unmitigated) + +/** + * Guaranteed, armor-bypassing hit to the Hatch hardpoint on every HIT_ZONE_REAR strike. + * No-ops if no Hatch is installed or it's already destroyed. + * + * Arguments: + * * damage = Raw incoming damage, pre-type-multiplier. + * * type = One of "acid"/"slash"/"bullet"/"explosive"/"blunt"/"abstract". + * * attacker = Whoever dealt this hit, if known. + */ +/obj/vehicle/multitile/proc/apply_guaranteed_rear_hatch_damage(damage, type, atom/attacker) + var/obj/item/hardpoint/hatch/installed_hatch = get_hardpoint_by_slot(HDPT_HATCH) + if(!installed_hatch || installed_hatch.health <= 0) + return + installed_hatch.take_damage(floor(damage), type, attacker, unmitigated = TRUE) + +/** + * Resolves a single hit against one module, picked by the attacker's aimed zone and redirected up the hierarchy if missing or destroyed. + * Rolls hull/turret internal damage, applies directional armor, and splashes damage onto Armor and adjacent modules. + * + * Arguments: + * * damage = Raw incoming damage, pre-type-multiplier. + * * type = One of "acid"/"slash"/"bullet"/"explosive"/"blunt"/"abstract". + * * attacker = Whoever dealt this hit, if known. + * * unmitigated = Skips directional and Armor splash mitigation entirely. Used by xeno abilities meant to land as one full hit. + */ +/obj/vehicle/multitile/proc/take_damage_type(damage, type, atom/attacker, unmitigated = FALSE) + if(type == "slash" && istype(attacker, /mob/living/carbon/xenomorph)) + var/mob/living/carbon/xenomorph/attacker_xeno = attacker + // must actually be on top of THIS vehicle (exterior), same z, and standing on one of our tiles + if(attacker_xeno.get_tank_on_top_of() == src && attacker_xeno.z == z) + var/turf/xturf = get_turf(attacker_xeno) + if(xturf && (xturf in src.locs)) + damage *= 0.7 + + var/hit_zone = get_hit_direction_zone(attacker) + var/external_scaled_damage = unmitigated ? damage : (damage * get_directional_dmg_multi(type, hit_zone)) + var/internal_scaled_damage = unmitigated ? damage : (damage * get_directional_internal_dmg_multi(type, hit_zone)) + var/internal_chance_bonus = get_internal_leak_chance_bonus(hit_zone) + var/target_slot = get_attack_target_slot(attacker) + var/obj/item/hardpoint/target = resolve_targeted_hardpoint(target_slot) + var/hit_name = "hull" + + if(!target) + apply_hull_damage(external_scaled_damage, type, attacker, unmitigated = unmitigated) + roll_internal_module_damage(WOUND_SLOT_HULL, internal_scaled_damage, type, attacker, internal_chance_bonus) + bleed_external_modules(WOUND_SLOT_HULL, external_scaled_damage) + else if(target.slot == HDPT_TURRET) + hit_name = target.name + target.take_damage(floor(external_scaled_damage), type, attacker, unmitigated = unmitigated) + // Air Filter is weighted 3x over Turret Ring in this cascade, it's a far more exposed target. + roll_internal_module_damage(HDPT_TURRET, internal_scaled_damage, type, attacker, internal_chance_bonus, favored_slot = HDPT_AIR_FILTER) + bleed_external_modules(HDPT_TURRET, external_scaled_damage) + else + hit_name = target.name + target.take_damage(floor(external_scaled_damage), type, attacker, unmitigated = unmitigated) + var/parent_slot = GLOB.hardpoint_target_parent[target.slot] + if(parent_slot == WOUND_SLOT_HULL) + apply_hull_damage(external_scaled_damage * HARDPOINT_BLEED_THROUGH_FRACTION, type, attacker, no_wound_roll = TRUE) + else if(parent_slot) + var/obj/item/hardpoint/parent = get_hardpoint_by_slot(parent_slot) + parent?.deal_raw_damage(external_scaled_damage * HARDPOINT_BLEED_THROUGH_FRACTION) + // A directly-targeted external module has no route to the parent's internal pool unless side/rear opens one up. + if(parent_slot && internal_chance_bonus) + roll_internal_module_damage(parent_slot, internal_scaled_damage, type, attacker, internal_chance_bonus, favored_slot = (parent_slot == HDPT_TURRET) ? HDPT_AIR_FILTER : null) + + // Every branch above lands on an external module, so Armor takes its own small splash from each one. + apply_armor_splash_damage(external_scaled_damage, type, attacker, unmitigated) + + // The hatch sits at the rear, a hit from directly behind always finds it regardless of the resolved target above. + if(hit_zone == HIT_ZONE_REAR) + apply_guaranteed_rear_hatch_damage(damage, type, attacker) + + // The attack's own flavor message can't know what part actually got hit, so show that separately here. + visible_message(SPAN_DANGER("\The [src]'s [hit_name] is struck!")) + + if(ismob(attacker)) + var/mob/M = attacker + log_attack("[src] took [damage] [type] damage (aimed at [target_slot]) from [M] ([M.client ? M.client.ckey : "disconnected"]).") + else + log_attack("[src] took [damage] [type] damage (aimed at [target_slot]) from [attacker].") + update_icon() + +/// Child slot -> parent slot, the redirect chain used when the aimed part is missing or destroyed. +GLOBAL_LIST_INIT(hardpoint_target_parent, list( + (HDPT_TREADS) = WOUND_SLOT_HULL, + (HDPT_PRIMARY) = HDPT_TURRET, + (HDPT_SECONDARY) = HDPT_TURRET, + (HDPT_IFF_MODULE) = HDPT_TURRET, + (HDPT_VISUAL_SENSORS) = HDPT_TURRET, + (HDPT_TURRET) = WOUND_SLOT_HULL, +)) + +/// Parent slot -> its internal, organ-style random-damage pool. +GLOBAL_LIST_INIT(hardpoint_internal_pool, list( + (WOUND_SLOT_HULL) = list(HDPT_ENGINE, HDPT_BATTERY, HDPT_RADIATOR, HDPT_FUEL_TANK, HDPT_HATCH, HDPT_SUPPORT), + (HDPT_TURRET) = list(HDPT_TURRET_RING, HDPT_AIR_FILTER), +)) + +/// What slot an incoming hit should be aimed at, read from the attacker's selected zone. +/obj/vehicle/multitile/proc/get_attack_target_slot(atom/attacker) + if(!ismob(attacker)) + return WOUND_SLOT_HULL + var/mob/attacker_mob = attacker + return attacker_mob.vehicle_zone_selected || WOUND_SLOT_HULL + +/// Finds the installed hardpoint occupying the given slot, top-level or holder-nested. +/obj/vehicle/multitile/proc/get_hardpoint_by_slot(slot) + for(var/obj/item/hardpoint/H in hardpoints) + if(H.slot == slot) + return H + if(istype(H, /obj/item/hardpoint/holder)) + var/obj/item/hardpoint/holder/holder_hp = H + for(var/obj/item/hardpoint/sub in holder_hp.hardpoints) + if(sub.slot == slot) + return sub + return null + +/** + * Resolves an aimed-at slot into the actual hardpoint that should take a hit right now, walking the + * redirect chain up through parents whenever the aimed part doesn't exist or is already destroyed. + * + * Arguments: + * * slot = One of the 7 targetable slots. Anything else falls back to WOUND_SLOT_HULL immediately. + * + * Returns: + * * The hardpoint to damage, or null if the resolved target is the hull itself. + */ +/obj/vehicle/multitile/proc/resolve_targeted_hardpoint(slot) + var/current_slot = slot + while(current_slot && current_slot != WOUND_SLOT_HULL) + var/obj/item/hardpoint/H = get_hardpoint_by_slot(current_slot) + if(H && H.health > 0) + return H + current_slot = GLOB.hardpoint_target_parent[current_slot] + return null + +/** + * Applies damage straight to the vehicle's own frame health, tracking the acid/brute sub-pool and + * rolling hull_melted/hull_hole. Shared by both a direct Hull hit and a redirected-to-Hull hit. + * + * Arguments: + * * damage = Type-scaled incoming damage, pre-hull_damage_multiplier. + * * type = One of "acid"/"slash"/"bullet"/"explosive"/"blunt"/"abstract". + * * attacker = Whoever dealt this hit, if known. + * * no_wound_roll = TRUE for a bleed-through splash. Still reduces health, no wound roll. + * * unmitigated = Skips hull_damage_multiplier and the incoming wound multiplier entirely. + * * wound_chance_mult = Passed straight through to roll_hull_wounds(). + */ +/obj/vehicle/multitile/proc/apply_hull_damage(damage, type, atom/attacker, no_wound_roll = FALSE, unmitigated = FALSE, wound_chance_mult = 1) + if(damage <= 0) + return + var/mapped_hull_type = wound_damage_type_for(type) + var/hull_damage = damage + if(!unmitigated) + var/hull_multiplier = get_hull_incoming_damage_wound_multiplier(mapped_hull_type) + hull_damage = damage * hull_damage_multiplier * hull_multiplier + health = max(0, health - hull_damage) + if(mapped_hull_type == WOUND_DAMTYPE_ACID) + hull_acid_damage_taken += hull_damage + else if(mapped_hull_type == WOUND_DAMTYPE_BRUTE) + hull_brute_damage_taken += hull_damage + if(mapped_hull_type && !no_wound_roll) + roll_hull_wounds(hull_damage, mapped_hull_type, attacker, wound_chance_mult) + if(!health) + on_hull_destroyed() + +/// Fires once when hull health first reaches 0. No-op by default; a vehicle that wants the ammo-cookoff finale (multitile_cookoff.dm) overrides this to call start_hull_cookoff_sequence(). +/obj/vehicle/multitile/proc/on_hull_destroyed() + return + +/** + * Organ-style random internal-module damage: a hit to an external part has a chance to also + * land on a random internal part behind it, rising as the parent gets more damaged. Picks + * one installed, non-destroyed hardpoint and hits it with a real take_damage() call. + * + * Arguments: + * * parent_slot = WOUND_SLOT_HULL or HDPT_TURRET, whose internal pool to roll against. + * * hit_damage = The type-scaled incoming hit that struck the parent. + * * type = One of "acid"/"slash"/"bullet"/"explosive"/"blunt"/"abstract". + * * attacker = Whoever dealt this hit, if known. + * * chance_bonus_pct = Flat percent added onto the computed chance, capped at 100. + * * favored_slot = A member of the internal pool to weight the victim pick toward. + */ +/obj/vehicle/multitile/proc/roll_internal_module_damage(parent_slot, hit_damage, type, atom/attacker, chance_bonus_pct = 0, favored_slot = null) + if(hit_damage <= 0) + return + var/list/candidates = list() + for(var/internal_slot in GLOB.hardpoint_internal_pool[parent_slot]) + var/obj/item/hardpoint/candidate = get_hardpoint_by_slot(internal_slot) + if(candidate && candidate.health > 0) + candidates += candidate + if(internal_slot == favored_slot) + candidates += list(candidate, candidate) // extra weight toward the favored slot + if(!length(candidates)) + return + + var/wound_count + var/integrity_pct + if(parent_slot == WOUND_SLOT_HULL) + wound_count = LAZYLEN(hull_wound_tiers) + integrity_pct = initial(health) ? (100 * health / initial(health)) : 100 + else + var/obj/item/hardpoint/parent = get_hardpoint_by_slot(parent_slot) + wound_count = parent ? LAZYLEN(parent.wound_tiers) : 0 + integrity_pct = parent ? parent.get_integrity_percent() : 100 + + var/chance = min(HARDPOINT_ORGAN_DAMAGE_CHANCE_CAP, hit_damage * HARDPOINT_ORGAN_DAMAGE_CHANCE_PER_DAMAGE) + chance += wound_count * HARDPOINT_ORGAN_DAMAGE_WOUND_BONUS + if(integrity_pct < HARDPOINT_ORGAN_DAMAGE_INTEGRITY_THRESHOLD) + chance += (HARDPOINT_ORGAN_DAMAGE_INTEGRITY_THRESHOLD - integrity_pct) * HARDPOINT_ORGAN_DAMAGE_INTEGRITY_SCALE + chance += chance_bonus_pct + chance = min(chance, 100) + + if(!prob(chance)) + return + + var/obj/item/hardpoint/victim = pick(candidates) + victim.take_damage(floor(hit_damage), type, attacker) + +/** + * Guaranteed counterpart to roll_internal_module_damage(). Skips the chance roll and + * always lands, unmitigated. + * + * Arguments: + * * parent_slot = WOUND_SLOT_HULL or HDPT_TURRET, whose internal pool to pick from. + * * damage = Damage to deal to the chosen victim. + * * type = One of "acid"/"slash"/"bullet"/"explosive"/"blunt"/"abstract". + * * attacker = Whoever dealt this hit, if known. + * + * Returns: + * * TRUE if a candidate existed and was hit, FALSE if the parent's internal pool is empty. + */ +/obj/vehicle/multitile/proc/force_internal_module_damage(parent_slot, damage, type, atom/attacker) + var/list/candidates = list() + for(var/internal_slot in GLOB.hardpoint_internal_pool[parent_slot]) + var/obj/item/hardpoint/candidate = get_hardpoint_by_slot(internal_slot) + if(candidate && candidate.health > 0) + candidates += candidate + if(!length(candidates)) + return FALSE + + var/obj/item/hardpoint/victim = pick(candidates) + victim.take_damage(damage, type, attacker, unmitigated = TRUE) + return TRUE + +/** + * Deals normal, single-layer-mitigated damage to one randomly-picked part of this tank, + * weighted toward the attacker's aimed slot. Used for xeno ammo with no real aim, and + * melee/environmental acid exposure. + * + * A genuinely aimed, single-target hit should use apply_targeted_acid_hit() instead. + * + * Arguments: + * * damage = Raw incoming damage for whichever part ends up picked. + * * type = "acid" (or a brute-mapping type string). + * * attacker = Whoever dealt this hit, if known. Used to weight the pick toward their aim. + * * ignore_aim = Skip the aimed-slot weighting and pick uniformly among installed parts. + * * wound_chance_mult = Passed through to apply_hull_damage()/take_damage(). + */ +/obj/vehicle/multitile/tank/proc/apply_weighted_module_hit(damage, type, atom/attacker, ignore_aim = FALSE, wound_chance_mult = 1) + var/aimed_slot = ignore_aim ? null : get_attack_target_slot(attacker) + var/list/weighted_candidates = list(null) // null = Hull + if(aimed_slot == WOUND_SLOT_HULL) + weighted_candidates += list(null, null) // extra weight toward the aimed slot + + for(var/obj/item/hardpoint/H in get_hardpoints_copy()) + if(H.health <= 0) + continue + weighted_candidates += H + if(H.slot == aimed_slot) + weighted_candidates += list(H, H) // extra weight toward the aimed slot + + var/picked = pick(weighted_candidates) + var/hit_name = "hull" + if(!picked) + apply_hull_damage(damage, type, attacker, wound_chance_mult = wound_chance_mult) + else + var/obj/item/hardpoint/victim = picked + hit_name = victim.name + victim.take_damage(damage, type, attacker, wound_chance_mult = wound_chance_mult) + + visible_message(SPAN_DANGER("\The [src]'s [hit_name] is struck!")) + +/** + * Same damage resolution as apply_weighted_module_hit(), but to the exact slot the attacker + * is aiming at. Used for a directed acid spit. + * + * Arguments: + * * damage = Raw incoming damage for whichever part this resolves to. + * * type = "acid" (or a brute-mapping type string). + * * attacker = Whoever fired the spit, read for aim. + * * wound_chance_mult = Passed straight through to apply_hull_damage()/take_damage(). + */ +/obj/vehicle/multitile/tank/proc/apply_targeted_acid_hit(damage, type, atom/attacker, wound_chance_mult = 1) + var/target_slot = get_attack_target_slot(attacker) + var/obj/item/hardpoint/target = resolve_targeted_hardpoint(target_slot) + var/hit_name = "hull" + if(!target) + apply_hull_damage(damage, type, attacker, wound_chance_mult = wound_chance_mult) + else + hit_name = target.name + target.take_damage(damage, type, attacker, wound_chance_mult = wound_chance_mult) + // Gives a directed acid spit the same Air-Filter-favored organ cascade chance a normal hit gets. + if(target.slot == HDPT_TURRET) + roll_internal_module_damage(HDPT_TURRET, damage, type, attacker, favored_slot = HDPT_AIR_FILTER) + + visible_message(SPAN_DANGER("\The [src]'s [hit_name] is struck!")) + +/** + * Acid Ball-specific resolution. Ignores aim and picks uniformly among external modules + * only, never Hull/Turret. + * + * Arguments: + * * damage = Raw incoming damage for whichever module ends up picked. + * * attacker = Whoever dealt this hit, if known. + */ +/obj/vehicle/multitile/tank/proc/apply_random_external_acid_hit(damage, atom/attacker) + var/list/candidates = list() + for(var/slot in list(HDPT_TREADS, HDPT_PRIMARY, HDPT_SECONDARY, HDPT_IFF_MODULE, HDPT_VISUAL_SENSORS)) + var/obj/item/hardpoint/H = get_hardpoint_by_slot(slot) + if(H && H.health > 0) + candidates += H + if(!length(candidates)) + return + + var/obj/item/hardpoint/victim = pick(candidates) + victim.take_damage(damage, "acid", attacker) + visible_message(SPAN_DANGER("\The [src]'s [victim.name] is struck!")) + +/** + * Deterministic splash damage to every external module mounted on the given parent. + * Always a small fixed fraction of the parent's incoming hit. + * + * Arguments: + * * parent_slot = WOUND_SLOT_HULL or HDPT_TURRET. + * * hit_damage = The type-scaled incoming hit that struck the parent. + */ +/obj/vehicle/multitile/proc/bleed_external_modules(parent_slot, hit_damage) + if(hit_damage <= 0) + return + var/bleed_amount = hit_damage * HARDPOINT_BLEED_THROUGH_FRACTION + for(var/child_slot in GLOB.hardpoint_target_parent) + if(GLOB.hardpoint_target_parent[child_slot] != parent_slot) + continue + var/obj/item/hardpoint/child = get_hardpoint_by_slot(child_slot) + if(child && child.health > 0) + child.deal_raw_damage(bleed_amount) + +/** + * Broadcasts a wound-gain notice to seated crew and nearby marines/xenos. Bold-tier wounds + * get a font-size bump. + * + * Arguments: + * * marine_text = tier_data["marine_feedback_red"]. Null-safe, no-ops the marine side if absent. + * * xeno_text = tier_data["xeno_feedback"]. Null-safe, no-ops the xeno side if absent. + * * bold = tier_data["bold_feedback"]. + */ +/obj/vehicle/multitile/proc/broadcast_wound_notice(marine_text, xeno_text, bold) + var/marine_line = marine_text ? (bold ? "[SPAN_BOLDWARNING(marine_text)]" : SPAN_WARNING(marine_text)) : null + var/xeno_line = xeno_text ? (bold ? "[SPAN_XENOBOLDNOTICE(xeno_text)]" : SPAN_XENOWARNING(xeno_text)) : null + + if(marine_line) + for(var/seat_key in seats) + var/mob/seated_mob = seats[seat_key] + if(seated_mob) + to_chat(seated_mob, marine_line) + + if(!marine_line && !xeno_line) + return + + for(var/mob/living/viewer in viewers(src)) + if(marine_line && ishuman(viewer)) + to_chat(viewer, marine_line) + else if(xeno_line && isxeno(viewer)) + to_chat(viewer, xeno_line) + +/// This vehicle's progress on one Hull wound family's current tier, or 0 if none. +/obj/vehicle/multitile/proc/get_hull_wound_repair_step(family_type) + var/list/progress = LAZYACCESS(hull_wound_repair_progress, family_type) + if(!progress || progress["tier"] != LAZYACCESS(hull_wound_tiers, family_type)) + return 0 + return progress["step"] + +/** + * Attempts one step of a Hull wound tier's repair_steps sequence, the frame's own counterpart to + * fix_wound(). Every Hull tier is unmount_required = FALSE, so this is the only Hull repair path. + * + * Arguments: + * * user = Whoever is attempting the repair. + * * tool = The tool used. Must match the next unfinished step. + * * family_type = A wound family's type path. + */ +/obj/vehicle/multitile/proc/fix_hull_wound(mob/living/user, obj/item/tool, family_type) + if(!ishuman(user) || user.is_mob_incapacitated()) + return + if(get_dist(src, user) > 2) // vehicle is 3x3, Adjacent() alone forces standing on its unreachable centre tile + return + + var/starting_tier = LAZYACCESS(hull_wound_tiers, family_type) + if(!starting_tier) + return + var/datum/hardpoint_wound_family/family = GLOB.hardpoint_wound_families_by_type[family_type] + if(!family || starting_tier > length(family.tiers)) + return + var/list/tier_data = family.tiers[starting_tier] + var/list/steps = tier_data["repair_steps"] + if(!length(steps)) + return + var/starting_step = get_hull_wound_repair_step(family_type) + if(starting_step >= length(steps)) + return + var/required_step = steps[starting_step + 1] + if(!wound_repair_step_matches_tool(required_step, tool)) + return + if(required_step == "welder") + var/obj/item/tool/weldingtool/WT = tool + if(!HAS_TRAIT(WT, TRAIT_TOOL_BLOWTORCH)) + to_chat(user, SPAN_WARNING("You need a stronger blowtorch!")) + return + if(!skillcheck(user, SKILL_ENGINEER, tier_data["repair_required_skill"] || SKILL_ENGINEER_NOVICE)) + to_chat(user, SPAN_WARNING("This task is beyond you!")) + return + + var/is_final_step = (starting_step + 1) >= length(steps) + user.visible_message(SPAN_NOTICE("[user] starts working on \the [src]'s [tier_data["wound_name"]]."), SPAN_NOTICE("You start fixing \the [src]'s [tier_data["wound_name"]].")) + if(!do_after(user, 5 SECONDS, INTERRUPT_ALL, BUSY_ICON_FRIENDLY)) + return + if(get_dist(src, user) > 2 || tool != user.get_active_hand() || get_hull_wound_repair_step(family_type) != starting_step || LAZYACCESS(hull_wound_tiers, family_type) != starting_tier || !wound_repair_step_matches_tool(required_step, tool)) + return + consume_wound_repair_step_material(required_step, tool) + + if(!is_final_step) + LAZYSET(hull_wound_repair_progress, family_type, list("tier" = starting_tier, "step" = starting_step + 1)) + user.visible_message(SPAN_NOTICE("[user] finishes a step on \the [src]'s [tier_data["wound_name"]]."), SPAN_NOTICE("You finish a step. [length(steps) - starting_step - 1] more to go.")) + return + + var/revert_tier = tier_data["repair_reverts_to_tier"] + if(revert_tier) + LAZYSET(hull_wound_tiers, family_type, revert_tier) + else + LAZYREMOVE(hull_wound_tiers, family_type) + LAZYREMOVE(hull_wound_repair_progress, family_type) + user.visible_message(SPAN_NOTICE("[user] fixes \the [src]'s [tier_data["wound_name"]]."), SPAN_NOTICE("You fix \the [src]'s [tier_data["wound_name"]].")) diff --git a/code/modules/vehicles/hardpoints/hatch/hatch.dm b/code/modules/vehicles/hardpoints/hatch/hatch.dm new file mode 100644 index 000000000000..fba1f0f06c62 --- /dev/null +++ b/code/modules/vehicles/hardpoints/hatch/hatch.dm @@ -0,0 +1,30 @@ +/obj/item/hardpoint/hatch + name = "\improper hatch" + desc = "Controls access in and out of the vehicle." + + slot = HDPT_HATCH + hdpt_layer = HDPT_LAYER_SUPPORT + + icon = 'icons/obj/vehicles/hardpoints/shared.dmi' + icon_state = "hatch-0" + + health = 400 + +/obj/item/hardpoint/hatch/armored + name = "\improper armored hatch" + desc = "A reinforced hatch fitted to an armored vehicle." + + health = 550 + damage_multiplier = 0.6 + +/obj/item/hardpoint/hatch/armored/uscm + name = "\improper M39 hatch" + desc = "The armored rear hatch of a USCM ground vehicle. Standard issue across the fleet." + +/obj/item/hardpoint/hatch/update_icon() + icon = 'icons/obj/vehicles/hardpoints/shared.dmi' + icon_state = "hatch-[get_shared_damage_suffix()]" + +/obj/item/hardpoint/hatch/on_uninstall(obj/vehicle/multitile/vehicle) + update_icon() + . = ..() diff --git a/code/modules/vehicles/hardpoints/holder/holder.dm b/code/modules/vehicles/hardpoints/holder/holder.dm index a1797cb68b8b..67fc240a8dfd 100644 --- a/code/modules/vehicles/hardpoints/holder/holder.dm +++ b/code/modules/vehicles/hardpoints/holder/holder.dm @@ -22,27 +22,30 @@ /obj/item/hardpoint/holder/get_examine_text(mob/user) . = ..() if(health <= 0) - . += "It's busted!" + . += SPAN_BOLDWARNING("It's busted!") else if(isobserver(user) || (ishuman(user) && (skillcheck(user, SKILL_ENGINEER, SKILL_ENGINEER_NOVICE) || skillcheck(user, SKILL_VEHICLE, SKILL_VEHICLE_CREWMAN)))) . += "It's at [round(get_integrity_percent(), 1)]% integrity!" for(var/obj/item/hardpoint/H in hardpoints) - . += "There is \a [H] module installed on [src]." - . += H.get_examine_text(user, TRUE) - + var/line = "There is \a [H] module installed on [src]." + // Styling has to live inside the tag's own text to render red, not wrap it. + // A mounted weapon at 0% health always reads "DESTROYED" and red/bold. + var/is_destroyed = H.health <= 0 + var/name_text = is_destroyed ? "DESTROYED [H.name]" : H.name + var/display_name = (is_destroyed || LAZYLEN(H.wound_tiers)) ? SPAN_BOLDWARNING(name_text) : name_text + var/styled_name = "[display_name]" + line = replacetext(line, H.name, styled_name) + . += "[icon2html(H, user)] [line]" + . += get_missing_hardpoint_slot_lines(accepted_hardpoints, hardpoints) + +/// Returns a flat list of dicts: this holder's own entry followed by one entry per mounted hardpoint. /obj/item/hardpoint/holder/get_tgui_info() - var/list/data = list() + var/list/data = list(..()) for(var/obj/item/hardpoint/H in hardpoints) data += list(H.get_tgui_info()) return data -/obj/item/hardpoint/holder/take_damage(damage) - ..() - - for(var/obj/item/hardpoint/H in hardpoints) - H.take_damage(damage) - /obj/item/hardpoint/holder/on_install(obj/vehicle/multitile/vehicle) ..() if(!vehicle) //in loose holder @@ -56,6 +59,8 @@ return for(var/obj/item/hardpoint/hardpoint in hardpoints) hardpoint.on_uninstall(vehicle) + // Must run before owner is nulled below, or reset_rottation() has nothing to read and no-ops. + hardpoint.reset_rotation() hardpoint.owner = null ..() @@ -135,6 +140,9 @@ H.on_install(owner) H.rotate(turning_angle(H.dir, dir)) + owner?.ensure_active_hardpoint(VEHICLE_DRIVER) + owner?.ensure_active_hardpoint(VEHICLE_GUNNER) + /obj/item/hardpoint/holder/proc/remove_hardpoint(obj/item/hardpoint/H, turf/uninstall_to) if(!hardpoints) return @@ -142,12 +150,25 @@ H.on_uninstall(owner) H.reset_rotation() + + if(H.self_gimballed) + H.self_gimballed = FALSE + H.allowed_seat = initial(H.allowed_seat) + hardpoints -= H H.owner = null + owner?.refresh_hardpoint_actions() + owner?.ensure_active_hardpoint(VEHICLE_DRIVER) + owner?.ensure_active_hardpoint(VEHICLE_GUNNER) if(H.health <= 0) qdel(H) + // Same rider-drop fix as the top-level remove_hardpoint(). Only tanks have the rider system. + if(!QDELETED(H)) + var/obj/vehicle/multitile/tank/tank_owner = istype(owner, /obj/vehicle/multitile/tank) ? owner : null + tank_owner?.obj_mark_on_top(H) + //Returns all activatable hardpoints /obj/item/hardpoint/holder/proc/get_activatable_hardpoints(seat) var/list/hps = list() @@ -161,7 +182,7 @@ /obj/item/hardpoint/holder/proc/get_hardpoints_with_ammo(seat) var/list/hps = list() for(var/obj/item/hardpoint/H in hardpoints) - if(!H.ammo || seat && seat != H.allowed_seat) + if(!H.ammo_type || seat && seat != H.allowed_seat) continue hps += H return hps @@ -184,8 +205,11 @@ images += HI return images -/obj/item/hardpoint/holder/rotate(deg) +/obj/item/hardpoint/holder/rotate(deg, override_gyro = FALSE, sync_angle = TRUE) for(var/obj/item/hardpoint/H in hardpoints) + // A child already mid-uninstall has no owner left to read. Skip it instead of crashing. + if(!H.owner) + continue H.rotate(deg) - ..() + return ..(deg, override_gyro, sync_angle) diff --git a/code/modules/vehicles/hardpoints/holder/tank_turret.dm b/code/modules/vehicles/hardpoints/holder/tank_turret.dm index edb119cc1327..089143ad51f5 100644 --- a/code/modules/vehicles/hardpoints/holder/tank_turret.dm +++ b/code/modules/vehicles/hardpoints/holder/tank_turret.dm @@ -1,6 +1,6 @@ /obj/item/hardpoint/holder/tank_turret name = "\improper M34A2-A Multipurpose Turret" - desc = "The centerpiece of the tank. Designed to support quick installation and deinstallation of various tank weapon modules. Has inbuilt smoke screen deployment system." + desc = "The centerpiece of the tank. Designed to support quick installation and deinstallation of various tank weapon modules. Has inbuilt flare launcher." icon = 'icons/obj/vehicles/tank.dmi' icon_state = "tank_turret_0" @@ -14,8 +14,8 @@ activatable = TRUE - ammo = new /obj/item/ammo_magazine/hardpoint/turret_smoke - max_clips = 2 + ammo = new /obj/item/ammo_magazine/hardpoint/turret_flare + max_clips = 0 use_muzzle_flash = FALSE w_class = SIZE_MASSIVE @@ -28,7 +28,7 @@ // big beefy chonk of metal health = 450 - damage_multiplier = 0.05 + damage_multiplier = 0.1 accepted_hardpoints = list( // primaries @@ -40,7 +40,13 @@ /obj/item/hardpoint/secondary/small_flamer, /obj/item/hardpoint/secondary/towlauncher, /obj/item/hardpoint/secondary/m56cupola, - /obj/item/hardpoint/secondary/grenade_launcher + /obj/item/hardpoint/secondary/grenade_launcher, + /obj/item/hardpoint/secondary/brute_launcher, + /obj/item/hardpoint/secondary/united_americas_flag, + /obj/item/hardpoint/iff_module/uscm, + /obj/item/hardpoint/visual_sensors/uscm, + /obj/item/hardpoint/turret_ring/uscm, + /obj/item/hardpoint/air_filter/uscm, ) hdpt_layer = HDPT_LAYER_TURRET @@ -51,21 +57,68 @@ "8" = list(10, 0) ) - var/gyro = FALSE + /// Index into accepted_hardpoints, used by /mob/living/carbon/verb/cycle_tank_weapon_hardpoint() to track cycling progress. + var/debug_cycle_index = 0 - // How long the windup is before the turret rotates - var/rotation_windup = 15 - // Used during the windup - var/rotating = FALSE + var/datum/looping_sound/tank_turret/turret_soundloop + + /// TRUE while a forced turn (Warrior Punch/Oppressor Dislocate) hasn't been released yet. + var/forced_wrench_active = FALSE + /// Turret's real max_angular_velocity/angular_accel, snapshotted once before the first forced wrench. + var/forced_wrench_original_velocity + var/forced_wrench_original_accel + + /// External turn-rate buff/malus multiplier, applied on top of everything else in recalculate_turn_rate(). + var/turret_turn_rate_mult = 1 scatter = 4 - gun_firemode = GUN_FIREMODE_BURSTFIRE - gun_firemode_list = list( - GUN_FIREMODE_BURSTFIRE, - ) - burst_amount = 2 - burst_delay = 1.0 SECONDS - extra_delay = 13.0 SECONDS + fire_delay = 7.0 SECONDS + + uses_starshell_ammo = TRUE + +/obj/item/hardpoint/holder/tank_turret/Initialize() + . = ..() + current_angle = dir2angle(dir) + desired_angle = current_angle + // Loaded shell-by-shell rather than swapped as a whole magazine. + ammo_type = null + turret_soundloop = new(src) + +/obj/item/hardpoint/holder/tank_turret/Destroy() + QDEL_NULL(turret_soundloop) + return ..() + +/obj/item/hardpoint/holder/tank_turret/on_rotation_started() + // A hand-cranked turret has no motor running, so no traverse noise. + if(owner?.has_vehicle_power() && turret_soundloop) + turret_soundloop.mid_sounds = is_damaged() ? 'sound/vehicles/turretdamaged.ogg' : 'sound/vehicles/tankturret.ogg' + turret_soundloop.start() + set_turret_ring_power_draw(TURRET_RING_ROTATING_POWER_DRAW) + +/obj/item/hardpoint/holder/tank_turret/on_rotation_stopped() + turret_soundloop?.stop() + set_turret_ring_power_draw(TURRET_RING_IDLE_POWER_DRAW) + +/** + * Whether the turret is damaged enough to grind (turretdamaged.ogg). + */ +/obj/item/hardpoint/holder/tank_turret/is_damaged() + var/obj/item/hardpoint/turret_ring/ring = locate() in hardpoints + return ring && (ring.get_integrity_percent() <= 50 || LAZYLEN(ring.wound_tiers)) + +/** + * Updates the turret ring's power_draw. Rotating pulls more power than idling. + * No-ops if there's no ring installed. + * + * Arguments: + * * new_draw = Power draw (units/sec) to set the ring to. + */ +/obj/item/hardpoint/holder/tank_turret/set_turret_ring_power_draw(new_draw) + if(!owner) + return + var/obj/item/hardpoint/turret_ring/ring = locate() in hardpoints + if(ring) + ring.power_draw = new_draw /obj/item/hardpoint/holder/tank_turret/update_icon() var/broken = (health <= 0) @@ -92,6 +145,40 @@ return I +/** + * Layers a continuous visual tilt on top of the turret's (and every mounted weapon's) cardinal + * sprite. The base dir still snaps every 90 degrees for sprite selection. + */ +/obj/item/hardpoint/holder/tank_turret/get_hardpoint_image() + var/list/images = ..() + + var/tilt = angle_delta(current_angle, dir2angle(dir)) + + var/image/turret_image = images[1] + if(turret_image && tilt) + turret_image.transform = build_tilt_matrix(tilt, interpolate_pivot(rotation_pivot, current_angle)) + + var/image_index = 2 + for(var/obj/item/hardpoint/weapon in hardpoints) + var/image/weapon_image = images[image_index] + image_index++ + if(!weapon_image) + continue + + // A self_gimballed weapon needs TWO nested rotations around TWO genuinely different pivots. + // Collapsing these into one combined rotation only works if both + // pivots happen to coincide, which isn't the case. + if(weapon.self_gimballed) + var/own_tilt = angle_delta(weapon.current_angle, current_angle) + if(own_tilt || tilt) + var/list/inner_pivot = weapon.interpolate_pivot(weapon.gimbal_pivot, current_angle) || weapon.interpolate_pivot(weapon.rotation_pivot, current_angle) + var/list/outer_pivot = weapon.interpolate_pivot(weapon.rotation_pivot, current_angle) + weapon_image.transform = weapon.build_nested_tilt_matrix(own_tilt, inner_pivot, tilt, outer_pivot) + else if(tilt) + weapon_image.transform = build_tilt_matrix(tilt, weapon.interpolate_pivot(weapon.rotation_pivot, current_angle)) + + return images + // no picking this big beast up /obj/item/hardpoint/holder/tank_turret/attack_hand(mob/user) return @@ -113,18 +200,18 @@ return TRUE ..() - /obj/item/hardpoint/holder/tank_turret/get_tgui_info() var/list/data = list() - data += list(list( // turret smokescreen data - "name" = "M34A2-A Turret Smoke Screen", + data += list(list( // turret flare launcher data + "name" = "M34A2-A Turret Flare Launcher", "health" = health <= 0 ? null : floor(get_integrity_percent()), "uses_ammo" = TRUE, - "current_rounds" = ammo.current_rounds / 2, - "max_rounds"= ammo.max_rounds / 2, + "current_rounds" = ammo.current_rounds, + "max_rounds"= ammo.max_rounds, "mags" = LAZYLEN(backup_clips), "max_mags" = max_clips, + "wounds" = get_wound_tgui_data(), )) for(var/obj/item/hardpoint/H in hardpoints) @@ -132,83 +219,102 @@ return data -//gyro ON locks the turret in one direction, OFF will make turret turning when tank turns -/obj/item/hardpoint/holder/tank_turret/proc/toggle_gyro(mob/user) - if(health <= 0) - to_chat(user, SPAN_WARNING("\The [src]'s stabilization systems are busted!")) - return - - gyro = !gyro - to_chat(user, SPAN_NOTICE("You toggle \the [src]'s gyroscopic stabilizer [gyro ? "ON" :"OFF"].")) +/// Keeps dir/origins in sync with current_angle's quadrant, then refreshes the vehicle's sprite. +/obj/item/hardpoint/holder/tank_turret/apply_current_angle() + var/target_cardinal = angle_to_cardinal(current_angle) + if(target_cardinal != dir) + rotate(turning_angle(dir, target_cardinal), override_gyro = TRUE, sync_angle = FALSE) + owner.update_icon() -/obj/item/hardpoint/holder/tank_turret/proc/user_rotation(mob/user, deg) - // no rotating a broken turret - if(health <= 0) +// See base proc's doc comment (hardpoint.dm) - drags any self_gimballed mounted weapon's angle along with this turret's own, so it keeps riding the turret's motion instead of holding a fixed absolute world angle. +/obj/item/hardpoint/holder/tank_turret/drag_self_gimballed_weapons(delta) + if(!delta) return - - if(rotating) + for(var/obj/item/hardpoint/mounted_weapon in hardpoints) + if(!mounted_weapon.self_gimballed) + continue + // %% (not %) since angles are often fractional. + mounted_weapon.current_angle = ((mounted_weapon.current_angle + delta) %% 360 + 360) %% 360 + mounted_weapon.desired_angle = ((mounted_weapon.desired_angle + delta) %% 360 + 360) %% 360 + +/** + * Gates firing for a weapon mounted on this turret. current_angle must have caught up to within + * FIRING_GATE_TOLERANCE of the angle from tthe weapon's own muzzle to target. + */ +/obj/item/hardpoint/holder/tank_turret/proc/in_turret_firing_arc(obj/item/hardpoint/weapon, atom/target) + var/turf/muzzle_turf = weapon.get_origin_turf() + var/turf/target_turf = get_turf(target) + + //same tile angle is undefined for Get_Angle, returning FALSE to match the legacy static-arc check + if(muzzle_turf == target_turf) + return FALSE + + var/target_angle = Get_Angle(muzzle_turf, target_turf) + . = abs(angle_delta(target_angle, current_angle)) <= FIRING_GATE_TOLERANCE + +/** + * Recomputes max_angular_velocity from the mounted primary weapon's traverse_arc, or + * TURRET_EMPTY_TRAVERSE_ARC if no primary is mounted. + * + * Scaled by the turret ring's condition, floored at TURRET_ABSOLUTE_MIN_ANGULAR_VELOCITY unless + * the ring is destroyed or missing. + * + * Skipped while forced_wrench_active, so a forced turn's speed boost doesn't get overwritten mid-wrench. + * + * Finally scaled by turret_turn_rate_mult. + */ +/obj/item/hardpoint/holder/tank_turret/proc/recalculate_turn_rate(ignore_battery = FALSE) + if(forced_wrench_active) return - - rotating = TRUE - to_chat(user, SPAN_NOTICE("You begin rotating the turret towards the [dir2text(turn(dir,deg))].")) - - if(!do_after(user, rotation_windup, INTERRUPT_ALL, BUSY_ICON_GENERIC)) - rotating = FALSE + var/obj/item/hardpoint/primary/mounted_primary = locate(/obj/item/hardpoint/primary) in hardpoints + var/arc = (mounted_primary && mounted_primary.traverse_arc) ? mounted_primary.traverse_arc : TURRET_EMPTY_TRAVERSE_ARC + + max_angular_velocity = max(TURRET_BASE_ANGULAR_VELOCITY * (arc / TURRET_ARC_NORMALIZATION), TURRET_MIN_ANGULAR_VELOCITY) + + var/ring_scale = get_turret_ring_scale(ignore_battery) + max_angular_velocity *= ring_scale + if(ring_scale > 0) + max_angular_velocity = max(max_angular_velocity, TURRET_ABSOLUTE_MIN_ANGULAR_VELOCITY) + max_angular_velocity *= turret_turn_rate_mult + +/** + * Turret ring integrity as a 0-1 scale, ramping down below WEAPON_DEGRADE_GRACE_THRESHOLD_PCT. + * Further scaled by active turn_rate_mult wounds, or 0 if no ring is installed. + * + * Additionally scaled by TURRET_NO_POWER_TURN_RATE_FRACTION when the vehicle has no power. + */ +/obj/item/hardpoint/holder/tank_turret/proc/get_turret_ring_scale(ignore_battery = FALSE) + if(!owner) + return 0 + var/obj/item/hardpoint/turret_ring/ring = locate() in hardpoints + if(!ring) + return 0 + . = ring.get_own_health_scale() * ring.get_wound_effect_multiplier("turn_rate_mult") + if(!owner.has_vehicle_power(ignore_battery)) + . *= TURRET_NO_POWER_TURN_RATE_FRACTION + +/obj/item/hardpoint/holder/tank_turret/add_hardpoint(obj/item/hardpoint/new_hardpoint) + . = ..() + recalculate_turn_rate() + +/obj/item/hardpoint/holder/tank_turret/remove_hardpoint(obj/item/hardpoint/removed_hardpoint, turf/uninstall_to) + . = ..() + recalculate_turn_rate() + +/// Only the turret-specific extras live here now. Gyro-check and current_angle sync live in the base proc. +/obj/item/hardpoint/holder/tank_turret/rotate(deg, override_gyro = FALSE, sync_angle = TRUE) + . = ..(deg, override_gyro, sync_angle) + if(!.) return - rotating = FALSE - rotate(deg, TRUE) - -/obj/item/hardpoint/holder/tank_turret/rotate(deg, override_gyro = FALSE) - if(gyro && !override_gyro) - return - - ..(deg) + if(sync_angle) + drag_self_gimballed_weapons(-deg) var/obj/vehicle/multitile/tank/C = owner var/obj/item/hardpoint/support/artillery_module/AM for(var/obj/item/hardpoint/support/artillery_module/A in C.hardpoints) AM = A - if(AM && AM.is_active) - var/mob/user = C.seats[VEHICLE_GUNNER] - if(user && user.client) - user = C.seats[VEHICLE_GUNNER] - user.client.change_view(AM.view_buff, src) - - switch(dir) - if(NORTH) - user.client.set_pixel_x(0) - user.client.set_pixel_y(AM.view_tile_offset * 32) - if(SOUTH) - user.client.set_pixel_x(0) - user.client.set_pixel_y(-1 * AM.view_tile_offset * 32) - if(EAST) - user.client.set_pixel_x(AM.view_tile_offset * 32) - user.client.set_pixel_y(0) - if(WEST) - user.client.set_pixel_x(-1 * AM.view_tile_offset * 32) - user.client.set_pixel_y(0) - -/obj/item/hardpoint/holder/tank_turret/try_fire(atom/target, mob/living/user, params) - var/turf/L - var/turf/R - switch(owner.dir) - if(NORTH) - L = locate(owner.x - 2, owner.y + 4, owner.z) - R = locate(owner.x + 2, owner.y + 4, owner.z) - if(SOUTH) - L = locate(owner.x + 2, owner.y - 4, owner.z) - R = locate(owner.x - 2, owner.y - 4, owner.z) - if(EAST) - L = locate(owner.x + 4, owner.y + 2, owner.z) - R = locate(owner.x + 4, owner.y - 2, owner.z) - else - L = locate(owner.x - 4, owner.y + 2, owner.z) - R = locate(owner.x - 4, owner.y - 2, owner.z) - - if(shots_fired) - target = R - else - target = L - - return ..() + if(AM) + var/mob/living/user = C.seats[VEHICLE_GUNNER] + if(user && (user in AM.optics_users)) + AM.apply_gunner_view(user) diff --git a/code/modules/vehicles/hardpoints/iff_module/iff_module.dm b/code/modules/vehicles/hardpoints/iff_module/iff_module.dm new file mode 100644 index 000000000000..9c4a40f67389 --- /dev/null +++ b/code/modules/vehicles/hardpoints/iff_module/iff_module.dm @@ -0,0 +1,114 @@ +/obj/item/hardpoint/iff_module + name = "\improper IFF module" + desc = "Sets whether the vehicle's weapons recognize friendly IFF-tagged rounds." + + slot = HDPT_IFF_MODULE + hdpt_layer = HDPT_LAYER_SUPPORT + + icon = 'icons/obj/vehicles/hardpoints/shared.dmi' + icon_state = "iff-0" + + health = 300 + damage_multiplier = 0.2 + power_draw = IFF_MODULE_POWER_DRAW + + /// Faction this module is set to, used for weapon IFF checks. + var/set_faction = FACTION_MARINE + + /// TRUE if the crew wants IFF broadcasting on. + var/manually_enabled = FALSE + +/obj/item/hardpoint/iff_module/uscm + name = "\improper AN/PPX-4 IFF module" + desc = "This module allows crewed turrets to respect friend-and-foe identifications. Necessary for unmanned, automated turrets to operate." + +/obj/item/hardpoint/iff_module/update_icon() + icon = 'icons/obj/vehicles/hardpoints/shared.dmi' + icon_state = "iff-[get_shared_damage_suffix()]" + +/// List of factions selectable from the module. +/obj/item/hardpoint/iff_module/proc/get_selectable_factions() + return list(FACTION_MARINE, FACTION_UPP, FACTION_TWE, FACTION_PMC, FACTION_CLF, FACTION_WY, FACTION_NEUTRAL) + +/obj/item/hardpoint/iff_module/attack_self(mob/user) + . = ..() + var/chosen = tgui_input_list(user, "Set IFF faction", "IFF Module", get_selectable_factions(), set_faction) + if(!chosen || !Adjacent(user)) + return + set_faction = chosen + to_chat(user, SPAN_NOTICE("You set \the [src]'s IFF faction to [set_faction].")) + +/obj/item/hardpoint/iff_module/get_examine_text(mob/user) + . = ..() + . += "It's set to recognize [set_faction] IFF." + +/// Whether this module is currently broadcasting a working IFF signal. +/obj/item/hardpoint/iff_module/proc/is_functional(ignore_battery = FALSE) + if(!manually_enabled || health <= 0 || get_wound_effect_flag("iff_disabled")) + return FALSE + return owner && owner.has_vehicle_power(ignore_battery) + +/** + * Manually toggles this module's IFF broadcast on/off. + * Turning it back on is blocked if the module is destroyed, disabled, or unpowered. + */ +/obj/item/hardpoint/iff_module/proc/toggle_online(mob/user) + if(manually_enabled) + manually_enabled = FALSE + check_functional_transition() + to_chat(user, SPAN_NOTICE("You shut down \the [src]'s IFF broadcast.")) + return + + if(health <= 0) + to_chat(user, SPAN_WARNING("\The [src] is destroyed - it can't be reactivated.")) + return + if(get_wound_effect_flag("iff_disabled")) + to_chat(user, SPAN_WARNING("\The [src] is too damaged to reactivate - repair it first.")) + return + if(!owner || !owner.has_vehicle_power()) + to_chat(user, SPAN_WARNING("\The [src] has no power - start the engine or install a charged battery first.")) + return + + manually_enabled = TRUE + check_functional_transition() + to_chat(user, SPAN_NOTICE("You bring \the [src]'s IFF broadcast back online.")) + +/obj/item/hardpoint/iff_module/take_damage(damage, type = "abstract", atom/attacker, unmitigated = FALSE, wound_chance_mult = 1) + . = ..() + check_functional_transition() + +/obj/item/hardpoint/iff_module/recalculate_wound_effects() + . = ..() + check_functional_transition() + +/obj/item/hardpoint/iff_module/on_install(obj/vehicle/multitile/vehicle) + . = ..() + check_functional_transition(vehicle) + +// Forces the offline state directly since the vehicle will have no IFF module left after this. +/obj/item/hardpoint/iff_module/on_uninstall(obj/vehicle/multitile/vehicle) + check_functional_transition(vehicle, force_offline = TRUE) + update_icon() + . = ..() + +/** + * Plays an online/offline beep whenever this vehicle's IFF functional state changes. + * Forces manually_enabled off on shutdown, requires a deliberate toggle_online() to recover. + * + * Arguments: + * * vehicle = The vehicle to check/update. Defaults to owner. + * * force_offline = Treat this as a hard "no module" transition. + * * ignore_battery = Passed through to is_functional()/has_vehicle_power(). + */ +/obj/item/hardpoint/iff_module/proc/check_functional_transition(obj/vehicle/multitile/vehicle = owner, force_offline = FALSE, ignore_battery = FALSE) + if(!vehicle) + return + var/currently_functional = force_offline ? FALSE : is_functional(ignore_battery) + if(currently_functional == vehicle.iff_online) + return + vehicle.iff_online = currently_functional + if(!currently_functional) + manually_enabled = FALSE + playsound(vehicle, currently_functional ? 'sound/vehicles/iffbeepactive.ogg' : 'sound/vehicles/iffbeepoff.ogg', 40, FALSE) + vehicle.refresh_hardpoint_actions() + vehicle.refresh_overwatch_camera_state() diff --git a/code/modules/vehicles/hardpoints/primary/arc_sentry.dm b/code/modules/vehicles/hardpoints/primary/arc_sentry.dm index 2ff6ac280993..8113e41ab951 100644 --- a/code/modules/vehicles/hardpoints/primary/arc_sentry.dm +++ b/code/modules/vehicles/hardpoints/primary/arc_sentry.dm @@ -13,11 +13,19 @@ health = 125 origins = list(0, 0) + traverse_arc = 360 + + px_offsets = list( + "1" = list(0, -5), + "2" = list(0, 9), + "4" = list(-10, 19), + "8" = list(10, 18) + ) ammo = new /obj/item/ammo_magazine/hardpoint/arc_sentry max_clips = 2 - use_muzzle_flash = TRUE + use_muzzle_flash = FALSE angle_muzzleflash = FALSE muzzleflash_icon_state = "muzzle_flash_double" @@ -27,12 +35,11 @@ "4" = list(16, 3), "8" = list(-16, 3) ) - gun_firemode = GUN_FIREMODE_BURSTFIRE + gun_firemode = GUN_FIREMODE_AUTOMATIC gun_firemode_list = list( - GUN_FIREMODE_BURSTFIRE, + GUN_FIREMODE_AUTOMATIC, ) - burst_delay = 2 - burst_amount = 3 + fire_delay = 0.2 SECONDS /// Potential targets the turret can shoot at var/list/targets = list() @@ -40,8 +47,27 @@ var/atom/movable/sentry_target = null /// The range that this turret can shoot at the furthest var/turret_range = 5 - /// What factions this sentry is aligned with - var/faction_group = FACTION_LIST_MARINE + /// TRUE while a recenter_turret() callback is pending, so losing targets repeatedly doesn't keep pushing the 5-second wait back out. + var/recenter_scheduled = FALSE + /// Width, in degrees, of the arc around the target's true bearing this turret must be facing within before it'll open fire. + var/firing_arc_width = 45 + +/// This vehicle's installed IFF module, if any. +/obj/item/hardpoint/primary/arc_sentry/proc/get_iff_module() + if(!owner) + return null + return locate(/obj/item/hardpoint/iff_module) in owner.get_hardpoints_copy() + +/// The faction this turret currently protects from friendly fire, read live from the installed IFF module. Null if none is installed and working. +/obj/item/hardpoint/primary/arc_sentry/proc/get_effective_faction_group() + var/obj/item/hardpoint/iff_module/iff = get_iff_module() + if(!iff || !iff.is_functional()) + return null + return iff.set_faction + +/obj/item/hardpoint/primary/arc_sentry/recalculate_own_turn_rate() + . = ..() + max_angular_velocity *= 2 /obj/item/hardpoint/primary/arc_sentry/on_install(obj/vehicle/multitile/vehicle) . = ..() @@ -51,9 +77,11 @@ /obj/item/hardpoint/primary/arc_sentry/on_uninstall(obj/vehicle/multitile/vehicle) . = ..() UnregisterSignal(owner, COMSIG_ARC_ANTENNA_TOGGLED) + SEND_SIGNAL(src, COMSIG_GUN_STOP_FIRE) STOP_PROCESSING(SSfastobj, src) /obj/item/hardpoint/primary/arc_sentry/Destroy() + SEND_SIGNAL(src, COMSIG_GUN_STOP_FIRE) STOP_PROCESSING(SSfastobj, src) sentry_target = null return ..() @@ -65,16 +93,34 @@ var/obj/vehicle/multitile/arc/vehicle = owner if(vehicle.antenna_deployed) + // Hands aiming back to the auto-targeting loop. + turret_safety_on = FALSE + recalculate_gyro() + aim_locked = FALSE START_PROCESSING(SSfastobj, src) else + // Stops firing immediately, since process() won't notice the antenna retracting on its own. + // Also locks the turret to hull facing + SEND_SIGNAL(src, COMSIG_GUN_STOP_FIRE) + turret_safety_on = TRUE + recalculate_gyro() + aim_locked = TRUE + desired_angle = dir2angle(owner.dir) + start_rotation_if_needed() STOP_PROCESSING(SSfastobj, src) /obj/item/hardpoint/primary/arc_sentry/process() + // No working IFF module means no faction to protect, so refuse to engage at all. + if(!get_effective_faction_group()) + handle_no_target() + return FALSE + for(var/mob/living/in_range_mob in range(turret_range, owner)) targets |= in_range_mob if(!length(targets)) + handle_no_target() return FALSE if(!sentry_target) @@ -83,10 +129,34 @@ get_target(sentry_target) return TRUE -/obj/item/hardpoint/primary/arc_sentry/set_bullet_traits() - LAZYADD(traits_to_give, list( - BULLET_TRAIT_ENTRY(/datum/element/bullet_trait_iff, faction_group) - )) +// Stops the turret from firing once it has no target. +/obj/item/hardpoint/primary/arc_sentry/proc/handle_no_target() + SEND_SIGNAL(src, COMSIG_GUN_STOP_FIRE) + if(recenter_scheduled) + return + recenter_scheduled = TRUE + addtimer(CALLBACK(src, PROC_REF(recenter_turret)), 5 SECONDS) // Makes it turn back to face the hull. + +/// Turns the turret back to face the hull's own direction, unless a target's been reacquired since this was scheduled. +/obj/item/hardpoint/primary/arc_sentry/proc/recenter_turret() + recenter_scheduled = FALSE + if(sentry_target || !owner) + return + desired_angle = dir2angle(owner.dir) + // Already facing the hull, no actual turn (and no rotation sound) needed. + if(abs(angle_delta(desired_angle, current_angle)) < ROTATION_SETTLE_TOLERANCE) + return + start_rotation_if_needed() + +/** + * Tags each fired bullet with the IFF module's current faction, read fresh at the moment of firing. + */ +/obj/item/hardpoint/primary/arc_sentry/generate_bullet(mob/user, turf/origin_turf) + . = ..() + var/obj/projectile/fired_bullet = . + if(!fired_bullet) + return + fired_bullet.runtime_iff_group = get_effective_faction_group() /obj/item/hardpoint/primary/arc_sentry/fire_wrapper(atom/target, mob/living/user, params) if(!target) @@ -99,21 +169,25 @@ /obj/item/hardpoint/primary/arc_sentry/start_fire(datum/source, atom/object, turf/location, control, params) if(QDELETED(object)) return - if(!COOLDOWN_FINISHED(src, fire_cooldown)) - return + // Keeps target synced even mid-cooldown, so full-auto fire doesn't go stale on a dead target. set_target(object) + + if(!COOLDOWN_FINISHED(src, fire_cooldown)) + return SEND_SIGNAL(src, COMSIG_GUN_FIRE) /obj/item/hardpoint/primary/arc_sentry/proc/get_target(atom/movable/new_target) if(QDELETED(new_target)) sentry_target = null + handle_no_target() return if(!targets.Find(new_target)) targets.Add(new_target) if(!length(targets)) + handle_no_target() return var/list/conscious_targets = list() @@ -124,7 +198,7 @@ purge_target(living_mob) continue - if(living_mob.get_target_lock(faction_group) || living_mob.invisibility || HAS_TRAIT(living_mob, TRAIT_ABILITY_BURROWED)) + if(living_mob.get_target_lock(get_effective_faction_group()) || living_mob.invisibility || HAS_TRAIT(living_mob, TRAIT_ABILITY_BURROWED)) purge_target(living_mob) continue @@ -175,6 +249,14 @@ sentry_target = pick(unconscious_targets) if(!sentry_target) //No targets, don't bother firing + handle_no_target() + return + + recenter_scheduled = FALSE + update_desired_angle(sentry_target, null, null) + + if(abs(angle_delta(desired_angle, current_angle)) > firing_arc_width * 0.5) + SEND_SIGNAL(src, COMSIG_GUN_STOP_FIRE) return start_fire(object = sentry_target) diff --git a/code/modules/vehicles/hardpoints/primary/autocannon.dm b/code/modules/vehicles/hardpoints/primary/autocannon.dm index 8148e5357dd8..8b6f26bc67ff 100644 --- a/code/modules/vehicles/hardpoints/primary/autocannon.dm +++ b/code/modules/vehicles/hardpoints/primary/autocannon.dm @@ -8,7 +8,7 @@ activation_sounds = list('sound/weapons/vehicles/autocannon_fire.ogg') health = 500 - firing_arc = 60 + traverse_arc = 90 ammo = new /obj/item/ammo_magazine/hardpoint/ace_autocannon max_clips = 2 @@ -19,10 +19,16 @@ "4" = list(32, 0), "8" = list(-32, 0) ) + rotation_pivot = list( + "1" = list(0, -22), + "2" = list(0, 32), + "4" = list(-32, 0), + "8" = list(32, 0) + ) scatter = 1 gun_firemode = GUN_FIREMODE_AUTOMATIC gun_firemode_list = list( GUN_FIREMODE_AUTOMATIC, ) - fire_delay = 0.7 SECONDS + fire_delay = 1.4 SECONDS diff --git a/code/modules/vehicles/hardpoints/primary/dual_cannon.dm b/code/modules/vehicles/hardpoints/primary/dual_cannon.dm index 9f4c7286afeb..2c37fa8046c9 100644 --- a/code/modules/vehicles/hardpoints/primary/dual_cannon.dm +++ b/code/modules/vehicles/hardpoints/primary/dual_cannon.dm @@ -12,22 +12,22 @@ damage_multiplier = 0.2 health = 500 - firing_arc = 60 + traverse_arc = 60 origins = list(0, 1) ammo = new /obj/item/ammo_magazine/hardpoint/boyars_dualcannon max_clips = 2 - use_muzzle_flash = TRUE - angle_muzzleflash = FALSE + // Disabled until muzzle flashes are reworked to follow a rotating weapon. + use_muzzle_flash = FALSE muzzleflash_icon_state = "muzzle_flash_double" - muzzle_flash_pos = list( - "1" = list(11, -29), - "2" = list(-11, 10), - "4" = list(-14, 9), - "8" = list(14, 9) + px_offsets = list( + "1" = list(11, -39), + "2" = list(-10, 50), + "4" = list(-40, 24), + "8" = list(40, 24) ) scatter = 1 @@ -46,3 +46,10 @@ /obj/item/hardpoint/primary/dualcannon/pmc icon_state = "dual_cannon_wy" disp_icon_state = "dual_cannon_wy" + + px_offsets = list( + "1" = list(11, -39), + "2" = list(-10, 50), + "4" = list(-40, 4), + "8" = list(40, 24) + ) diff --git a/code/modules/vehicles/hardpoints/primary/flamer.dm b/code/modules/vehicles/hardpoints/primary/flamer.dm index 60a7979cd5fb..45cc1f0bd34f 100644 --- a/code/modules/vehicles/hardpoints/primary/flamer.dm +++ b/code/modules/vehicles/hardpoints/primary/flamer.dm @@ -8,22 +8,34 @@ activation_sounds = list('sound/weapons/vehicles/flamethrower.ogg') health = 400 - firing_arc = 90 + traverse_arc = 110 ammo = new /obj/item/ammo_magazine/hardpoint/primary_flamer max_clips = 1 px_offsets = list( - "1" = list(0, 21), - "2" = list(0, -32), + "1" = list(0, 27), + "2" = list(0, -26), "4" = list(32, 1), "8" = list(-32, 1) ) + rotation_pivot = list( + "1" = list(0, -27), + "2" = list(0, 26), + "4" = list(-32, -1), + "8" = list(32, -1) + ) use_muzzle_flash = FALSE scatter = 5 - fire_delay = 2.0 SECONDS + fire_delay = 3.0 SECONDS + + var/flame_mode = FLAME_MODE_GLOB + // Flat fuel cost for a single FLAME_MODE_GLOB shot + var/glob_fuel_cost = 5 + // The reagent-based ammo/fuel deduction is done manually in handle_fire() below + ammo_cost_per_shot = 0 /obj/item/hardpoint/primary/flamer/set_bullet_traits() ..() @@ -37,3 +49,52 @@ return NONE return ..() + +// Toggles between FLAME_MODE_GLOB and FLAME_MODE_STREAM +/obj/item/hardpoint/primary/flamer/toggle_fire_mode(mob/user) + flame_mode = (flame_mode == FLAME_MODE_STREAM) ? FLAME_MODE_GLOB : FLAME_MODE_STREAM + to_chat(user, SPAN_NOTICE("You switch \the [src] to [flame_mode == FLAME_MODE_STREAM ? "stream" : "glob shot"] mode.")) + +/obj/item/hardpoint/primary/flamer/get_flame_mode() + return flame_mode + +/obj/item/hardpoint/primary/flamer/handle_fire(atom/target, mob/living/user, params) + // A smoke tank overrides flame_mode entirely and always disperses smoke, like the M240. + if(istype(ammo, /obj/item/ammo_magazine/hardpoint/primary_flamer/smoke)) + return fire_smoke_stream(target, user, DRG_N_SMOKE_RANGE, DRG_N_SMOKE_UNITS) + + if(flame_mode == FLAME_MODE_STREAM) + // FLAMESHAPE_TRIANGLE is a fixed weapon trait matching the DRG-N's "wide radius" flavor text. + // DRG_N_STREAM_RANGE_BONUS is a flat extra reach on top of the loaded fuel's own chem.rangefire. + return fire_flame_stream(target, user, FLAMESHAPE_TRIANGLE, range_bonus = DRG_N_STREAM_RANGE_BONUS) + + var/datum/reagent/chem = LAZYACCESS(ammo.reagents?.reagent_list, 1) + if(!chem || ammo.reagents.get_reagent_amount(chem.id) < glob_fuel_cost) + click_empty(user) + return NONE + ammo.reagents.remove_reagent(chem.id, glob_fuel_cost) + sync_ammo_from_reagents() + return ..() + +// Tags the fired glob with whichever fuel is loaded, and tints it to match. +// Glob range doesn't depend on chem.rangefire, it always reaches its targeted tile. +/obj/item/hardpoint/primary/flamer/generate_bullet(mob/user, turf/origin_turf) + . = ..() + var/obj/projectile/fired = . + if(istype(fired?.ammo, /datum/ammo/flamethrower/tank_flamer)) + var/datum/ammo/flamethrower/tank_flamer/glob_ammo = fired.ammo + glob_ammo.max_range += DRG_N_GLOB_RANGE_BONUS + var/datum/reagent/chem = LAZYACCESS(ammo.reagents?.reagent_list, 1) + glob_ammo.loaded_chem_id = chem?.id + if(chem) + fired.color = chem.burncolor + fired.hit_effect_color = chem.burncolor + +// Layers an overlay on the mounted/turret sprite, colored via mix_color_from_reagents() to match whatever fuel is currently loaded +/obj/item/hardpoint/primary/flamer/get_icon_image(x_offset, y_offset, new_dir) + var/image/base_image = ..() + var/image/strip = image(icon = disp_icon, icon_state = "drgn_flamer_strip", dir = new_dir) + if(ammo?.reagents && length(ammo.reagents.reagent_list)) + strip.color = mix_color_from_reagents(ammo.reagents.reagent_list) + base_image.overlays += strip + return base_image diff --git a/code/modules/vehicles/hardpoints/primary/ltb.dm b/code/modules/vehicles/hardpoints/primary/ltb.dm index 6c818f30b8d2..b14327ea090b 100644 --- a/code/modules/vehicles/hardpoints/primary/ltb.dm +++ b/code/modules/vehicles/hardpoints/primary/ltb.dm @@ -1,3 +1,6 @@ +/// Low end of the LTB's aiming laser opacity ramp +#define LTB_LASER_MIN_ALPHA 50 + /obj/item/hardpoint/primary/cannon name = "\improper LTB Cannon" desc = "A primary cannon for tanks that shoots explosive rounds." @@ -8,10 +11,10 @@ activation_sounds = list('sound/weapons/vehicles/cannon_fire1.ogg', 'sound/weapons/vehicles/cannon_fire2.ogg') health = 500 - firing_arc = 60 + traverse_arc = 80 - ammo = new /obj/item/ammo_magazine/hardpoint/ltb_cannon - max_clips = 3 + ammo = new /obj/item/ammo_magazine/hardpoint/ltb_cannon/he_shell + max_clips = 1 px_offsets = list( "1" = list(0, 21), @@ -19,6 +22,12 @@ "4" = list(32, 0), "8" = list(-32, 0) ) + rotation_pivot = list( + "1" = list(0, -21), + "2" = list(0, 32), + "4" = list(-32, 0), + "8" = list(32, 0) + ) muzzle_flash_pos = list( "1" = list(0, 59), @@ -28,4 +37,168 @@ ) scatter = 2 - fire_delay = 20.0 SECONDS + fire_delay = 15.0 SECONDS + + /// How long the lock-on charge takes before firing is allowed. + var/f_aiming_time = 3 SECONDS + /// Stashed for generate_bullet() below, which doesn't receive target directly. + var/atom/homing_target + + var/image/lockon_icon + var/image/lockon_direction_icon + var/lockon_pixel_x = 0 + var/lockon_pixel_y = 0 + var/datum/beam/laser_beam + var/obj/effect/beam_anchor/anchor + /// Incrementted every start_aim_visuals(), lets a stale cycle_laser_color() invocation from a + /// previous charge recognize it's outdated and stop, even if a new charge starts before it wakes + /// up from its own sleep() and notices laser beam has already been replaced. fixes a very annoying bug where more than one reticle can appear. + var/aim_generation = 0 + /// world.time the current charge started - used to ramp the beam's opacity up gradually across the whole charge + var/charge_start_time = 0 + +/** + * Same lock-on/tracking charge as the secondary BRUTE launcher. Targets any living mob, blocked by the same things Aimed Shot is + * (dense+opaque turfs, opaque objects, obscuring smoke... + * The fired round homes in on the target same as Aimed Shot's own /datum/component/homing_projectile. + */ +/obj/item/hardpoint/primary/cannon/handle_fire(atom/target, mob/living/user, params) + if(charging) + SEND_SIGNAL(src, COMSIG_GUN_INTERRUPT_FIRE) + return NONE + + if(!isliving(target)) + to_chat(user, SPAN_WARNING("Invalid target!")) + return NONE + + var/mob/living/living_target = target + if(living_target.is_dead()) + to_chat(user, SPAN_WARNING("Invalid target!")) + return NONE + + if(check_shot_is_blocked(target)) + to_chat(user, SPAN_WARNING("Target obscured!")) + return NONE + + if(!track_and_charge(target, user, f_aiming_time)) + return NONE + + // Re-validate once more after the charge + if(QDELETED(target) || living_target.is_dead() || check_shot_is_blocked(target)) + to_chat(user, SPAN_WARNING("You lose the shot!")) + return NONE + + homing_target = target + . = ..() + homing_target = null + return . + +/obj/item/hardpoint/primary/cannon/generate_bullet(mob/user, turf/origin_turf) + var/obj/projectile/fired_projectile = ..() + if(homing_target) + fired_projectile.AddComponent(/datum/component/homing_projectile, homing_target, user) + + // Carries a shell's engraved message onto its fired projectile. + var/obj/item/ammo_magazine/hardpoint/ltb_cannon/shell = ammo + if(istype(shell) && shell.engraved_message) + fired_projectile.name += " \"[shell.engraved_message]\"" + + return fired_projectile + +/obj/item/hardpoint/primary/cannon/proc/check_shot_is_blocked(atom/target) + var/turf/origin_turf = get_origin_turf() + var/turf/target_turf = get_turf(target) + var/list/turf/path = get_line(origin_turf, target_turf, include_start_atom = FALSE) + if(!length(path)) + return TRUE + + for(var/turf/path_turf in path) + if(path_turf.density && path_turf.opacity) + return TRUE + + for(var/obj/path_obj in path_turf) + if(path_obj.get_projectile_hit_boolean(null) && path_obj.opacity) + return TRUE + + for(var/obj/effect/particle_effect/smoke/smoke in path_turf) + if(smoke.obscuring) + return TRUE + + return FALSE + +/obj/item/hardpoint/primary/cannon/start_aim_visuals(atom/target, mob/living/user) + anchor = new(get_origin_turf()) + charge_start_time = world.time + + var/mob/living_target = target + lockon_pixel_x = -living_target.pixel_x + living_target.base_pixel_x + lockon_pixel_y = (living_target.icon_size - world.icon_size) * 0.5 - living_target.pixel_y + living_target.base_pixel_y + + apply_beam_visuals(target, TRUE) + aim_generation++ + INVOKE_ASYNC(src, PROC_REF(cycle_laser_color), target, aim_generation) + +/** + * Rebuilds the reticle/direction overlays and the beam using the AMR's focused-fire beam. + * The reticle shows one of 5 discrete stages tracking how close the charge is to completing. + * Also refreshes the direction arrow's facing and ramps the beam's opacity with charge time. + */ +/obj/item/hardpoint/primary/cannon/proc/apply_beam_visuals(atom/target, use_intense) + var/turf/origin_turf = get_origin_turf() + + if(lockon_icon) + target.overlays -= lockon_icon + if(lockon_direction_icon) + target.overlays -= lockon_direction_icon + + lockon_icon = image(icon = 'icons/effects/Targeted.dmi', icon_state = "tank_ltb_[get_aim_stage()]") + lockon_icon.pixel_x = lockon_pixel_x + lockon_icon.pixel_y = lockon_pixel_y + target.overlays += lockon_icon + + lockon_direction_icon = image(icon = 'icons/effects/Targeted.dmi', icon_state = "sniper_lockon_direction", dir = get_cardinal_dir(target, origin_turf)) + lockon_direction_icon.pixel_x = lockon_pixel_x + lockon_direction_icon.pixel_y = lockon_pixel_y + target.overlays += lockon_direction_icon + + QDEL_NULL(laser_beam) + laser_beam = anchor.beam(target, use_intense ? "laser_beam_intense" : "laser_beam", 'icons/effects/beam.dmi', (f_aiming_time + 1 SECONDS), beam_type = /obj/effect/ebeam/laser/intense/tank_mounted) + + var/elapsed = world.time - charge_start_time + var/current_progress = clamp(elapsed / f_aiming_time, 0, 1) + var/next_progress = clamp((elapsed + 5) / f_aiming_time, 0, 1) + laser_beam.visuals.alpha = LTB_LASER_MIN_ALPHA + (255 - LTB_LASER_MIN_ALPHA) * current_progress + animate(laser_beam.visuals, alpha = LTB_LASER_MIN_ALPHA + (255 - LTB_LASER_MIN_ALPHA) * next_progress, 0.5 SECONDS, easing = SINE_EASING|EASE_OUT) + +/// Which of the 5 reticle stages the charge is currently on. +/obj/item/hardpoint/primary/cannon/proc/get_aim_stage() + var/elapsed = world.time - charge_start_time + var/progress = clamp(elapsed / f_aiming_time, 0, 1) + return clamp(ceil(progress * 5), 1, 5) + +/// Flickers the beam between red and blue about twice a second while charging. +/obj/item/hardpoint/primary/cannon/proc/cycle_laser_color(atom/locked_target, my_generation) + var/use_intense = TRUE + while(laser_beam && !QDELETED(laser_beam) && !QDELETED(locked_target) && aim_generation == my_generation) + sleep(5) + if(!laser_beam || QDELETED(laser_beam) || QDELETED(locked_target) || aim_generation != my_generation) + break + use_intense = !use_intense + apply_beam_visuals(locked_target, use_intense) + +/obj/item/hardpoint/primary/cannon/on_track_tick(atom/target) + if(!anchor) + return + var/turf/origin_turf = get_origin_turf() + if(anchor.loc != origin_turf) + anchor.forceMove(origin_turf) + +/obj/item/hardpoint/primary/cannon/end_aim_visuals(atom/target, mob/living/user, success) + if(lockon_icon) + target.overlays -= lockon_icon + lockon_icon = null + if(lockon_direction_icon) + target.overlays -= lockon_direction_icon + lockon_direction_icon = null + QDEL_NULL(laser_beam) + QDEL_NULL(anchor) diff --git a/code/modules/vehicles/hardpoints/primary/minigun.dm b/code/modules/vehicles/hardpoints/primary/minigun.dm index fd1f81990445..d88911bae991 100644 --- a/code/modules/vehicles/hardpoints/primary/minigun.dm +++ b/code/modules/vehicles/hardpoints/primary/minigun.dm @@ -7,7 +7,7 @@ disp_icon_state = "ltaaap_minigun" health = 350 - firing_arc = 90 + traverse_arc = 110 ammo = new /obj/item/ammo_magazine/hardpoint/ltaaap_minigun max_clips = 1 @@ -18,6 +18,12 @@ "4" = list(32, 0), "8" = list(-32, 0) ) + rotation_pivot = list( + "1" = list(0, -21), + "2" = list(0, 32), + "4" = list(-32, 0), + "8" = list(32, 0) + ) muzzle_flash_pos = list( "1" = list(0, 57), @@ -26,7 +32,7 @@ "8" = list(-77, 0) ) - scatter = 18 //base scatter, modified by stake_delay_mult + scatter = 2 gun_firemode = GUN_FIREMODE_AUTOMATIC gun_firemode_list = list( GUN_FIREMODE_AUTOMATIC, @@ -35,7 +41,7 @@ activation_sounds = list('sound/weapons/gun_minigun.ogg') /// Active firing time to reach max spin_stage. - var/spinup_time = 10 SECONDS + var/spinup_time = 3 SECONDS /// Grace period before losing spin_stage. var/spindown_grace_time = 2 SECONDS COOLDOWN_DECLARE(spindown_grace_cooldown) @@ -43,8 +49,9 @@ var/spindown_time = 3 SECONDS /// Index of stage_rate. var/spin_stage = 1 - /// Shots fired per fire_delay at a particular spin_stage. - var/list/stage_rate = list(1, 1, 2, 2, 3, 3, 3, 4, 4, 4, 5) + /// this is meant to be an already-fearsome close range weapon even before it spins + /// all the way up, not a weapon that needs several seconds of commitment before it does anything. + var/list/stage_rate = list(3, 3, 4, 4, 4, 5, 5, 5, 5, 5, 5) /// Fire delay multiplier for current spin_stage. var/stage_delay_mult = 1 /// When it was last fired, related to world.time. @@ -52,7 +59,7 @@ /obj/item/hardpoint/primary/minigun/set_fire_delay(value) fire_delay = value - SEND_SIGNAL(src, COMSIG_GUN_AUTOFIREDELAY_MODIFIED, fire_delay * stage_delay_mult, scatter * stage_delay_mult) + SEND_SIGNAL(src, COMSIG_GUN_AUTOFIREDELAY_MODIFIED, fire_delay * stage_delay_mult) /obj/item/hardpoint/primary/minigun/set_fire_cooldown() calculate_stage_delay_mult() //needs to check grace_cooldown before refreshed @@ -79,7 +86,7 @@ var/new_stage_rate = stage_rate[floor(spin_stage)] if(old_stage_rate != new_stage_rate) - scatter = initial(scatter) * (1/new_stage_rate) + // deliberate low scatter at every stage instead of the old huge-scatter-while-spinning-up behavior. stage_delay_mult = 1 / new_stage_rate SEND_SIGNAL(src, COMSIG_GUN_AUTOFIREDELAY_MODIFIED, fire_delay * stage_delay_mult) diff --git a/code/modules/vehicles/hardpoints/primary/primary.dm b/code/modules/vehicles/hardpoints/primary/primary.dm index 7e826217acc6..9c85895843da 100644 --- a/code/modules/vehicles/hardpoints/primary/primary.dm +++ b/code/modules/vehicles/hardpoints/primary/primary.dm @@ -7,3 +7,20 @@ damage_multiplier = 0.15 activatable = TRUE + uses_live_rotation_tracking = TRUE + //so it draws above wheels. + hdpt_layer = HDPT_LAYER_TURRET + +/** + * Accuracy/scatter depend on this weapon's raw integrity, not just its wound tiers. + * Health drifts on every hit, so bonuses need a refresh here to stay live. + */ +/obj/item/hardpoint/primary/take_damage(damage, type = "abstract", atom/attacker, unmitigated = FALSE, wound_chance_mult = 1) + . = ..() + if(owner) + recalculate_hardpoint_bonuses() + recalculate_own_turn_rate() + +/obj/item/hardpoint/primary/recalculate_wound_effects() + . = ..() + recalculate_own_turn_rate() diff --git a/code/modules/vehicles/hardpoints/radiator/radiator.dm b/code/modules/vehicles/hardpoints/radiator/radiator.dm new file mode 100644 index 000000000000..e86562cc8271 --- /dev/null +++ b/code/modules/vehicles/hardpoints/radiator/radiator.dm @@ -0,0 +1,209 @@ +/obj/item/hardpoint/radiator + name = "\improper radiator" + desc = "Cools a vehicle's power plant. Light enough to be carried by hand." + + slot = HDPT_RADIATOR + hdpt_layer = HDPT_LAYER_SUPPORT + + icon = 'icons/obj/vehicles/hardpoints/shared.dmi' + icon_state = "radiator-0" + + flags_atom = OPENCONTAINER + + health = 350 + damage_multiplier = 0.15 + + var/max_coolant = 300 + var/amount_per_transfer_from_this = 25 + var/possible_transfer_amounts = list(10, 25, 50, 100, 250, 500) + +/obj/item/hardpoint/radiator/uscm + name = "\improper M39 radiator" + desc = "The standard-issue radiator assembly of a USCM ground vehicle. Keeps the power plant from overheating under load." + +// Civilian equivalent, shared by every colony van variant. +/obj/item/hardpoint/radiator/civilian + name = "\improper civilian radiator" + desc = "A commercial-grade radiator assembly. Keeps the engine from overheating under load." + +/obj/item/hardpoint/radiator/update_icon() + icon = 'icons/obj/vehicles/hardpoints/shared.dmi' + icon_state = "radiator-[get_shared_damage_suffix()]" + +/obj/item/hardpoint/radiator/on_uninstall(obj/vehicle/multitile/vehicle) + update_icon() + . = ..() + +/obj/item/hardpoint/radiator/Initialize(mapload) + . = ..() + create_reagents(max_coolant) + reagents.add_reagent("water", max_coolant) + START_PROCESSING(SSslowobj, src) + +/obj/item/hardpoint/radiator/Destroy() + STOP_PROCESSING(SSslowobj, src) + return ..() + +/// Sets this radiator's per-transfer amount. +/obj/item/hardpoint/radiator/verb/set_APTFT() + set name = "Set Transfer Amount" + set category = "Object" + set src in view(1) + + if(!reagents) + return + + var/new_amount = tgui_input_list(usr, "Amount per transfer from this:", "[src]", possible_transfer_amounts) + if(new_amount) + amount_per_transfer_from_this = new_amount + +/** + * Drains coolant from an active leak wound. + * Drains at a flat rate once fully destroyed. + */ +/obj/item/hardpoint/radiator/process(delta_time) + if(health <= 0) + consume_coolant(HARDPOINT_BUSTED_LEAK_RATE * delta_time) + return + var/leak_rate = get_wound_effect_sum("passive_leak_rate") + if(leak_rate <= 0) + return + consume_coolant(leak_rate * delta_time) + +/** + * Reports how full this radiator currently is. + * + * Returns: + * * Current coolant level as a percentage (0-100) of max_coolant. 0 if max_coolant is unset. + */ +/obj/item/hardpoint/radiator/proc/get_coolant_percent() + if(!reagents || !max_coolant) + return 0 + return 100.0 * reagents.total_volume / max_coolant + +/obj/item/hardpoint/radiator/get_examine_text(mob/user) + . = ..() + . += SPAN_NOTICE("It contains:") + if(reagents && length(reagents.reagent_list)) + for(var/datum/reagent/current_reagent in reagents.reagent_list) + . += SPAN_NOTICE(" [current_reagent.volume] units of [current_reagent.name].") + else + . += SPAN_NOTICE(" Nothing.") + +/// Pours the held item's reagents into this radiator. +/obj/item/hardpoint/radiator/attackby(obj/item/item, mob/user) + if(!item.reagents || iswelder(item)) + return ..() + + if(!item.reagents.total_volume) + to_chat(user, SPAN_WARNING("\The [item] is empty.")) + return ATTACKBY_HINT_NO_AFTERATTACK + + if(!can_accept_reagents(item.reagents)) + to_chat(user, SPAN_WARNING("You can't pour that into \the [src]!")) + return ATTACKBY_HINT_NO_AFTERATTACK + + var/available = max_coolant - reagents.total_volume + if(available <= 0) + to_chat(user, SPAN_WARNING("\The [src] is full!")) + return ATTACKBY_HINT_NO_AFTERATTACK + + var/transferred = item.reagents.trans_to(src, available) + to_chat(user, SPAN_NOTICE("You pour [transferred] units of coolant into \the [src].")) + return ATTACKBY_HINT_NO_AFTERATTACK + +/// Drains this radiator's contents into another reagent-holding target. +/obj/item/hardpoint/radiator/afterattack(atom/target, mob/user, proximity) + if(!proximity || ismob(target)) + return ..() + + if(istype(target, /obj/structure/reagent_dispensers)) + var/obj/structure/reagent_dispensers/dispenser = target + try_refill_from_dispenser(dispenser, user) + return + + if(!reagents || !reagents.total_volume) + return ..() + if(!target.reagents || !target.is_open_container()) + return ..() + + var/room = target.reagents.maximum_volume - target.reagents.total_volume + if(room <= 0) + to_chat(user, SPAN_WARNING("\The [target] is full.")) + return + + var/drained = reagents.trans_to(target, min(room, amount_per_transfer_from_this)) + to_chat(user, SPAN_NOTICE("You pour [drained] units from \the [src] into \the [target].")) + +/** + * Refills this radiator from a fixed reagent dispenser. + * + * Arguments: + * * dispenser = The dispenser being drawn from. + * * user = Whoever is doing the refilling. + */ +/obj/item/hardpoint/radiator/proc/try_refill_from_dispenser(obj/structure/reagent_dispensers/dispenser, mob/user) + if(!dispenser.reagents || !dispenser.dispensing) + return + if(!dispenser.reagents.total_volume) + to_chat(user, SPAN_WARNING("\The [dispenser] is empty.")) + return + if(!can_accept_reagents(dispenser.reagents)) + to_chat(user, SPAN_WARNING("\The [dispenser] doesn't contain anything suitable for \the [src].")) + return + + var/available = max_coolant - reagents.total_volume + if(available <= 0) + to_chat(user, SPAN_WARNING("\The [src] is full!")) + return + + var/transferred = dispenser.reagents.trans_to(src, min(available, amount_per_transfer_from_this)) + if(!transferred) + to_chat(user, SPAN_WARNING("You fail to draw anything from \the [dispenser].")) + return + to_chat(user, SPAN_NOTICE("You fill \the [src] with [transferred] units from \the [dispenser].")) + +/** + * Checks every reagent in a source holder is flagged as valid radiator coolant. + * + * Arguments: + * * source = Reagent holder being poured into this radiator. + * + * Returns: + * * FALSE if source holds any reagent without vehicle_coolant set, TRUE otherwise. + */ +/obj/item/hardpoint/radiator/proc/can_accept_reagents(datum/reagents/source) + for(var/datum/reagent/reagent in source.reagent_list) + if(!reagent.vehicle_coolant) + return FALSE + return TRUE + +/** + * Consumes coolant, removing the same fraction from every loaded reagent. + * + * Arguments: + * * amount = Units of coolant to remove. + * + * Returns: + * * Units actually removed. + */ +/obj/item/hardpoint/radiator/proc/consume_coolant(amount) + if(!reagents || reagents.total_volume <= 0 || amount <= 0) + return 0 + + var/actual_amount = min(amount, reagents.total_volume) + var/fraction = actual_amount / reagents.total_volume + for(var/datum/reagent/current_reagent as anything in reagents.reagent_list.Copy()) + reagents.remove_reagent(current_reagent.id, current_reagent.volume * fraction) + return actual_amount + +/** + * How effectively this radiator is currently cooling the engine. + * + * Returns: + * * 0-1 scale, 0 if destroyed or out of coolant, 1 at full health and full coolant. + */ +/obj/item/hardpoint/radiator/proc/get_cooling_effectiveness() + if(!reagents || reagents.total_volume <= 0) + return 0 + return (get_integrity_percent() / 100) * (get_coolant_percent() / 100) diff --git a/code/modules/vehicles/hardpoints/secondary/brute_launcher.dm b/code/modules/vehicles/hardpoints/secondary/brute_launcher.dm new file mode 100644 index 000000000000..404373e55a38 --- /dev/null +++ b/code/modules/vehicles/hardpoints/secondary/brute_launcher.dm @@ -0,0 +1,124 @@ +/obj/item/hardpoint/secondary/brute_launcher + name = "\improper M6H-BRUTE Launcher" + desc = "A tank-mounted variant of the M6H-BRUTE breaching rocket system. Laser-guides a 90mm shaped-charge rocket onto a fortified position, given a few seconds to lock on." + + icon_state = "brute_launcher" + disp_icon = "tank" + disp_icon_state = "brutelauncher" + + health = 500 + traverse_arc = 120 + activation_sounds = list('sound/weapons/gun_rocketlauncher.ogg') + + // reuses the handheld BRUTE launcher's own rocket item directly + // It's very silly that the M56D also needed its own 'vehicle magazines' instead of just using the default drum. This has properly been changed. + ammo = new /obj/item/ammo_magazine/rocket/brute + max_clips = 4 + + px_offsets = list( + "1" = list(1, 10), + "2" = list(-1, 5), + "4" = list(0, 0), + "8" = list(0, 18) + ) + rotation_pivot = list( + "1" = list(-1, -10), + "2" = list(1, -5), + "4" = list(0, 0), + "8" = list(0, -18) + ) + gimbal_pivot = list( + "1" = list(6, -6), + "2" = list(-6, 6), + "4" = list(-6, 6), + "8" = list(6, 6) + ) + muzzle_flash_pos = list( + "1" = list(8, -1), + "2" = list(-8, -16), + "4" = list(5, -8), + "8" = list(-5, 10) + ) + + scatter = 4 // irrelevant - /datum/ammo/rocket/brute sets AMMO_HITS_TARGET_TURF, which skips scatter entirely + fire_delay = 15.0 SECONDS + + // How long the lock-on charge takes before firing is allowed. + var/f_aiming_time = 5 SECONDS + + var/image/lockon_icon + var/image/lockon_direction_icon + var/datum/beam/laser_beam + var/obj/effect/beam_anchor/anchor + +/obj/item/hardpoint/secondary/brute_launcher/set_bullet_traits() + ..() + LAZYADD(traits_to_give, list( + BULLET_TRAIT_ENTRY(/datum/element/bullet_trait_iff) + )) + +/obj/item/hardpoint/secondary/brute_launcher/handle_fire(atom/target, mob/living/user, params) + if(charging) + SEND_SIGNAL(src, COMSIG_GUN_INTERRUPT_FIRE) + return NONE + + if(!(istype(target, /obj/structure) || istype(target, /turf/closed/wall))) + to_chat(user, SPAN_WARNING("Invalid target!")) + return NONE + + var/turf/origin_turf = get_origin_turf() + var/list/turf/path = get_line(origin_turf, get_turf(target), include_start_atom = FALSE) + for(var/turf/turf_path in path) + if(turf_path.opacity && turf_path != target) + to_chat(user, SPAN_WARNING("Target obscured!")) + return NONE + + if(!track_and_charge(target, user, f_aiming_time)) + return NONE + + return ..() + +/obj/item/hardpoint/secondary/brute_launcher/start_aim_visuals(atom/target, mob/living/user) + var/turf/origin_turf = get_origin_turf() + + lockon_icon = image(icon = 'icons/effects/Targeted.dmi', icon_state = "sniper_lockon_guided") + target.overlays += lockon_icon + + lockon_direction_icon = image(icon = 'icons/effects/Targeted.dmi', icon_state = "sniper_lockon_guided_direction", dir = get_cardinal_dir(target, origin_turf)) + target.overlays += lockon_direction_icon + + anchor = new(origin_turf) + laser_beam = anchor.beam(target, "laser_beam_guided", 'icons/effects/beam.dmi', (f_aiming_time + 1 SECONDS), beam_type = /obj/effect/ebeam/laser/intense/tank_mounted) + laser_beam.visuals.alpha = 0 + animate(laser_beam.visuals, alpha = initial(laser_beam.visuals.alpha), f_aiming_time, easing = SINE_EASING|EASE_OUT) + +/obj/item/hardpoint/secondary/brute_launcher/on_track_tick(atom/target) + if(!anchor) + return + var/turf/origin_turf = get_origin_turf() + if(anchor.loc != origin_turf) + anchor.forceMove(origin_turf) + +/obj/item/hardpoint/secondary/brute_launcher/end_aim_visuals(atom/target, mob/living/user, success) + if(lockon_icon) + target.overlays -= lockon_icon + lockon_icon = null + if(lockon_direction_icon) + target.overlays -= lockon_direction_icon + lockon_direction_icon = null + QDEL_NULL(laser_beam) + QDEL_NULL(anchor) + +// Plain /obj/effect/ebeam/laser/intense defaults to the base /obj layer, which renders underneath the +// tank's own ABOVE_MOB_LAYER (4.1) sprite, so, we bump it above it. +/obj/effect/ebeam/laser/intense/tank_mounted + layer = 4.501 + +// Small invisible movable used solely as a beam origin that actually moves with the muzzle +/obj/effect/beam_anchor + name = "" + icon = null + anchored = FALSE + density = FALSE + invisibility = INVISIBILITY_MAXIMUM + mouse_opacity = MOUSE_OPACITY_TRANSPARENT diff --git a/code/modules/vehicles/hardpoints/secondary/cupola.dm b/code/modules/vehicles/hardpoints/secondary/cupola.dm index cf32340070b0..418c320807d9 100644 --- a/code/modules/vehicles/hardpoints/secondary/cupola.dm +++ b/code/modules/vehicles/hardpoints/secondary/cupola.dm @@ -8,11 +8,19 @@ activation_sounds = list('sound/weapons/gun_smartgun1.ogg', 'sound/weapons/gun_smartgun2.ogg', 'sound/weapons/gun_smartgun3.ogg', 'sound/weapons/gun_smartgun4.ogg') health = 350 - firing_arc = 120 + traverse_arc = 120 - ammo = new /obj/item/ammo_magazine/hardpoint/m56_cupola + // Uses the real M56D drum magazine. + ammo = new /obj/item/ammo_magazine/m56d max_clips = 1 + gimbal_pivot = list( + "1" = list(8, 4), + "2" = list(-7, 11), + "4" = list(-6, 6), + "8" = list(5, 24) + ) + muzzle_flash_pos = list( "1" = list(8, -1), "2" = list(-7, -15), @@ -26,3 +34,9 @@ GUN_FIREMODE_AUTOMATIC, ) fire_delay = 0.3 SECONDS + +/obj/item/hardpoint/secondary/m56cupola/set_bullet_traits() + ..() + LAZYADD(traits_to_give, list( + BULLET_TRAIT_ENTRY(/datum/element/bullet_trait_iff) + )) diff --git a/code/modules/vehicles/hardpoints/secondary/flag.dm b/code/modules/vehicles/hardpoints/secondary/flag.dm new file mode 100644 index 000000000000..962599b099e5 --- /dev/null +++ b/code/modules/vehicles/hardpoints/secondary/flag.dm @@ -0,0 +1,158 @@ +#define PLANTED_FLAG_BUFF 4 +#define PLANTED_FLAG_RANGE 11 + +/obj/item/hardpoint/secondary/united_americas_flag + name = "\improper Mounted UA Flag" + desc = "An United Americas flag on a mounting support for the M34A2-A Multipurpose Turret. You feel a burst of energy by its mere sight." + + icon_state = "mounted_flag" + disp_icon = "tank" + disp_icon_state = "mountedflag" + activatable = FALSE // Cannot be toggled - always active when mounted + health = 300 + use_muzzle_flash = FALSE + // Own dedicated wound-family pool instead of sharing HDPT_SECONDARY's generic weapon wounds. + wound_family_slot = HDPT_UA_FLAG + var/buff_range = PLANTED_FLAG_RANGE + var/buff_strength = PLANTED_FLAG_BUFF + var/faction = FACTION_MARINE + var/flag_active = FALSE + var/datum/shape/range_bounds + var/luminosity_strength = 3 + + /// lets a fitted tank be selected as a laser/airstrike aiming point, same system as a planted flag turret + var/datum/cas_signal/signal + /// Dedicated per-tank-flag counter for the display name below + var/static/tank_flag_count = 0 + var/flag_number + + px_offsets = list( + "1" = list(0, 0), + "2" = list(0, 1), + "4" = list(0, 0), + "8" = list(0, 1) + ) + + +/obj/item/hardpoint/secondary/united_americas_flag/Destroy() + if(flag_active) + deactivate_flag() + range_bounds = null + return ..() + +/obj/item/hardpoint/secondary/united_americas_flag/on_install(obj/vehicle/multitile/vehicle) + ..() + activate_flag() + +/obj/item/hardpoint/secondary/united_americas_flag/on_uninstall(obj/vehicle/multitile/vehicle) + if(flag_active) + deactivate_flag() + ..() + +/obj/item/hardpoint/secondary/united_americas_flag/proc/activate_flag() + if(flag_active) + return + if(!owner) + return + flag_active = TRUE + set_light(luminosity_strength) + start_processing() + activate_signal() + +/obj/item/hardpoint/secondary/united_americas_flag/proc/deactivate_flag() + if(!flag_active) + return + + flag_active = FALSE + set_light(0) + stop_processing() + range_bounds = null + deactivate_signal() + +/obj/item/hardpoint/secondary/united_americas_flag/proc/activate_signal() + if(signal || !owner) + return + if(!faction || !GLOB.cas_groups[faction]) + return + + if(!flag_number) + flag_number = ++tank_flag_count + + // signal_loc = owner (the tank itself, a movable atom) rather than a snapshotted turf + signal = new(owner) + signal.target_id = ++GLOB.cas_tracking_id_increment + signal.name = "TANK-[flag_number < 10 ? "0[flag_number]" : "[flag_number]"]" + signal.linked_cam = new(get_turf(owner), signal.name) + GLOB.cas_groups[faction].add_signal(signal) + +/obj/item/hardpoint/secondary/united_americas_flag/proc/deactivate_signal() + if(!signal) + return + if(faction && GLOB.cas_groups[faction]) + GLOB.cas_groups[faction].remove_signal(signal) + QDEL_NULL(signal) + +/obj/item/hardpoint/secondary/united_americas_flag/proc/start_processing() + START_PROCESSING(SSobj, src) + +/obj/item/hardpoint/secondary/united_americas_flag/proc/stop_processing() + STOP_PROCESSING(SSobj, src) + +/obj/item/hardpoint/secondary/united_americas_flag/process() + if(!flag_active || !owner) + stop_processing() + return + + if(health <= 0) + deactivate_flag() + return + + apply_area_effect() + +/obj/item/hardpoint/secondary/united_americas_flag/proc/apply_area_effect() + if(!owner || !flag_active) + return + + // Update range bounds to follow the vehicle + var/turf/owner_turf = get_turf(owner) + if(!owner_turf) + return + + // Keep the CAS signal's z-lock in sync with the tank's real position + if(signal && signal.z_initial != owner_turf.z) + signal.z_initial = owner_turf.z + + // Keep the CAS camera view following the tank. + if(signal?.linked_cam && signal.linked_cam.loc != owner_turf) + signal.linked_cam.forceMove(owner_turf) + + range_bounds = SQUARE(owner_turf.x, owner_turf.y, buff_range) + + // Wounds progressively silence the buff itself, never the CAS aiming point/camera tracking. + var/effective_strength = buff_strength - get_wound_effect_sum("aura_reduction_add") + if(effective_strength <= 0) + return + + var/list/targets = SSquadtree.players_in_range(range_bounds, owner_turf.z, QTREE_SCAN_MOBS | QTREE_FILTER_LIVING) + if(!targets) + return + + for(var/mob/living/carbon/human/H in targets) + // Check if the human is of the correct faction + if(!H.faction || !(faction in H.faction_group)) + continue + + apply_buff_to_player(H, effective_strength) + +/obj/item/hardpoint/secondary/united_americas_flag/proc/apply_buff_to_player(mob/living/carbon/human/H, strength) + H.activate_order_buff(COMMAND_ORDER_HOLD, strength, 5 SECONDS) + H.activate_order_buff(COMMAND_ORDER_FOCUS, strength, 5 SECONDS) + H.activate_order_buff(COMMAND_ORDER_MOVE, strength, 5 SECONDS) + +/obj/item/hardpoint/secondary/united_americas_flag/on_destroy() + if(flag_active) + deactivate_flag() + ..() + +#undef PLANTED_FLAG_BUFF +#undef PLANTED_FLAG_RANGE diff --git a/code/modules/vehicles/hardpoints/secondary/flamer.dm b/code/modules/vehicles/hardpoints/secondary/flamer.dm index 56a9995b60c5..0651d34bdf64 100644 --- a/code/modules/vehicles/hardpoints/secondary/flamer.dm +++ b/code/modules/vehicles/hardpoints/secondary/flamer.dm @@ -8,25 +8,47 @@ activation_sounds = list('sound/weapons/vehicles/flamethrower.ogg') health = 300 - firing_arc = 120 + traverse_arc = 120 ammo = new /obj/item/ammo_magazine/hardpoint/secondary_flamer max_clips = 1 use_muzzle_flash = FALSE - var/max_range = 7 - px_offsets = list( "1" = list(2, 14), "2" = list(-2, 3), "4" = list(3, 0), "8" = list(-3, 18) ) + rotation_pivot = list( + "1" = list(-2, -14), + "2" = list(2, -3), + "4" = list(-3, 0), + "8" = list(3, -18) + ) + + gimbal_pivot = list( + "1" = list(6, -6), + "2" = list(-6, 6), + "4" = list(-6, 6), + "8" = list(6, 6) + ) scatter = 6 fire_delay = 3.0 SECONDS + var/flame_mode = FLAME_MODE_STREAM + var/glob_fuel_cost = 3 + /// The reagent-based ammo/fuel deduction is done manually in handle_fire() + ammo_cost_per_shot = 0 + +/obj/item/hardpoint/secondary/small_flamer/set_bullet_traits() + ..() + LAZYADD(traits_to_give, list( + BULLET_TRAIT_ENTRY(/datum/element/bullet_trait_iff) + )) + /obj/item/hardpoint/secondary/small_flamer/try_fire(atom/target, mob/living/user, params) if(get_turf(target) in owner.locs) to_chat(user, SPAN_WARNING("The target is too close.")) @@ -34,24 +56,40 @@ return ..() -/obj/item/hardpoint/secondary/small_flamer/handle_fire(atom/target, mob/living/user, params) - //step forward along path so flame starts outside hull - var/list/turfs = get_line(get_origin_turf(), get_turf(target)) - var/turf/origin_turf - for(var/turf/turf as anything in turfs) - if(turf in owner.locs) - continue - origin_turf = turf - break - var/distance = get_dist(origin_turf, get_turf(target)) - var/fire_amount = min(ammo.current_rounds, distance+1, max_range) - ammo.current_rounds -= fire_amount +// Toggles between FLAME_MODE_STREAM and FLAME_MODE_GLOB +/obj/item/hardpoint/secondary/small_flamer/toggle_fire_mode(mob/user) + flame_mode = (flame_mode == FLAME_MODE_STREAM) ? FLAME_MODE_GLOB : FLAME_MODE_STREAM + to_chat(user, SPAN_NOTICE("You switch \the [src] to [flame_mode == FLAME_MODE_STREAM ? "stream" : "glob shot"] mode.")) + +/obj/item/hardpoint/secondary/small_flamer/get_flame_mode() + return flame_mode - new /obj/flamer_fire(origin_turf, create_cause_data(initial(name), user), null, fire_amount, null, FLAMESHAPE_LINE, target) +/obj/item/hardpoint/secondary/small_flamer/handle_fire(atom/target, mob/living/user, params) + // A smoke tank overrides flame_mode entirely, always dispersing smoke regardless of the toggle. + if(istype(ammo, /obj/item/ammo_magazine/hardpoint/secondary_flamer/smoke)) + return fire_smoke_stream(target, user) - play_firing_sounds() + if(flame_mode == FLAME_MODE_STREAM) + // No flameshape override, defers to the loaded fuel's own flameshape and range. + return fire_flame_stream(target, user) - COOLDOWN_START(src, fire_cooldown, fire_delay) + var/datum/reagent/chem = LAZYACCESS(ammo.reagents?.reagent_list, 1) + if(!chem || ammo.reagents.get_reagent_amount(chem.id) < glob_fuel_cost) + click_empty(user) + return NONE + ammo.reagents.remove_reagent(chem.id, glob_fuel_cost) + sync_ammo_from_reagents() + return ..() - return AUTOFIRE_CONTINUE +// Tags the freshly-generated glob projectile with the loaded fuel's id and tints it to match. +/obj/item/hardpoint/secondary/small_flamer/generate_bullet(mob/user, turf/origin_turf) + . = ..() + var/obj/projectile/fired = . + if(istype(fired?.ammo, /datum/ammo/flamethrower/tank_flamer_secondary)) + var/datum/ammo/flamethrower/tank_flamer_secondary/glob_ammo = fired.ammo + var/datum/reagent/chem = LAZYACCESS(ammo.reagents?.reagent_list, 1) + glob_ammo.loaded_chem_id = chem?.id + if(chem) + fired.color = chem.burncolor + fired.hit_effect_color = chem.burncolor diff --git a/code/modules/vehicles/hardpoints/secondary/frontal_cannon.dm b/code/modules/vehicles/hardpoints/secondary/frontal_cannon.dm index 57ce6e2e5ff8..9ca5ec2a3237 100644 --- a/code/modules/vehicles/hardpoints/secondary/frontal_cannon.dm +++ b/code/modules/vehicles/hardpoints/secondary/frontal_cannon.dm @@ -11,7 +11,7 @@ damage_multiplier = 0.11 health = 350 - firing_arc = 120 + traverse_arc = 120 origins = list(0, -1) diff --git a/code/modules/vehicles/hardpoints/secondary/grenade_launcher.dm b/code/modules/vehicles/hardpoints/secondary/grenade_launcher.dm index ee06e7405e07..486350bee25b 100644 --- a/code/modules/vehicles/hardpoints/secondary/grenade_launcher.dm +++ b/code/modules/vehicles/hardpoints/secondary/grenade_launcher.dm @@ -8,7 +8,7 @@ activation_sounds = list('sound/weapons/gun_m92_attachable.ogg') health = 500 - firing_arc = 90 + traverse_arc = 120 ammo = new /obj/item/ammo_magazine/hardpoint/tank_glauncher max_clips = 3 @@ -22,6 +22,20 @@ "8" = list(-6, 17) ) + rotation_pivot = list( + "1" = list(0, -17), + "2" = list(0, 0), + "4" = list(-6, 0), + "8" = list(6, -17) + ) + + gimbal_pivot = list( + "1" = list(6, -6), + "2" = list(-6, 6), + "4" = list(-6, 6), + "8" = list(6, 6) + ) + scatter = 10 fire_delay = 3.0 SECONDS diff --git a/code/modules/vehicles/hardpoints/secondary/secondary.dm b/code/modules/vehicles/hardpoints/secondary/secondary.dm index 366c7fcfa9fb..a27785303042 100644 --- a/code/modules/vehicles/hardpoints/secondary/secondary.dm +++ b/code/modules/vehicles/hardpoints/secondary/secondary.dm @@ -7,3 +7,80 @@ damage_multiplier = 0.125 activatable = TRUE + // draws above wheels. + hdpt_layer = HDPT_LAYER_TURRET + +/** + * Accuracy/scatter now depend on this weapon's own raw integrity. + * Also refreshes this secondary's slaved turn rate if it's currently active. + */ +/obj/item/hardpoint/secondary/take_damage(damage, type = "abstract", atom/attacker, unmitigated = FALSE, wound_chance_mult = 1) + . = ..() + if(owner) + recalculate_hardpoint_bonuses() + recalculate_slaved_turn_rate() + recalculate_own_turn_rate() + +/obj/item/hardpoint/secondary/recalculate_wound_effects() + . = ..() + recalculate_slaved_turn_rate() + recalculate_own_turn_rate() + +/// Slows fire rate by SECONDARY_SLAVED_COOLDOWN_MULT while this secondary is slaved to the driver. +/obj/item/hardpoint/secondary/recalculate_hardpoint_bonuses() + . = ..() + if(!self_gimballed) + return + set_fire_delay(fire_delay * SECONDARY_SLAVED_COOLDOWN_MULT) + set_burst_delay(burst_delay * SECONDARY_SLAVED_COOLDOWN_MULT) + extra_delay *= SECONDARY_SLAVED_COOLDOWN_MULT + +/** + * Refreshes max_angular_velocity for a slaved-to-driver secondary from its own current integrity. + * No-ops if not slaved. Floored at the absolute minimum unless fully destroyed. + */ +/obj/item/hardpoint/secondary/proc/recalculate_slaved_turn_rate() + if(!self_gimballed || !owner) + return + var/health_scale = get_own_health_scale() + max_angular_velocity = max(TURRET_BASE_ANGULAR_VELOCITY * (traverse_arc / TURRET_ARC_NORMALIZATION), TURRET_MIN_ANGULAR_VELOCITY) * health_scale + if(health_scale > 0) + max_angular_velocity = max(max_angular_velocity, TURRET_ABSOLUTE_MIN_ANGULAR_VELOCITY) + +/// Toggles control of this secondary between the gunner and the driver (self-gimballed, independent mouse-aim). +/obj/item/hardpoint/secondary/proc/toggle_slaved_to_driver(mob/user) + if(health <= 0) + to_chat(user, SPAN_WARNING("\The [src]'s controls are busted!")) + return + if(!owner || !traverse_arc) + return + + self_gimballed = !self_gimballed + SEND_SIGNAL(src, COMSIG_GUN_INTERRUPT_FIRE) + recalculate_hardpoint_bonuses() + + if(self_gimballed) + allowed_seat = VEHICLE_DRIVER + recalculate_slaved_turn_rate() + current_angle = dir2angle(dir) + desired_angle = current_angle + angular_velocity = 0 + rotation_active = FALSE + + to_chat(user, SPAN_NOTICE("You slave \the [src]'s controls to the driver's seat.")) + var/mob/driver = owner.seats[VEHICLE_DRIVER] + if(driver) + to_chat(driver, SPAN_NOTICE("\The [src] has been slaved to your controls. Use \"Cycle Active Hardpoint\" to select it.")) + else + allowed_seat = initial(allowed_seat) + + current_angle = dir2angle(dir) + desired_angle = current_angle + angular_velocity = 0 + rotation_active = FALSE + + to_chat(user, SPAN_NOTICE("You return \the [src]'s controls to the gunner's seat.")) + + // Re-picks a valid active hardpoint for whichever seat just lost its selected pick. + owner.ensure_active_hardpoint(VEHICLE_GUNNER) + owner.ensure_active_hardpoint(VEHICLE_DRIVER) diff --git a/code/modules/vehicles/hardpoints/secondary/tow.dm b/code/modules/vehicles/hardpoints/secondary/tow.dm index c15f8aa1ef63..412f04d39d63 100644 --- a/code/modules/vehicles/hardpoints/secondary/tow.dm +++ b/code/modules/vehicles/hardpoints/secondary/tow.dm @@ -7,7 +7,7 @@ disp_icon_state = "towlauncher" health = 500 - firing_arc = 60 + traverse_arc = 120 ammo = new /obj/item/ammo_magazine/hardpoint/towlauncher max_clips = 1 @@ -19,6 +19,20 @@ "8" = list(0, 18) ) + rotation_pivot = list( + "1" = list(-1, -10), + "2" = list(1, -5), + "4" = list(0, 0), + "8" = list(0, -18) + ) + + gimbal_pivot = list( + "1" = list(6, -6), + "2" = list(-6, 6), + "4" = list(-6, 6), + "8" = list(6, 6) + ) + muzzle_flash_pos = list( "1" = list(8, -1), "2" = list(-8, -16), @@ -29,3 +43,9 @@ scatter = 4 fire_delay = 15.0 SECONDS +/obj/item/hardpoint/secondary/towlauncher/set_bullet_traits() + ..() + LAZYADD(traits_to_give, list( + BULLET_TRAIT_ENTRY(/datum/element/bullet_trait_iff) + )) + diff --git a/code/modules/vehicles/hardpoints/armor/snowplow.dm b/code/modules/vehicles/hardpoints/snowplow/snowplow.dm similarity index 76% rename from code/modules/vehicles/hardpoints/armor/snowplow.dm rename to code/modules/vehicles/hardpoints/snowplow/snowplow.dm index db39fe3e2af0..1fac2e044285 100644 --- a/code/modules/vehicles/hardpoints/armor/snowplow.dm +++ b/code/modules/vehicles/hardpoints/snowplow/snowplow.dm @@ -1,22 +1,27 @@ -/obj/item/hardpoint/armor/snowplow +/obj/item/hardpoint/snowplow name = "\improper Snowplow" desc = "Clears a path in the snow for friendlies. It doesn't seem to have much use beyond that." + slot = HDPT_SNOWPLOW + hdpt_layer = HDPT_LAYER_ARMOR + icon_state = "snowplow" disp_icon = "tank" disp_icon_state = "snowplow" + damage_multiplier = 0.5 + health = 150 activatable = 1 -/obj/item/hardpoint/armor/snowplow/livingmob_interact(mob/living/M) +/obj/item/hardpoint/snowplow/livingmob_interact(mob/living/M) var/turf/targ = get_step(M, owner.dir) targ = get_step(M, owner.dir) targ = get_step(M, owner.dir) M.throw_atom(targ, 4, SPEED_FAST, src, 1) M.apply_damage(7 + rand(0, 3), BRUTE) -/obj/item/hardpoint/armor/snowplow/on_move(turf/old, turf/new_turf, move_dir) +/obj/item/hardpoint/snowplow/on_move(turf/old, turf/new_turf, move_dir) if(health <= 0) return diff --git a/code/modules/vehicles/hardpoints/special/firing_port_weapon.dm b/code/modules/vehicles/hardpoints/special/firing_port_weapon.dm index d9d68e0af237..6a9d2720ce4f 100644 --- a/code/modules/vehicles/hardpoints/special/firing_port_weapon.dm +++ b/code/modules/vehicles/hardpoints/special/firing_port_weapon.dm @@ -10,7 +10,7 @@ activation_sounds = list('sound/weapons/gun_smartgun1.ogg', 'sound/weapons/gun_smartgun2.ogg', 'sound/weapons/gun_smartgun3.ogg', 'sound/weapons/gun_smartgun4.ogg') health = 100 - firing_arc = 120 + traverse_arc = 120 //FPWs reload automatically var/reloading = FALSE var/reload_time = 10 SECONDS @@ -48,6 +48,7 @@ data["current_rounds"] = ammo.current_rounds data["max_rounds"] = ammo.max_rounds data["fpw"] = TRUE + data["wounds"] = get_wound_tgui_data() return data diff --git a/code/modules/vehicles/hardpoints/support/antenna.dm b/code/modules/vehicles/hardpoints/support/antenna.dm index 8e68a2f437bc..6381cb0f2767 100644 --- a/code/modules/vehicles/hardpoints/support/antenna.dm +++ b/code/modules/vehicles/hardpoints/support/antenna.dm @@ -51,7 +51,7 @@ disp_icon_state = initial(disp_icon_state) /obj/item/hardpoint/support/arc_antenna/get_icon_image(x_offset, y_offset, new_dir) - var/is_broken = health <= 0 + var/is_broken = get_mounted_damage_suffix() var/antenna_extended = FALSE if(istype(owner, /obj/vehicle/multitile/arc)) var/obj/vehicle/multitile/arc/arc_owner = owner diff --git a/code/modules/vehicles/hardpoints/support/artillery.dm b/code/modules/vehicles/hardpoints/support/artillery.dm index 758f6b828cb3..cd970d62c39c 100644 --- a/code/modules/vehicles/hardpoints/support/artillery.dm +++ b/code/modules/vehicles/hardpoints/support/artillery.dm @@ -1,37 +1,72 @@ /obj/item/hardpoint/support/artillery_module name = "\improper Artillery Module" - desc = "Allows the user to look far into the distance." + desc = "Allows the user to look far into the distance, and offers a night vision overlay." icon_state = "artillery" disp_icon = "tank" disp_icon_state = "artillerymod" - health = 250 + health = 550 - activatable = TRUE + // Not fired/toggled through the normal active-hardpoint cycle anymore. Uses itts own verbs. + activatable = FALSE - var/is_active = 0 var/view_buff = 10 //This way you can VV for more or less fun - var/view_tile_offset = 7 + var/view_tile_offset = 2 -/obj/item/hardpoint/support/artillery_module/handle_fire(atom/target, mob/living/user, params) - if(!user.client) + /// Mobs (driver and/or gunner) that currently have the magnified optics view toggled on for themselves. + var/list/mob/living/optics_users = list() + /// Mobs (driver and/or gunner) that currently have the night vision overlay toggled on for themselves. + var/list/mob/living/nvg_users = list() + + /// Color used for the night vision overlay's tint. + var/nvg_color = NV_COLOR_GREEN + /// The alpha of darkness the user is set to see through while night vision is active. + var/nvg_lighting_alpha = 100 + +/obj/item/hardpoint/support/artillery_module/proc/toggle_optics(mob/living/user) + if(!is_functional()) + to_chat(user, SPAN_WARNING("\The [src] is [health <= 0 ? "broken" : "malfunctioning"]!")) + return + if(!user?.client) return - if(is_active) - user.client.change_view(8, owner) - user.client.set_pixel_x(0) - user.client.set_pixel_y(0) - is_active = FALSE + if(user in optics_users) + disable_optics(user) + else + enable_optics(user) + +/obj/item/hardpoint/support/artillery_module/proc/enable_optics(mob/living/user) + if(!user?.client || (user in optics_users)) + return + if(user.observed_atom) + to_chat(user, SPAN_WARNING("You cannot use the optics while looking up.")) return + optics_users += user - var/atom/holder = owner - for(var/obj/item/hardpoint/holder/tank_turret/T in owner.hardpoints) - holder = T - break + var/obj/vehicle/multitile/vehicle = owner + if(user == vehicle.seats[VEHICLE_GUNNER]) + apply_gunner_view(user) + else + apply_driver_view(user) + +/obj/item/hardpoint/support/artillery_module/proc/disable_optics(mob/living/user) + if(!(user in optics_users)) + return + optics_users -= user + reset_view(user) + +/// Zooms the gunner's view and shifts it forward along the turret's current facing. +/obj/item/hardpoint/support/artillery_module/proc/apply_gunner_view(mob/living/user) + if(!user || !user.client) + return user.client.change_view(view_buff, owner) - is_active = TRUE + + var/atom/holder = owner + for(var/obj/item/hardpoint/holder/tank_turret/turret in owner.hardpoints) + holder = turret + break switch(holder.dir) if(NORTH) @@ -47,25 +82,88 @@ user.client.set_pixel_x(-1 * view_tile_offset * 32) user.client.set_pixel_y(0) -/obj/item/hardpoint/support/artillery_module/deactivate() - if(!is_active) +/// Zooms the driver's view, centered on the tank instead of shifted forwards +/obj/item/hardpoint/support/artillery_module/proc/apply_driver_view(mob/living/user) + if(!user || !user.client) return + user.client.change_view(view_buff, owner) + user.client.set_pixel_x(0) + user.client.set_pixel_y(0) + +/obj/item/hardpoint/support/artillery_module/proc/reset_view(mob/living/user) + if(!user || !user.client) + return + user.client.change_view(VEHICLE_SEAT_VIEW_RADIUS, owner) + user.client.set_pixel_x(0) + user.client.set_pixel_y(0) + +/obj/item/hardpoint/support/artillery_module/proc/toggle_nvg(mob/living/user) + if(!is_functional()) + to_chat(user, SPAN_WARNING("\The [src] is [health <= 0 ? "broken" : "malfunctioning"]!")) + return + if(!ishuman(user)) + return + + if(user in nvg_users) + disable_nvg(user) + else + enable_nvg(user) + +/obj/item/hardpoint/support/artillery_module/proc/enable_nvg(mob/living/carbon/human/user) + if(!ishuman(user) || (user in nvg_users)) + return + nvg_users += user + + RegisterSignal(user, COMSIG_HUMAN_POST_UPDATE_SIGHT, PROC_REF(on_update_sight)) + user.add_client_color_matrix("tank_artillery_nvg", 99, color_matrix_multiply(color_matrix_saturation(0), color_matrix_from_string(nvg_color))) + user.overlay_fullscreen("tank_artillery_nvg", /atom/movable/screen/fullscreen/flash/noise/nvg) + user.overlay_fullscreen("tank_artillery_nvg_blur", /atom/movable/screen/fullscreen/brute/nvg, 3) + user.update_sight() + to_chat(user, SPAN_NOTICE("You activate \the [src]'s night vision overlay.")) + playsound_client(user.client, 'sound/handling/toggle_nv1.ogg', null, 75) + +/obj/item/hardpoint/support/artillery_module/proc/disable_nvg(mob/living/carbon/human/user) + if(!(user in nvg_users)) + return + nvg_users -= user + + UnregisterSignal(user, COMSIG_HUMAN_POST_UPDATE_SIGHT) + user.remove_client_color_matrix("tank_artillery_nvg", 1 SECONDS) + user.clear_fullscreen("tank_artillery_nvg", 0.5 SECONDS) + user.clear_fullscreen("tank_artillery_nvg_blur", 0.5 SECONDS) + user.update_sight() + to_chat(user, SPAN_NOTICE("You deactivate \the [src]'s night vision overlay.")) + playsound_client(user.client, 'sound/handling/toggle_nv2.ogg', null, 75) + +/obj/item/hardpoint/support/artillery_module/proc/on_update_sight(mob/living/carbon/human/user) + SIGNAL_HANDLER + if(nvg_lighting_alpha < 255) + user.see_in_dark = 12 + user.lighting_alpha = nvg_lighting_alpha + user.sync_lighting_plane_alpha() + +/// Clears both effects off of one specific user +/obj/item/hardpoint/support/artillery_module/proc/clear_user_effects(mob/living/user) + disable_optics(user) + disable_nvg(user) + +/// Clears both effects off of everyone currently using them if the module gets broken or uninstalled when used +/obj/item/hardpoint/support/artillery_module/deactivate() + for(var/mob/living/user as anything in optics_users.Copy()) + disable_optics(user) + for(var/mob/living/user as anything in nvg_users.Copy()) + disable_nvg(user) + +/** + * Kicks out anyone using the optics/NVG when this goes non-functional, and refreshes + * crew action buttons so they show up or disappear to match. + */ +/obj/item/hardpoint/support/artillery_module/refresh_functional_state(obj/vehicle/multitile/vehicle = owner, ignore_battery = FALSE) + . = ..() + if(!is_functional(vehicle, ignore_battery)) + deactivate() + vehicle?.refresh_hardpoint_actions() - var/obj/vehicle/multitile/C = owner - for(var/seat in C.seats) - if(!ismob(C.seats[seat])) - continue - var/mob/user = C.seats[seat] - if(!user.client) - continue - user.client.change_view(GLOB.world_view_size, owner) - user.client.set_pixel_x(0) - user.client.set_pixel_y(0) - is_active = FALSE - -/obj/item/hardpoint/support/artillery_module/try_fire(target, user, params) - if(health <= 0) - to_chat(usr, SPAN_WARNING("\The [src] is broken!")) - return NONE - - return handle_fire(target, user, params) +/obj/item/hardpoint/support/artillery_module/on_uninstall(obj/vehicle/multitile/vehicle) + deactivate() + ..() diff --git a/code/modules/vehicles/hardpoints/support/flare.dm b/code/modules/vehicles/hardpoints/support/flare.dm index 0fa37de34d2f..e91dfc3a30cf 100644 --- a/code/modules/vehicles/hardpoints/support/flare.dm +++ b/code/modules/vehicles/hardpoints/support/flare.dm @@ -11,12 +11,14 @@ damage_multiplier = 0.1 activatable = TRUE + allowed_seat = VEHICLE_DRIVER health = 500 - firing_arc = 120 + traverse_arc = 120 - ammo = new /obj/item/ammo_magazine/hardpoint/flare_launcher - max_clips = 3 + ammo = new /obj/item/ammo_magazine/hardpoint/turret_flare/apc + max_clips = 0 + uses_starshell_ammo = TRUE use_muzzle_flash = TRUE angle_muzzleflash = FALSE @@ -32,6 +34,11 @@ scatter = 6 fire_delay = 3.0 SECONDS +/obj/item/hardpoint/support/flare_launcher/Initialize() + . = ..() + // Loaded shell-by-shell rather than swapped as a whole magazine. + ammo_type = null + /obj/item/hardpoint/support/flare_launcher/set_bullet_traits() ..() LAZYADD(traits_to_give, list( diff --git a/code/modules/vehicles/hardpoints/support/iwsa.dm b/code/modules/vehicles/hardpoints/support/iwsa.dm index 27eeb3a2ccb5..722dca58d7ce 100644 --- a/code/modules/vehicles/hardpoints/support/iwsa.dm +++ b/code/modules/vehicles/hardpoints/support/iwsa.dm @@ -6,9 +6,41 @@ disp_icon = "tank" disp_icon_state = "warray" - health = 250 + px_offsets = list( + "1" = list(0, 0), + "2" = list(0, 0), + "4" = list(0, 32), + "8" = list(0, 0) + ) + + health = 550 buff_multipliers = list( "cooldown" = 0.67, "accuracy" = 1.67 ) + + var/turret_turn_rate_buff = 1.2 + +/** + * Also scales the turret's turn rate and recalculates it, on top of the normal + * cooldown/accuracy handling from ..(). + */ +/obj/item/hardpoint/support/weapons_sensor/apply_buff(obj/vehicle/multitile/vehicle) + if(buff_applied) + return + . = ..() + var/obj/item/hardpoint/holder/tank_turret/turret = locate() in vehicle.hardpoints + if(turret) + turret.turret_turn_rate_mult *= turret_turn_rate_buff + turret.recalculate_turn_rate() + +/// Reverses apply_buff()'s turret turn rate scaling. +/obj/item/hardpoint/support/weapons_sensor/remove_buff(obj/vehicle/multitile/vehicle) + if(!buff_applied) + return + var/obj/item/hardpoint/holder/tank_turret/turret = locate() in vehicle.hardpoints + . = ..() + if(turret) + turret.turret_turn_rate_mult /= turret_turn_rate_buff + turret.recalculate_turn_rate() diff --git a/code/modules/vehicles/hardpoints/support/overdrive.dm b/code/modules/vehicles/hardpoints/support/overdrive.dm index 5b482261077a..9950051adc55 100644 --- a/code/modules/vehicles/hardpoints/support/overdrive.dm +++ b/code/modules/vehicles/hardpoints/support/overdrive.dm @@ -6,11 +6,10 @@ disp_icon = "tank" disp_icon_state = "odrive_enhancer" - health = 250 + health = 550 - // 20% movespeed increase. Remember that movespeed is given in delay buff_multipliers = list( - "move" = 0.8 + "move" = 1.2 ) px_offsets = list( @@ -25,12 +24,12 @@ return for(var/obj/item/hardpoint/locomotion/TR in V.hardpoints) if(TR.health > 0) - V.misc_multipliers["move"] *= LAZYACCESS(buff_multipliers, "move") + V.gear_performance_mult *= LAZYACCESS(buff_multipliers, "move") buff_applied = TRUE break /obj/item/hardpoint/support/overdrive_enhancer/remove_buff(obj/vehicle/multitile/V) if(!buff_applied) return - V.misc_multipliers["move"] /= LAZYACCESS(buff_multipliers, "move") + V.gear_performance_mult /= LAZYACCESS(buff_multipliers, "move") buff_applied = FALSE diff --git a/code/modules/vehicles/hardpoints/support/support.dm b/code/modules/vehicles/hardpoints/support/support.dm index 18e4acae04d3..516138772fe0 100644 --- a/code/modules/vehicles/hardpoints/support/support.dm +++ b/code/modules/vehicles/hardpoints/support/support.dm @@ -6,3 +6,48 @@ hdpt_layer = HDPT_LAYER_SUPPORT damage_multiplier = 0.075 + +/// Support modules switch to their damaged sprite on any active wound +/obj/item/hardpoint/support/get_mounted_damage_suffix(threshold_pct = 50) + if(get_integrity_percent() < threshold_pct) + return 1 + return LAZYLEN(wound_tiers) ? 1 : 0 + +/** + * Whether this module's own effect should currently be active. + * FALSE if destroyed, disabled by a wound, or the vehicle has no power. + * + * Arguments: + * * vehicle = Defaults to owner. + * * ignore_battery = Passed through to has_vehicle_power(). + */ +/obj/item/hardpoint/support/proc/is_functional(obj/vehicle/multitile/vehicle = owner, ignore_battery = FALSE) + return health > 0 && !get_wound_effect_flag("module_disabled") && vehicle && vehicle.has_vehicle_power(ignore_battery) + +/** + * Applies or removes this module's own effect to match is_functional() right now. + * Subtypes with a real active ability, like Artillery Module, override this too. + * + * Arguments: + * * vehicle = Defaults to owner. + * * ignore_battery = Passed through to is_functional(). + */ +/obj/item/hardpoint/support/proc/refresh_functional_state(obj/vehicle/multitile/vehicle = owner, ignore_battery = FALSE) + if(!vehicle) + return + if(is_functional(vehicle, ignore_battery)) + apply_buff(vehicle) + else + remove_buff(vehicle) + +/obj/item/hardpoint/support/on_install(obj/vehicle/multitile/vehicle) + . = ..() + refresh_functional_state(vehicle) + +/obj/item/hardpoint/support/take_damage(damage, type = "abstract", atom/attacker, unmitigated = FALSE, wound_chance_mult = 1) + . = ..() + refresh_functional_state() + +/obj/item/hardpoint/support/recalculate_wound_effects() + . = ..() + refresh_functional_state() diff --git a/code/modules/vehicles/hardpoints/turret_ring/turret_ring.dm b/code/modules/vehicles/hardpoints/turret_ring/turret_ring.dm new file mode 100644 index 000000000000..2d97c2cbd27d --- /dev/null +++ b/code/modules/vehicles/hardpoints/turret_ring/turret_ring.dm @@ -0,0 +1,63 @@ +/obj/item/hardpoint/turret_ring + name = "\improper turret ring" + desc = "Lets the turret rotate. Slows the turret's turn rate as it takes damage." + + slot = HDPT_TURRET_RING + hdpt_layer = HDPT_LAYER_SUPPORT + + icon = 'icons/obj/vehicles/hardpoints/shared.dmi' + icon_state = "ring-0" + + health = 400 + damage_multiplier = 0.15 + power_draw = TURRET_RING_IDLE_POWER_DRAW + +/obj/item/hardpoint/turret_ring/uscm + name = "\improper M39 turret ring" + desc = "Lets a vehicle's mounted weapon traverse. Slows its turn rate as it takes damage." + +/obj/item/hardpoint/turret_ring/update_icon() + icon = 'icons/obj/vehicles/hardpoints/shared.dmi' + icon_state = "ring-[get_shared_damage_suffix()]" + +/obj/item/hardpoint/turret_ring/take_damage(damage, type = "abstract", atom/attacker, unmitigated = FALSE, wound_chance_mult = 1) + . = ..() + recalculate_owner_turret() + +/obj/item/hardpoint/turret_ring/recalculate_wound_effects() + . = ..() + recalculate_owner_turret() + +/obj/item/hardpoint/turret_ring/on_install(obj/vehicle/multitile/vehicle) + . = ..() + recalculate_owner_turret(vehicle) + +/// Forces every turret/weapon this ring drives to 0 turn rate directly, since the ring is already known to be departing. +/obj/item/hardpoint/turret_ring/on_uninstall(obj/vehicle/multitile/vehicle) + if(vehicle) + var/obj/item/hardpoint/holder/tank_turret/turret = locate() in vehicle.hardpoints + if(turret) + turret.max_angular_velocity = 0 + for(var/obj/item/hardpoint/weapon in vehicle.hardpoints) + if(weapon != src) + weapon.max_angular_velocity = 0 + update_icon() + . = ..() + +/** + * Refreshes the vehicle's turret turn rate so it reflects this ring's current integrity - both the + * holder-based turret (if any) and any top-level rotating weapon that's its own rotation owner (e.g. + * the APC's dualcannon/frontalcannon). + * + * Arguments: + * * vehicle = Vehicle to look up the turret/weapons on. Defaults to owner. + */ +/obj/item/hardpoint/turret_ring/proc/recalculate_owner_turret(obj/vehicle/multitile/vehicle = owner) + if(!vehicle) + return + var/obj/item/hardpoint/holder/tank_turret/turret = locate() in vehicle.hardpoints + if(turret) + turret.recalculate_turn_rate() + for(var/obj/item/hardpoint/weapon in vehicle.hardpoints) + if(weapon != src) + weapon.recalculate_own_turn_rate() diff --git a/code/modules/vehicles/hardpoints/visual_sensors/visual_sensors.dm b/code/modules/vehicles/hardpoints/visual_sensors/visual_sensors.dm new file mode 100644 index 000000000000..96cc049d0188 --- /dev/null +++ b/code/modules/vehicles/hardpoints/visual_sensors/visual_sensors.dm @@ -0,0 +1,58 @@ +/obj/item/hardpoint/visual_sensors + name = "\improper visual sensors" + desc = "External cameras and periscopes feeding the crew's view. Obscures their vision, like a fouled welding mask, as it takes damage." + + slot = HDPT_VISUAL_SENSORS + hdpt_layer = HDPT_LAYER_SUPPORT + + icon = 'icons/obj/vehicles/hardpoints/shared.dmi' + icon_state = "camera-0" + + health = 350 + damage_multiplier = 0.2 + power_draw = VISUAL_SENSORS_POWER_DRAW + +/obj/item/hardpoint/visual_sensors/uscm + name = "\improper M39 visual sensors" + desc = "The external cameras and periscopes feeding a USCM vehicle crew's view." + +/obj/item/hardpoint/visual_sensors/update_icon() + icon = 'icons/obj/vehicles/hardpoints/shared.dmi' + icon_state = "camera-[get_shared_damage_suffix()]" + +/obj/item/hardpoint/visual_sensors/on_uninstall(obj/vehicle/multitile/vehicle) + update_icon() + . = ..() + +/** + * How obscured a seated crew member's vision should be right now. + * Always maxed out if the vehicle has no power. + * + * Returns: + * * A VISION_IMPAIR_* severity, from none to ultra when destroyed or unpowered. + */ +/obj/item/hardpoint/visual_sensors/proc/get_overlay_severity() + if(!owner?.has_vehicle_power()) + return VISION_IMPAIR_ULTRA + var/damage_scale = 1 - (get_integrity_percent() / 100) + var/severity = damage_scale * VISION_IMPAIR_ULTRA + get_wound_effect_sum("vision_impair_add") + return clamp(round(severity), VISION_IMPAIR_NONE, VISION_IMPAIR_ULTRA) + +/obj/item/hardpoint/visual_sensors/take_damage(damage, type = "abstract", atom/attacker, unmitigated = FALSE, wound_chance_mult = 1) + . = ..() + refresh_seated_overlays() + +/obj/item/hardpoint/visual_sensors/recalculate_wound_effects() + . = ..() + refresh_seated_overlays() + +/obj/item/hardpoint/visual_sensors/handle_repair(obj/item/tool/weldingtool/WT, mob/user) + . = ..() + refresh_seated_overlays() + +/// Immediattely refreshes the vision-impair overlay for every currently seated crew member. +/obj/item/hardpoint/visual_sensors/proc/refresh_seated_overlays() + if(!owner) + return + for(var/seat_key in owner.seats) + owner.update_visual_sensor_overlay(owner.seats[seat_key]) diff --git a/code/modules/vehicles/hardpoints/wheels/apc_wheels.dm b/code/modules/vehicles/hardpoints/wheels/apc_wheels.dm index a11fce5cfa52..3b53e94462d6 100644 --- a/code/modules/vehicles/hardpoints/wheels/apc_wheels.dm +++ b/code/modules/vehicles/hardpoints/wheels/apc_wheels.dm @@ -3,6 +3,7 @@ desc = "Integral to the movement of the APC." icon = 'icons/obj/vehicles/hardpoints/apc.dmi' + slot = HDPT_WHEELS damage_multiplier = 0.15 icon_state = "tires" diff --git a/code/modules/vehicles/hardpoints/wheels/arc_wheels.dm b/code/modules/vehicles/hardpoints/wheels/arc_wheels.dm index 17726e75c024..54b1fbe734dc 100644 --- a/code/modules/vehicles/hardpoints/wheels/arc_wheels.dm +++ b/code/modules/vehicles/hardpoints/wheels/arc_wheels.dm @@ -3,6 +3,7 @@ desc = "Integral to the movement of the ARC." icon = 'icons/obj/vehicles/hardpoints/arc.dmi' + slot = HDPT_WHEELS damage_multiplier = 0.15 icon_state = "tires" diff --git a/code/modules/vehicles/hardpoints/wheels/locomotion.dm b/code/modules/vehicles/hardpoints/wheels/locomotion.dm index e4aad7c10a00..40b7d83a94a7 100644 --- a/code/modules/vehicles/hardpoints/wheels/locomotion.dm +++ b/code/modules/vehicles/hardpoints/wheels/locomotion.dm @@ -6,12 +6,27 @@ damage_multiplier = 0.15 var/acid_resistant = FALSE //reduces damage dealt by acid spray + /// world.time of the last acid spray hit this locomotion module took. + var/last_spray_hit_time = 0 + /// How many acid-spray hits in a row, each one divides the next hit's damage further. + var/spray_hit_streak = 0 + // these are used to change all vehicle's movement characteristics, 0 means no change var/move_delay = VEHICLE_SPEED_FASTNORMAL var/move_max_momentum = 0 var/move_momentum_build_factor = 0 var/move_turn_momentum_loss_factor = 0 + /// How much of a turf's traction deviation from neutral this locomotion type actually feels. + var/traction_dampening = TRACTION_DAMPENING_WHEELS + + /// Acceleration malus for gear-transmission locomotion, multiplies torque. 1 is no penalty. + var/gear_torque_mult = 1 + +/// Only swaps to the damaged sprite once fully destroyed, unlike other mounted parts. +/obj/item/hardpoint/locomotion/get_mounted_damage_suffix() + return health <= 0 ? 1 : 0 + /obj/item/hardpoint/locomotion/p_are(temp_gender) if(!temp_gender) temp_gender = gender @@ -49,29 +64,58 @@ /obj/item/hardpoint/locomotion/on_uninstall(obj/vehicle/multitile/V) deactivate() +/** + * Dampens a turf's raw traction value by how much this locomotion type feels terrain. + * + * Arguments: + * * turf_traction = Raw traction value of a turf (or the average of several). + * + * Returns: + * * Effective traction multiplier, pulled toward 1.0 by (1 - traction_dampening). + */ +/obj/item/hardpoint/locomotion/proc/get_effective_traction(turf_traction) + return 1 + (turf_traction - 1) * traction_dampening + //unique proc for locomotion modules, taking damage from acid spray and toxic waters and other stuff on ground /obj/item/hardpoint/locomotion/proc/handle_acid_damage(atom/A) if(health <= 0) return - var/take_damage = 0 + var/damage_amount = 0 if(istype(A, /obj/effect/xenomorph/spray)) var/obj/effect/xenomorph/spray/acid = A - take_damage = acid.damage_amount + damage_amount = acid.damage_amount //First we check source of acid. Due to traps generating 3x3 acid spray field and triggering only when at least 4 tiles //of vehicle enter the spray spawn area, it deals a huge amount of damage. But simply nerfing damage will also nerf it for //acid spraying castes like spitters and praetorians, which is not ideal. if(acid.cause_data.cause_name == "resin acid trap") - take_damage = floor(take_damage / 3) + damage_amount = floor(damage_amount / 3) + + // Repeated hits from the same spray do progressively less damage. + if(world.time - last_spray_hit_time <= ACID_SPRAY_STREAK_WINDOW) + spray_hit_streak++ + else + spray_hit_streak = 0 + last_spray_hit_time = world.time + damage_amount /= (spray_hit_streak + 1) else if(istype(A, /obj/effect/blocker/water/toxic)) //multitile vehicles are, well, multitile and will be receiving damage for each tile, so damage is low per tile. - take_damage = 10 + damage_amount = 10 + + else if(istype(A, /obj/effect/xenomorph/acid_damage_delay/boiler_landmine)) + var/obj/effect/xenomorph/acid_damage_delay/boiler_landmine/mine = A + damage_amount = mine.damage * (mine.empowered ? 1.25 : 1) + + else if(istype(A, /obj/effect/lingering_acid)) + var/obj/effect/lingering_acid/puddle = A + damage_amount = puddle.damage //then we check whether this locomotion module is acid-resistant if(acid_resistant) - take_damage = take_damage / 2 - health -= take_damage + damage_amount = damage_amount / 2 + + take_damage(damage_amount, "acid", A) if(!(world.time % 3)) playsound(A, 'sound/bullets/acid_impact1.ogg', 10, 1) diff --git a/code/modules/vehicles/hardpoints/wheels/treads.dm b/code/modules/vehicles/hardpoints/wheels/treads.dm index e4bb68552b47..40c9bf922e11 100644 --- a/code/modules/vehicles/hardpoints/wheels/treads.dm +++ b/code/modules/vehicles/hardpoints/wheels/treads.dm @@ -9,6 +9,7 @@ slot = HDPT_TREADS health = 300 + traction_dampening = TRACTION_DAMPENING_TREADS //with this settings, takes 3 tiles to reach top speed move_delay = 3.8 @@ -24,10 +25,12 @@ acid_resistant = TRUE move_max_momentum = 5 //same top speed, but takes 5 tiles to reach it + gear_torque_mult = 0.6 /obj/item/hardpoint/locomotion/treads/on_install(obj/vehicle/multitile/V) + // Uses is_functional(), don't re-apply an Overdrive Enhancer buff if wounded or unpowered. for(var/obj/item/hardpoint/support/overdrive_enhancer/OD in V.hardpoints) - if(OD.health > 0) + if(OD.is_functional(V)) OD.apply_buff(V) if(move_delay) V.move_delay = move_delay @@ -37,6 +40,7 @@ V.move_momentum_build_factor = move_momentum_build_factor if(move_turn_momentum_loss_factor) V.move_turn_momentum_loss_factor = move_turn_momentum_loss_factor + V.gear_torque_mult = gear_torque_mult /obj/item/hardpoint/locomotion/treads/deactivate() for(var/obj/item/hardpoint/support/overdrive_enhancer/OD in owner.hardpoints) @@ -46,3 +50,4 @@ owner.move_max_momentum = initial(owner.move_max_momentum) owner.move_momentum_build_factor = initial(owner.move_momentum_build_factor) owner.move_turn_momentum_loss_factor = initial(owner.move_turn_momentum_loss_factor) + owner.gear_torque_mult = initial(owner.gear_torque_mult) diff --git a/code/modules/vehicles/interior/interactable/doors.dm b/code/modules/vehicles/interior/interactable/doors.dm index 8fe877b68676..b360b007c74d 100644 --- a/code/modules/vehicles/interior/interactable/doors.dm +++ b/code/modules/vehicles/interior/interactable/doors.dm @@ -130,6 +130,7 @@ var/exit_time = V.entrance_speed if(dragged_atom) exit_time = 2 SECONDS + exit_time *= V.get_hatch_lock_result(M, FALSE)["delay_mult"] to_chat(M, SPAN_NOTICE("You start climbing out of \the [interior.exterior].")) if(!do_after(M, exit_time, INTERRUPT_NO_NEEDHAND, BUSY_ICON_GENERIC)) @@ -151,8 +152,10 @@ interior.exit(M, exit_turf) /obj/structure/interior_exit/vehicle/attack_alien(mob/living/carbon/xenomorph/M, dam_bonus) + var/obj/vehicle/multitile/V = interior.exterior + var/exit_time = 1 SECONDS * V.get_hatch_lock_result(M, FALSE)["delay_mult"] to_chat(M, SPAN_NOTICE("You start climbing out of \the [interior.exterior].")) - if(!do_after(M, 1 SECONDS, INTERRUPT_NO_NEEDHAND, BUSY_ICON_GENERIC)) + if(!do_after(M, exit_time, INTERRUPT_NO_NEEDHAND, BUSY_ICON_GENERIC)) to_chat(M, SPAN_WARNING("Something has interrupted you.")) else interior.exit(M, get_exit_turf()) diff --git a/code/modules/vehicles/interior/interactable/intercom.dm b/code/modules/vehicles/interior/interactable/intercom.dm new file mode 100644 index 000000000000..ef5064a00289 --- /dev/null +++ b/code/modules/vehicles/interior/interactable/intercom.dm @@ -0,0 +1,82 @@ +/obj/structure/vehicle_intercom + name = "vehicle intercom" + desc = "A wall-mounted intercom wired into the vehicle's hull, relaying speech between the crew compartment and the outside world." + icon = 'icons/obj/structures/phone.dmi' + icon_state = "wall_phone" + anchored = TRUE + density = FALSE + layer = 3.2 + + unacidable = TRUE + unslashable = TRUE + explo_proof = TRUE + + flags_atom = USES_HEARING + + var/obj/vehicle/multitile/vehicle = null + var/obj/item/phone/handset + + // Mic = picks up interior speech to broadcast outside. Speaker = plays exterior speech inside. + var/mic_muted = FALSE + var/speaker_muted = FALSE + +/obj/structure/vehicle_intercom/Initialize(mapload, ...) + . = ..() + handset = new /obj/item/phone(src) + +/obj/structure/vehicle_intercom/Destroy() + QDEL_NULL(handset) + vehicle = null + return ..() + +/obj/structure/vehicle_intercom/attack_hand(mob/user) + . = ..() + if(.) + return + + var/choice = tgui_input_list(user, "Adjust the intercom's microphone and speaker.", "Vehicle Intercom", + list("Toggle Microphone ([mic_muted ? "Muted" : "Live"])", "Toggle Speaker ([speaker_muted ? "Muted" : "Live"])", "Cancel")) + + if(!choice || choice == "Cancel" || !Adjacent(user)) + return + + if(findtext(choice, "Microphone")) + mic_muted = !mic_muted + to_chat(user, SPAN_NOTICE("You [mic_muted ? "mute" : "unmute"] the intercom's microphone. [mic_muted ? "Nobody outside will hear the crew compartment." : "The crew compartment can be heard outside again."]")) + else if(findtext(choice, "Speaker")) + speaker_muted = !speaker_muted + to_chat(user, SPAN_NOTICE("You [speaker_muted ? "mute" : "unmute"] the intercom's speaker. [speaker_muted ? "The crew compartment will no longer hear the outside." : "The crew compartment can hear the outside again."]")) + +// anyone speaking near the inttercom inside the interior gets relayed outside. +/obj/structure/vehicle_intercom/hear_talk(mob/living/M, msg, verb = "says", datum/language/speaking, italics = 0) + if(!mic_muted) + relay_to_exterior(M, msg, verb, speaking, italics) + ..() + +/obj/structure/vehicle_intercom/proc/relay_to_exterior(mob/living/M, msg, verb, datum/language/speaking, italics) + if(!vehicle) + return + var/turf/exterior_turf = get_turf(vehicle) + if(!exterior_turf) + return + var/list/listeners = get_mobs_in_view(7, exterior_turf) + if(!length(listeners)) + return + for(var/mob/L in listeners) + L.hear_radio(msg, verb, speaking, part_a = "", + part_b = " ", vname = "[vehicle] Intercom", + speaker = M, no_paygrade = TRUE) + vehicle.langchat_speech(msg, listeners, speaking, M.langchat_color, FALSE, LANGCHAT_FAST_POP, list(M.langchat_styles)) + +// Called by /obj/vehicle/multitile/hear_talk() when someone speaks near the vehicle's exterior. +/obj/structure/vehicle_intercom/proc/relay_exterior_speech(mob/living/M, msg, verb, datum/language/speaking, italics) + if(speaker_muted || !vehicle || !vehicle.interior) + return + var/list/listeners = vehicle.interior.get_passengers() + if(!length(listeners)) + return + for(var/mob/L in listeners) + L.hear_radio(msg, verb, speaking, part_a = "", + part_b = " ", vname = "Intercom", + speaker = M, no_paygrade = TRUE) + langchat_speech(msg, listeners, speaking, M.langchat_color, FALSE, LANGCHAT_FAST_POP, list(M.langchat_styles)) diff --git a/code/modules/vehicles/interior/interactable/seats.dm b/code/modules/vehicles/interior/interactable/seats.dm index 7048aa44ccc3..b86a28d3532f 100644 --- a/code/modules/vehicles/interior/interactable/seats.dm +++ b/code/modules/vehicles/interior/interactable/seats.dm @@ -1,3 +1,15 @@ +/** + * Snaps a client's camera pixel offset back to (0,0), cancelling any in-flight animate() chain instead of just assigning the var. + * A vehicle crash's camera shake could otherwise leave a driver's view permanently offset. + * + * Arguments: + * * client_to_reset = Client whose camera offset to reset. + */ +/proc/reset_client_pixel_offset(client/client_to_reset) + if(!client_to_reset) + return + animate(client_to_reset, pixel_x = 0, pixel_y = 0, time = 0) + //regular vehicle seats for general vehicles. /obj/structure/bed/chair/comfy/vehicle name = "seat" @@ -42,22 +54,33 @@ if(QDELETED(buckled_mob)) M.unset_interaction() vehicle.set_seated_mob(seat, null) + // Also clears the oxy overlay and blur filter, or they'd stick on this mob after leaving. + vehicle.clear_visual_sensor_overlay(M) if(M.client) M.client.change_view(GLOB.world_view_size, vehicle) - M.client.set_pixel_x(0) - M.client.set_pixel_y(0) + reset_client_pixel_offset(M.client) M.reset_view() + if(isliving(M)) + var/mob/living/living_mob = M + if(living_mob.observed_atom) + QDEL_NULL(living_mob.observed_atom) else if(M.stat == DEAD) unbuckle() return + if(M.client) + // Force eye to genuinely change (away, then back) instead of a no-op reassignment to the same value. + M.client.set_eye(null) vehicle.set_seated_mob(seat, M) if(M && M.client) - M.client.change_view(8, vehicle) + reset_client_pixel_offset(M.client) + M.client.change_view(VEHICLE_SEAT_VIEW_RADIUS, vehicle) + vehicle.update_visual_sensor_overlay(M) /obj/structure/bed/chair/comfy/vehicle/clicked(mob/user, list/mods) // If you're buckled, you can shift-click on the seat in order to return to camera-view if(user == buckled_mob && mods[SHIFT_CLICK] && !user.is_mob_incapacitated()) - user.client.change_view(8, vehicle) + reset_client_pixel_offset(user.client) + user.client.change_view(VEHICLE_SEAT_VIEW_RADIUS, vehicle) vehicle.set_seated_mob(seat, user) return TRUE else @@ -188,21 +211,26 @@ if(QDELETED(buckled_mob)) M.unset_interaction() vehicle.set_seated_mob(seat, null) + // Also clears the oxy overlay and blur filter, or they'd stick on this mob after leaving. + vehicle.clear_visual_sensor_overlay(M) if(M.client) M.client.change_view(GLOB.world_view_size, vehicle) - M.client.set_pixel_x(0) - M.client.set_pixel_y(0) + reset_client_pixel_offset(M.client) else if(M.stat != CONSCIOUS) unbuckle() return + if(M.client) + M.client.set_eye(null) // see comfy/vehicle/handle_afterbuckle()'s comment - forces a genuine eye change instead of a possible no-op reassignment vehicle.set_seated_mob(seat, M) if(M && M.client) + reset_client_pixel_offset(M.client) if(istype(vehicle, /obj/vehicle/multitile/apc)) var/obj/vehicle/multitile/apc/APC = vehicle M.client.change_view(APC.gunner_view_buff, vehicle) else - M.client.change_view(8, vehicle) + M.client.change_view(VEHICLE_SEAT_VIEW_RADIUS, vehicle) + vehicle.update_visual_sensor_overlay(M) /obj/structure/bed/chair/comfy/vehicle/gunner/armor/update_icon() overlays.Cut() @@ -268,18 +296,23 @@ if(QDELETED(buckled_mob)) M.unset_interaction() vehicle.set_seated_mob(seat, null) + // Also clears the oxy overlay and blur filter, or they'd stick on this mob after leaving. + vehicle.clear_visual_sensor_overlay(M) if(M.client) M.client.change_view(GLOB.world_view_size, vehicle) - M.client.set_pixel_x(0) - M.client.set_pixel_y(0) + reset_client_pixel_offset(M.client) M.reset_view() else if(M.stat == DEAD) unbuckle() return + if(M.client) + M.client.set_eye(null) // see comfy/vehicle/handle_afterbuckle()'s comment - forces a genuine eye change instead of a possible no-op reassignment vehicle.set_seated_mob(seat, M) if(M && M.client) - M.client.change_view(8, vehicle) + reset_client_pixel_offset(M.client) + M.client.change_view(VEHICLE_SEAT_VIEW_RADIUS, vehicle) + vehicle.update_visual_sensor_overlay(M) if(vehicle.health < initial(vehicle.health) / 2) to_chat(M, SPAN_WARNING("\The [vehicle] is too damaged to operate the Firing Port Weapon!")) diff --git a/code/modules/vehicles/interior/interactable/weapons_loader.dm b/code/modules/vehicles/interior/interactable/weapons_loader.dm index 18b3f894e2a2..e1ed7c3e389f 100644 --- a/code/modules/vehicles/interior/interactable/weapons_loader.dm +++ b/code/modules/vehicles/interior/interactable/weapons_loader.dm @@ -14,7 +14,20 @@ // Loading new magazines /obj/structure/weapons_loader/attackby(obj/item/I, mob/user) - if(!istype(I, /obj/item/ammo_magazine/hardpoint)) + // Hardpoints that load star shells/star shell packets directly (the tank turret, the APC's flare + // launcher) aren't loaded via a swappable magazine, so they shouldn't be picked up by the generic + // magazine-matching loop below. Delegate loose star shells and star shell packets straight to + // whichever hardpoint accepts them. + if(istype(I, /obj/item/explosive/grenade/high_explosive/airburst/starshell) || istype(I, /obj/item/storage/box/packet/flare)) + if(!skillcheck(user, SKILL_VEHICLE, SKILL_VEHICLE_LARGE)) + to_chat(user, SPAN_NOTICE("You have no idea how to operate this thing!")) + return + var/obj/item/hardpoint/starshell_hardpoint = vehicle?.get_starshell_hardpoint() + if(!starshell_hardpoint) + return ..() + return starshell_hardpoint.attackby(I, user) + + if(!istype(I, /obj/item/ammo_magazine)) return ..() if(!skillcheck(user, SKILL_VEHICLE, SKILL_VEHICLE_LARGE)) @@ -24,10 +37,10 @@ // Check if any of the hardpoints accept the magazine var/obj/item/hardpoint/reloading_hardpoint = null for(var/obj/item/hardpoint/H in vehicle.get_hardpoints_with_ammo()) - if(QDELETED(H) || QDELETED(H.ammo)) + if(QDELETED(H)) continue - if(istype(I, H.ammo.type)) + if(H.accepts_magazine(I)) reloading_hardpoint = H break @@ -88,7 +101,16 @@ return if(!LAZYLEN(HP.backup_clips)) - to_chat(user, SPAN_WARNING("\The [HP] has no remaining backup magazines!")) + if(!HP.ammo) + to_chat(user, SPAN_WARNING("\The [HP] has no remaining backup magazines, and no magazine currently loaded!")) + return + + to_chat(user, SPAN_WARNING("\The [HP] has no remaining backup magazines, so you remove the current magazine instead.")) + HP.ammo.forceMove(get_turf(user)) + HP.ammo.update_icon() + HP.ammo = null + HP.owner?.update_icon() + playsound(loc, 'sound/machines/hydraulics_3.ogg', 50) return var/obj/item/ammo_magazine/M = LAZYACCESS(HP.backup_clips, 1) @@ -102,10 +124,12 @@ to_chat(user, SPAN_WARNING("Something interrupted you while reloading \the [HP].")) return - HP.ammo.forceMove(get_turf(src)) - HP.ammo.update_icon() + if(HP.ammo) + HP.ammo.forceMove(get_turf(src)) + HP.ammo.update_icon() HP.ammo = M LAZYREMOVE(HP.backup_clips, M) + HP.owner?.update_icon() playsound(loc, 'sound/machines/hydraulics_3.ogg', 50) to_chat(user, SPAN_NOTICE("You reload \the [HP]. Ammo: [SPAN_HELPFUL(HP.ammo.current_rounds)]/[SPAN_HELPFUL(HP.ammo.max_rounds)] | Mags: [SPAN_HELPFUL(LAZYLEN(HP.backup_clips))]/[SPAN_HELPFUL(HP.max_clips)]")) diff --git a/code/modules/vehicles/interior/interior.dm b/code/modules/vehicles/interior/interior.dm index a6abcc5547bc..8f77194a20e6 100644 --- a/code/modules/vehicles/interior/interior.dm +++ b/code/modules/vehicles/interior/interior.dm @@ -286,8 +286,14 @@ return FALSE -// Moves the atom to the exterior -/datum/interior/proc/exit(atom/movable/A, turf/exit_turf) +/** + * Dry-run of exit()'s own validation, with no side effects. + * Whether `A` could actually get out through `exit_turf` right now. + * + * Arguments: + * * show_message = Whether to tell `A` "Something is blocking the exit!" on failure. + */ +/datum/interior/proc/can_exit(atom/movable/A, turf/exit_turf, show_message = TRUE) if(!exit_turf) exit_turf = get_turf(exterior) if(!exit_turf) @@ -311,15 +317,26 @@ continue if(O.BlockedPassDirs(A, exit_dir)) - if(ismob(A)) + if(show_message && ismob(A)) to_chat(A, SPAN_WARNING("Something is blocking the exit!")) return FALSE + return TRUE + +// Moves the atom to the exterior +/datum/interior/proc/exit(atom/movable/A, turf/exit_turf) + if(!exit_turf) + exit_turf = get_turf(exterior) + if(!can_exit(A, exit_turf)) + return FALSE + var/mob/living/mob if(ismob(A)) mob = A for(var/datum/action/minimap/user_map in mob.actions) user_map.clear_locator_override() + // Undoes the automatic minimap tracking redirect, or the blip would stay stuck on the vehicle. + mob.cancel_override_minimap_tracking(exterior, mob) A.forceMove(get_turf(exit_turf)) update_passenger_count() return TRUE diff --git a/code/modules/vehicles/interior/interior_landmarks.dm b/code/modules/vehicles/interior/interior_landmarks.dm index b57defea5f4b..a2c44a44bc2c 100644 --- a/code/modules/vehicles/interior/interior_landmarks.dm +++ b/code/modules/vehicles/interior/interior_landmarks.dm @@ -241,12 +241,45 @@ Phone.pixel_y = pixel_y Phone.phone_category = "Vehicles" Phone.phone_id = I.map_template.name + if(name != initial(name)) + Phone.name = name + + // Back-reference so the "use vehicle phone" action button can reach this instance. + var/obj/vehicle/multitile/vehicle = I.exterior + if(istype(vehicle)) + vehicle.phone = Phone qdel(src) /obj/effect/landmark/interior/spawn/telephone/wy icon = 'icons/obj/vehicles/interiors/general_wy.dmi' +// Landmark for spawning the ambient vehicle intercom +/obj/effect/landmark/interior/spawn/intercom + name = "vehicle intercom spawner" + icon = 'icons/obj/vehicles/interiors/general.dmi' + icon_state = "wall_phone" + color = "cyan" + +/obj/effect/landmark/interior/spawn/intercom/on_load(datum/interior/interior_data) + var/obj/structure/vehicle_intercom/Intercom = new(loc) + var/obj/vehicle/multitile/vehicle = interior_data.exterior + + Intercom.icon = icon + Intercom.icon_state = icon_state + Intercom.layer = layer + Intercom.setDir(dir) + Intercom.alpha = alpha + Intercom.update_icon() + Intercom.pixel_x = pixel_x + Intercom.pixel_y = pixel_y + Intercom.vehicle = vehicle + + vehicle.intercom = Intercom + vehicle.flags_atom |= USES_HEARING + + qdel(src) + // Landmark for spawning the reloader /obj/effect/landmark/interior/spawn/weapons_loader name = "vehicle weapons reloader spawner" diff --git a/code/modules/vehicles/multitile/multitile.dm b/code/modules/vehicles/multitile/multitile.dm index d04e1e0e8f3e..ee44a69e3d48 100644 --- a/code/modules/vehicles/multitile/multitile.dm +++ b/code/modules/vehicles/multitile/multitile.dm @@ -5,12 +5,52 @@ This applies to for example interior entrances and hardpoint origins */ +/// Order gear-transmission hotkeys/verbs step through. Clamped at both ends, no wraparound. +GLOBAL_LIST_INIT(vehicle_gear_order, list("P", "R", "N", "D", "1", "2")) + /obj/vehicle/multitile name = "multitile vehicle" desc = "Get inside to operate the vehicle." health = 1000 + /// Fraction of an incoming hit that actually reaches the hull's own frame health. + var/hull_damage_multiplier = 0.2 + + /// Cumulative post-multiplier Acid damage this vehicle's frame has taken. Drives the hull_melted wound family. + var/hull_acid_damage_taken = 0 + /// Cumulative post-multiplier Brute damage this vehicle's frame has taken. Drives the hull_hole wound family. + var/hull_brute_damage_taken = 0 + /// Hull wound family type path to current tier. Separate from any hardpoint's own wound_tiers. + var/list/hull_wound_tiers + /// This vehicle's progress on one Hull wound family's current tier. + var/list/hull_wound_repair_progress + + /// Set the instant start_hull_cookoff_sequence() starts, so it can never fire more than once. + var/hull_destroyed_sequence_started = FALSE + /// cell_explosion() power at this vehicle's own exterior epicenter during hull-destruction cookoff. + var/hull_cookoff_exterior_power = HULL_COOKOFF_EXTERIOR_EXPLOSION_POWER + /// cell_explosion() falloff pairing with hull_cookoff_exterior_power. + var/hull_cookoff_exterior_falloff = HULL_COOKOFF_EXTERIOR_EXPLOSION_FALLOFF + /// cell_explosion() power for the separate interior blast if anyone's still trapped inside during cookoff. + var/hull_cookoff_interior_power = HULL_COOKOFF_INTERIOR_EXPLOSION_POWER + /// cell_explosion() falloff pairing with hull_cookoff_interior_power. + var/hull_cookoff_interior_falloff = HULL_COOKOFF_INTERIOR_EXPLOSION_FALLOFF + /// Seconds between the hull-death warning starting and the cookoff detonating. + var/hull_cookoff_warning_time = HULL_COOKOFF_WARNING_TIME + /// Seconds between repeats of the on-screen "get out now" banner during cookoff. + var/hull_cookoff_passenger_warning_interval = HULL_COOKOFF_PASSENGER_WARNING_INTERVAL + /// Radius of the small warning-phase napalm flame lit the instant the cookoff countdown starts. + var/hull_cookoff_warning_flame_radius = HULL_COOKOFF_WARNING_FLAME_RADIUS + var/hull_cookoff_fire_radius = 1 + + /// Mobs currently riding atop this vehicle's hull. + var/list/on_top_mobs = list() + /// Objs currently riding atop this vehicle's hull. + var/list/on_top_obj = list() + /// Per-seat crew HUD. Seat to list of the 7 grid-cell screen objects shown while that seat is occupied. + var/list/crew_hud_elements = list() + //How big the vehicle is in pixels, defined facing SOUTH, which is the byond default (i.e. a 3x3 vehicle is going to be 96x96) ~Cakey bound_width = 32 bound_height = 32 @@ -33,6 +73,9 @@ var/obj/structure/machinery/camera/vehicle/camera = null var/obj/structure/machinery/camera/vehicle/camera_int = null + /// Hidden rangefinder instance backing the gunner's Ctrl+Click rangefinding. Lazily created on first use. + var/obj/item/device/binoculars/range/gunner_rangefinder + var/nickname //used for single-use verb to name the vehicle. Put anything here to prevent naming var/honk_sound = 'sound/vehicles/honk_4_light.ogg' @@ -48,12 +91,135 @@ var/move_momentum = 0 // How much momentum the vehicle can achieve var/move_max_momentum = 5 + /// Fraction of move_max_momentum above which this vehicle can't safely carry riders. 0 means always desant-able (tank, APC). + var/desant_momentum_cap = 0 // How much momentum is lost when turning/rotating the vehicle var/move_turn_momentum_loss_factor = 0.5 // Determines how much slower the vehicle is when it lacks its full momentum // When the vehicle has 0 momentum, it's movement delay will be move_delay * momentum_build_factor // The movement delay gradually reduces up to move_delay when momentum increases + //// This is mostly vestigial and it looks like it cancels itself out when calculating movement delay??? -bwsb var/move_momentum_build_factor = 1.3 + // How fast momentum decays when you stop moving. + var/move_momentum_loss_factor = 1 + // How long do you have to go without moving for momentum decay to start + // Slower moving vehicles like the tank require more time, or else they won't get past minimum speed. + var/idle_time_required = 10 // 10 seconds. Tested to be OK on APC and Van. + + /// If TRUE, this vehicle uses the gear-transmission movement model instead of the legacy momentum model above. + var/uses_gear_transmission = FALSE + /// If TRUE, this vehicle can drive onto open_space turfs instead of crashng. Only ever set on the tank for now. + var/can_enter_open_space = FALSE + /// If TRUE, a charging Crusher's hit is resolved elsewhere (resolve_crusher_charge_hit()) instead of Collided()'s own generic ram. Only ever set on the tank for now. + var/has_own_crusher_charge_handling = FALSE + /// Debounce for resolve_crusher_charge_hit() so a diagonal Charge can't double up its hit in one collision. + var/last_crusher_charge_hit = 0 + /// TRUE while knockback() is stepping this vehicle through a forced Crusher Charge/Ram shove. + var/movement_locked = FALSE + /// TRUE while the current movement_locked shove came from a Crusher caste ability specifically. + var/knockback_crusher_source = FALSE + /// Gear name to list("max_speed", "torque", "fuel_use"). Built by build_gear_stats(). + var/list/gear_stats + /// Multiplier applied to every gear's max_speed and torque, on top of traction/part-condition scaling. + var/gear_performance_mult = 1 + /// Same idea as gear_performance_mult, but torque-only. max_speed stays untouched. + var/gear_torque_mult = 1 + /// Top speed (tiles/sec) in D/R gear. Gear 1/2's speeds and torques are derived from this. + var/top_speed = 0 + /// Acceleration (tiles/sec^2) in D/R gear at 100% engine/tread condition and neutral traction. + var/base_acceleration = 0 + /// Fuel use (units/sec) in D/R gear at full throttle. + var/base_fuel_use = 0 + /// Multiplier applied to every idle-related fuel cost (ENGINE_IDLE_FUEL_USE, ENGINE_REV_FUEL_USE, and Park/Neutral's own base fuel_use). Defaults to the general 0.67 across-the-board idle reduction; override per-vehicle for a different rate. + var/idle_fuel_use_mult = 0.67 + /// Fractional gear_performance_mult boost activate_overdrive() gives this vehicle. 0 disables the ability entirely. + var/overdrive_speed_mult = 0 + /// world.time before which activate_overdrive() refuses to ttrigger again. + var/overdrive_next = 0 + var/overdrive_cooldown = 15 SECONDS + var/overdrive_duration = 3 SECONDS + /// Sound played when activate_overdrive() triggers. + var/overdrive_sound = 'sound/vehicles/overdrive_activate.ogg' + /// Currently selected gear, one of GLOB.vehicle_gear_order. + var/current_gear = "P" + /// Current speed, in tiles/sec. + var/current_speed = 0 + /// Deciseconds between turn-key presses in gear-transmission mode. + var/gear_turn_delay = 8 + /// Direction the vehicle is actually moving under its own power. + var/current_move_direction = SOUTH + /// Complex acceleration only. Leftover speed on the axis the vehicle was facing before its last turn. + var/drift_speed = 0 + /// The compass direction drift_speed is pushing toward. + var/drift_direction = 0 + /// Whether drift_movement_loop() is currently running. + var/drift_loop_active = FALSE + /// Whether the current drift has been "engaged" by a fresh gas/brake input since the turn that created it. + var/drift_braking = FALSE + /// world.time until which the gas key is considered "still held". + var/throttle_held_until = 0 + /// world.time until which the brake key is considered "still held". + var/brake_held_until = 0 + /// Under Simple acceleration, discrete notch the driver has stepped up/down to. + var/speed_notch = 0 + /// world.time before which gear_cruise_loop() won't passively decay speed_notch again. + var/next_notch_decay = 0 + /// world.time before which gear_accelerate() won't step speed_notch up again. + var/next_notch_climb_time = 0 + /// Set when an accelerate press actually changed current_speed, cleared by the next idle tick. + var/simple_accel_pressed = FALSE + /// world.time before which attempt_simple_accel_move() won't issue another try_move(). + var/next_simple_accel_move = 0 + /// If TRUE, gas/brake presses adjust cruise_control_target_speed instead of throttle/brake intent. + var/cruise_control_enabled = FALSE + /// Speed (tiles/sec) cruise control tries to reach and hold. + var/cruise_control_target_speed = 0 + /// How much a single gas/brake press changes cruise_control_target_speed by, in tiles/sec. + var/cruise_control_granularity = 0 + /// Throttles on_crash()/interior_crash_effect() from spamming on a repeated bump. + var/next_crash_effect = 0 + /// Set when a vehicle-vs-vehicle collision already resolved its own crash response, so the generic one doesn't also run. + var/skip_generic_crash_response = FALSE + /// Set when a handle_vehicle_bump() override represents no real collision at all (e.g. a bump-opened door). + var/skip_crash_response_entirely = FALSE + /// Throttles check_engine_exhaust_smoke() so a sustained condition puffs steadily instead of every tick. + var/next_exhaust_smoke_time = 0 + /// Throttles handle_vehicle_gas_exposure() re-checks from a Boiler glob's exterior cloud. + var/next_gas_exposure_time = 0 + /// Throttles expose_to_acid_mine() so one detonation's overlapping tiles don't each re-apply the hit. + var/next_acid_mine_damage_time = 0 + /// Throttles expose_to_lingering_acid() re-hits from sitting in or re-crossing the same puddle. + var/next_lingering_acid_damage_time = 0 + /// Ignition state for gear-transmission vehicles. See set_engine_on(). + var/engine_on = TRUE + /// world.time before which a just-started engine can't build any new torque yet. + var/engine_spinup_until = 0 + /// Throttles the manual toggle_engine() verb so ignition can't be spammed. + var/next_engine_toggle_time = 0 + /// Purely cosmetic. 0-1 "how revved up" the engine sounds while idling in Park/Neutral with gas held. + var/engine_rev_level = 0 + /// Bitmask of which of the 4 independent turn signal lights are currently on. + var/turn_signal_flags = 0 + /// Current blink phase of the turn signal overlays. + var/turn_signal_blink_visible = FALSE + /// Looping blinker-relay sound, active while any turn signal light is on. + var/datum/looping_sound/turn_signal/turn_signal_soundloop + /// Looping engine drone, active while engine_on is TRUE. + var/datum/looping_sound/tank_engine/engine_soundloop + /// Which tank_engine subtype (start/idle/shutdown sounds) engine_soundloop instantiates. Null by default, so no engine sound unless a vehicle sets this. + var/engine_soundloop_type = null + /// Looping track rattle, active only while current_speed > 0. Only instantiated if uses_tank_track_sound is TRUE. + var/datum/looping_sound/tank_tracks/track_soundloop + /// Looping skid sound, active only while drift_speed > 0. Only instantiated if uses_tank_track_sound is TRUE. + var/datum/looping_sound/tank_drift/drift_soundloop + /// Whether this vehicle has actual tracks to rattle/skid. FALSE by default (wheeled vehicles rely on movement_sound instead), TRUE only on the tank. + var/uses_tank_track_sound = FALSE + /// Whether this vehicle's IFF is currently broadcasting. + var/iff_online = FALSE + /// Dedicated CIC overwatch camera, parented to this vehicle. Independent of the standard security camera. Null unless setup_overwatch_camera() was called (opt-in per vehicle type). + var/obj/structure/machinery/camera/overwatch/vehicle/overwatch_camera + /// Squad this vehicle's overwatch is assigned to. Sticky: stays with the last driver's squad. + var/datum/squad/overwatch_squad //Sound to play when moving var/movement_sound @@ -76,19 +242,25 @@ //List of all hardpoints you can attach to this vehicle var/list/hardpoints_allowed = list() + /// slot string to /image currently shown for that slot in this vehicle's hardpoint status HUD. + var/list/hardpoint_hud_images = list() + var/mob_size_required_to_hit = MOB_SIZE_XENO_SMALL //variable for various flags var/vehicle_flags = VEHICLE_CLASS_WEAK // References to the active/chosen hardpoint for each seat - var/active_hp = list( + var/list/obj/item/hardpoint/active_hp = list( VEHICLE_DRIVER = null ) // Map file name of the vehicle interior var/interior_map = null var/datum/interior/interior = null + var/obj/structure/vehicle_intercom/intercom = null + /// The telephone spawned inside this vehicle's interior, if the map has one. + var/obj/structure/transmitter/phone = null //common passenger slots var/passengers_slots = 2 @@ -174,12 +346,25 @@ ///Minimap iconstate to use for this vehicle var/minimap_icon_state + // Structures that we should collide with, but that aren't being collided with when we call T.Enter in multitile_movement + // associative list should guarantee an O(1) lookup in case this needs to be expanded. + var/static/list/blocking_structures = list( + /obj/structure/shuttle/part = TRUE, + /obj/structure/mineral_door/resin = TRUE, + ) + + var/momentum_decay_active = FALSE // Track if momentum decay loop is running + /// Whether gear_movement_loop() is currently running. + var/movement_loop_active = FALSE + var/last_input_time = 0 // Track when last movement input was received + /obj/vehicle/multitile/Initialize() . = ..() var/angle_to_turn = turning_angle(SOUTH, dir) rotate_entrances(angle_to_turn) rotate_bounds(angle_to_turn) + update_langchat_height() if(bound_width > world.icon_size || bound_height > world.icon_size) lighting_holder = new(src) @@ -192,12 +377,27 @@ light_pixel_x = -bound_x light_pixel_y = -bound_y + turn_signal_soundloop = new(src) + if(engine_soundloop_type) + engine_soundloop = new engine_soundloop_type(src) + if(uses_tank_track_sound) + track_soundloop = new(src) + drift_soundloop = new(src) + if(uses_gear_transmission) + if(engine_on) + engine_soundloop?.start() + spawn(0) + battery_power_loop() + healthcheck() update_icon() update_minimap_icon() GLOB.all_multi_vehicles += src + GLOB.vehicle_hardpoint_hud.add_to_hud(src) + INVOKE_ASYNC(src, PROC_REF(hardpoint_hud_loop)) + return INITIALIZE_HINT_LATELOAD /obj/vehicle/multitile/LateInitialize() @@ -216,15 +416,72 @@ return /obj/vehicle/multitile/Destroy() + // Clears this vehicle's own CIC overwatch link before the rest of destruction runs. No-op for a + // vehicle that never set one up. + overwatch_squad?.remove_overwatch_vehicle(src) + overwatch_squad = null + QDEL_NULL(overwatch_camera) + + for(var/mob/living/M in on_top_mobs.Copy()) + if(M) + clear_on_top(M) + for(var/obj/O in on_top_obj.Copy()) + if(O) + obj_clear_on_top(O) + on_top_mobs.Cut() + on_top_obj.Cut() + // Otherwise any barricade currently covered (update_covered_barricades()) would be stuck + // rendering above a vehicle that no longer exists to be above. + for(var/turf/T as anything in locs) + for(var/obj/structure/barricade/B in T) + B.covered_by_vehicle = FALSE + B.update_icon() + if(!QDELETED(interior)) QDEL_NULL(interior) QDEL_NULL_LIST(hardpoints) + QDEL_NULL(turn_signal_soundloop) + QDEL_NULL(engine_soundloop) + QDEL_NULL(track_soundloop) + QDEL_NULL(drift_soundloop) + QDEL_NULL(gunner_rangefinder) + + GLOB.vehicle_hardpoint_hud.remove_from_hud(src) + hardpoint_hud_images = null GLOB.all_multi_vehicles -= src return ..() +/// A vehicle has no client of its own, so its stairwell vision goes to the seated driver/gunner instead. +/obj/vehicle/multitile/get_staircase_vision_clients() + . = list() + var/mob/living/driver = seats[VEHICLE_DRIVER] + if(driver?.client) + . += driver.client + var/mob/living/gunner = seats[VEHICLE_GUNNER] + if(gunner?.client) + . += gunner.client + +// No-op unless an intercom landmark spawned one into this vehicle's interior. +/obj/vehicle/multitile/hear_talk(mob/living/speaker, msg, verb = "says", datum/language/speaking, italics = 0) + if(intercom) + intercom.relay_exterior_speech(speaker, msg, verb, speaking, italics) + if(!intercom.speaker_muted) + for(var/seat_key in seats) + var/mob/seated = seats[seat_key] + if(seated?.client?.prefs && !seated.client.prefs.lang_chat_disabled && !seated.ear_deaf && seated.say_understands(speaker, speaking)) + speaker.langchat_display_image(seated) + ..() + +// offsets langchat height to show, properly, above and around the middle of the vehicle. +/obj/vehicle/multitile/proc/update_langchat_height() + langchat_height = bound_height - bound_y - 8 + +/obj/vehicle/multitile/get_maxptext_x_offset(image/maptext_image) + return ..() - bound_x - 16 + /obj/vehicle/multitile/proc/initialize_cameras() return @@ -261,12 +518,205 @@ var/image/J = image(icon, icon_state = "vehicle_clamp", layer = layer+0.1) overlays += J -//Normal examine() but tells the player what is installed and if it's broken + if(turn_signal_flags && turn_signal_blink_visible) + for(var/signal_direction in list(NORTH, SOUTH, EAST, WEST)) + if(turn_signal_flags & signal_direction) + overlays += build_turn_signal_image(signal_direction) + +/** + * Pixel offset for a turn signal light's maptext overlay, per compass direction. Hand-tuned against + * observed in-game rendering. + * + * Arguments: + * * direction = NORTH, SOUTH, EAST or WEST. + * + * Returns: + * * A 2-element list, list(maptext_x, maptext_y). + */ +/obj/vehicle/multitile/proc/get_turn_signal_offset(direction) + var/base_x = (bound_width - 64) / 2 + var/base_y = bound_height - bound_y + 4 + + switch(direction) + if(NORTH) + return list(base_x + 32, base_y - 16) + if(WEST) + return list(base_x - 24, base_y - 72) + if(SOUTH) + return list(base_x + 32, base_y - 128) + if(EAST) + return list(base_x + 96, base_y - 72) + return list(base_x, base_y) + +/** + * Builds this vehicle's turn signal maptext overlay for one compass direction. + * + * Arguments: + * * signal_direction = NORTH, SOUTH, EAST or WEST. + * + * Returns: + * * A new /image, ready to add to overlays. + */ +/obj/vehicle/multitile/proc/build_turn_signal_image(signal_direction) + var/glyph + switch(signal_direction) + if(NORTH) + glyph = "▲▲" + if(SOUTH) + glyph = "▼▼" + if(EAST) + glyph = "▶▶" + if(WEST) + glyph = "◀◀" + + var/image/turn_signal_image = image(null, src) + turn_signal_image.maptext_width = 64 + turn_signal_image.maptext_height = 32 + var/list/offset = get_turn_signal_offset(signal_direction) + turn_signal_image.maptext_x = offset[1] + turn_signal_image.maptext_y = offset[2] + turn_signal_image.layer = 20 + // font-size kept small relative to maptext_height (32px) so the glyph doesn't get clipped. + turn_signal_image.maptext = MAPTEXT("[glyph]") + return turn_signal_image + +/** + * Whether this vehicle currently has power to run its turn signals. + * + * Arguments: + * * ignore_battery = Passed straight through to has_vehicle_power(). + */ +/obj/vehicle/multitile/proc/can_use_turn_signals(ignore_battery = FALSE) + return has_vehicle_power(ignore_battery) + +/** + * Force-switches off whatever turn signal is currently active if this vehicle can no longer power it. + * + * Arguments: + * * ignore_battery = Passed straight through to can_use_turn_signals(). + */ +/obj/vehicle/multitile/proc/recheck_turn_signals(ignore_battery = FALSE) + if(turn_signal_flags && !can_use_turn_signals(ignore_battery)) + toggle_turn_signal(turn_signal_flags) + +/** + * Toggles one of this vehicle's 4 independent turn signal lights on/off. Only one can be active at once. + * Purely manual, toggling the active direction again is the only way to turn it off. + * + * Arguments: + * * direction = NORTH, SOUTH, EAST or WEST. Which light to toggle. + */ +/obj/vehicle/multitile/proc/toggle_turn_signal(direction) + var/was_active = (turn_signal_flags != 0) + var/turning_on = !(turn_signal_flags & direction) + + turn_signal_flags = turning_on ? direction : 0 + turn_signal_blink_visible = (turn_signal_flags != 0) + update_icon() + + if(turning_on) + if(!was_active) + INVOKE_ASYNC(src, PROC_REF(turn_signal_blink_loop)) + turn_signal_soundloop?.stop() + turn_signal_soundloop?.start() + addtimer(CALLBACK(src, PROC_REF(stop_turn_signal_sound)), TURN_SIGNAL_SOUND_DURATION, TIMER_UNIQUE|TIMER_OVERRIDE) + else + turn_signal_soundloop?.stop() + +/// Cuts the turn signal's relay sound after it's played for TURN_SIGNAL_SOUND_DURATION. The visual blink keeps going regardless. Audio spam = bad +/obj/vehicle/multitile/proc/stop_turn_signal_sound() + if(turn_signal_flags != 0) + turn_signal_soundloop?.stop() + +/// Blinks the turn signal overlays on/off at a fixed cadence while a signal is active. +/obj/vehicle/multitile/proc/turn_signal_blink_loop() + while(turn_signal_flags != 0) + sleep(TURN_SIGNAL_BLINK_INTERVAL) + turn_signal_blink_visible = !turn_signal_blink_visible + update_icon() + + turn_signal_blink_visible = FALSE + update_icon() + +/** + * Toggles this vehicle's ignition state. While off, gear_cruise_loop() can't generate torque and the + * tank_engine sound loop stops. Turning it on starts a fresh ENGINE_SPINUP_TIME window before it can build + * any new torque. Existing speed still coasts freely, only gaining more speed is gated. + * + * Arguments: + * * new_state = TRUE to start the engine, FALSE to shut it off. + */ +/obj/vehicle/multitile/proc/set_engine_on(new_state) + if(engine_on == new_state) + return + engine_on = new_state + if(engine_on) + engine_spinup_until = world.time + ENGINE_SPINUP_TIME + engine_soundloop?.start() + start_momentum_decay_if_needed() + else + engine_soundloop?.stop() + + // Engine state feeds into has_vehicle_power(), so refresh everything that depends on it immediately. + var/obj/item/hardpoint/holder/tank_turret/turret = locate() in hardpoints + if(turret) + turret.recalculate_turn_rate() + recheck_iff_module() + recheck_support_modules() + recheck_turn_signals() + recheck_visual_sensors() + + // Keeps the Toggle Engine action button's icon in sync when something other than the driver flips engine_on. + refresh_hardpoint_actions() + +/** + * Every client physically inside this vehicle's interior, seated or not. + * + * Returns: + * * A list of /client. Empty if this vehicle has no interior instance yet. + */ +/obj/vehicle/multitile/proc/get_interior_mob_clients() + var/list/clients = list() + if(!interior) + return clients + + var/list/bounds = interior.get_bound_turfs() + for(var/turf/interior_turf as anything in block(bounds[1], bounds[2])) + for(var/mob/living/interior_mob in interior_turf) + if(interior_mob.client) + clients += interior_mob.client + return clients + +/// Normal examine() but a compact one-line-per-part list, each linking to its own full examine popup. /obj/vehicle/multitile/get_examine_text(mob/user) . = ..() for(var/obj/item/hardpoint/H in hardpoints) - . += "There [H.p_are()] \a [H] module[H.p_s()] installed." - H.examine(user, TRUE) + . += H.build_examine_line(user) + // Holders (e.g. the turret) mount their own weapons in a nested hardpoints list, list those inline too. + if(istype(H, /obj/item/hardpoint/holder)) + var/obj/item/hardpoint/holder/sub_holder = H + for(var/obj/item/hardpoint/sub in sub_holder.hardpoints) + . += sub.build_examine_line(user, "\the [sub_holder]") + . += get_missing_hardpoint_slot_lines(hardpoints_allowed, hardpoints) + // Same reasoning as above, but for a holder's own empty nested slots. + for(var/obj/item/hardpoint/holder/sub_holder in hardpoints) + . += get_missing_hardpoint_slot_lines(sub_holder.accepted_hardpoints, sub_holder.hardpoints) + var/is_xeno_examiner = isxeno(user) + for(var/family_type in hull_wound_tiers) + var/tier = hull_wound_tiers[family_type] + var/datum/hardpoint_wound_family/family = GLOB.hardpoint_wound_families_by_type[family_type] + if(!family || tier > length(family.tiers)) + continue + var/list/tier_data = family.tiers[tier] + var/blurb = is_xeno_examiner ? tier_data["xeno_feedback"] : tier_data["marine_feedback_red"] + if(!blurb) + continue + if(is_xeno_examiner) + . += tier_data["bold_feedback"] ? SPAN_XENOBOLDNOTICE(blurb) : SPAN_XENOWARNING(blurb) + continue + var/line = tier_data["bold_feedback"] ? SPAN_BOLDWARNING(blurb) : SPAN_WARNING(blurb) + line += " (Repair Info)" + . += line if(clamped) . += "There is a vehicle clamp attached." if(isxeno(user) && interior) @@ -276,6 +726,25 @@ if(passengers_amount > 0) . += "You can sense approximately [passengers_amount] host\s inside." +/// Resolves the "(Repair Info)" link the Hull wound lines above add. No in-place repair action for Hull wounds yet. +/obj/vehicle/multitile/Topic(href, list/href_list) + if(href_list["hull_wound_info"]) + show_hull_wound_repair_info(usr, href_list["hull_wound_info"]) + return + return ..() + +/// Same shape as /obj/item/hardpoint/proc/show_wound_repair_info(), reads hull_wound_tiers instead. +/obj/vehicle/multitile/proc/show_hull_wound_repair_info(mob/user, family_type_text) + var/family_type = text2path(family_type_text) + var/tier = LAZYACCESS(hull_wound_tiers, family_type) + if(!tier) + return + var/datum/hardpoint_wound_family/family = GLOB.hardpoint_wound_families_by_type[family_type] + if(!family || tier > length(family.tiers)) + return + var/list/tier_data = family.tiers[tier] + to_chat(user, SPAN_NOTICE("[SPAN_BOLD(tier_data["wound_name"])]: [format_repair_info_sentence(tier_data)]")) + /obj/vehicle/multitile/proc/load_hardpoints() return @@ -292,29 +761,9 @@ return 1 return dmg_multipliers[type] * dmg_multipliers["all"] -//Generic proc for taking damage -//ALWAYS USE THIS WHEN INFLICTING DAMAGE TO THE VEHICLES -/obj/vehicle/multitile/proc/take_damage_type(damage, type, atom/attacker) - var/all_broken = TRUE - for(var/obj/item/hardpoint/H in hardpoints) - // Health check is done before the hardpoint takes damage - // This way, the frame won't take damage at the same time hardpoints break - if(H.can_take_damage()) - H.take_damage(floor(damage * get_dmg_multi(type))) - all_broken = FALSE - - // If all hardpoints are broken, the vehicle frame begins taking full damage - if(all_broken) - health = max(0, health - damage * get_dmg_multi(type)) - else //otherwise, 1/10th of damage lands on the hull - health = max(0, health - floor(damage * get_dmg_multi(type) / 10)) - - if(ismob(attacker)) - var/mob/M = attacker - log_attack("[src] took [damage] [type] damage from [M] ([M.client ? M.client.ckey : "disconnected"]).") - else - log_attack("[src] took [damage] [type] damage from [attacker].") - update_icon() +// take_damage_type() now lives in hardpoint_wounds.dm, alongside the rest of the +// single-target damage resolution machinery it depends on (get_attack_target_slot(), +// resolve_targeted_hardpoint(), apply_hull_damage(), etc). /obj/vehicle/multitile/Entered(atom/movable/A) if(istype(A, /obj) && !istype(A, /obj/item/ammo_magazine/hardpoint) && !istype(A, /obj/item/hardpoint)) @@ -329,6 +778,94 @@ /obj/vehicle/multitile/proc/remove_seated_verbs(mob/living/M, seat) return +/** + * Re-evaluates any per-seat HUD action buttons that depend on currently-installed hardpoints. + * Generic DRIVER/GUNNER handling only. A vehicle with its own holder/support-module logic (e.g. + * the tank's turret, artillery module, and slavable secondary) overrides this instead. + */ +/obj/vehicle/multitile/proc/refresh_hardpoint_actions() + for(var/seat in list(VEHICLE_DRIVER, VEHICLE_GUNNER)) + var/mob/living/M = seats[seat] + if(!istype(M) || !M.client) + continue + + if(length(get_activatable_hardpoints(seat)) >= 2) + give_action(M, /datum/action/human_action/vehicle_action/cycle_hardpoint) + // give_action() no-ops when this seat already has the action, so force the icon refresh here too. + for(var/datum/action/human_action/vehicle_action/cycle_hardpoint/action in M.actions) + action.update_button_icon() + else + remove_action(M, /datum/action/human_action/vehicle_action/cycle_hardpoint) + + if(active_hp[seat]?.get_flame_mode()) + give_action(M, /datum/action/human_action/vehicle_action/toggle_hardpoint_fire_mode) + else + remove_action(M, /datum/action/human_action/vehicle_action/toggle_hardpoint_fire_mode) + + if(seat == VEHICLE_GUNNER) + if(get_slavable_secondary()) + give_action(M, /datum/action/human_action/vehicle_action/slave_secondary) + else + remove_action(M, /datum/action/human_action/vehicle_action/slave_secondary) + + if(seat == VEHICLE_DRIVER) + if(!(M.client.prefs.toggles_vehicle & VEHICLE_SIMPLE_ACCELERATION)) + give_action(M, /datum/action/human_action/vehicle_action/toggle_cruise_control) + give_action(M, /datum/action/human_action/vehicle_action/set_cruise_control_granularity) + else + remove_action(M, /datum/action/human_action/vehicle_action/toggle_cruise_control) + remove_action(M, /datum/action/human_action/vehicle_action/set_cruise_control_granularity) + + for(var/datum/action/human_action/vehicle_action/action in M.actions) + action.update_button_icon() + +/** + * Ensures active_hp[seat] holds a valid, currently-activatable hardpoint whenever one exists. + * Leaves an existing valid selection alone. Generic DRIVER/GUNNER pick: the first mounted Primary, + * else the first mounted Secondary. A vehicle with its own holder-based weapon (e.g. the tank's + * turret) overrides this instead. + */ +/obj/vehicle/multitile/proc/ensure_active_hardpoint(seat) + if(seat != VEHICLE_DRIVER && seat != VEHICLE_GUNNER) + return + + var/list/usable_hps = get_activatable_hardpoints(seat) + var/obj/item/hardpoint/current = active_hp[seat] + if(current && (current in usable_hps)) + return + + var/obj/item/hardpoint/new_active = null + for(var/obj/item/hardpoint/primary/candidate in usable_hps) + new_active = candidate + break + if(!new_active) + for(var/obj/item/hardpoint/secondary/candidate in usable_hps) + new_active = candidate + break + if(!new_active) + for(var/obj/item/hardpoint/support/candidate in usable_hps) + new_active = candidate + break + + if(current && !QDELETED(current)) + SEND_SIGNAL(current, COMSIG_GUN_INTERRUPT_FIRE) + active_hp[seat] = new_active + // Keeps action buttons that depend on the specific active hardpoint correct across every caller of this proc. + refresh_hardpoint_actions() + +/** + * Registered on the driver while seated, so a live preference toggle re-evaluates this seat's action buttons. + * Also force-disengages cruise control if the driver just toggled into Simple Acceleration while already engaged. + */ +/obj/vehicle/multitile/proc/on_driver_prefs_changed(datum/source) + SIGNAL_HANDLER + if(cruise_control_enabled && (get_driver_vehicle_prefs() & VEHICLE_SIMPLE_ACCELERATION)) + cruise_control_enabled = FALSE + var/mob/driver = source + if(istype(driver)) + to_chat(driver, SPAN_WARNING("Cruise control disengaged - not available with simple vehicle acceleration.")) + refresh_hardpoint_actions() + /obj/vehicle/multitile/set_seated_mob(seat, mob/living/M) // Give/remove verbs if(QDELETED(M)) @@ -339,6 +876,10 @@ seats[seat] = M + // add_seated_verbs() above grants this seat's action buttons before seats[seat] is set, so their first + // icon render can't find the seat's real active_hp yet. Refresh again now that seats[] is correct. + refresh_hardpoint_actions() + // Checked here because we want to be able to null the mob in a seat if(!istype(M)) return FALSE @@ -376,7 +917,9 @@ //Special armored vic healthcheck that mainly updates the hardpoint states /obj/vehicle/multitile/healthcheck() - var/all_broken = 1 //Whether or not to call handle_all_modules_broken() + // A vehicle with zero installed hardpoints (e.g. mid-Initialize(), before the spawner adds any) has + // nothing to be "all broken" about, so don't default this TRUE regardless of hardpoints. + var/all_broken = length(hardpoints) > 0 //Whether or not to call handle_all_modules_broken() for(var/obj/item/hardpoint/H in hardpoints) if(H.health <= 0) H.deactivate() @@ -395,6 +938,10 @@ else if(!lighting_holder.light) lighting_holder.set_light_on(TRUE) + + for(var/seat_key in seats) + update_visual_sensor_overlay(seats[seat_key]) + update_icon() /* @@ -446,11 +993,51 @@ /obj/vehicle/multitile/get_applying_acid_time() return 3 SECONDS -//handling dangerous acidic environment, like acidic spray or toxic waters, maybe toxic vapor in future +/** + * Handling dangerous acidic environment, like acidic spray or toxic waters, maybe toxic vapor in future. + * + * Spray Acid and Despoiler's lingering acid puddles get a dedicated tank-only redirect + * (expose_to_weighted_acid()) instead of the plain Treads-only hit below. Acid Mine and toxic water keep + * the original Treads-only path, since Acid Mine already has its own explicit Treads + Hull design. + */ /obj/vehicle/multitile/proc/handle_acidic_environment(atom/A) + if(istype(src, /obj/vehicle/multitile/tank) && (istype(A, /obj/effect/xenomorph/spray) || istype(A, /obj/effect/lingering_acid))) + var/obj/vehicle/multitile/tank/tank = src + tank.expose_to_weighted_acid(A) + return + for(var/obj/item/hardpoint/locomotion/Loco in hardpoints) Loco.handle_acid_damage(A) +/** + * Handles one Acid Mine detonation tile touching this vehicle. A parked tank's 3x3 footprint can overlap + * several of one cast's simultaneously-detonating tiles at once, deduped via next_acid_mine_damage_time so + * that only applies once. Hits Treads like the acid spray does, plus a direct Hull hit on top. + */ +/obj/vehicle/multitile/proc/expose_to_acid_mine(obj/effect/xenomorph/acid_damage_delay/boiler_landmine/mine) + if(world.time < next_acid_mine_damage_time) + return + next_acid_mine_damage_time = world.time + ACID_MINE_VEHICLE_DAMAGE_COOLDOWN + + handle_acidic_environment(mine) + + var/hull_damage = mine.damage * (mine.empowered ? 1.25 : 1) * get_hull_incoming_damage_wound_multiplier(WOUND_DAMTYPE_ACID) + health = max(0, health - hull_damage) + hull_acid_damage_taken += hull_damage + roll_hull_wounds(hull_damage, WOUND_DAMTYPE_ACID, mine.linked_xeno) + +/** + * Handles a Despoiler acid puddle (Oozing Wounds) touching this vehicle. Unlike Acid Mine's one-shot + * detonation, a puddle lasts 15-20 seconds and can be sat in or re-crossed repeatedly, deduped via + * next_lingering_acid_damage_time. Treads-only, no bonus Hull hit like Acid Mine gets. + */ +/obj/vehicle/multitile/proc/expose_to_lingering_acid(obj/effect/lingering_acid/puddle) + if(world.time < next_lingering_acid_damage_time) + return + next_lingering_acid_damage_time = world.time + LINGERING_ACID_VEHICLE_DAMAGE_COOLDOWN + + handle_acidic_environment(puddle) + /atom/movable/vehicle_light_holder light_system = MOVABLE_LIGHT mouse_opacity = MOUSE_OPACITY_TRANSPARENT @@ -468,6 +1055,24 @@ forceMove(get_turf(mover)) +/obj/vehicle/multitile/proc/is_blocking_structure(atom/A) + for(var/blocked_type in blocking_structures) + if(ispath(A.type, blocked_type)) + return TRUE + return FALSE + +/// Whether A is a "directional" ON_BORDER obstacle that only blocks one edge of its tile (barricades, non-full-tile windows, flipped tables). +/obj/vehicle/multitile/proc/is_directional_obstacle(atom/A) + if(istype(A, /obj/structure/barricade)) + return TRUE + if(istype(A, /obj/structure/window)) + var/obj/structure/window/W = A + return !W.is_full_window() + if(istype(A, /obj/structure/surface/table)) + var/obj/structure/surface/table/table = A + return table.flipped + return FALSE + ///Updates the vehicles minimap icon /obj/vehicle/multitile/proc/update_minimap_icon(modules_broken) if(!minimap_icon_state) diff --git a/code/modules/vehicles/multitile/multitile_actions.dm b/code/modules/vehicles/multitile/multitile_actions.dm new file mode 100644 index 000000000000..8cfc4987cfcb --- /dev/null +++ b/code/modules/vehicles/multitile/multitile_actions.dm @@ -0,0 +1,271 @@ +/** + * Shared base for a vehicle's driver/gunner HUD action buttons. + * Resolves the vehicle via the seat chair rather than a stored reference, so it stays correct + * across re-buckles. + */ +/datum/action/human_action/vehicle_action + name = "Vehicle Action" + +// Deliberately not auto-cleaning up via COMSIG_MOB_RESET_VIEW. That signal fires on every seat +// change, not just unbuckle, so it would self-destruct the button the instant it's granted. +// remove_seated_verbs() already handles cleanup on unbuckle. +/datum/action/human_action/vehicle_action/give_to(mob/user) + . = ..() + update_button_icon() + +/datum/action/human_action/vehicle_action/proc/get_vehicle() + var/mob/living/carbon/human/user = owner + if(!istype(user) || !istype(user.buckled, /obj/structure/bed/chair/comfy/vehicle)) + return null + var/obj/structure/bed/chair/comfy/vehicle/chair = user.buckled + return chair.vehicle + +// 1) Door Lock ------------------------------------------------------------------------- + +/datum/action/human_action/vehicle_action/toggle_door_lock + name = "Toggle Door Locks" + action_icon_state = "id_lock_unlocked" + +/datum/action/human_action/vehicle_action/toggle_door_lock/update_button_icon() + var/obj/vehicle/multitile/vehicle = get_vehicle() + action_icon_state = (vehicle && vehicle.door_locked) ? "id_lock_locked" : "id_lock_unlocked" + button.overlays.Cut() + button.overlays += image('icons/mob/hud/actions.dmi', button, action_icon_state) + +/datum/action/human_action/vehicle_action/toggle_door_lock/action_activate() + . = ..() + var/obj/vehicle/multitile/vehicle = get_vehicle() + if(!vehicle) + return + vehicle.toggle_door_lock(owner) + update_button_icon() + +// 2) Cycle Active Hardpoint ------------------------------------------------------------- + +/datum/action/human_action/vehicle_action/cycle_hardpoint + name = "Cycle Active Hardpoint" + action_icon_state = "tank_cycle_primary" + +/datum/action/human_action/vehicle_action/cycle_hardpoint/update_button_icon() + var/obj/vehicle/multitile/vehicle = get_vehicle() + var/seat = vehicle?.get_mob_seat(owner) + var/obj/item/hardpoint/current = seat ? vehicle.active_hp[seat] : null + action_icon_state = istype(current, /obj/item/hardpoint/secondary) ? "tank_cycle_secondary" : "tank_cycle_primary" + button.overlays.Cut() + button.overlays += image('icons/mob/hud/actions.dmi', button, action_icon_state) + +/datum/action/human_action/vehicle_action/cycle_hardpoint/action_activate() + . = ..() + var/obj/vehicle/multitile/vehicle = get_vehicle() + if(!vehicle) + return + vehicle.cycle_hardpoint(owner) + update_button_icon() + +// 3) Vehicle Ignition ----------------------------------------------------------------------------- + +/datum/action/human_action/vehicle_action/toggle_engine + name = "Toggle Engine" + action_icon_state = "engine" + +/datum/action/human_action/vehicle_action/toggle_engine/update_button_icon() + var/obj/vehicle/multitile/vehicle = get_vehicle() + action_icon_state = (vehicle && vehicle.engine_on) ? "engine_off" : "engine" + button.overlays.Cut() + button.overlays += image('icons/mob/hud/actions.dmi', button, action_icon_state) + +/datum/action/human_action/vehicle_action/toggle_engine/action_activate() + . = ..() + var/obj/vehicle/multitile/vehicle = get_vehicle() + if(!vehicle) + return + vehicle.toggle_engine(owner) + update_button_icon() + +// 4) Vehicle Phone ----------------------------------------------------------------------------------- + +/** + * Lets seated crew answer/place/hang up the vehicle's mounted phone without leaving their seat. + * The button always shows the phone's own sprite, forced south-facing, and never touches the + * actual in-game object's icon or dir. + */ +/datum/action/human_action/vehicle_action/use_phone + name = "Use Vehicle Phone" + action_icon_state = "wall_phone" + +// Registers for the phone's update signal so the button flips to "ringing" the instant a call +// comes in, rather than waiting on the next unrelated icon refresh. Unregistered in remove_from(). +/datum/action/human_action/vehicle_action/use_phone/give_to(mob/user) + . = ..() + var/obj/vehicle/multitile/vehicle = get_vehicle() + if(vehicle?.phone) + RegisterSignal(vehicle.phone, COMSIG_TRANSMITTER_UPDATE_ICON, PROC_REF(on_phone_icon_update)) + +/datum/action/human_action/vehicle_action/use_phone/remove_from(mob/L) + var/obj/vehicle/multitile/vehicle = get_vehicle() + if(vehicle?.phone) + UnregisterSignal(vehicle.phone, COMSIG_TRANSMITTER_UPDATE_ICON) + return ..() + +/datum/action/human_action/vehicle_action/use_phone/proc/on_phone_icon_update() + SIGNAL_HANDLER + update_button_icon() + +/datum/action/human_action/vehicle_action/use_phone/update_button_icon() + var/obj/vehicle/multitile/vehicle = get_vehicle() + var/obj/structure/transmitter/phone = vehicle?.phone + var/base = phone?.base_icon_state || "wall_phone" + + if(phone && phone.attached_to && phone.attached_to.loc != phone) + action_icon_state = "[base]_ear" // picked up/off the hook - mid-call + else if(phone?.inbound_call) + action_icon_state = "[base]_ring" // ringing, not yet answered + else + action_icon_state = base // idle, on the hook + + button.overlays.Cut() + button.overlays += image('icons/obj/structures/phone.dmi', button, action_icon_state) // dir defaults to SOUTH + +/** + * Off the hook, hangs up. Otherwise defers to the phone's own attack_hand(), same as clicking it. + */ +/datum/action/human_action/vehicle_action/use_phone/action_activate() + . = ..() + var/obj/vehicle/multitile/vehicle = get_vehicle() + var/obj/structure/transmitter/phone = vehicle?.phone + if(!phone) + return + + if(phone.attached_to && phone.attached_to.loc != phone) + phone.recall_phone() + else + phone.attack_hand(owner) + update_button_icon() + +// 5) Hardpoint Fire Mode (Flamer Glob/Stream) ----------------------------------------------------- + +/** + * Toggles a flamer hardpoint between FLAME_MODE_GLOB and FLAME_MODE_STREAM. Only granted while + * this seat's active hardpoint actually is a flamer. + */ +/datum/action/human_action/vehicle_action/toggle_hardpoint_fire_mode + name = "Toggle Fire Mode" + action_icon_state = "nozzle_ball" + +/datum/action/human_action/vehicle_action/toggle_hardpoint_fire_mode/update_button_icon() + var/obj/vehicle/multitile/vehicle = get_vehicle() + var/obj/item/hardpoint/current = vehicle?.get_mob_hp(owner) + action_icon_state = (current?.get_flame_mode() == FLAME_MODE_STREAM) ? "nozzle_spray" : "nozzle_ball" + button.overlays.Cut() + button.overlays += image('icons/mob/hud/actions.dmi', button, action_icon_state) + +/datum/action/human_action/vehicle_action/toggle_hardpoint_fire_mode/action_activate() + . = ..() + var/obj/vehicle/multitile/vehicle = get_vehicle() + var/obj/item/hardpoint/current = vehicle?.get_mob_hp(owner) + current?.toggle_fire_mode(owner) + update_button_icon() + +// 6) Cruise Control --------------------------------------------------------------------------- + +/** + * Only meaningful under Complex vehicle acceleration. on_driver_prefs_changed() listens for the + * driver flipping that preference live and refreshes this button immediately. + */ +/datum/action/human_action/vehicle_action/toggle_cruise_control + name = "Toggle Cruise Control" + action_icon_state = "cruise_control" + +/datum/action/human_action/vehicle_action/toggle_cruise_control/update_button_icon() + var/obj/vehicle/multitile/vehicle = get_vehicle() + action_icon_state = (vehicle && vehicle.cruise_control_enabled) ? "cruise_control_off" : "cruise_control" + button.overlays.Cut() + button.overlays += image('icons/mob/hud/actions.dmi', button, action_icon_state) + +/datum/action/human_action/vehicle_action/toggle_cruise_control/action_activate() + . = ..() + var/obj/vehicle/multitile/vehicle = get_vehicle() + if(!vehicle) + return + vehicle.toggle_cruise_control() + update_button_icon() + +// 7) Cruise Control Granularity ----------------------------------------------------------------- + +/// Same driver-only, Complex-acceleration-only availability as toggle_cruise_control above. +/datum/action/human_action/vehicle_action/set_cruise_control_granularity + name = "Set Cruise Control Granularity" + action_icon_state = "cruise_control_granularity" + +/datum/action/human_action/vehicle_action/set_cruise_control_granularity/update_button_icon() + button.overlays.Cut() + button.overlays += image('icons/mob/hud/actions.dmi', button, action_icon_state) + +/datum/action/human_action/vehicle_action/set_cruise_control_granularity/action_activate() + . = ..() + var/obj/vehicle/multitile/vehicle = get_vehicle() + if(!vehicle) + return + vehicle.set_cruise_control_granularity() + +// 8) IFF Toggle ------------------------------------------------------------------------------------- + +/datum/action/human_action/vehicle_action/toggle_iff + name = "Toggle IFF" + action_icon_state = "iff_toggle_off" + +/datum/action/human_action/vehicle_action/toggle_iff/update_button_icon() + var/obj/vehicle/multitile/vehicle = get_vehicle() + action_icon_state = (vehicle && vehicle.iff_online) ? "iff_toggle_on" : "iff_toggle_off" + button.overlays.Cut() + button.overlays += image('icons/mob/hud/actions.dmi', button, action_icon_state) + +/datum/action/human_action/vehicle_action/toggle_iff/action_activate() + . = ..() + var/obj/vehicle/multitile/vehicle = get_vehicle() + if(!vehicle) + return + vehicle.toggle_iff_module(owner) + update_button_icon() + +// 9) Turret Rotation and Hardpoint Safety ---------------------------------------------------- + +/datum/action/human_action/vehicle_action/toggle_gyro + name = "Turret Rotation and Hardpoint Safety" + action_icon_state = "turret_gyro" + +/datum/action/human_action/vehicle_action/toggle_gyro/update_button_icon() + var/obj/vehicle/multitile/vehicle = get_vehicle() + var/obj/item/hardpoint/turret = vehicle?.get_gyro_hardpoint() + action_icon_state = (turret && turret.turret_safety_on) ? "turret_gyro_off" : "turret_gyro" + button.overlays.Cut() + button.overlays += image('icons/mob/hud/actions.dmi', button, action_icon_state) + +/datum/action/human_action/vehicle_action/toggle_gyro/action_activate() + . = ..() + var/obj/vehicle/multitile/vehicle = get_vehicle() + if(!vehicle) + return + vehicle.toggle_turret_safety(owner) + update_button_icon() + +// 10) Slave Secondary to Driver ------------------------------------------------------------ + +/datum/action/human_action/vehicle_action/slave_secondary + name = "Slave Secondary to Driver" + action_icon_state = "driver_slave" + +/datum/action/human_action/vehicle_action/slave_secondary/update_button_icon() + var/obj/vehicle/multitile/vehicle = get_vehicle() + var/obj/item/hardpoint/secondary/current = vehicle?.get_slavable_secondary() + action_icon_state = (current && current.self_gimballed) ? "driver_slave_off" : "driver_slave" + button.overlays.Cut() + button.overlays += image('icons/mob/hud/actions.dmi', button, action_icon_state) + +/datum/action/human_action/vehicle_action/slave_secondary/action_activate() + . = ..() + var/obj/vehicle/multitile/vehicle = get_vehicle() + if(!vehicle) + return + vehicle.toggle_slave_secondary_to_driver(owner) + update_button_icon() diff --git a/code/modules/vehicles/multitile/multitile_bump.dm b/code/modules/vehicles/multitile/multitile_bump.dm index 82ba0d0c684d..c4b4249117b9 100644 --- a/code/modules/vehicles/multitile/multitile_bump.dm +++ b/code/modules/vehicles/multitile/multitile_bump.dm @@ -17,10 +17,22 @@ /turf/closed/wall/handle_vehicle_bump(obj/vehicle/multitile/V) if(!(turf_flags & TURF_HULL) && !(V.vehicle_flags & VEHICLE_CLASS_WEAK)) - take_damage(V.wall_ram_damage) - V.take_damage_type(10, "blunt", src) + var/speed_scale = V.get_collision_speed_scale() + take_damage(V.wall_ram_damage * speed_scale) + V.take_damage_type(10 * speed_scale, "blunt", src) playsound(V, 'sound/effects/metal_crash.ogg', 35) visible_message(SPAN_DANGER("\The [V] rams \the [src]!")) + + // movement_locked means this wall hit came from a forced knockback, not a driver-caused crash. + var/obj/vehicle/multitile/tank/tank = V + if(istype(tank) && tank.movement_locked) + tank.take_damage_type(KNOCKBACK_WALL_SLAM_DAMAGE, "blunt", src) + + if(tank.knockback_crusher_source && tank.engine_on) + tank.set_engine_on(FALSE) // also refreshes the Toggle Engine action button + var/mob/living/driver = tank.get_seat_mob(VEHICLE_DRIVER) + if(driver) + to_chat(driver, SPAN_WARNING("The impact stalls out \the [tank]'s engine!")) return FALSE //----------------------------------------------------------- @@ -29,7 +41,7 @@ /obj/handle_vehicle_bump(obj/vehicle/multitile/V) if(!unacidable) - V.take_damage_type(5, "blunt", src) + V.take_damage_type(5 * V.get_collision_speed_scale(), "blunt", src) visible_message(SPAN_DANGER("\The [V] crushes [src]!")) playsound(V, 'sound/effects/metal_crash.ogg', 20) qdel(src) @@ -46,6 +58,12 @@ return TRUE return FALSE +/obj/structure/mineral_door/resin/handle_vehicle_bump(obj/vehicle/multitile/V) + take_damage(V.wall_ram_damage * V.get_collision_speed_scale()) + visible_message(SPAN_DANGER("\The [V] rams [src]!")) + playsound(V, 'sound/effects/metal_crash.ogg', 20) + return FALSE + /obj/structure/barricade/handle_vehicle_bump(obj/vehicle/multitile/V) if(!(V.vehicle_flags & VEHICLE_CLASS_WEAK)) take_damage(maxhealth) @@ -56,12 +74,12 @@ return FALSE /obj/structure/barricade/plasteel/handle_vehicle_bump(obj/vehicle/multitile/V) + if(V.vehicle_flags & VEHICLE_CLASS_MEDIUM || V.vehicle_flags & VEHICLE_CLASS_HEAVY) + close(src) + return TRUE if(V.seats[VEHICLE_DRIVER]) close(src) - return FALSE - else - . = ..() - return FALSE + return ..() /obj/structure/barricade/deployable/handle_vehicle_bump(obj/vehicle/multitile/V) visible_message(SPAN_DANGER("\The [V] crushes [src]!")) @@ -77,13 +95,14 @@ /obj/structure/alien/movable_wall/handle_vehicle_bump(obj/vehicle/multitile/V) playsound(V, 'sound/effects/metal_crash.ogg', 35) - V.take_damage_type(5, "blunt", src) + var/speed_scale = V.get_collision_speed_scale() + V.take_damage_type(5 * speed_scale, "blunt", src) if(V.vehicle_flags & VEHICLE_CLASS_WEAK) return FALSE if(group) - take_damage(V.wall_ram_damage) + take_damage(V.wall_ram_damage * speed_scale) group.try_move_in_direction(get_dir(V.loc, get_turf(src))) else qdel(src) @@ -114,15 +133,14 @@ /obj/structure/surface/table/handle_vehicle_bump(obj/vehicle/multitile/V) playsound(V, 'sound/effects/metal_crash.ogg', 20) visible_message(SPAN_DANGER("\The [V] crushes \the [src]!")) - if(prob(50)) - new sheet_type(loc) + new sheet_type(loc) // always drops exactly one sheet, no more a coin flip qdel(src) return TRUE /obj/structure/surface/rack/handle_vehicle_bump(obj/vehicle/multitile/V) playsound(V, 'sound/effects/metal_crash.ogg', 20) visible_message(SPAN_DANGER("\The [V] crushes \the [src]!")) - deconstruct() + deconstruct(FALSE) // drops a metal sheet instead of rack parts, this isn't a clean disassembly return TRUE /obj/structure/reagent_dispensers/handle_vehicle_bump(obj/vehicle/multitile/V) @@ -134,7 +152,7 @@ return TRUE /obj/structure/reagent_dispensers/tank/fuel/handle_vehicle_bump(obj/vehicle/multitile/V) - reagents.source_mob = V.seats[VEHICLE_DRIVER] + reagents.source_mob = WEAKREF(V.seats[VEHICLE_DRIVER]) if(reagents.handle_volatiles()) if(V.seats[VEHICLE_DRIVER]) log_game("[key_name(V.seats[VEHICLE_DRIVER])] exploded [name] by ramming it with [V] in [get_area(src)] ([loc.x],[loc.y],[loc.z]).") @@ -203,15 +221,15 @@ /obj/structure/fence/handle_vehicle_bump(obj/vehicle/multitile/V) if(!(V.vehicle_flags & VEHICLE_CLASS_MEDIUM || V.vehicle_flags & VEHICLE_CLASS_HEAVY)) - V.move_momentum -= V.move_momentum * 0.5 + V.apply_collision_speed_penalty(0.5) visible_message(SPAN_DANGER("\The [V] crushes \the [src]!")) playsound(src, 'sound/effects/grillehit.ogg', 20) - qdel(src) + deconstruct(TRUE) // spawns its normal rods and handles its own qdel return TRUE /obj/structure/foamed_metal/handle_vehicle_bump(obj/vehicle/multitile/V) if(!(V.vehicle_flags & VEHICLE_CLASS_MEDIUM || V.vehicle_flags & VEHICLE_CLASS_HEAVY)) - V.move_momentum -= V.move_momentum * 0.5 + V.apply_collision_speed_penalty(0.5) visible_message(SPAN_DANGER("\The [V] crushes \the [src]!")) playsound(src, 'sound/effects/metalhit.ogg', 20) qdel(src) @@ -219,15 +237,16 @@ /obj/structure/grille/handle_vehicle_bump(obj/vehicle/multitile/V) if(!(V.vehicle_flags & VEHICLE_CLASS_MEDIUM || V.vehicle_flags & VEHICLE_CLASS_HEAVY)) - V.move_momentum -= V.move_momentum * 0.5 + V.apply_collision_speed_penalty(0.5) visible_message(SPAN_DANGER("\The [V] crushes \the [src]!")) playsound(src, 'sound/effects/grillehit.ogg', 20) + handle_debris() // same rods drop as cutting it apart normally qdel(src) return TRUE /obj/structure/inflatable/handle_vehicle_bump(obj/vehicle/multitile/V) if(V.vehicle_flags & VEHICLE_CLASS_WEAK) - V.move_momentum -= V.move_momentum * 0.5 + V.apply_collision_speed_penalty(0.5) visible_message(SPAN_DANGER("\The [V] rams \the [src]!")) density = FALSE deflate(TRUE) @@ -235,15 +254,23 @@ /obj/structure/bed/chair/handle_vehicle_bump(obj/vehicle/multitile/V) visible_message(SPAN_DANGER("\The [V] rams \the [src]!")) + new /obj/item/stack/sheet/metal(loc, 1) if(stacked_size) stack_collapse() else qdel(src) return TRUE +/obj/structure/bed/stool/handle_vehicle_bump(obj/vehicle/multitile/V) + visible_message(SPAN_DANGER("\The [V] rams \the [src]!")) + playsound(V, 'sound/effects/metal_crash.ogg', 20) + new /obj/item/stack/sheet/metal(loc, 1) + qdel(src) + return TRUE + /obj/structure/prop/dam/handle_vehicle_bump(obj/vehicle/multitile/V) if(V.vehicle_flags & VEHICLE_CLASS_MEDIUM) - V.move_momentum -= V.move_momentum * 0.5 + V.apply_collision_speed_penalty(0.5) else if(!(V.vehicle_flags & VEHICLE_CLASS_HEAVY)) return FALSE @@ -262,7 +289,7 @@ if(V.vehicle_flags & VEHICLE_CLASS_WEAK) return FALSE else if(V.vehicle_flags & VEHICLE_CLASS_LIGHT) - V.move_momentum -= V.move_momentum * 0.5 + V.apply_collision_speed_penalty(0.5) visible_message(SPAN_DANGER("\The [V] crushes \the [src]!")) playsound(src, 'sound/effects/metal_crash.ogg', 20) @@ -273,6 +300,13 @@ /obj/structure/flora/tree/jungle/handle_vehicle_bump(obj/vehicle/multitile/V) return FALSE +/obj/structure/girder/handle_vehicle_bump(obj/vehicle/multitile/V) + visible_message(SPAN_DANGER("\The [V] crushes \the [src]!")) + playsound(V, 'sound/effects/metal_crash.ogg', 20) + new /obj/item/stack/sheet/metal(loc, 2) + qdel(src) + return TRUE + /obj/structure/window/handle_vehicle_bump(obj/vehicle/multitile/V) if(not_damageable) return FALSE @@ -281,6 +315,13 @@ healthcheck() return TRUE +/obj/structure/window_frame/handle_vehicle_bump(obj/vehicle/multitile/V) + visible_message(SPAN_DANGER("\The [V] crushes \the [src]!")) + playsound(V, 'sound/effects/metal_crash.ogg', 20) + new /obj/item/stack/sheet/metal(loc, 2) + qdel(src) + return TRUE + //-------------------------MACHINERY------------------------ /obj/structure/machinery/door/handle_vehicle_bump(obj/vehicle/multitile/V) @@ -299,6 +340,7 @@ if(!unacidable) visible_message(SPAN_DANGER("\The [V] pushes [src] over!")) playsound(V, 'sound/effects/metal_crash.ogg', 20) + new /obj/item/stack/sheet/metal(loc, 5) qdel(src) return FALSE @@ -307,7 +349,7 @@ if(!unacidable) visible_message(SPAN_DANGER("\The [V] pushes [src] over!")) playsound(V, 'sound/effects/metal_crash.ogg', 35) - V.take_damage_type(10, "blunt", V) + V.take_damage_type(10 * V.get_collision_speed_scale(), "blunt", V) qdel(src) return FALSE @@ -443,6 +485,32 @@ qdel(src) return TRUE +/obj/structure/machinery/telecomms/relay/handle_vehicle_bump(obj/vehicle/multitile/V) + visible_message(SPAN_DANGER("\The [V] slams into \the [src], but it holds firm!")) + playsound(V, 'sound/effects/metal_crash.ogg', 20) + return FALSE + +/obj/structure/machinery/sensortower/handle_vehicle_bump(obj/vehicle/multitile/V) + visible_message(SPAN_DANGER("\The [V] slams into \the [src], but it holds firm!")) + playsound(V, 'sound/effects/metal_crash.ogg', 20) + return FALSE + +// Non-dense, only runs via can_move()'s dedicated non-dense crush-scan. +/obj/structure/machinery/constructable_frame/handle_vehicle_bump(obj/vehicle/multitile/V) + visible_message(SPAN_DANGER("\The [V] crushes \the [src]!")) + playsound(V, 'sound/effects/metal_crash.ogg', 20) + new /obj/item/stack/sheet/metal(loc, 5) + qdel(src) + return TRUE + +/obj/structure/machinery/vending/handle_vehicle_bump(obj/vehicle/multitile/V) + if(is_tipped_over) + return TRUE + visible_message(SPAN_DANGER("\The [V] tips \the [src] over!")) + playsound(V, 'sound/effects/metal_crash.ogg', 20) + tip_over() + return TRUE + /obj/structure/machinery/floodlight/handle_vehicle_bump(obj/vehicle/multitile/V) if(V.vehicle_flags & VEHICLE_CLASS_WEAK) return FALSE @@ -455,14 +523,14 @@ if(V.vehicle_flags & VEHICLE_CLASS_WEAK) return FALSE if(!(V.vehicle_flags & VEHICLE_CLASS_HEAVY)) - V.move_momentum -= V.move_momentum * 0.5 + V.apply_collision_speed_penalty(0.5) playsound(V, 'sound/effects/metal_crash.ogg', 20) visible_message(SPAN_DANGER("\The [V]crushes \the [src]!")) qdel(src) return TRUE /obj/structure/machinery/floodlight/landing/handle_vehicle_bump(obj/vehicle/multitile/V) - if(V.vehicle_flags & VEHICLE_CLASS_HEAVY) + if(V.vehicle_flags & VEHICLE_CLASS_MEDIUM || V.vehicle_flags & VEHICLE_CLASS_HEAVY) playsound(V, 'sound/effects/metal_crash.ogg', 20) visible_message(SPAN_DANGER("\The [V] crushes \the [src]!")) qdel(src) @@ -499,35 +567,93 @@ playsound(V, 'sound/effects/metal_crash.ogg', 35) return FALSE +/// Weight-class rank derived from vehicle_flags, higher is heavier. +/obj/vehicle/multitile/proc/get_weight_class_rank() + if(vehicle_flags & VEHICLE_CLASS_HEAVY) + return 4 + if(vehicle_flags & VEHICLE_CLASS_MEDIUM) + return 3 + if(vehicle_flags & VEHICLE_CLASS_LIGHT) + return 2 + return 1 // VEHICLE_CLASS_WEAK, or no class flag set at all + +/** + * Vehicle-vs-vehicle ramming. Weight class decides who wins: + * + * * Equal weight: both hard crash, V loses all momentum and jousts its riders. We get shoved back. + * * V lighter than us: V still hard crashes, but we're too heavy to be pushed. + * * V heavier than us: V soft crashes instead, only some momentum bleeds off. + * + * Knockback and damage scale with how much of V's own top speed it crashed in at. + * + * skip_generic_crash_response tells can_move() that momentum loss/jousting are already resolved here. + */ /obj/vehicle/multitile/handle_vehicle_bump(obj/vehicle/multitile/V) - var/damage + var/target_rank = get_weight_class_rank() + var/mover_rank = V.get_weight_class_rank() - if(last_move_dir == REVERSE_DIR(V.last_move_dir)) //crashing into each other - damage = move_momentum + V.move_momentum - else if(last_move_dir == V.last_move_dir) //crashing into something from behind - damage = max(V.move_momentum - move_momentum, 0) - else - damage = V.move_momentum + // A skidding vehicle can ram sideways off drift_speed, so read whichever axis has more momentum. + var/facing_speed = abs(V.move_momentum) + var/drift_dominant = V.drift_speed > facing_speed + var/collision_speed = drift_dominant ? V.drift_speed : facing_speed + var/collision_direction = drift_dominant ? V.drift_direction : V.last_move_dir - damage = 5 * (damage + 1) //5 is minimal damage of bumping which is multiplied on vehicles' current momentum. - if(V.vehicle_flags & VEHICLE_CLASS_WEAK) - V.take_damage_type(TIER_3_RAM_DAMAGE_TAKEN, "blunt", src) - else - V.take_damage_type(damage, "blunt", src) + var/fraction = V.move_max_momentum ? clamp(collision_speed / V.move_max_momentum, 0, 1) : 0 + var/effective_fraction = max(0, (fraction - VEHICLE_COLLISION_MOMENTUM_THRESHOLD) / (1 - VEHICLE_COLLISION_MOMENTUM_THRESHOLD)) + var/damage = round(effective_fraction * VEHICLE_COLLISION_MAX_DAMAGE) + // A lighter mover still crashes hard, just without displacing something equal-or-heavier. + var/knockback_tiles = (mover_rank >= target_rank) ? round(effective_fraction * VEHICLE_COLLISION_MAX_KNOCKBACK_TILES) : 0 - if(vehicle_flags & VEHICLE_CLASS_WEAK) - take_damage_type(TIER_3_RAM_DAMAGE_TAKEN, "blunt", V) - else - take_damage_type(damage, "blunt", V) - - visible_message(SPAN_DANGER("\The [V] crushes into \the [src]!")) playsound(V, 'sound/effects/metal_crash.ogg', 35) + + if(mover_rank > target_rank) + // Soft crash, too heavy to be bothered by something lighter. + V.apply_collision_speed_penalty(VEHICLE_SOFT_CRASH_MOMENTUM_LOSS) + V.drift_speed *= (1 - VEHICLE_SOFT_CRASH_MOMENTUM_LOSS) + if(damage) + // Same cosmetic cue a real hardpoint wound-gain uses. + play_wound_gain_effects(src, WOUND_DAMTYPE_BRUTE, V) + take_damage_type(damage, "blunt", V) + else + // Hard crash, stops V dead like slamming into a wall. on_crash() must run before we zero out + // V's momentum below, it reads the untouched values to decide whether to joust V's riders. + V.on_crash() + V.apply_collision_speed_penalty(1) + V.drift_speed = 0 + if(damage) + play_wound_gain_effects(src, WOUND_DAMTYPE_BRUTE, V) + take_damage_type(damage, "blunt", V) + play_wound_gain_effects(V, WOUND_DAMTYPE_BRUTE, src) + V.take_damage_type(damage, "blunt", src) + + V.skip_generic_crash_response = TRUE + + if(knockback_tiles) + // Deferred a tick so we don't pull ourselves out from under this collision mid-resolution. + addtimer(CALLBACK(src, PROC_REF(knockback), collision_direction, knockback_tiles), 0) + + visible_message(SPAN_DANGER("\The [V] crashes into \the [src]!")) return FALSE +// legacy cargo train gets ran over and crushed without a fuss by medium and heavy vehicles +/obj/vehicle/train/cargo/handle_vehicle_bump(obj/vehicle/multitile/V) + if(V.vehicle_flags & VEHICLE_CLASS_MEDIUM || V.vehicle_flags & VEHICLE_CLASS_HEAVY) + health = 0 + healthcheck() + + visible_message(SPAN_DANGER("\The [V] crushes into \the [src]!")) + playsound(V, 'sound/effects/metal_crash.ogg', 35) + return TRUE + . = ..() + //----------------------------------------------------------- //-------------------------MOBS------------------------------ //----------------------------------------------------------- +/// Pure read of whether this mob would stop the vehicle. Used by the tank's stairs transition. +/mob/living/proc/would_block_tank_stairs(obj/vehicle/multitile/V) + return FALSE + /mob/living/handle_vehicle_bump(obj/vehicle/multitile/V) if(is_mob_incapacitated(1)) apply_damage(7 + rand(0, 5), BRUTE) @@ -549,7 +675,7 @@ apply_effect(1, WEAKEN) else if(V.vehicle_flags & VEHICLE_CLASS_LIGHT) - if(get_target_lock(driver.faction)) + if(driver && get_target_lock(driver.faction)) apply_effect(0.5, WEAKEN) else apply_effect(2, WEAKEN) @@ -582,7 +708,7 @@ playsound(loc, "punch", 25, 1) last_damage_data = create_cause_data("[initial(V.name)] roadkill", driver) log_attack("[key_name(src)] was rammed by [key_name(driver)] with [V].") - if(faction == driver.faction) + if(driver && faction == driver.faction) msg_admin_ff("[key_name(driver)] rammed [key_name(src)] with \the [V] in [get_area(src)] [ADMIN_JMP(driver)] [ADMIN_PM(driver)]", TRUE, loc.z) else log_attack("[key_name(src)] was friendly pushed by [key_name(driver)] with [V].") //to be able to determine whether vehicle was pushign friendlies @@ -598,6 +724,20 @@ var/mob_moved = FALSE var/mob_knocked_down = is_mob_incapacitated() + // A front hit scoops the marine onto the hull instead of leaving them stuck underneath, only a stun. + var/obj/vehicle/multitile/tank/tank_rammer = istype(V, /obj/vehicle/multitile/tank) ? V : null + var/front_hit = tank_rammer && (get_dir(tank_rammer, src) == tank_rammer.dir) + if(front_hit) + // Captured before apply_effect(WEAKEN) drops whatever's in both hands from the knockdown. + var/obj/item/prev_l_hand = l_hand + var/obj/item/prev_r_hand = r_hand + apply_effect(2, WEAKEN) + // Deferred a tick, the tank's locs don't include this tile yet so mark_on_top() would reject it. + addtimer(CALLBACK(tank_rammer, TYPE_PROC_REF(/obj/vehicle/multitile/tank, mark_on_top_after_ram), src, prev_l_hand, prev_r_hand), 0) + visible_message(SPAN_DANGER("\The [V] scoops up \the [src]!"), SPAN_DANGER("\The [V] scoops you up!")) + log_attack("[key_name(src)] was scooped up by [key_name(driver)] with [V].") + return TRUE + if(V.vehicle_flags & VEHICLE_CLASS_WEAK) if(!mob_knocked_down) var/direction_taken = pick(45, 0, -45) @@ -606,7 +746,7 @@ mob_moved = step(src, turn(V.last_move_dir, -direction_taken)) else if(V.vehicle_flags & VEHICLE_CLASS_LIGHT) dmg = TRUE - if(get_target_lock(driver.faction)) + if(driver && get_target_lock(driver.faction)) apply_effect(0.5, WEAKEN) apply_damage(5 + rand(0, 5), BRUTE, no_limb_loss = TRUE) to_chat(V.seats[VEHICLE_DRIVER], SPAN_WARNING(SPAN_BOLD("*YOU RAMMED AN ALLY AND HURT THEM!*"))) @@ -615,8 +755,11 @@ apply_damage(10 + rand(0, 10), BRUTE) else if(V.vehicle_flags & VEHICLE_CLASS_MEDIUM) - apply_effect(3, WEAKEN) - apply_damage(10 + rand(0, 10), BRUTE) + // move_momentum is 0 during a Crusher-forced knockback, treat that as a full-force impact instead. + var/effective_momentum_ratio = (tank_rammer && tank_rammer.movement_locked) ? 1 : (V.move_momentum / V.move_max_momentum) + apply_effect((1.5 * effective_momentum_ratio), WEAKEN) // Even with the nerfs, still by far the most frustrating thing about the tank. + apply_effect((3 * effective_momentum_ratio), SLOW) + apply_damage(5 + rand(0, 5), BRUTE) dmg = TRUE else if(V.vehicle_flags & VEHICLE_CLASS_HEAVY) @@ -624,12 +767,16 @@ apply_damage(15 + rand(0, 10), BRUTE) dmg = TRUE + // Shove them clear of the tank's new footprint too. A front hit already returned early. + if(tank_rammer && tank_rammer.movement_locked && !mob_knocked_down) + step_away(src, get_turf(tank_rammer), 2) + visible_message(SPAN_DANGER("\The [V] rams \the [src]!"), SPAN_DANGER("\The [V] rams you! Get out of the way!")) if(dmg) playsound(loc, "punch", 25, 1) last_damage_data = create_cause_data("[initial(V.name)] roadkill", driver) log_attack("[key_name(src)] was rammed by [key_name(driver)] with [V].") - if(faction == driver.faction) + if(driver && faction == driver.faction) msg_admin_ff("[key_name(driver)] rammed and damaged member of allied faction [key_name(src)] with \the [V] in [get_area(src)] [ADMIN_JMP(driver)] [ADMIN_PM(driver)]", TRUE, loc.z) else log_attack("[key_name(src)] was friendly pushed by [key_name(driver)] with [V].") //to be able to determine whether vehicle was pushing friendlies @@ -643,6 +790,20 @@ //-------------------------XENOS------------------------ +/// Mirrors handle_vehicle_bump() below. Large xenos planted head-on, or a Queen hit from any side, +/// stop the vehicle instead of being pushed. +/mob/living/carbon/xenomorph/would_block_tank_stairs(obj/vehicle/multitile/V) + if((mob_size >= MOB_SIZE_IMMOBILE) && !is_mob_incapacitated() && !buckled) + if(!(V.vehicle_flags & VEHICLE_CLASS_HEAVY)) + var/dir_between = get_dir(src, V) + if(V.vehicle_flags & VEHICLE_CLASS_WEAK) + return TRUE + else if(dir_between == dir) // front hit (facing the vehicle) + return TRUE + else if(caste.caste_type == XENO_CASTE_QUEEN) // queen blocks even with sides + return TRUE + return FALSE + /mob/living/carbon/xenomorph/handle_vehicle_bump(obj/vehicle/multitile/V) //whether xeno is knocked down @@ -752,8 +913,7 @@ return TRUE else if (mob_moved) if(momentum_penalty) - V.move_momentum = floor(V.move_momentum*0.8) - V.update_next_move() + V.apply_collision_speed_penalty(0.2, reschedule = TRUE) playsound(loc, "punch", 25, 1) return TRUE @@ -777,8 +937,7 @@ visible_message(SPAN_DANGER("[src] digs its claws into the ground, slowing [V]'s movement!"), SPAN_DANGER("You dig your claws into the ground, slowing [V]'s movement!")) var/mob_moved = step(src, V.last_move_dir) - V.move_momentum = floor(V.move_momentum/3) - V.update_next_move() + V.apply_collision_speed_penalty(2/3, reschedule = TRUE) return mob_moved //medium-to-heavy vehicles will still push fortified defender back but without dealing damage. Need to change snowplow effects later @@ -798,26 +957,62 @@ else return . = ..() -//CRUSHER CHARGE COLLISION -//Crushers going top speed can charge into & move vehicles with broken/without locmotion module -/obj/vehicle/multitile/Collided(atom/A) - . = ..() - - if(iscrusher(A)) - var/mob/living/carbon/xenomorph/crusher/C = A - if(!C.throwing) +/** + * Handles something bumping into this vehicle. + * Does nothing for our own riders. A charging Crusher gets a generic ram/damage/knockback, unless + * this vehicle resolves its own crusher-charge hits elsewhere (resolve_crusher_charge_hit(), tank.dm). + * Otherwise, attempts to climb the bumper onto the hull (mark_on_top()/climb_onto(), multitile_riding.dm). + * + * Arguments: + * * atom/movable/AM = The atom bumping into this vehicle. + */ +/obj/vehicle/multitile/Collided(atom/movable/AM) + if(_is_our_rider(AM)) + return + + if(!ismob(AM)) + if(istype(AM, /obj)) + var/obj/O = AM + if(O.buckled_mob) + Collided(O.buckled_mob) + return ..() + + var/mob/living/M = AM + if(!istype(M)) + return ..() + + if(!has_own_crusher_charge_handling && iscrusher(M)) + var/mob/living/carbon/xenomorph/crusher/C = M + if(C.throwing) + var/do_move = TRUE + if(health > 0) + take_damage_type(100, "blunt", C) + visible_message(SPAN_DANGER("\The [C] ramms \the [src]!")) + for(var/obj/item/hardpoint/locomotion/Loco in hardpoints) + if(Loco.health > 0) + do_move = FALSE + break + if(do_move) + try_move(C.dir, force=TRUE) + visible_message(SPAN_DANGER("The sheer force of the impact makes \the [src] slide back!")) + log_attack("\The [src] was rammed [do_move ? "and pushed " : " "]by [key_name(C)].") + playsound(loc, 'sound/effects/metal_crash.ogg', 35) + interior_crash_effect() return - var/do_move = TRUE - if(health > 0) - take_damage_type(100, "blunt", C) - visible_message(SPAN_DANGER("\The [A] ramms \the [src]!")) - for(var/obj/item/hardpoint/locomotion/Loco in hardpoints) - if(Loco.health > 0) - do_move = FALSE - break - if(do_move) - try_move(C.dir, force=TRUE) - visible_message(SPAN_DANGER("The sheer force of the impact makes \the [src] slide back!")) - log_attack("\The [src] was rammed [do_move ? "and pushed " : " "]by [key_name(C)].") - playsound(loc, 'sound/effects/metal_crash.ogg', 35) - interior_crash_effect() + + if(M.action_busy) + return + if(M.pulledby || M.throwing) + return + var/turf/facing_turf = get_step(get_turf(M), M.dir) + if(!facing_turf) + return ..() + if(M.resting) + // A resting mob crawling into/under the vehicle should just slide through, never become a tracked rider here. + M.forceMove(facing_turf) + return ..() + if(get_turf(M) in locs) + return + + climb_onto(M, facing_turf) + return diff --git a/code/modules/vehicles/multitile/multitile_cookoff.dm b/code/modules/vehicles/multitile/multitile_cookoff.dm new file mode 100644 index 000000000000..9a5775d8efda --- /dev/null +++ b/code/modules/vehicles/multitile/multitile_cookoff.dm @@ -0,0 +1,119 @@ +/* + * Hull-destruction grand finale: an optional ammo-cookoff sequence a vehicle opts into by having + * its own on_hull_destroyed() override (hardpoint_wounds.dm) call start_hull_cookoff_sequence(). + * A vehicle that never overrides on_hull_destroyed() just goes inert once its hull reaches 0 health. + */ + +/** + * Stops the vehicle dead, plays a hissing warning, and gives everyone a chance to clear out before + * resolve_hull_destruction_sequence() detonates. Guarded by hull_destroyed_sequence_started since + * this can otherwise fire again on every subsequent hit. + */ +/obj/vehicle/multitile/proc/start_hull_cookoff_sequence() + if(hull_destroyed_sequence_started) + return + hull_destroyed_sequence_started = TRUE + + move_momentum = 0 + drift_speed = 0 + current_speed = 0 + set_engine_on(FALSE) + + visible_message("[SPAN_HIGHDANGER("\The [src] lets out a violent hiss from somewhere deep inside - it's about to cook off!")]") + playsound(src, 'sound/effects/pipe_hissing10s.ogg', vol = 40) + warn_cookoff_passengers() + // The on-screen banner fades out before the countdown ends, so repeat it at this cadence. + for(var/elapsed = hull_cookoff_passenger_warning_interval, elapsed < hull_cookoff_warning_time, elapsed += hull_cookoff_passenger_warning_interval) + addtimer(CALLBACK(src, PROC_REF(warn_cookoff_passengers)), elapsed) + + var/obj/effect/warning/explosive/warning = new(get_turf(src), hull_cookoff_warning_time) + // Scoped to just this instance so only the cookoff warning icon can mount on the vehicle. + warning.is_allowed_atop_vehicle = TRUE + // Same per-instance scoping, a hull-cookoff crash shouldn't fling its own warning icon off the vehicle. + warning.immune_to_tank_crash_scatter = TRUE + obj_mark_on_top(warning) + + // Bumped above the vehicle's own sprite and the warning-phase flame/fireball so draw order isn't left to chance. + warning.layer = HULL_COOKOFF_WARNING_ICON_LAYER + + ignite_cookoff_warning_flame(get_turf(src)) + + addtimer(CALLBACK(src, PROC_REF(resolve_hull_destruction_sequence)), hull_cookoff_warning_time) + +/** + * A single tile of regular napalm lit at the start of the countdown + */ +/obj/vehicle/multitile/proc/ignite_cookoff_warning_flame(turf/epicenter) + var/datum/cause_data/fire_cause = create_cause_data("vehicle ammunition cookoff") + new /obj/flamer_fire(epicenter, fire_cause, new /datum/reagent/napalm/ut()) + raise_cookoff_fire_layer(epicenter, hull_cookoff_warning_flame_radius) + addtimer(CALLBACK(src, PROC_REF(raise_cookoff_fire_layer), epicenter, hull_cookoff_warning_flame_radius), 3) + +/// Warns everyone inside the interior that the cookoff is coming, with both a chat message and an on-screen banner. +/obj/vehicle/multitile/proc/warn_cookoff_passengers() + for(var/mob/living/passenger in get_passengers()) + to_chat(passenger, SPAN_BOLDWARNING("\The [src] is about to cook off - get out NOW!")) + passenger.play_screen_text("HULL BREACH IMMINENT
GET OUT NOW!", /atom/movable/screen/text/screen_text/command_order, "#ff3b3b", TRUE) + +/** + * The actual detonation, hull_cookoff_warning_time after start_hull_cookoff_sequence() started the countdown. + * Whoever's still trapped inside takes a separate, contained explosion and spreading napalm fire, since the exterior blast can't reach it. + * The exterior takes a hit sized to hull_cookoff_exterior_power, every hardpoint is zeroed, and launch_special_wreckage() gets a chance to eject anything worth ejecting. + * The hull itself is never qdel'd, it's meant to remain a permanent wreck sitting on the map. + */ +/obj/vehicle/multitile/proc/resolve_hull_destruction_sequence() + var/list/mob/living/trapped = get_passengers() + if(length(trapped)) + var/turf/interior_epicenter = interior.get_middle_turf() + var/datum/cause_data/interior_cause = create_cause_data("vehicle ammunition cookoff") + cell_explosion(interior_epicenter, hull_cookoff_interior_power, hull_cookoff_interior_falloff, EXPLOSION_FALLOFF_SHAPE_EXPONENTIAL, null, interior_cause) + fire_spread(interior_epicenter, interior_cause, 2, new /datum/reagent/napalm/ut()) + + // The hull is a total wreck by this point, so its own explosive resistance shouldn't block its own detonation. + explosive_resistance = 0 + + var/turf/epicenter = get_turf(src) + cell_explosion(epicenter, hull_cookoff_exterior_power, hull_cookoff_exterior_falloff, EXPLOSION_FALLOFF_SHAPE_LINEAR, null, create_cause_data("vehicle ammunition cookoff")) + ignite_cookoff_fireball(epicenter) + + destroy_all_hardpoints() + + if(!is_mainship_level(z)) + launch_special_wreckage() + + update_icon() + +/** + * Ignites a fireball at the vehicle's own turf. Bumped up to ride atop it. + */ +/obj/vehicle/multitile/proc/ignite_cookoff_fireball(turf/epicenter) + var/datum/cause_data/fire_cause = create_cause_data("vehicle ammunition cookoff") + flame_radius(fire_cause, hull_cookoff_fire_radius, epicenter, BURN_TIME_TIER_5 + 5, BURN_LEVEL_TIER_4, FLAMESHAPE_DEFAULT, null, FIRE_VARIANT_TYPE_B) + raise_cookoff_fire_layer(epicenter, hull_cookoff_fire_radius) + addtimer(CALLBACK(src, PROC_REF(raise_cookoff_fire_layer), epicenter, hull_cookoff_fire_radius), 3) + +/// Bumps every /obj/flamer_fire within `radius` tiles of epicenter up to TANK_ABOVE_RIDER_LAYER, and marks any that landed on this vehicle's own footprint as riding along with it (see obj_mark_on_top(), multitile_riding.dm) so a subsequent push/shove carries the fire along instead of leaving it behind. +/obj/vehicle/multitile/proc/raise_cookoff_fire_layer(turf/epicenter, radius) + for(var/obj/flamer_fire/fire in range(radius, epicenter)) + obj_mark_on_top(fire) + fire.layer = TANK_ABOVE_RIDER_LAYER + +/// Zeroes out every currently-alive hardpoint on this vehicle, including ones nested inside a holder. +/obj/vehicle/multitile/proc/destroy_all_hardpoints() + for(var/obj/item/hardpoint/H in get_hardpoints_copy()) + if(H.health <= 0) + continue + H.health = 0 + H.on_destroy() + if(!istype(H, /obj/item/hardpoint/holder)) + continue + var/obj/item/hardpoint/holder/holder_hp = H + for(var/obj/item/hardpoint/nested in holder_hp.hardpoints) + if(nested.health <= 0) + continue + nested.health = 0 + nested.on_destroy() + +/// Overridable hook for ejecting something special (e.g. a turret) as flying wreckage once the hull cooks off. No-op on the base class. +/obj/vehicle/multitile/proc/launch_special_wreckage() + return diff --git a/code/modules/vehicles/multitile/multitile_hardpoint_hud.dm b/code/modules/vehicles/multitile/multitile_hardpoint_hud.dm new file mode 100644 index 000000000000..77c6ad888454 --- /dev/null +++ b/code/modules/vehicles/multitile/multitile_hardpoint_hud.dm @@ -0,0 +1,222 @@ +/** + * Vehicle hardpoint status HUD. A west-side vertical stack of 16x16 icons, one per hardpoint slot + * the vehicle's design supports, plus "hull". Only visible to viewers granted GLOB.vehicle_hardpoint_hud. + * + * Refreshed on a plain timer (hardpoint_hud_loop()) rather than hooked into every damage/wound/ + * install/uninstall call site, since this is a passive diagnostic display, not a twitch mechanic. + */ + +/// slot string to HUD sprite icon_state. Keep in sync with GLOB.hardpoint_hud_slot_order below. +/proc/get_hardpoint_hud_sprite(slot) + switch(slot) + if(WOUND_SLOT_HULL) + return "hull" + if(HDPT_ENGINE) + return "engine" + if(HDPT_TREADS, HDPT_WHEELS) + return "wheels" + if(HDPT_FUEL_TANK) + return "fuel_tank" + if(HDPT_RADIATOR) + return "radiator" + if(HDPT_BATTERY) + return "battery" + if(HDPT_ARMOR) + return "armor" + if(HDPT_TURRET) + return "turret" + if(HDPT_TURRET_RING) + return "turret_ring" + if(HDPT_PRIMARY) + return "primary" + if(HDPT_SECONDARY) + return "secondary" + if(HDPT_IFF_MODULE) + return "iff" + if(HDPT_VISUAL_SENSORS) + return "sensors" + if(HDPT_AIR_FILTER) + return "air_filter" + return null + +/// Vertical stacking order (top to bottom) for every slot the hardpoint HUD knows how to render. +GLOBAL_LIST_INIT(hardpoint_hud_slot_order, list( + WOUND_SLOT_HULL, + HDPT_ENGINE, + HDPT_TREADS, + HDPT_WHEELS, + HDPT_FUEL_TANK, + HDPT_RADIATOR, + HDPT_BATTERY, + HDPT_ARMOR, + HDPT_TURRET, + HDPT_TURRET_RING, + HDPT_PRIMARY, + HDPT_SECONDARY, + HDPT_IFF_MODULE, + HDPT_VISUAL_SENSORS, + HDPT_AIR_FILTER, +)) + +/** + * Maps a wound family to the outline color its wound type should render as. Brute families are red, + * acid families are green, and "gunked" families are yellow/gold despite being flagged acid. + */ +/proc/get_hardpoint_hud_wound_color(datum/hardpoint_wound_family/family) + if(family.neuro_alt_trigger_only) + return COLOR_YELLOW + if(family.damage_type == WOUND_DAMTYPE_ACID) + return COLOR_GREEN + return COLOR_RED + +/** + * Every currently-active wound on a hardpoint/hull, as outline colors ordered most-to-least severe. + * Uses a plain insertion sort since there are never more than a handful of wound families at once. + * + * Arguments: + * * wound_tiers = A hardpoint's own wound_tiers or a vehicle's hull_wound_tiers. + */ +/proc/get_hardpoint_hud_wound_colors(list/wound_tiers) + var/list/tier_color_pairs = list() + for(var/family_type in wound_tiers) + var/tier = wound_tiers[family_type] + if(!tier) + continue + var/datum/hardpoint_wound_family/family = GLOB.hardpoint_wound_families_by_type[family_type] + if(!family) + continue + tier_color_pairs += list(list(tier, get_hardpoint_hud_wound_color(family))) + + for(var/i in 2 to length(tier_color_pairs)) + var/list/current = tier_color_pairs[i] + var/j = i - 1 + while(j >= 1 && tier_color_pairs[j][1] < current[1]) + tier_color_pairs[j + 1] = tier_color_pairs[j] + j-- + tier_color_pairs[j + 1] = current + + . = list() + for(var/list/pair in tier_color_pairs) + . += pair[2] + +/** + * Builds one slot's outline overlay image(s) from its sorted wound colors. None for 0 wounds, + * full_outline for 1, tr_outline + bl_outline for 2. For 3+, only the 3 most severe show: + * tr_outline holds the worst solid, bl_outline alternates the other two by blink_phase. + */ +/proc/build_hardpoint_hud_outlines(list/colors, blink_phase) + . = list() + switch(length(colors)) + if(0) + return + if(1) + . += get_hardpoint_hud_outline_image("full_outline", colors[1]) + if(2) + . += get_hardpoint_hud_outline_image("tr_outline", colors[1]) + . += get_hardpoint_hud_outline_image("bl_outline", colors[2]) + else + . += get_hardpoint_hud_outline_image("tr_outline", colors[1]) + . += get_hardpoint_hud_outline_image("bl_outline", blink_phase ? colors[2] : colors[3]) + +/** + * Builds one wound-outline overlay image, colored independently of the base part icon's integrity + * color. RESET_COLOR is required on the overlay itself, or it blends with the parent's color + * instead of rendering its own. + */ +/proc/get_hardpoint_hud_outline_image(icon_state, color) + var/image/outline = image('icons/obj/vehicles/hud/tankhud.dmi', icon_state = icon_state) + outline.appearance_flags = NO_CLIENT_COLOR|KEEP_APART|RESET_COLOR + outline.color = color + return outline + +/** + * White at full integrity, dark grey once destroyed, a green-to-red linear blend in between. + * Missing-but-slot-exists hardpoints get the same dark grey via the caller passing null instead. + */ +/proc/get_hardpoint_hud_integrity_color(percent) + if(percent >= 100) + return COLOR_WHITE + if(percent <= 0) + return HARDPOINT_HUD_DESTROYED_COLOR + return rgb(round(255 * (1 - percent / 100)), round(255 * (percent / 100)), 0) + +/** + * This vehicle's ordered list of hardpoint HUD slots to display right now: every slot + * hardpoints_allowed declares, plus every slot any currently-installed holder's accepted_hardpoints + * declares, plus "hull" always. Filtered and ordered by GLOB.hardpoint_hud_slot_order. + */ +/obj/vehicle/multitile/proc/get_hardpoint_hud_design_slots() + var/list/design_slots = list() + for(var/hardpoint_type in hardpoints_allowed) + design_slots[initial(hardpoint_type:slot)] = TRUE + for(var/obj/item/hardpoint/holder/installed_holder in hardpoints) + for(var/nested_type in installed_holder.accepted_hardpoints) + design_slots[initial(nested_type:slot)] = TRUE + design_slots[WOUND_SLOT_HULL] = TRUE + + . = list() + for(var/slot in GLOB.hardpoint_hud_slot_order) + if(design_slots[slot]) + . += slot + +/** + * The live integrity percent and wound_tiers for one slot right now. Null percent means the slot + * is design-supported but nothing is currently installed there. + * + * Returns: + * * list(percent or null, wound_tiers list or null) + */ +/obj/vehicle/multitile/proc/get_hardpoint_hud_state(slot) + if(slot == WOUND_SLOT_HULL) + return list(initial(health) ? (100.0 * health / initial(health)) : 100, hull_wound_tiers) + for(var/obj/item/hardpoint/installed as anything in get_hardpoints_copy()) + if(installed.slot == slot) + return list(installed.get_integrity_percent(), installed.wound_tiers) + return list(null, null) + +/// Starts this vehicle's hardpoint HUD refresh loop, called once from Initialize(). Ends on deletion. +/obj/vehicle/multitile/proc/hardpoint_hud_loop() + while(!QDELETED(src)) + update_hardpoint_hud() + sleep(HARDPOINT_HUD_UPDATE_INTERVAL) + +/** + * Rebuilds this vehicle's entire hardpoint HUD state for the current tick: which slots to show, + * each slot's base icon color, and each slot's wound outline(s). Slot images are only created or + * destroyed here when the design-supported slot list actually changes. + */ +/obj/vehicle/multitile/proc/update_hardpoint_hud() + var/list/new_slots = get_hardpoint_hud_design_slots() + var/blink_phase = (world.time % (2 * HARDPOINT_HUD_BLINK_HALF_PERIOD)) < HARDPOINT_HUD_BLINK_HALF_PERIOD + + for(var/slot in hardpoint_hud_images) + if(slot in new_slots) + continue + var/image/old_image = hardpoint_hud_images[slot] + GLOB.vehicle_hardpoint_hud.sync_single_image(src, old_image, null) + hardpoint_hud_images -= slot + + for(var/slot in new_slots) + if(hardpoint_hud_images[slot]) + continue + var/image/new_image = image('icons/obj/vehicles/hud/tankhud.dmi', src, get_hardpoint_hud_sprite(slot)) + new_image.appearance_flags = NO_CLIENT_COLOR|KEEP_APART|RESET_COLOR + hardpoint_hud_images[slot] = new_image + GLOB.vehicle_hardpoint_hud.sync_single_image(src, null, new_image) + + // Two columns instead of one long column. Up to 15 slots would tower over the vehicle otherwise. + var/slot_count = length(new_slots) + var/row_count = CEILING(slot_count / 2, 1) + var/index = 0 + for(var/slot in new_slots) + var/image/element = hardpoint_hud_images[slot] + var/column = index % 2 + var/row = (index - column) / 2 + element.pixel_x = HARDPOINT_HUD_PIXEL_X + (column ? HARDPOINT_HUD_ICON_SIZE / 2 : -HARDPOINT_HUD_ICON_SIZE / 2) + element.pixel_y = (row_count - 1 - row) * HARDPOINT_HUD_ICON_SIZE - (row_count * HARDPOINT_HUD_ICON_SIZE) / 2 + HARDPOINT_HUD_PIXEL_Y_OFFSET + index++ + + var/list/hud_state = get_hardpoint_hud_state(slot) + var/percent = hud_state[1] + element.color = isnull(percent) ? HARDPOINT_HUD_DESTROYED_COLOR : get_hardpoint_hud_integrity_color(percent) + element.overlays = build_hardpoint_hud_outlines(get_hardpoint_hud_wound_colors(hud_state[2]), blink_phase) diff --git a/code/modules/vehicles/multitile/multitile_hardpoints.dm b/code/modules/vehicles/multitile/multitile_hardpoints.dm index d40749aedcf5..68431882572a 100644 --- a/code/modules/vehicles/multitile/multitile_hardpoints.dm +++ b/code/modules/vehicles/multitile/multitile_hardpoints.dm @@ -1,3 +1,57 @@ +/// Human-readable "empty " descriptor per hardpoint slot. +GLOBAL_LIST_INIT(hardpoint_slot_labels, list( + HDPT_PRIMARY = "primary weapon mount", + HDPT_SECONDARY = "secondary weapon mount", + HDPT_SUPPORT = "support module slot", + HDPT_ARMOR = "armor mount", + HDPT_SNOWPLOW = "snowplow mount", + HDPT_TREADS = "tread mount", + HDPT_WHEELS = "wheel mount", + HDPT_TURRET = "turret mount", + HDPT_ENGINE = "engine compartment", + HDPT_FUEL_TANK = "fuel tank slot", + HDPT_RADIATOR = "radiator mount", + HDPT_BATTERY = "battery compartment", + HDPT_HATCH = "hatch mount", + HDPT_IFF_MODULE = "IFF module slot", + HDPT_VISUAL_SENSORS = "visual sensor mount", + HDPT_TURRET_RING = "turret ring mount", + HDPT_AIR_FILTER = "air filter slot", +)) + +/** + * Builds one bolded "An EMPTY ." examine line per distinct slot with nothing occupying it. + * Shared between a vehicle's top-level slots and a holder's nested weapon slots. + * + * Arguments: + * * allowed_types = List of hardpoint types that could go here. + * * installed_hardpoints = List of hardpoints actually mounted here right now. + * + * Returns: + * * List of ready-to-append examine line strings, already SPAN_BOLDWARNING-wrapped. + */ +/proc/get_missing_hardpoint_slot_lines(list/allowed_types, list/installed_hardpoints) + . = list() + if(!length(allowed_types)) + return + + var/list/occupied_slots = list() + for(var/obj/item/hardpoint/H in installed_hardpoints) + occupied_slots[H.slot] = TRUE + + var/list/seen_slots = list() + for(var/type in allowed_types) + var/slot = initial(type:slot) + if(!slot || (slot in seen_slots)) + continue + seen_slots[slot] = TRUE + if(occupied_slots[slot]) + continue + if(slot == HDPT_SNOWPLOW) + continue // purely optional, never treated as missing gear + var/label = GLOB.hardpoint_slot_labels[slot] || "[slot] slot" + . += SPAN_BOLDWARNING("An EMPTY [label].") + // Returns all hardpoints that are attached to the vehicle, including ones held by holder hardpoints (e.g. turrets) /obj/vehicle/multitile/proc/get_hardpoints_copy() var/list/all_hardpoints = hardpoints.Copy() @@ -8,6 +62,148 @@ return all_hardpoints +/** + * Every in-place-fixable wound across every hardpoint whose current repair step matches tool. + * + * Returns: + * * An assoc list, "Fix ()" -> list(hardpoint, family_type). Empty if none match. + */ +/obj/vehicle/multitile/proc/get_wound_fix_candidates(obj/item/tool) + . = list() + for(var/obj/item/hardpoint/H in get_hardpoints_copy()) + for(var/family_type in H.wound_tiers) + var/list/tier_data = H.get_wound_tier_data(family_type) + if(!tier_data || tier_data["unmount_required"]) + continue + var/list/steps = tier_data["repair_steps"] + if(!length(steps)) + continue + var/current_step = H.get_wound_repair_step(family_type) + if(current_step >= length(steps)) + continue + if(!wound_repair_step_matches_tool(steps[current_step + 1], tool)) + continue + .["Fix [tier_data["wound_name"]] ([H.name])"] = list(H, family_type) + +/** + * Wrench/welder half of the wound-fix dispatch. Fixes the wound if there's one match, asks via + * tgui_input_list() if there's more than one. Falls through to handle_repairs() if there are none. + * + * Returns: + * * TRUE if a wound-fix was offered or attempted. FALSE if there was nothing to offer. + */ +/obj/vehicle/multitile/proc/try_fix_wound_with_tool(obj/item/tool, mob/living/user) + var/list/candidates = get_wound_fix_candidates(tool) + if(!length(candidates)) + return FALSE + + var/list/labels = list() + for(var/label in candidates) + labels += label + + var/chosen_label = length(labels) == 1 ? labels[1] : tgui_input_list(user, "What would you like to fix?", "Tank Repair", labels + list("Cancel")) + if(!chosen_label || chosen_label == "Cancel") + return TRUE + + // Re-validate since tgui_input_list() can suspend for a while awaiting input. + if(QDELETED(user) || user.is_mob_incapacitated() || get_dist(src, user) > 2 || tool != user.get_active_hand()) + return TRUE + var/list/fix_data = candidates[chosen_label] + if(!fix_data) + return TRUE + var/obj/item/hardpoint/target_hardpoint = fix_data[1] + target_hardpoint.fix_wound(user, tool, fix_data[2]) + return TRUE + +/** + * Every in-place-fixable Hull wound whose current repair step matches tool. Mirrors + * get_wound_fix_candidates() but reads hull_wound_tiers instead of hardpoints. + * + * Returns: + * * An assoc list, "Fix (Hull)" -> a family_type path. Empty if none match. + */ +/obj/vehicle/multitile/proc/get_hull_wound_fix_candidates(obj/item/tool) + . = list() + for(var/family_type in hull_wound_tiers) + var/tier = hull_wound_tiers[family_type] + var/datum/hardpoint_wound_family/family = GLOB.hardpoint_wound_families_by_type[family_type] + if(!family || tier > length(family.tiers)) + continue + var/list/tier_data = family.tiers[tier] + var/list/steps = tier_data["repair_steps"] + if(!length(steps)) + continue + var/current_step = get_hull_wound_repair_step(family_type) + if(current_step >= length(steps)) + continue + if(!wound_repair_step_matches_tool(steps[current_step + 1], tool)) + continue + .["Fix [tier_data["wound_name"]] (Hull)"] = family_type + +/** + * Wrench/welder half of the Hull wound-fix dispatch. Fixes the Hull wound if tool matches, or asks + * via tgui_input_list() if more than one applies. Falls through to handle_repairs() otherwise. + * + * Returns: + * * TRUE if a wound-fix was offered or attempted. FALSE if there was nothing to offer. + */ +/obj/vehicle/multitile/proc/try_fix_hull_wound_with_tool(obj/item/tool, mob/living/user) + var/list/candidates = get_hull_wound_fix_candidates(tool) + if(!length(candidates)) + return FALSE + + var/list/labels = list() + for(var/label in candidates) + labels += label + + var/chosen_label = length(labels) == 1 ? labels[1] : tgui_input_list(user, "What would you like to fix?", "Tank Repair", labels + list("Cancel")) + if(!chosen_label || chosen_label == "Cancel") + return TRUE + + if(QDELETED(user) || user.is_mob_incapacitated() || get_dist(src, user) > 2 || tool != user.get_active_hand()) + return TRUE + var/family_type = candidates[chosen_label] + if(!family_type) + return TRUE + fix_hull_wound(user, tool, family_type) + return TRUE + +/** + * Crowbar half of the wound-fix dispatch. Merges any crowbar-fixable wound options into the same + * picker as uninstall_hardpoint(), instead of guessing which one the player wants. + * + * Returns: + * * TRUE if this handled the interaction. FALSE if there was no wound-fix option to merge in. + */ +/obj/vehicle/multitile/proc/try_fix_or_remove_with_crowbar(obj/item/tool, mob/living/user) + var/list/candidates = get_wound_fix_candidates(tool) + if(!length(candidates)) + return FALSE + + var/list/options = candidates.Copy() + for(var/obj/item/hardpoint/H in get_hardpoints_copy()) + if(H.w_class == SIZE_MASSIVE || istype(H, /obj/item/hardpoint/special)) + continue + options["Remove [H.name]"] = H + + var/list/labels = list() + for(var/label in options) + labels += label + var/chosen_label = tgui_input_list(user, "What would you like to do with \the [tool]?", "Tank Repair", labels + list("Cancel")) + if(!chosen_label || chosen_label == "Cancel") + return TRUE + + if(QDELETED(user) || user.is_mob_incapacitated() || get_dist(src, user) > 2 || tool != user.get_active_hand()) + return TRUE + var/chosen_value = options[chosen_label] + if(istype(chosen_value, /obj/item/hardpoint)) + uninstall_hardpoint(tool, user, chosen_value) + else if(islist(chosen_value)) + var/list/fix_data = chosen_value + var/obj/item/hardpoint/target_hardpoint = fix_data[1] + target_hardpoint.fix_wound(user, tool, fix_data[2]) + return TRUE + //Returns all activatable hardpoints /obj/vehicle/multitile/proc/get_activatable_hardpoints(seat) var/list/hps = list() @@ -21,6 +217,25 @@ hps += H return hps +/// The first activatable secondary mounted on this vehicle (or its turret holder), eligible to be slaved to the driver. +/obj/vehicle/multitile/proc/get_slavable_secondary() + var/obj/item/hardpoint/holder/tank_turret/turret = locate() in hardpoints + var/list/search_list = turret ? turret.hardpoints : hardpoints + for(var/obj/item/hardpoint/secondary/candidate in search_list) + if(!candidate.is_activatable()) + continue + return candidate + return null + +/// The first hardpoint on this vehicle (or its turret holder) that loads star shells/star shell packets directly. +/obj/vehicle/multitile/proc/get_starshell_hardpoint() + var/obj/item/hardpoint/holder/tank_turret/turret = locate() in hardpoints + var/list/search_list = turret ? turret.hardpoints : hardpoints + for(var/obj/item/hardpoint/candidate in search_list) + if(candidate.uses_starshell_ammo) + return candidate + return null + //Returns hardpoints that use ammunition /obj/vehicle/multitile/proc/get_hardpoints_with_ammo(seat) var/list/hps = list() @@ -29,11 +244,64 @@ var/obj/item/hardpoint/holder/HP = H if(HP.hardpoints) hps += HP.get_hardpoints_with_ammo(seat) - if(!H.ammo || seat && seat != H.allowed_seat) + if(!H.ammo_type || seat && seat != H.allowed_seat) continue hps += H return hps +/** + * Whether this vehicle has a Visual Sensors slot at all. FALSE by default, TRUE on the tank. + */ +/obj/vehicle/multitile/proc/supports_visual_sensors() + return FALSE + +/** + * Whether this vehicle's gunner gets the Ctrl+Click rangefinder feature. FALSE by default, TRUE + * on the tank only. + */ +/obj/vehicle/multitile/proc/has_gunner_rangefinder() + return FALSE + +/** + * Applies or clears the Visual Sensors vision-impair overlay on a seated crew member, mirroring + * welding goggles. A damaged sensor also layers in a red vignette and a gaussian blur filter. + * + * Arguments: + * * seated_mob = The buckled driver/gunner/passenger to update. + */ +/obj/vehicle/multitile/proc/update_visual_sensor_overlay(mob/seated_mob) + if(!seated_mob?.client) + return + + var/obj/item/hardpoint/visual_sensors/sensors = locate() in get_hardpoints_copy() + var/severity + if(sensors) + severity = sensors.get_overlay_severity() + else if(supports_visual_sensors()) + // No sensor installed despite having the slot for one, worst-case impair. + severity = VISION_IMPAIR_ULTRA + else + // This vehicle type has no Visual Sensors slot at all, nothing to be missing. + severity = VISION_IMPAIR_NONE + + if(severity <= VISION_IMPAIR_NONE) + clear_visual_sensor_overlay(seated_mob) + else + var/atom/movable/plane_master_controller/game_plane_master_controller = seated_mob.hud_used?.plane_master_controllers[PLANE_MASTERS_GAME] + seated_mob.overlay_fullscreen("visual_sensors", /atom/movable/screen/fullscreen/impaired, severity) + seated_mob.overlay_fullscreen("visual_sensors_oxy", /atom/movable/screen/fullscreen/oxy, severity) + game_plane_master_controller?.add_filter("visual_sensors_blur", 1, gauss_blur_filter(clamp(severity * 0.5, 0.6, 3))) + +/** + * Strips all Visual Sensors vision effects off M, so unbuckle handlers don't leave them stuck on + * a mob who already left the seat. + */ +/obj/vehicle/multitile/proc/clear_visual_sensor_overlay(mob/M) + M.clear_fullscreen("visual_sensors") + M.clear_fullscreen("visual_sensors_oxy") + var/atom/movable/plane_master_controller/game_plane_master_controller = M.hud_used?.plane_master_controllers[PLANE_MASTERS_GAME] + game_plane_master_controller?.remove_filter("visual_sensors_blur") + // Returns a hardpoint by its name /obj/vehicle/multitile/proc/find_hardpoint(name) for(var/obj/item/hardpoint/H in hardpoints) @@ -106,7 +374,7 @@ num_delays = 3 if(HDPT_SUPPORT) num_delays = 2 - if(HDPT_ARMOR) + if(HDPT_ARMOR, HDPT_SNOWPLOW) num_delays = 10 if(HDPT_TREADS, HDPT_WHEELS) num_delays = 7 @@ -135,9 +403,15 @@ add_hardpoint(HP, user) -//User-orientated proc for taking of hardpoints -//Again, similar to the above ones -/obj/vehicle/multitile/proc/uninstall_hardpoint(obj/item/O, mob/user) +/** + * User-orientated proc for taking off hardpoints. Similar to install_hardpoint(). + * + * Arguments: + * * O = The tool used (crowbar, or a powerloader clamp). + * * user = Whoever's removing it. + * * preselected = Optional, skips the "Select a hardpoint to remove" picker and acts on this one. + */ +/obj/vehicle/multitile/proc/uninstall_hardpoint(obj/item/O, mob/user, obj/item/hardpoint/preselected) if(!skillcheck(user, SKILL_ENGINEER, SKILL_ENGINEER_TRAINED)) to_chat(user, SPAN_WARNING("You don't know what to do with \the [O] on \the [src].")) return @@ -147,18 +421,19 @@ if(!PC.linked_powerloader || PC.loaded) return - var/list/hps = list() - for(var/obj/item/hardpoint/H in get_hardpoints_copy()) - // Only allow uninstalls of massive hardpoints when using powerloaders - if(H.w_class == SIZE_MASSIVE && !ispowerclamp(O) || H.w_class <= SIZE_HUGE && ispowerclamp(O) || istype(H, /obj/item/hardpoint/special)) - continue - hps += H - - var/chosen_hp = tgui_input_list(usr, "Select a hardpoint to remove", "Hardpoint Removal", (hps + "Cancel")) - if(chosen_hp == "Cancel" || !chosen_hp || (get_dist(src, user) > 2)) //get_dist uses 2 because the vehicle is 3x3 - return - - var/obj/item/hardpoint/old = chosen_hp + var/obj/item/hardpoint/old = preselected + if(!old) + var/list/hps = list() + for(var/obj/item/hardpoint/H in get_hardpoints_copy()) + // Only allow uninstalls of massive hardpoints when using powerloaders + if(H.w_class == SIZE_MASSIVE && !ispowerclamp(O) || H.w_class <= SIZE_HUGE && ispowerclamp(O) || istype(H, /obj/item/hardpoint/special)) + continue + hps += H + + var/chosen_hp = tgui_input_list(usr, "Select a hardpoint to remove", "Hardpoint Removal", (hps + "Cancel")) + if(chosen_hp == "Cancel" || !chosen_hp || (get_dist(src, user) > 2)) //get_dist uses 2 because the vehicle is 3x3 + return + old = chosen_hp if(!old) to_chat(user, SPAN_WARNING("There is nothing installed there.")) @@ -186,7 +461,7 @@ num_delays = 3 if(HDPT_SUPPORT) num_delays = 2 - if(HDPT_ARMOR) + if(HDPT_ARMOR, HDPT_SNOWPLOW) num_delays = 10 if(HDPT_TREADS) num_delays = 7 @@ -222,6 +497,8 @@ update_minimap_icon() update_icon() + ensure_active_hardpoint(VEHICLE_DRIVER) + ensure_active_hardpoint(VEHICLE_GUNNER) //General proc for taking off hardpoints //ALWAYS CALL THIS WHEN REMOVING HARDPOINTS @@ -238,9 +515,18 @@ old.reset_rotation() hardpoints -= old old.owner = null + refresh_hardpoint_actions() + ensure_active_hardpoint(VEHICLE_DRIVER) + ensure_active_hardpoint(VEHICLE_GUNNER) if(old.health <= 0 && !old.gc_destroyed) // Make sure it's not already being deleted. visible_message(SPAN_WARNING("\The [src] disintegrates into useless pile of scrap under the damage it suffered.")) qdel(old) + // Register the dropped hardpoint as an on-top object too, so it doesn't get left behind and + // rendered under the hull's sprite next time the tank moves. + if(!QDELETED(old)) + var/obj/vehicle/multitile/tank/tank_self = istype(src, /obj/vehicle/multitile/tank) ? src : null + tank_self?.obj_mark_on_top(old) + update_icon() diff --git a/code/modules/vehicles/multitile/multitile_hud.dm b/code/modules/vehicles/multitile/multitile_hud.dm new file mode 100644 index 000000000000..a0ff10345df4 --- /dev/null +++ b/code/modules/vehicles/multitile/multitile_hud.dm @@ -0,0 +1,233 @@ +/** + * A vehicle's crew HUD. A small maptext readout of speed/acceleration/gear/temperature/battery/fuel, + * shown above the driver and gunner's hand slots while they're seated. + * + * Laid out as a 3x3 grid of separate screen objects, plus a Cruise Control readout above SPEED: + * CC + * SPEED . TEMP + * . GEARS FUEL + * ACCEL . BATTERY + * + * Speed/acceleration/gears/cruise control show "N/A" for a vehicle that doesn't use the + * gear-transmission movement model (uses_gear_transmission FALSE). Temperature/battery/fuel are + * independent of that and show real data whenever the matching hardpoint is actually installed. + */ +/atom/movable/screen/tank_crew_hud + icon = null + icon_state = null + plane = HUD_PLANE + layer = HUD_LAYER + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + maptext_width = 90 + maptext_height = 20 + // Opts out of F12's clear_screen() wipe. crew_hud_loop() below decides visibility itself, + // avoiding a flicker on style transitions that shouldn't touch the HUD at all. + clear_with_screen = FALSE + +/** + * Builds one fresh set of the 7 grid-cell screen objects making up the crew HUD. A new set is made + * every time a seat is taken, so the driver and gunner each get their own. + */ +/obj/vehicle/multitile/proc/build_crew_hud_elements() + // Rows sit 14px apart within tile 3 to pack the grid tighter vertically. The right column + // sits a full tile further out than the middle one to avoid clipping the gear column's maptext. + var/atom/movable/screen/tank_crew_hud/cc_elem = new() + cc_elem.screen_loc = "WEST+5:16,3:31" + + var/atom/movable/screen/tank_crew_hud/speed_elem = new() + speed_elem.screen_loc = "WEST+5:16,3:17" + + var/atom/movable/screen/tank_crew_hud/temp_elem = new() + temp_elem.screen_loc = "WEST+8:16,3:17" + + var/atom/movable/screen/tank_crew_hud/gear_elem = new() + gear_elem.screen_loc = "WEST+6:16,3:3" + gear_elem.maptext_width = 160 + + var/atom/movable/screen/tank_crew_hud/fuel_elem = new() + fuel_elem.screen_loc = "WEST+8:16,3:3" + + var/atom/movable/screen/tank_crew_hud/accel_elem = new() + accel_elem.screen_loc = "WEST+5:16,3:-11" + + var/atom/movable/screen/tank_crew_hud/battery_elem = new() + battery_elem.screen_loc = "WEST+8:16,3:-11" + + return list( + "cc" = cc_elem, + "speed" = speed_elem, + "temp" = temp_elem, + "gear" = gear_elem, + "fuel" = fuel_elem, + "accel" = accel_elem, + "battery" = battery_elem, + ) + +/** + * Creates and shows this vehicle's crew HUD to a freshly-seated driver or gunner. No-op for a + * headless/SSD crewman. + */ +/obj/vehicle/multitile/proc/start_crew_hud(mob/living/M, seat) + if(!M.client) + return + var/list/elements = build_crew_hud_elements() + crew_hud_elements[seat] = elements + for(var/key in elements) + M.client.add_to_screen(elements[key]) + update_crew_hud(M, seat) + INVOKE_ASYNC(src, PROC_REF(crew_hud_loop), M, seat, elements) + +/// Tears down the crew HUD created by start_crew_hud() the instant that seat is vacated. +/obj/vehicle/multitile/proc/stop_crew_hud(mob/living/M, seat) + var/list/elements = crew_hud_elements[seat] + if(!elements) + return + for(var/key in elements) + var/atom/movable/screen/element = elements[key] + M.client?.remove_from_screen(element) + qdel(element) + crew_hud_elements -= seat + +/** + * Self-rescheduling refresh loop for one seat's crew HUD, paced at CREW_HUD_UPDATE_INTERVAL. Ends + * itself once this element set stops being crew_hud_elements[seat]. + * + * Also polls M's F12 HUD style each tick, adding/removing the elements from screen on the + * transitions that matter: hidden at HUD_STYLE_NOHUD, shown again at HUD_STYLE_STANDARD. + */ +/obj/vehicle/multitile/proc/crew_hud_loop(mob/living/M, seat, list/elements) + var/hud_currently_shown = TRUE + while(crew_hud_elements[seat] == elements) + sleep(CREW_HUD_UPDATE_INTERVAL) + if(crew_hud_elements[seat] != elements) + return + + var/should_show = M.hud_used ? (M.hud_used.hud_version != HUD_STYLE_NOHUD) : TRUE + if(should_show != hud_currently_shown) + hud_currently_shown = should_show + for(var/key in elements) + if(hud_currently_shown) + M.client?.add_to_screen(elements[key]) + else + M.client?.remove_from_screen(elements[key]) + + if(hud_currently_shown) + update_crew_hud(M, seat) + +/** + * Rebuilds one seat's crew HUD grid cells from live gear/engine/battery/fuel state. No-op if that + * seat's HUD isn't currently up. + * + * Speed: current_speed vs. this gear's reachable ceiling. White normally, yellow above + * DRIFT_MIN_SPEED_FRACTION (80%) of that ceiling - the same threshold that gates whether a 90-degree + * turn drifts instead of scrubbing speed - red above 95%. "N/A" if this vehicle doesn't use + * gear-transmission movement. + * Acceleration: this gear's live computed torque. "N/A" if not gear-transmission. + * Gears: every entry in GLOB.vehicle_gear_order, current_gear shown bold and yellow. "N/A" if not gear-transmission. + * Temperature: 0-200 scale. White under 80%, yellow under 100%, red beyond. "N/A" with no Engine installed. + * Battery: white while charging, yellow while draining, red under 20%. "N/A" with no Battery installed. + * Fuel: white normally, yellow under 40%, red under 20%. "N/A" with no Fuel Tank installed. + * + * Speed/acceleration show in km/h unless M's VEHICLE_UNITS_MPH preference is set, in which case + * both switch to mph. Display units only. + * + * Cruise Control: only rendered under Complex acceleration, blank otherwise. White "CC: OFF" while + * disabled, red "CC: " while enabled. "N/A" if not gear-transmission. The reverse sign + * is derived from current_gear == "R" rather than the stored (always non-negative) target speed. + */ +/obj/vehicle/multitile/proc/update_crew_hud(mob/living/M, seat) + var/list/elements = crew_hud_elements[seat] + if(!elements) + return + + var/use_mph = M.client && (M.client.prefs.toggles_vehicle & VEHICLE_UNITS_MPH) + var/unit_factor = use_mph ? VEHICLE_HUD_MPH_PER_TILE : VEHICLE_HUD_KMH_PER_TILE + var/unit_name = use_mph ? "MPH" : "KM/H" + + var/cc_line = "" + var/cc_color = "white" + var/speed_line + var/speed_color = "white" + var/accel_line + var/gear_line + + if(!uses_gear_transmission) + cc_line = "CC: N/A" + speed_line = "SPD N/A" + accel_line = "ACC N/A" + gear_line = "N/A" + else + if(!(get_driver_vehicle_prefs() & VEHICLE_SIMPLE_ACCELERATION)) + if(cruise_control_enabled) + cc_color = "red" + var/cc_sign = (current_gear == "R") ? "-" : "" + cc_line = "CC: [cc_sign][round(cruise_control_target_speed * unit_factor, 0.1)][unit_name]" + else + cc_line = "CC: OFF" + + var/list/stats = get_current_gear_stats() + var/traction_scale = get_effective_traction() + var/part_scale = get_part_condition_scale() + var/gear_max_speed = get_gear_max_speed(stats, traction_scale, part_scale) + + if(gear_max_speed > 0) + if(current_speed > gear_max_speed * 0.95) + speed_color = "red" + else if(current_speed > gear_max_speed * DRIFT_MIN_SPEED_FRACTION) + speed_color = "yellow" + speed_line = "[round(current_speed * unit_factor, 0.1)]/[round(gear_max_speed * unit_factor, 0.1)] [unit_name]" + + var/computed_torque = get_gear_computed_torque(stats, traction_scale, part_scale) + accel_line = "[round(computed_torque * unit_factor, 0.1)] [unit_name]²" + + gear_line = "" + for(var/gear in GLOB.vehicle_gear_order) + gear_line += (gear == current_gear) ? "[gear] " : "[gear] " + + var/obj/item/hardpoint/engine/engine_hardpoint = get_engine_hardpoint() + var/temp_line = "TEMP N/A" + var/temp_color = "white" + if(engine_hardpoint) + var/temp_percent = engine_hardpoint.get_temperature_percent() + temp_line = "TEMP [round(temp_percent)]%" + if(temp_percent >= 100) + temp_color = "red" + else if(temp_percent >= 80) + temp_color = "yellow" + + var/obj/item/hardpoint/battery/battery_hardpoint = get_battery_hardpoint() + var/battery_line = "BATT N/A" + var/battery_color = "white" + if(battery_hardpoint) + var/charge_percent = battery_hardpoint.get_charge_percent() + battery_line = "BATT [round(charge_percent)]%" + if(charge_percent < 20) + battery_color = "red" + else if(!engine_on) + battery_color = "yellow" + + var/obj/item/hardpoint/fuel_tank/fuel_hardpoint = get_fuel_tank_hardpoint() + var/fuel_line = "FUEL N/A" + var/fuel_color = "white" + if(fuel_hardpoint) + var/fuel_percent = fuel_hardpoint.get_fuel_percent() + fuel_line = "FUEL [round(fuel_percent)]%" + if(fuel_percent < 20) + fuel_color = "red" + else if(fuel_percent < 40) + fuel_color = "yellow" + + var/atom/movable/screen/cc_elem = elements["cc"] + cc_elem.maptext = cc_line ? SMALL_FONTS_COLOR(7, cc_line, cc_color) : "" + var/atom/movable/screen/speed_elem = elements["speed"] + speed_elem.maptext = SMALL_FONTS_COLOR(7, speed_line, speed_color) + var/atom/movable/screen/accel_elem = elements["accel"] + accel_elem.maptext = SMALL_FONTS_COLOR(7, accel_line, "white") + var/atom/movable/screen/gear_elem = elements["gear"] + gear_elem.maptext = SMALL_FONTS_COLOR(7, gear_line, "white") + var/atom/movable/screen/temp_elem = elements["temp"] + temp_elem.maptext = SMALL_FONTS_COLOR(7, temp_line, temp_color) + var/atom/movable/screen/fuel_elem = elements["fuel"] + fuel_elem.maptext = SMALL_FONTS_COLOR(7, fuel_line, fuel_color) + var/atom/movable/screen/battery_elem = elements["battery"] + battery_elem.maptext = SMALL_FONTS_COLOR(7, battery_line, battery_color) diff --git a/code/modules/vehicles/multitile/multitile_interaction.dm b/code/modules/vehicles/multitile/multitile_interaction.dm index d3106ea8183c..d0136a138325 100644 --- a/code/modules/vehicles/multitile/multitile_interaction.dm +++ b/code/modules/vehicles/multitile/multitile_interaction.dm @@ -1,4 +1,56 @@ +/** + * Pours a reagent container into whichever fuel tank/radiator hardpoint matches its contents, without + * needing to uninstall either first. + * + * Classified by category: any vehicle_fuel reagent counts as fuel, any vehicle_coolant reagent counts + * as water, anything else is "other". Mixing categories is rejected. + * + * Arguments: + * * O = The container being poured onto this vehicle. + * * user = Whoever's pouring it. + * + * Returns: + * * TRUE if O was a reagent container with something in it. FALSE if O has no reagents. + */ +/obj/vehicle/multitile/proc/try_pour_container(obj/item/O, mob/user) + if(!O.reagents || !length(O.reagents.reagent_list)) + return FALSE + + var/has_water = FALSE + var/has_fuel = FALSE + var/has_other = FALSE + for(var/datum/reagent/current_reagent in O.reagents.reagent_list) + if(current_reagent.vehicle_coolant) + has_water = TRUE + else if(current_reagent.vehicle_fuel) + has_fuel = TRUE + else + has_other = TRUE + + if((has_water + has_fuel + has_other) > 1) + to_chat(user, SPAN_WARNING("You shouldn't mix different fluids together!")) + return TRUE + + if(has_fuel) + var/obj/item/hardpoint/fuel_tank/tank = get_fuel_tank_hardpoint() + if(!tank) + to_chat(user, SPAN_WARNING("\The [src] has no fuel tank installed!")) + return TRUE + tank.attackby(O, user) + return TRUE + + if(has_water) + var/obj/item/hardpoint/radiator/rad = get_radiator_hardpoint() + if(!rad) + to_chat(user, SPAN_WARNING("\The [src] has no radiator installed!")) + return TRUE + rad.attackby(O, user) + return TRUE + + to_chat(user, SPAN_WARNING("Neither of those fluids are suitable for a vehicle.")) + return TRUE + // Special cases abound, handled below or in subclasses /obj/vehicle/multitile/attackby(obj/item/O, mob/user) // Are we trying to install stuff? @@ -13,16 +65,35 @@ install_hardpoint(PC, user) return - // Are we trying to remove stuff? + // Are we trying to remove stuff, or fix an in-place wound a crowbar happens to also fix? + // If there's a crowbar-fixable wound, offer it in the same picker as the normal removal choices. if(HAS_TRAIT(O, TRAIT_TOOL_CROWBAR) || ispowerclamp(O)) + if(!ispowerclamp(O) && try_fix_or_remove_with_crowbar(O, user)) + return uninstall_hardpoint(O, user) return - // Are we trying to repair the frame? + // Are we trying to fix an in-place wound, or (if none apply) repair the frame? if(iswelder(O) || HAS_TRAIT(O, TRAIT_TOOL_WRENCH)) + if(try_fix_wound_with_tool(O, user)) + return + if(try_fix_hull_wound_with_tool(O, user)) + return handle_repairs(O, user) return + // Are we trying to fix a wound step that needs a stack of material instead of a tool trait? + // No-ops if neither wound-fix dispatcher has a use for it. + if(istype(O, /obj/item/stack)) + if(try_fix_wound_with_tool(O, user)) + return + if(try_fix_hull_wound_with_tool(O, user)) + return + + // Are we trying to refuel the fuel tank, or top off the radiator's coolant? + if(try_pour_container(O, user)) + return + // Are we trying to immobilize the vehicle? if(istype(O, /obj/item/vehicle_clamp)) if(clamped) @@ -302,6 +373,13 @@ if(X.frenzy_aura > 0) damage += (X.frenzy_aura * FRENZY_DAMAGE_MULTIPLIER) + // Same per-caste damage-modifier hooks a plain claw attack against a mob goes through. + if(X.behavior_delegate) + X.behavior_delegate.melee_attack_modify_burn_damage(0, src) + damage = X.behavior_delegate.melee_attack_modify_damage(damage, src) + X.behavior_delegate.melee_attack_additional_effects_target(src) + X.behavior_delegate.melee_attack_additional_effects_self() + X.animation_attack_on(src) //Somehow we will deal no damage on this attack @@ -312,7 +390,7 @@ return XENO_ATTACK_ACTION X.visible_message(SPAN_DANGER("\The [X] slashes \the [src]!"), - SPAN_DANGER("We slash \the [src]!")) + SPAN_DANGER("We slash [get_attack_desc(X)]!")) playsound(X.loc, pick('sound/effects/metalhit.ogg', 'sound/weapons/alien_claw_metal1.ogg', 'sound/weapons/alien_claw_metal2.ogg', 'sound/weapons/alien_claw_metal3.ogg'), 25, 1) take_damage_type(damage * damage_mult, "slash", X) @@ -349,7 +427,36 @@ pixel_y_offset -= 32 bullet_ping(P, pixel_x_offset, pixel_y_offset) - take_damage_type(damage * (0.33 + penetration/100), dam_type, firer) + var/scaled_damage = damage * (0.33 + penetration/100) + + // Xeno spit/acid/toxin ammo hitting a tank gets a dedicated resolution instead of the normal + // single-target pipeline, which crushed this kind of hit down to nearly nothing. + if(istype(src, /obj/vehicle/multitile/tank) && istype(P.ammo, /datum/ammo/xeno)) + var/obj/vehicle/multitile/tank/tank = src + if(istype(P.ammo, /datum/ammo/xeno/toxin)) + // Neurotoxin deals 0 damage against the tank, same as mobs. Instead it advances a + // neuro-foulable wound directly. + tank.expose_to_neurotoxin_spit(firer, ignore_aim = istype(P.ammo, /datum/ammo/xeno/toxin/shotgun)) + else if(istype(P.ammo, /datum/ammo/xeno/acid/prae_nade)) + tank.apply_random_external_acid_hit(scaled_damage, firer) + else if(istype(P.ammo, /datum/ammo/xeno/acid)) + // Every other acid spit is a directed single shot, resolve it to the aimed slot instead of + // the weighted-random pick below. + tank.apply_targeted_acid_hit(scaled_damage * ACID_SPIT_TANK_DAMAGE_MULT, dam_type, firer, wound_chance_mult = ACID_SPIT_TANK_WOUND_CHANCE_MULT) + else if(istype(P.ammo, /datum/ammo/xeno/acid_shotgun)) + // Trapper Boiler's Acid Shotgun falls through to the same weighted-random module pick as + // other unaimed xeno ammo, but with its own damage multiplier and effective penetration. + var/tank_scaled_damage = damage * (0.33 + ACID_SHOTGUN_TANK_EFFECTIVE_PENETRATION/100) + tank.apply_weighted_module_hit(tank_scaled_damage * ACID_SHOTGUN_TANK_DAMAGE_MULT, dam_type, firer, wound_chance_mult = ACID_SHOTGUN_TANK_WOUND_CHANCE_MULT) + else if(istype(P.ammo, /datum/ammo/xeno/bone_chips)) + // Hedgehog Ravager's bone chips are deliberately weak, so double the wound-roll chance + // instead of inflating damage. + tank.apply_weighted_module_hit(scaled_damage, dam_type, firer, wound_chance_mult = BONE_CHIPS_TANK_WOUND_CHANCE_MULT) + else + tank.apply_weighted_module_hit(scaled_damage, dam_type, firer) + return + + take_damage_type(scaled_damage, dam_type, firer) healthcheck() @@ -373,9 +480,10 @@ RegisterSignal(user, COMSIG_MOB_MOUSEDOWN, PROC_REF(crew_mousedown)) RegisterSignal(user, COMSIG_MOB_MOUSEDRAG, PROC_REF(crew_mousedrag)) RegisterSignal(user, COMSIG_MOB_MOUSEUP, PROC_REF(crew_mouseup)) + RegisterSignal(user, COMSIG_MOB_MOUSEMOVE, PROC_REF(crew_mousemove)) /obj/vehicle/multitile/on_unset_interaction(mob/user) - UnregisterSignal(user, list(COMSIG_MOB_MOUSEUP, COMSIG_MOB_MOUSEDOWN, COMSIG_MOB_MOUSEDRAG)) + UnregisterSignal(user, list(COMSIG_MOB_MOUSEUP, COMSIG_MOB_MOUSEDOWN, COMSIG_MOB_MOUSEDRAG, COMSIG_MOB_MOUSEMOVE)) var/obj/item/hardpoint/hardpoint = get_mob_hp(user) if(hardpoint) @@ -399,6 +507,46 @@ hardpoint.change_target(source, src_object, over_object, src_location, over_location, src_control, over_control, params) + // BYOND doesn't send MouseMove while a button is held - MouseDrag takes over. Feed it into + // the same tracking patths so aiming doesn't freeze while the crew is firing/holding M1 + track_gunner_mouse(source, over_object, params) + track_driver_mouse(source, over_object, params) + +/// Relays live cursor position to the gunner's turret / driver's slaved secondary, if any, so they can track the mouse. +/obj/vehicle/multitile/proc/crew_mousemove(datum/source, atom/object, turf/location, control, params) + SIGNAL_HANDLER + track_gunner_mouse(source, object, params) + track_driver_mouse(source, object, params) + +/// Updates the gunner's turret, or their own tracked active hardpoint if there's no turret, to aim toward the given screen position. +/obj/vehicle/multitile/proc/track_gunner_mouse(mob/source, atom/object, params) + if(get_mob_seat(source) != VEHICLE_GUNNER) + return + + var/obj/item/hardpoint/holder/tank_turret/turret = locate() in hardpoints + if(turret) + turret.update_desired_angle(object, source, params) + return + + var/obj/item/hardpoint/active = active_hp[VEHICLE_GUNNER] + if(active && active.uses_live_rotation_tracking && active.get_rotation_owner() == active) + active.update_desired_angle(object, source, params) + +/// Updates the driver's slaved secondary to aim at the given position, only once cycled to as active. +/obj/vehicle/multitile/proc/track_driver_mouse(mob/source, atom/object, params) + if(get_mob_seat(source) != VEHICLE_DRIVER) + return + + var/obj/item/hardpoint/holder/tank_turret/turret = locate() in hardpoints + var/list/search_list = turret ? turret.hardpoints : hardpoints + for(var/obj/item/hardpoint/secondary/secondary_weapon in search_list) + if(!secondary_weapon.self_gimballed || !secondary_weapon.has_independent_aim()) + continue + if(active_hp[VEHICLE_DRIVER] != secondary_weapon) + return + secondary_weapon.update_desired_angle(object, source, params) + return + /// Checks for special control keybinds, else relays crew mouse press to active hardpoint. /obj/vehicle/multitile/proc/crew_mousedown(datum/source, atom/object, turf/location, control, params) SIGNAL_HANDLER @@ -414,8 +562,11 @@ activate_horn() return if(VEHICLE_GUNNER) + if(modifiers[LEFT_CLICK] && modifiers[CTRL_CLICK]) + gunner_rangefind(source, object, modifiers) + return if(modifiers[LEFT_CLICK] && modifiers[ALT_CLICK]) - toggle_gyrostabilizer() + toggle_turret_safety() return var/obj/item/hardpoint/hardpoint = get_mob_hp(source) @@ -425,6 +576,69 @@ hardpoint.start_fire(source, object, location, control, params) +/** + * Lets the gunner Ctrl+Click a tile to rangefind it, exactly like the rangefinder binoculars. + * + * Tank-exclusive. Requires a working, unwounded visual sensors module. + */ +/obj/vehicle/multitile/proc/gunner_rangefind(mob/living/carbon/human/user, atom/target, list/modifiers) + if(!ishuman(user)) + return + if(!has_gunner_rangefinder()) + return + var/obj/item/hardpoint/visual_sensors/sensors = locate() in get_hardpoints_copy() + if(!sensors || LAZYLEN(sensors.wound_tiers)) + to_chat(user, SPAN_WARNING("The rangefinder needs working, unwounded visual sensors to get a fix on anything.")) + return + if(!gunner_rangefinder) + gunner_rangefinder = new /obj/item/device/binoculars/range(src) + gunner_rangefinder.handle_click(user, target, modifiers) + +/** + * Decides whether M is let through this vehicle's door lock, and how much longer or shorter it takes. + * Called on both entry and exit. + * + * Only the tank overrides this, to read its Hatch hardpoint's wound state instead. + * + * Arguments: + * * M = Whoever's trying to get through. + * * entering = TRUE for entry, FALSE for exit. + * + * Returns: + * * An assoc list: "blocked" and "delay_mult". + */ +/obj/vehicle/multitile/proc/get_hatch_lock_result(mob/M, entering = TRUE) + if(!entering || isxeno(M)) + return list("blocked" = FALSE, "delay_mult" = 1) + + if(door_locked && health > 0) + if(ishuman(M)) + var/mob/living/carbon/human/user = M + if(!allowed(user) || !get_target_lock(user.faction_group)) //if we are human, we check access and faction + to_chat(user, SPAN_WARNING("\The [src] is locked!")) + return list("blocked" = TRUE, "delay_mult" = 1) + else + to_chat(M, SPAN_WARNING("\The [src] is locked!")) //animals are not allowed inside without supervision + return list("blocked" = TRUE, "delay_mult" = 1) + + return list("blocked" = FALSE, "delay_mult" = 1) + +/** + * Whether at least one of this vehicle's known exits is currently usable. Matters because a xeno + * breaching a hull-dead vehicle can enter anywhere, but can still only leave through a usable exit. + */ +/obj/vehicle/multitile/proc/has_usable_exit(atom/movable/A) + if(!interior) + return TRUE // no interior instance, nothing to be trapped inside of + + for(var/entrance_id in entrances) + var/entrance_coord = entrances[entrance_id] + var/turf/exit_turf = locate(x + entrance_coord[1], y + entrance_coord[2], z) + if(interior.can_exit(A, exit_turf, show_message = FALSE)) + return TRUE + + return FALSE + /obj/vehicle/multitile/proc/handle_player_entrance(mob/M) if(!M || M.client == null) return @@ -438,21 +652,14 @@ entrance_used = entrance break + var/list/lock_result = get_hatch_lock_result(M, TRUE) + if(lock_result["blocked"]) + return + var/enter_time = 0 // door locks break when hull is destroyed. Xenos enter slower, but their speed is not affected by anything and they ignore locks if(isxeno(M)) enter_time = 3 SECONDS - else - if(door_locked && health > 0) //check if lock on and actually works - if(ishuman(M)) - var/mob/living/carbon/human/user = M - if(!allowed(user) || !get_target_lock(user.faction_group)) //if we are human, we check access and faction - to_chat(user, SPAN_WARNING("\The [src] is locked!")) - return - else - to_chat(M, SPAN_WARNING("\The [src] is locked!")) //animals are not allowed inside without supervision - return - // Only xenos can force their way in without doors, and only when the frame is completely broken if(!entrance_used && health > 0) @@ -460,6 +667,11 @@ else if(!entrance_used && !isxeno(M)) return + // A xeno breaching a dead hull can enter anywhere, but don't trap them if every exit is blocked. + if(!entrance_used && isxeno(M) && !has_usable_exit(M)) + to_chat(M, SPAN_WARNING("We would be trapped in there with no way out!")) + return + var/enter_msg = "We start climbing into \the [src]..." if(health <= 0 && isxeno(M)) enter_msg = "We start prying away loose plates, squeezing into \the [src]..." @@ -477,6 +689,7 @@ enter_time = entrance_speed if(dragged_atom) enter_time = 2 SECONDS + enter_time *= lock_result["delay_mult"] to_chat(M, SPAN_NOTICE(enter_msg)) if(!do_after(M, enter_time, INTERRUPT_NO_NEEDHAND, BUSY_ICON_GENERIC)) diff --git a/code/modules/vehicles/multitile/multitile_movement.dm b/code/modules/vehicles/multitile/multitile_movement.dm index b4faf7f5ba9a..c372302cec15 100644 --- a/code/modules/vehicles/multitile/multitile_movement.dm +++ b/code/modules/vehicles/multitile/multitile_movement.dm @@ -34,6 +34,8 @@ if(health <= 0) return FALSE + last_input_time = world.time + return pre_movement(direction) // This determines what type of movement to execute @@ -41,6 +43,9 @@ if(world.time < next_move) return FALSE + if(uses_gear_transmission) + return gear_pre_movement(direction) + var/success = FALSE if(dir == turn(direction, 180) || dir == direction) @@ -54,56 +59,178 @@ if(move_on_turn) try_move(direction) + if(success) + start_momentum_decay_if_needed() + return success -// Attempts to execute the given movement input +/** + * Attempts to execute the given movement input, then carries any on-hull riders along with it. + * + * Arguments: + * * direction = Direction to move in. + * * force = Bypasses the normal momentum/gas gate. Used by knockback/ram shoves. + */ /obj/vehicle/multitile/proc/try_move(direction, force=FALSE) - if(!can_move(direction)) - return FALSE + var/list/old_center = _current_center() + var/old_z = z + var/old_dir = dir + + var/success + var/stairs_travel = get_stairs_edge_direction(direction) + if(stairs_travel) + success = attempt_stairs_transition(direction, stairs_travel) + else + if(!can_move(direction)) + return FALSE - if(!force) - var/should_move = update_momentum(direction) - update_next_move() + if(!force) + var/should_move = update_momentum(direction) + update_next_move() - if(!should_move) - return FALSE + if(!should_move) + return FALSE - var/turf/old_turf = get_turf(src) - forceMove(get_step(src, direction)) + var/turf/old_turf = get_turf(src) + var/list/old_locs = locs.Copy() + forceMove(get_step(src, direction)) - var/turf/current_loc = get_turf(src) - for(var/obj/item/hardpoint/H in hardpoints) - H.on_move(old_turf, current_loc, direction) + var/turf/current_loc = get_turf(src) + update_covered_barricades(old_locs) + for(var/obj/item/hardpoint/H in hardpoints) + H.on_move(old_turf, current_loc, direction) - if(movement_sound && world.time > move_next_sound_play) - playsound(src, movement_sound, vol = 20, sound_range = 30) - move_next_sound_play = world.time + 10 + if(movement_sound && world.time > move_next_sound_play) + playsound(src, movement_sound, vol = 20, sound_range = 30) + move_next_sound_play = world.time + 10 + + last_move_dir = direction + + if(force && (health <= 0)) // Broken and forced movement (currently only xenos) + interior.drop_human_bodies(old_turf) + + success = TRUE - last_move_dir = direction + if(!success) + return FALSE - if(force && (health <= 0)) // Broken and forced movement (currently only xenos) - interior.drop_human_bodies(old_turf) + var/list/new_center = _current_center() + if(new_center[1] != old_center[1] || new_center[2] != old_center[2] || z != old_z || dir != old_dir) + _update_riders_after_motion(old_center[1], old_center[2], old_z, old_dir, new_center[1], new_center[2], dir, direction) return TRUE -// Rotates the vehicle by deg degrees if possible +// Overridden by tanks to elevate barricades they're crossing above their own/riders' layers. +/obj/vehicle/multitile/proc/update_covered_barricades(list/old_locs) + return + +/** + * Whether the vehicle is still coasting in the opposite direction from what dir/current_gear now call for. + * True right after a U-turn or a Reverse/Drive shift while still moving the old way. + */ +/obj/vehicle/multitile/proc/is_momentum_reversed() + var/new_direction = (current_gear == "R") ? turn(dir, 180) : dir + return current_speed > 0 && current_move_direction && new_direction == REVERSE_DIR(current_move_direction) + +/** + * Recomputes current_move_direction from dir/current_gear (opposite the hull's facing while in Reverse). + * + * Called from every place dir or current_gear can change, not just the physics tick, so a turn or gear + * shift is picked up immediately instead of waiting on the next tick. + * + * While is_momentum_reversed() is TRUE, this leaves current_move_direction untouched so the vehicle keeps + * coasting the old way until that residual speed decays or gets braked down to 0. + */ +/obj/vehicle/multitile/proc/update_move_direction() + if(is_momentum_reversed()) + return + current_move_direction = (current_gear == "R") ? turn(dir, 180) : dir + +/** + * Rotates the vehicle by deg degrees if possible. + * + * Complex acceleration's drift mechanic lives here. A 180-degree turn keeps dir on the same axis, so + * current_speed just carries over under the turn-scrub penalty instead of becoming drift. Only a 90-degree + * turn moves onto the other axis: + * - The old facing axis's speed becomes drift, unless it's below DRIFT_MIN_SPEED_FRACTION of top_speed, in + * which case it's too slow to skid and no drift is created. + * - Whatever was already drifting on the new axis is reclaimed as the new current_speed. If nothing was + * drifting there, current_speed starts fresh at 0 when the old speed became drift, or gets the same + * turn-scrub penalty as a same-axis turn when it was too slow to drift in the first place. + * + * Known simplification: if the driver shifted into/out of Reverse mid-drift, the gear-derived direction + * wins over the reclaimed one. Accepted as a rare edge case. + */ /obj/vehicle/multitile/proc/try_rotate(deg) + var/list/old_center = _current_center() + var/old_z = z + if(!can_rotate(deg)) return FALSE - move_momentum = move_momentum * move_turn_momentum_loss_factor - if(abs(move_momentum) < 0.5) - if(move_momentum < 0) - move_momentum = -0.5 - else - move_momentum = 0.5 - update_next_move() + var/old_dir = dir + var/pre_turn_speed = current_speed + var/pre_turn_move_direction = current_move_direction + // Cruise control holds a fixed target speed, so a sideways drift would fight that speed-holding logic + // instead of coasting freely. Drifting is only ever available with cruise control off. + var/drift_eligible = uses_gear_transmission && !(get_driver_vehicle_prefs() & VEHICLE_SIMPLE_ACCELERATION) && !cruise_control_enabled + + if(!uses_gear_transmission) + move_momentum = move_momentum * move_turn_momentum_loss_factor + if(abs(move_momentum) < 0.5) + if(move_momentum < 0) + move_momentum = -0.5 + else + move_momentum = 0.5 + update_next_move() + else + next_move = world.time + gear_turn_delay rotate_hardpoints(deg) rotate_entrances(deg) rotate_bounds(deg) + update_langchat_height() setDir(turn(dir, deg), TRUE) + if(uses_gear_transmission) + var/axis_changed = ((old_dir & (EAST|WEST)) != 0) != ((dir & (EAST|WEST)) != 0) + if(drift_eligible && axis_changed) + var/old_drift_speed = drift_speed + var/old_drift_direction = drift_direction + var/fast_enough_to_drift = pre_turn_speed >= top_speed * DRIFT_MIN_SPEED_FRACTION + + if(fast_enough_to_drift) + drift_speed = pre_turn_speed + drift_direction = pre_turn_move_direction + drift_braking = FALSE // fresh drift coasts under plain drag until the driver presses gas/brake again + ensure_drift_movement_loop() + else + drift_speed = 0 + drift_direction = 0 + + if(old_drift_speed > 0) + current_speed = old_drift_speed + current_move_direction = old_drift_direction + else if(fast_enough_to_drift) + current_speed = 0 + else + // Too slow to skid, but that shouldn't mean a hard stop either - scrub it the same as a + // same-axis (180-degree) turn instead of zeroing outright, matching the else branch below. + current_speed = pre_turn_speed * move_turn_momentum_loss_factor + move_momentum = current_speed + else + // 180-degree turn, or Simple acceleration (no drift at all): unchanged existing behavior. + current_speed *= move_turn_momentum_loss_factor + move_momentum = current_speed + // speed_notch needs the same turn-scrub penalty or it silently snaps current_speed back up. + speed_notch = round(speed_notch * move_turn_momentum_loss_factor) + // Reset so this scrub doesn't leave a stale timestamp blocking the next notch climb. + next_notch_climb_time = 0 + + // Refreshes current_move_direction immediately instead of waiting for the next physics tick, + // which would otherwise let the vehicle take one more step on the old facing after turning. + update_move_direction() + last_move_dir = dir if(movement_sound && world.time > move_next_sound_play) @@ -112,6 +239,10 @@ update_icon() + var/list/new_center = _current_center() + if(new_center[1] != old_center[1] || new_center[2] != old_center[2] || z != old_z || dir != old_dir) + _update_riders_after_motion(old_center[1], old_center[2], old_z, old_dir, new_center[1], new_center[2], dir) + return TRUE /obj/vehicle/multitile/setDir(newdir, real_rotate = FALSE) @@ -121,10 +252,6 @@ // Increases/decreases the vehicle's momentum according to whether or not the user is steppin' on the gas or not /obj/vehicle/multitile/proc/update_momentum(direction) - // If we've stood still for long enough we go back to 0 momentum - if(world.time > next_move + move_delay*move_momentum_build_factor) - move_momentum = 0 - if(direction == dir) move_momentum = min(move_momentum + 1, move_max_momentum) else @@ -140,12 +267,12 @@ /obj/vehicle/multitile/proc/update_next_move() // 1/((m/M)*b) where m is momentum, M is max momentum and b is the build factor + //// move_momentum_build_factor seems to cancel itself out here. It's worth to revisit this section and maybe refactor it. var/anti_build_factor = 1/((max(abs(move_momentum), 1)/move_max_momentum) * move_momentum_build_factor) next_move = world.time + move_delay * move_momentum_build_factor * anti_build_factor * misc_multipliers["move"] l_move_time = world.time - // This just checks if the vehicle can physically move in the given direction /obj/vehicle/multitile/proc/can_move(direction) var/can_move = TRUE @@ -158,25 +285,213 @@ var/bound_height_tiles = bound_height / world.icon_size var/list/old_turfs = CORNER_BLOCK(min_turf, bound_width_tiles, bound_height_tiles) + for(var/turf/T as anything in old_turfs) + for(var/atom/A in T) + if(!QDELETED(A) && A.last_bumped != world.time && is_directional_obstacle(A) && (A.dir & direction)) + Collide(A) + var/turf/new_loc = get_step(src, direction) min_turf = locate(new_loc.x + bound_x_tiles, new_loc.y + bound_y_tiles, z) - for(var/turf/T as anything in CORNER_BLOCK(min_turf, bound_width_tiles, bound_height_tiles)) + var/list/new_turfs = CORNER_BLOCK(min_turf, bound_width_tiles, bound_height_tiles) + for(var/turf/T as anything in new_turfs) // only check the turfs we're moving to if(T in old_turfs) continue + if(can_enter_open_space && istype(T, /turf/open_space)) + // early return so we skip crash behavior. + halve_speed() + return FALSE + if(!T.Enter(src)) can_move = FALSE + for(var/atom/A in T) + if(QDELETED(A) || A.last_bumped == world.time || !is_directional_obstacle(A)) + continue + if(REVERSE_DIR(A.dir) & direction) + // Head-on: its own facing points straight back at us. Ram it. + Collide(A) + else if(!(A.dir & direction)) + // Perpendicular to our travel direction. Only crush it if its facing edge points into + // another tile of our own footprint, meaning it bisects our body and gets hit either way. + var/turf/inward_turf = get_step(T, A.dir) + if(inward_turf && (inward_turf in new_turfs)) + Collide(A) + + // Non-dense structures the tank should still crush even though T.Enter() never sees them. + // The last_bumped check avoids double-handling anything already bumped via T.Enter() this tick. + for(var/atom/A in T) + if(QDELETED(A) || A.last_bumped == world.time) + continue + if(istype(A, /obj/structure/machinery/constructable_frame) || istype(A, /obj/structure/bed/chair) || istype(A, /obj/structure/bed/stool)) + Collide(A) + + // A framed window's deconstruct() spawns a new window_frame into T's contents mid-iteration, so + // T.Enter() isn't guaranteed to have bumped it. Rescan explicitly to catch it. + for(var/obj/structure/window_frame/WF in T) + if(!QDELETED(WF) && WF.last_bumped != world.time) + Collide(WF) + + // any other tile-blocking items that weren't caught by !T.Enter + for(var/atom/A in T) + if(is_blocking_structure(A)) + can_move = FALSE + break + // Crashed with something that stopped us if(!can_move) - move_momentum = floor(move_momentum/2) - update_next_move() - interior_crash_effect() + // A bump-opening door isn't a real obstacle. It blocks this attempt while it animates open, then + // lets the vehicle through next retry, so skip every crash side effect entirely. + if(skip_crash_response_entirely) + skip_crash_response_entirely = FALSE + return can_move + + // If the driver's seat is empty, nobody can turn cruise control off, and repeated crashes can + // stun-lock anyone trying to get back in. Turn it off automatically to break that lock. + if(cruise_control_enabled && !get_seat_mob(VEHICLE_DRIVER)) + cruise_control_enabled = FALSE + // A vehicle-vs-vehicle collision already resolved momentum loss and rider jousting itself, so + // skip re-running on_crash()/halve_speed(), but still let the camera-shake/fling juice play. + var/already_resolved = skip_generic_crash_response + skip_generic_crash_response = FALSE + + // Cooldown the crash juice so repeated blocked-move retries don't spam camera-shake/rider-fling + // faster than the animation can finish. Must run before halve_speed() mutates move_momentum. + if(world.time >= next_crash_effect) + next_crash_effect = world.time + 5 + if(!already_resolved) + on_crash() + interior_crash_effect() + if(!already_resolved) + halve_speed() return can_move +/** + * Halves whichever speed model this vehicle uses, on crash/blocked-move. + * + * Also halves speed_notch so it doesn't silently snap current_speed back up on the next accelerate press, + * and resets next_notch_climb_time so a stale timestamp doesn't block the next notch climb. + */ +/obj/vehicle/multitile/proc/halve_speed() + if(uses_gear_transmission) + current_speed = current_speed / 2 + move_momentum = current_speed + speed_notch = floor(speed_notch / 2) + next_notch_climb_time = 0 + return + move_momentum = floor(move_momentum/2) + update_next_move() + +/** + * How hard this vehicle is currently hitting things, relative to its own top speed. Scales collision + * damage instead of using a flat constant regardless of speed. + * + * Non-gear vehicles return 1 unconditionally. Only gear-transmission vehicles scale by current_speed. + * + * Arguments: + * * floor_scale = Minimum multiplier returned, so even a near-standstill bump still does a little damage. + * + * Returns: + * * A multiplier from floor_scale to 1, proportional to current_speed / move_max_momentum. + */ +/obj/vehicle/multitile/proc/get_collision_speed_scale(floor_scale = COLLISION_SPEED_SCALE_FLOOR) + if(!uses_gear_transmission || !move_max_momentum) + return 1 + return clamp(current_speed / move_max_momentum, floor_scale, 1) + +/** + * Applies a proportional speed penalty after crashing through a fragile obstacle (fence, grille, foamed + * metal, etc.). Reduces speed by a fraction of its current value. + * + * Gear-transmission vehicles track real speed as current_speed, so the penalty applies there instead of + * to move_momentum (which just mirrors it and would get overwritten next tick). speed_notch gets the + * same proportional reduction so it doesn't snap current_speed back up on the next accelerate press. + * + * Arguments: + * * fraction = Fraction of current speed to remove (0.5 = halve it). + * * reschedule = Whether to also push back next_move via update_next_move(). Only meaningful for + * non-gear vehicles, since gear vehicles schedule their own ticks independently. + */ +/obj/vehicle/multitile/proc/apply_collision_speed_penalty(fraction, reschedule = FALSE) + if(uses_gear_transmission) + current_speed -= current_speed * fraction + move_momentum = current_speed + speed_notch = round(speed_notch * (1 - fraction)) + // Reset so a stale timestamp from before the crash doesn't block the next notch climb. + next_notch_climb_time = 0 + return + move_momentum -= move_momentum * fraction + if(reschedule) + update_next_move() + +/** + * Shoves this vehicle a number of tiles for ram and charge abilities. Steps one tile at a time so it + * reads as a knockback, not a teleport. Locks out driver input for the duration. + * + * Uses try_move(direction, force=TRUE), so a target wedged against a wall or another vehicle just stops + * early the moment a step is blocked. + * + * Arguments: + * * direction = Direction to shove the vehicle in. + * * tiles = How many tiles to attempt to move. + * * scatter_momentum = Momentum value passed to _scatter_riders_on_crash() to scatter riders. + * * jostle_riders = Whether to also scatter/stun riders once the shove ends. + * * is_crusher_source = Whether this shove came from a Crusher ability. + */ +/obj/vehicle/multitile/proc/knockback(direction, tiles, scatter_momentum = 2, jostle_riders = TRUE, is_crusher_source = FALSE) + movement_locked = TRUE + knockback_crusher_source = is_crusher_source + for(var/i in 1 to tiles) + if(QDELETED(src)) + break + if(!try_move(direction, force = TRUE)) // rider tracking lives in try_move() itself + break + if(i < tiles) + sleep(KNOCKBACK_STEP_DELAY) + if(QDELETED(src)) + return + movement_locked = FALSE + knockback_crusher_source = FALSE + if(jostle_riders) + _scatter_riders_on_crash(scatter_momentum) + +/// Damage/knockback scale for resolve_crusher_charge_hit(), keyed off this vehicle's weight class. Tank-tuned baseline values pass through unscaled. +/obj/vehicle/multitile/proc/get_crusher_ram_scale() + if(vehicle_flags & (VEHICLE_CLASS_MEDIUM|VEHICLE_CLASS_HEAVY)) + return 1 + if(vehicle_flags & VEHICLE_CLASS_LIGHT) + return 0.85 + return 0.65 // VEHICLE_CLASS_WEAK + +/** + * Handles a Crusher Charge or Charger ram hitting this vehicle: crash sound, damage, knockback. + * Damage and knockback distance both scale with get_crusher_ram_scale(), so a lighter vehicle takes + * less structural damage but gets shoved further. + * + * Debounced via last_crusher_charge_hit, a diagonal Charge can register more than one hit at once. + * + * Arguments: + * * attacker = The charging/ramming Crusher. + * * direction = Direction to knock this vehicle back in, at the tank-tuned baseline distance. + * * tiles = How many tiles to attempt to knock this vehicle back, at the tank-tuned baseline distance. + * * damage = Damage to deal, at the tank-tuned baseline value. + */ +/obj/vehicle/multitile/proc/resolve_crusher_charge_hit(mob/living/carbon/xenomorph/attacker, direction, tiles, damage) + if(world.time < last_crusher_charge_hit + 10) + return + last_crusher_charge_hit = world.time + + var/ram_scale = get_crusher_ram_scale() + playsound(src, 'sound/effects/metal_crash.ogg', 35) + play_wound_gain_effects(src, WOUND_DAMTYPE_BRUTE, attacker) + take_damage_type(damage * ram_scale, "blunt", attacker) + + // Deferred a tick so we don't pull the vehicle out from under a still-in-flight throw. + addtimer(CALLBACK(src, PROC_REF(knockback), direction, round(tiles / ram_scale), 2, TRUE, TRUE), 0) + /obj/vehicle/multitile/proc/can_rotate(deg) if(bound_width == bound_height) return TRUE @@ -283,11 +598,12 @@ if(isliving(A)) var/mob/living/M = A + if(M.buckled) + continue shake_camera(M, 2, ceil(move_momentum/move_max_momentum) * 1) - if(!M.buckled) - M.apply_effect(1, STUN) - M.apply_effect(2, WEAKEN) + M.apply_effect(1, STUN) + M.apply_effect(2, WEAKEN) // YOU'RE LIKE A CAR CRASH IN SLOW MOTION! // IT'S LIKE I'M WATCHIN' YA FLY THROUGH A WINDSHIELD! @@ -307,3 +623,1414 @@ return else cell_explosion(target, explosion_strength, explosion_falloff, EXPLOSION_FALLOFF_SHAPE_LINEAR, null, cause_data) + +/** + * Base proc for defining special behavior when crashing + * + * on_crash is a proc meant to be overridable by child classes + * That way, all of the effects that happen when crashing are in the same spot. + */ +/obj/vehicle/multitile/proc/on_crash() + return + + +/** + * Starts the momentum decay loop if not already running and momentum exists. + * This should be called after any movement to ensure decay starts when player stops. + */ +/obj/vehicle/multitile/proc/start_momentum_decay_if_needed() + if(uses_gear_transmission) + if((engine_on || current_speed > 0 || throttle_held_until > world.time || cruise_control_enabled) && !momentum_decay_active) + momentum_decay_active = TRUE + spawn(0) + gear_cruise_loop() + return + if(abs(move_momentum) > 0 && !momentum_decay_active) + momentum_decay_active = TRUE + spawn(0) + momentum_decay_loop() + +/** + * The main momentum decay loop - runs continuously while vehicle has momentum. + * Checks every second if the player has stopped giving input, and decays momentum accordingly. + * + * decay_interval controls how fast mometum will decay. By default, it decays every second. + * + * idle_time_required is how long you have to go without pressing a movement key. + * + * So, for example, if idle time is 20 and decay interval is 5, you will start losing momentum twice per second after you spend 2 seconds without moving. + * + */ +/obj/vehicle/multitile/proc/momentum_decay_loop() + var/decay_interval = 10 // Decay every 1 second + + while(abs(move_momentum) > 0) + sleep(decay_interval) + + var/time_since_input = world.time - last_input_time + if(time_since_input < idle_time_required) + continue + + var/momentum_abs = abs(move_momentum) + momentum_abs -= move_momentum_loss_factor + if(momentum_abs <= 0) + move_momentum = 0 + momentum_decay_active = FALSE + return + + if(move_momentum > 0) + move_momentum = momentum_abs + + else + move_momentum = -momentum_abs + + momentum_decay_active = FALSE + +/** + * The gear-transmission's whole physics tick. Drag decay, acceleration, braking, fuel burn, heat, and + * tile movement all happen here on a fixed GEAR_TICK_INTERVAL cadence instead of off key-repeat timing. + * + * Drag decay only applies once current_speed has already reached whatever it's chasing. While still + * climbing toward that target, the whole acceleration budget goes to net speed gain instead of partly + * fighting decay, so Complex mode doesn't burn extra fuel/heat during the climb that Simple mode's decay- + * free notch climb never pays either. + * + * Gas/cruise control torque-building is gated on the engine's spinup window, but drag-decay and movement + * stay ungated by it, so existing current_speed keeps coasting through that window same as engine-off. + * + * Runs continuously while the engine is on, or as long as there's residual speed/input/cruise control + * left to resolve after the engine turns off. + * + * Actual tile movement does not happen on this fixed tick. It's handed off to a separately-paced loop + * (gear_movement_loop()) that reschedules itself off the real time-per-tile implied by current_speed, + * since a fixed 5Hz tick doesn't divide evenly into whole tiles and would make movement choppy. + * + * Exception: under VEHICLE_SIMPLE_ACCELERATION, none of the chase-and-move logic runs here. See + * handle_simple_accel_idle_tick() and attempt_simple_accel_move() instead. + */ +/obj/vehicle/multitile/proc/gear_cruise_loop() + var/tick_dt = GEAR_TICK_INTERVAL / 10 + + // Keeps running with the engine off and the vehicle stopped until drift, engine temperature, and + // mounted/underneath fire have all settled, so a parked vehicle still cools down and decays properly. + while(engine_on || current_speed > 0 || throttle_held_until > world.time || cruise_control_enabled || drift_speed > 0 || !is_engine_temperature_settled() || get_mounted_flame_tile_count() > 0 || get_underneath_flame_tile_count() > 0) + sleep(GEAR_TICK_INTERVAL) + + if(drift_speed > 0) + apply_drift_decay(tick_dt) + ensure_drift_movement_loop() + update_drift_sound() + + // Recomputed every tick, not just on gear/dir change, so switching gears while cruise control is + // engaged doesn't leave the vehicle driving in a stale direction. + update_move_direction() + + // Purely cosmetic. Placed before the Simple Acceleration early-continue so revving reads the + // same under either acceleration preference. + var/revving = engine_on && world.time < throttle_held_until && (current_gear == "P" || current_gear == "N") + engine_rev_level = clamp(engine_rev_level + (revving ? ENGINE_REV_RISE_RATE : -ENGINE_REV_DECAY_RATE) * tick_dt, 0, 1) + + // Movement is entirely input-driven under Simple Acceleration. This tick only handles idle + // fuel/heat and passive notch decay for it. See handle_simple_accel_idle_tick(). + if(get_driver_vehicle_prefs() & VEHICLE_SIMPLE_ACCELERATION) + handle_simple_accel_idle_tick(tick_dt) + continue + + var/list/stats = get_current_gear_stats() + var/old_speed = current_speed + var/consumed_fraction = 0 + + var/spinup_done = world.time >= engine_spinup_until + + if(!engine_on) + // Mechanical brakes don't need the engine running, only building new speed does. + if(world.time < brake_held_until) + current_speed = max(current_speed - VEHICLE_BRAKE_DECELERATION * tick_dt, 0) + else + apply_speed_decay(tick_dt) + consume_fuel(tick_dt, 0, 0) + else if(cruise_control_enabled) + if(is_momentum_reversed()) + // A held-back direction reversal has to be fought down first, not chased toward a + // cruise target as if current_speed were already valid motion the new way. + current_speed = max(current_speed - VEHICLE_BRAKE_DECELERATION * tick_dt, 0) + consume_fuel(tick_dt, 0, 0) + else if(spinup_done && stats["torque"] > 0 && has_fuel()) + var/traction_scale = get_effective_traction() + var/part_scale = get_part_condition_scale() + var/computed_torque = get_gear_computed_torque(stats, traction_scale, part_scale) + var/gear_max_speed = get_gear_max_speed(stats, traction_scale, part_scale) + var/effective_target = min(cruise_control_target_speed, gear_max_speed) + + if(current_speed > effective_target) + // Target was lowered while cruising: active torque-based deceleration, not drag. + current_speed = max(current_speed - computed_torque * tick_dt, effective_target) + else + // At or below target. Decay only bites once genuinely at it, a tick still climbing + // below it puts its whole budget into net gain instead. + if(current_speed >= effective_target) + apply_speed_decay(tick_dt) + var/budget = computed_torque * tick_dt + var/actual_delta = min(max(effective_target - current_speed, 0), budget) + current_speed += actual_delta + consumed_fraction = budget > 0 ? (actual_delta / budget) : 0 + else + apply_speed_decay(tick_dt) + consume_fuel(tick_dt, abs(current_speed - old_speed), consumed_fraction) + else + // Requires an actual seated driver, not just a live throttle_held_until timestamp, or a + // leftover timestamp from a driver who just got up mid-input would keep autonomously + // building and spending real current_speed with nobody in the seat. + var/gas_held = world.time < throttle_held_until && get_seat_mob(VEHICLE_DRIVER) + var/brake_held = world.time < brake_held_until + // A direction reversal that update_move_direction() just held back can't just take fresh + // torque. Holding gas in that state has to fight the stale speed down instead, like a + // brake press would, or the vehicle would just get faster the wrong way forever. + var/fighting_reversal = gas_held && is_momentum_reversed() + + if(brake_held || fighting_reversal) + current_speed = max(current_speed - VEHICLE_BRAKE_DECELERATION * tick_dt, 0) + consume_fuel(tick_dt, 0, 0) + else if(gas_held && has_fuel() && spinup_done) + if(stats["torque"] > 0) + var/traction_scale = get_effective_traction() + var/part_scale = get_part_condition_scale() + var/computed_torque = get_gear_computed_torque(stats, traction_scale, part_scale) + var/target_speed = get_gear_max_speed(stats, traction_scale, part_scale) + + // Decay only bites once at/above the ceiling. A tick still climbing toward it puts + // its whole budget into net gain instead, matching Simple mode's decay-free climb. + if(current_speed >= target_speed) + apply_speed_decay(tick_dt) + + var/budget = computed_torque * tick_dt + var/actual_delta = min(max(target_speed - current_speed, 0), budget) + current_speed += actual_delta + consumed_fraction = budget > 0 ? (actual_delta / budget) : 0 + else + // Park/Neutral revving. No torque to apply, but fully "on" for this tick since + // there's no drivetrain resistance to work against. + consumed_fraction = 1 + + consume_fuel(tick_dt, abs(current_speed - old_speed), consumed_fraction) + else + apply_speed_decay(tick_dt) + consume_fuel(tick_dt, 0, 0) + + update_engine_temperature(tick_dt, consumed_fraction) + + move_momentum = current_speed + apply_mounted_acid_damage(tick_dt) + + if(current_speed > VEHICLE_MIN_CRAWL_SPEED) + ensure_gear_movement_loop() + + current_speed = 0 + move_momentum = 0 + speed_notch = 0 + next_notch_climb_time = 0 // reset to avoid a stale timestamp blocking the next notch climb + momentum_decay_active = FALSE + +/// Starts gear_movement_loop() if it isn't already running. Cheap enough to call unconditionally. +/obj/vehicle/multitile/proc/ensure_gear_movement_loop() + if(movement_loop_active) + return + movement_loop_active = TRUE + spawn(0) + gear_movement_loop() + +/** + * Advances the vehicle one tile at a time under Complex acceleration, paced independently from + * gear_cruise_loop()'s fixed 5Hz physics tick. + * + * Accumulates fractional tile-progress every GEAR_MOVEMENT_POLL_INTERVAL, re-reading current_speed fresh + * on every poll rather than computing one sleep duration from a single snapshot, which could otherwise + * leave the vehicle stuck sleeping on a stale near-zero speed while current_speed had already climbed. + * + * Exits once current_speed decays/brakes to a stop, or the driver switches to Simple Acceleration + * mid-drive, which hands current_speed off to that preference's own reconciliation instead. + */ +/obj/vehicle/multitile/proc/gear_movement_loop() + var/progress = 0 + while(current_speed > VEHICLE_MIN_CRAWL_SPEED && !(get_driver_vehicle_prefs() & VEHICLE_SIMPLE_ACCELERATION)) + sleep(GEAR_MOVEMENT_POLL_INTERVAL) + progress += current_speed * (GEAR_MOVEMENT_POLL_INTERVAL / 10) + if(progress < 1) + continue + progress -= 1 + if(!try_move(current_move_direction, force = TRUE)) + halve_speed() + progress = 0 + movement_loop_active = FALSE + +/// Starts drift_movement_loop() if it isn't already running. +/obj/vehicle/multitile/proc/ensure_drift_movement_loop() + if(drift_loop_active) + return + drift_loop_active = TRUE + spawn(0) + drift_movement_loop() + +/** + * Advances the vehicle one tile at a time along drift_direction, independent of gear_movement_loop()'s + * facing-axis stepping. The two run concurrently, so the vehicle's path naturally interleaves cardinal + * steps from both axes, a "staircase" approximation of diagonal movement. + * + * Unlike gear_movement_loop(), a blocked drift step doesn't halve_speed(). Drift is unpowered momentum, + * so hitting something just ends the slide instead of bouncing back at reduced speed. + * + * The skid sound isn't tied to individual steps here. See update_drift_sound() instead, which plays as + * one continuous scrape for the duration of the skid. + */ +/obj/vehicle/multitile/proc/drift_movement_loop() + var/progress = 0 + while(drift_speed > VEHICLE_MIN_CRAWL_SPEED) + sleep(GEAR_MOVEMENT_POLL_INTERVAL) + progress += drift_speed * (GEAR_MOVEMENT_POLL_INTERVAL / 10) + if(progress < 1) + continue + progress -= 1 + if(!try_move(drift_direction, force = TRUE)) + drift_speed = 0 + break + drift_speed = 0 + drift_direction = 0 + drift_loop_active = FALSE + +/** + * Per-tick idle bookkeeping for VEHICLE_SIMPLE_ACCELERATION, called from gear_cruise_loop() instead of + * that loop's usual drag-decay-and-chase logic. Movement is entirely input-driven under this preference, + * so nothing here moves the vehicle, only current_speed/speed_notch change from a press or decay. + * + * Also the sole place fuel/heat get charged for this preference, mirroring Complex mode's fuel-cost + * regimes as closely as this input-driven model allows: + * - A press landed this tick and changed current_speed: same "reaching a speed" formula Complex mode uses. + * - Still short of the max notch with gas held recently, but the climb gate hasn't allowed the next notch + * yet: full load, matching Complex mode spending its entire budget on net gain while below target. + * - Already at the cap (or a redundant press), still within the re-engagement window: this is the + * "holding" cost, computed from what decay would be rather than applying it directly. + * - Park/Neutral with gas pressed recently: mirrors Complex mode's own P/N revving, unconditionally + * "fully on" since there's no drivetrain resistance to meter against. + * - Genuinely idle: zero extra cost, same as Complex mode's idle/brake branches. + * + * The idle grace is measured from next_move rather than throttle_held_until, since gear_accelerate() + * refreshes next_move to a small fixed interval on every press, so it reads as "time since last press." + * + * Arguments: + * * tick_dt = Seconds elapsed this physics tick. + */ +/obj/vehicle/multitile/proc/handle_simple_accel_idle_tick(tick_dt) + var/old_speed = current_speed + var/pressed_this_tick = simple_accel_pressed + simple_accel_pressed = FALSE + + if(current_speed > 0 && speed_notch == 0) + // Orphaned current_speed, inherited from a driver switching to Simple acceleration mid-drive. + // This preference's decay logic is gated on speed_notch, so reverse-derive the closest matching + // notch here to let the existing decay/holding machinery take over normally. + var/list/orphan_stats = get_current_gear_stats() + var/orphan_gear_max_speed = get_gear_max_speed(orphan_stats, get_effective_traction(), get_part_condition_scale()) + if(orphan_gear_max_speed > 0) + speed_notch = clamp(round(current_speed / orphan_gear_max_speed * SIMPLE_ACCEL_STEP_COUNT), 1, SIMPLE_ACCEL_STEP_COUNT) + + var/decay_eligible = world.time > next_move + SIMPLE_ACCEL_IDLE_GRACE + + if(speed_notch > 0 && world.time >= next_notch_decay && decay_eligible) + speed_notch -= 1 + next_notch_decay = world.time + SIMPLE_ACCEL_DECAY_INTERVAL + + var/list/decay_stats = get_current_gear_stats() + var/decay_traction_scale = get_effective_traction() + var/decay_part_scale = get_part_condition_scale() + current_speed = get_gear_max_speed(decay_stats, decay_traction_scale, decay_part_scale) * (speed_notch / SIMPLE_ACCEL_STEP_COUNT) + move_momentum = current_speed + + var/list/stats = get_current_gear_stats() + var/traction_scale = get_effective_traction() + var/part_scale = get_part_condition_scale() + var/computed_torque = get_gear_computed_torque(stats, traction_scale, part_scale) + var/budget = computed_torque * tick_dt + + var/speed_delta = 0 + var/consumed_fraction = 0 + + if(pressed_this_tick && current_speed != old_speed) + // A press this tick changed current_speed. Same "reaching a speed" formula Complex mode uses. + speed_delta = abs(current_speed - old_speed) + consumed_fraction = budget > 0 ? min(speed_delta / budget, 1) : 0 + else if(speed_notch < SIMPLE_ACCEL_STEP_COUNT && stats["torque"] > 0 && !decay_eligible) + // Still short of the max notch with gas held recently, but the climb gate hasn't allowed the + // next notch yet. Charges full load, matching Complex mode's climb behavior. + speed_delta = budget + consumed_fraction = 1 + else if(stats["torque"] > 0 && current_speed > 0 && !decay_eligible) + // At the max notch (or a redundant press), still within the re-engagement window. This is + // Complex mode's "holding" cost, computed from what decay would be rather than applying it. + var/would_be_decay = get_speed_decay_amount(tick_dt) + speed_delta = would_be_decay + consumed_fraction = budget > 0 ? min(would_be_decay / budget, 1) : 0 + else if(stats["torque"] <= 0 && world.time < throttle_held_until) + // Park/Neutral revving, mirroring Complex mode's own gas_held-in-P/N branch. No torque-budget + // ratio, since there's no drivetrain resistance to meter against. + consumed_fraction = 1 + + consume_fuel(tick_dt, speed_delta, consumed_fraction) + update_engine_temperature(tick_dt, consumed_fraction) + apply_mounted_acid_damage(tick_dt) + +/** + * Performs one discrete movement update for VEHICLE_SIMPLE_ACCELERATION. Called synchronously from + * gear_accelerate() on every accelerate press, and also drives simple_accel_movement_loop()'s + * self-paced stepping between presses (see there for why that's needed). + * + * Recomputes current_speed fresh from speed_notch every call, since Simple acceleration has no ramp and + * each notch directly is a speed. Marks simple_accel_pressed so the next tick's + * handle_simple_accel_idle_tick() charges fuel/heat, and attempts at most one tile move, paced by + * next_simple_accel_move rather than gear_accelerate()'s own next_move, so a low gear's punchy torque + * advantage can actually show up instead of being blocked by a shared gate. + * + * Deliberately does not use a fractional-tile accumulator for movement pacing, since movement is + * triggered by however often gear_accelerate() calls this rather than a fixed tick interval. Carrying + * leftover fractional progress into a later call at a higher speed could overcount distance and fire + * try_move() twice back to back with no animation between them, visible as the vehicle "teleporting." + */ +/obj/vehicle/multitile/proc/attempt_simple_accel_move() + // A direction reversal that update_move_direction() is holding back can't just be overwritten with a + // fresh notch-derived speed, so this presses the same decisive stop gear_brake() already uses for a + // mismatched-direction press instead of getting faster the wrong way forever. + if(is_momentum_reversed()) + current_speed = 0 + move_momentum = 0 + speed_notch = 0 + next_notch_climb_time = 0 + return + + var/list/stats = get_current_gear_stats() + var/traction_scale = get_effective_traction() + var/part_scale = get_part_condition_scale() + var/gear_max_speed = get_gear_max_speed(stats, traction_scale, part_scale) + + current_speed = gear_max_speed * (speed_notch / SIMPLE_ACCEL_STEP_COUNT) + move_momentum = current_speed + simple_accel_pressed = TRUE + + simple_accel_move_step() + ensure_simple_accel_movement_loop() + +/// The actual try_move() attempt behind attempt_simple_accel_move(), split out so +/// simple_accel_movement_loop() can keep pacing tile steps off next_simple_accel_move without also +/// re-deriving current_speed or re-marking simple_accel_pressed on every poll. +/obj/vehicle/multitile/proc/simple_accel_move_step() + if(current_speed > VEHICLE_MIN_CRAWL_SPEED && world.time >= next_simple_accel_move) + if(!try_move(current_move_direction, force = TRUE)) + halve_speed() + next_simple_accel_move = world.time + 10 / max(current_speed, VEHICLE_MIN_CRAWL_SPEED) + +/// Starts simple_accel_movement_loop() if it isn't already running. Cheap enough to call unconditionally. +/obj/vehicle/multitile/proc/ensure_simple_accel_movement_loop() + if(movement_loop_active) + return + movement_loop_active = TRUE + spawn(0) + simple_accel_movement_loop() + +/** + * Keeps simple_accel_move_step() advancing on its own pace once the driver has pressed gas, instead of + * requiring a fresh gear_accelerate() call (and therefore a fresh held-key repeat event from the client) + * for every single tile. + * + * Without this, Simple Acceleration's top speed was silently capped by however often BYOND re-fires + * relaymove() for a held movement key, which at higher speed notches is slower than the tile rate + * current_speed actually implies - Complex acceleration never has this problem since gear_movement_loop() + * already paces tile steps off its own fixed poll, decoupled from input cadence, as long as + * throttle_held_until stays within its grace window. This mirrors that: current_speed/speed_notch only + * ever change from an actual press (attempt_simple_accel_move()), this loop just keeps spending the + * already-decided speed at the correct real-time rate. + * + * Exits once current_speed decays to a stop or the driver switches to Complex acceleration mid-drive. + */ +/obj/vehicle/multitile/proc/simple_accel_movement_loop() + while(current_speed > VEHICLE_MIN_CRAWL_SPEED && (get_driver_vehicle_prefs() & VEHICLE_SIMPLE_ACCELERATION)) + sleep(GEAR_MOVEMENT_POLL_INTERVAL) + if(world.time >= throttle_held_until) + break + simple_accel_move_step() + movement_loop_active = FALSE + +//----------------------------- +//------GEAR TRANSMISSION------ +//----------------------------- + +/** + * Gear-transmission dispatch for pre_movement(), used when uses_gear_transmission is TRUE. + * + * direction is the fixed screen-absolute compass value BYOND sends for whichever movement key was + * pressed. It does not rotate with the vehicle's facing, so the four keys have fixed roles here: gas + * always drives whatever direction the current gear implies, brake only decelerates, and the side + * keys always turn the hull left/right relative to its own facing. + * + * Arguments: + * * direction = Compass direction of the key pressed, same meaning as pre_movement()'s argument. + * + * Returns: + * * TRUE if the vehicle moved or turned this call, FALSE otherwise. + */ +/obj/vehicle/multitile/proc/gear_pre_movement(direction) + var/success = FALSE + + if(get_driver_vehicle_prefs() & VEHICLE_SIMPLE_CONTROLS) + success = gear_pre_movement_simple(direction) + else + switch(direction) + if(NORTH) // gas + success = gear_accelerate() + if(SOUTH) // brake + var/driver_prefs = get_driver_vehicle_prefs() + // Under Simple Transmission, brake doubles as reverse throttle once already stopped and + // in R, since there's no manual gear shifter to fall back on. Under Complex Transmission, + // R is a deliberate driver choice, so brake there only ever decelerates. + if((driver_prefs & VEHICLE_SIMPLE_TRANSMISSION) && current_gear == "R") + success = gear_accelerate(TRUE) + else + success = gear_brake() + if(EAST) // turn right relative to current facing (positive deg is counterclockwise, so "right" is -90) + success = engine_on && world.time >= engine_spinup_until && try_rotate(-90) // turning needs the drivetrain actively powering it, no coasting on existing momentum + if(WEST) // turn left relative to current facing + success = engine_on && world.time >= engine_spinup_until && try_rotate(90) + + start_momentum_decay_if_needed() + + return success + +/** + * Gear-transmission dispatch for pre_movement() under the VEHICLE_SIMPLE_CONTROLS preference. Legacy + * absolute-compass WASD movement instead of tank-relative gas/brake/turn, but drives the gear- + * transmission's throttle/gear state through gear_accelerate()/try_rotate() rather than move_momentum. + * + * Still fully respects gear/transmission state, this only changes how a key press is interpreted. + * Pressing the key opposite of current travel direction always brakes first while still moving that + * way. Only once stopped does transmission mode matter: under Simple Transmission the same key + * auto-shifts gear and starts moving the other way; under Complex transmission, the driver must have + * already manually shifted into (or out of) R, or the attempt is refused with a hint. + * + * Arguments: + * * direction = Compass direction of the key pressed, same meaning as pre_movement()'s argument. + * + * Returns: + * * TRUE if the vehicle moved, turned, or braked this call, FALSE otherwise. + */ +/obj/vehicle/multitile/proc/gear_pre_movement_simple(direction) + if(direction == dir || direction == turn(dir, 180)) + var/reverse_intent = (direction == turn(dir, 180)) + var/driver_prefs = get_driver_vehicle_prefs() + var/mismatched_direction = reverse_intent != (current_gear == "R") + + // Requesting the opposite of current travel direction while still moving that way brakes first + // instead of immediately flipping gear/reversing underneath us. Once current_speed reaches 0, + // the next press of the same key falls through below instead. + // + // Gated on mismatched_direction, not raw reverse_intent, so holding the same reverse key while + // already in R keeps falling through to gear_accelerate() instead of braking every press. + // + // Deliberately current_speed > 0, not > VEHICLE_MIN_CRAWL_SPEED, matching is_momentum_reversed()'s + // own threshold. A looser threshold let a small residual speed slip past this guard with + // current_gear already flipped but current_move_direction still stale, silently eating every + // other rapid direction-flip press until current_speed happened to hit a true 0. + if(mismatched_direction && current_speed > 0) + return gear_brake() + + if(!(driver_prefs & VEHICLE_SIMPLE_TRANSMISSION)) + if(mismatched_direction) + var/mob/driver = get_seat_mob(VEHICLE_DRIVER) + if(driver) + to_chat(driver, SPAN_WARNING("[reverse_intent ? "Shift into Reverse" : "Shift out of Reverse"] first.")) + return FALSE + + return gear_accelerate(reverse_intent) + + // Turning the hull needs the drivetrain actively powering it, same as the turn branches above. + return engine_on && world.time >= engine_spinup_until && try_rotate(turning_angle(dir, direction)) + +/** + * Handles the gas key in gear-transmission mode. + * + * Under VEHICLE_SIMPLE_TRANSMISSION, this repicks the gear via get_best_auto_gear(reverse_intent) fresh + * on every call, driven by the argument rather than the existing current_gear, so forward presses can + * always shift back out of R. + * + * While cruise control is engaged, this raises the target speed instead of driving current_speed + * directly. Otherwise, under Simple Acceleration, it steps speed_notch up by one and immediately performs + * one discrete movement update via attempt_simple_accel_move(). When torque can't be built (engine off, + * spinning up, out of fuel, or in P/N), existing momentum still lets the driver coast on it without + * stepping speed_notch any higher. Under Complex acceleration, this just marks the gas key as "held" for + * GEAR_INPUT_GRACE_PERIOD, and the actual acceleration math happens on gear_cruise_loop()'s fixed tick. + * + * next_notch_climb_time paces how fast speed_notch can step up, based on how long that speed increment + * would take to build under Complex mode's constant-torque ramp, so a gear tuned to be punchy under + * Complex acceleration doesn't climb slower (and cost more fuel/heat) under Simple acceleration. + * + * Arguments: + * * reverse_intent = TRUE if this call means "go backward" (only passed by Simple-controls' backward + * key or gear_brake()'s stopped-and-braking convenience). FALSE for the ordinary gas key. + * + * Returns: + * * TRUE if the current gear actually has torque to give, FALSE otherwise (P/N, or no gear set up). + */ +/obj/vehicle/multitile/proc/gear_accelerate(reverse_intent = FALSE) + // Exiting a skid, the first accelerate press since the turn that created this drift. Transfers the + // portion of drift_speed a driftless turn-scrub would have discarded entirely onto current_speed as + // a head start, instead of starting the new axis from 0. The remainder stays on drift_speed, still + // decaying as normal. + if(!drift_braking && drift_speed > 0) + var/transfer_amount = drift_speed * (1 - move_turn_momentum_loss_factor) + drift_speed -= transfer_amount + current_speed += transfer_amount + move_momentum = current_speed + + drift_braking = TRUE // a real gas press is what starts braking off any active drift, not the turn itself + var/driver_prefs = get_driver_vehicle_prefs() + + if(driver_prefs & VEHICLE_SIMPLE_TRANSMISSION) + current_gear = get_best_auto_gear(reverse_intent) + + update_move_direction() + + var/list/stats = get_current_gear_stats() + + if(cruise_control_enabled) + adjust_cruise_control_target(cruise_control_granularity) + return stats["torque"] > 0 + + throttle_held_until = world.time + GEAR_INPUT_GRACE_PERIOD + update_next_move() // some vehicles (e.g. the tank) hook side effects into this; Simple Acceleration overrides it below + + if(driver_prefs & VEHICLE_SIMPLE_ACCELERATION) + // Small fixed interval, just pre_movement()'s outer "don't spam-call this proc" gate. Actual + // tile-movement pacing lives in next_simple_accel_move, and notch-climb pacing in + // next_notch_climb_time below. + next_move = world.time + 1 + var/can_build_torque = engine_on && world.time >= engine_spinup_until && stats["torque"] > 0 && has_fuel() + if(!can_build_torque) + // No new torque available, but existing momentum should still let the driver coast and steer. + // Only building more speed is gated, not spending what's already there. + if(speed_notch <= 0) + return FALSE + attempt_simple_accel_move() + return TRUE + if(speed_notch < SIMPLE_ACCEL_STEP_COUNT && world.time >= next_notch_climb_time) + speed_notch += 1 + var/traction_scale = get_effective_traction() + var/part_scale = get_part_condition_scale() + var/computed_torque = get_gear_computed_torque(stats, traction_scale, part_scale) + var/gear_max_speed = get_gear_max_speed(stats, traction_scale, part_scale) + var/notch_speed_increment = gear_max_speed / SIMPLE_ACCEL_STEP_COUNT + next_notch_climb_time = world.time + 10 * (notch_speed_increment / max(computed_torque, 0.01)) + attempt_simple_accel_move() + return TRUE + + next_move = world.time + 1 // just rate-limits key-repeat; gear_cruise_loop() paces the actual physics/movement + return stats["torque"] > 0 + +/** + * Handles the brake key in gear-transmission mode. Under VEHICLE_SIMPLE_TRANSMISSION with Complex + * controls, there's no manual R to shift into, so holding brake once stopped is treated as reverse + * intent instead, shifting into R and handing off to gear_accelerate(). Checked before cruise control, + * so a lingering cruise_control_enabled state never swallows this. + * + * Otherwise, while cruise control is engaged, this lowers the target speed instead of braking directly. + * + * Otherwise, under Simple Acceleration, zeroes speed_notch and current_speed immediately since braking + * is a decisive stop, not a gradual taper, and movement is input-driven so nothing else would zero it. + * Under Complex acceleration, this just marks the brake key as "held"; gear_cruise_loop() decelerates + * current_speed on its own fixed tick. + * + * Returns: + * * TRUE if this handed off to gear_accelerate() to start reversing, FALSE otherwise. + */ +/obj/vehicle/multitile/proc/gear_brake() + drift_braking = TRUE // a real brake press is what starts braking off any active drift, not the turn itself + var/driver_prefs = get_driver_vehicle_prefs() + + // Checked before the cruise control branch below, so a lingering cruise_control_enabled state can't + // swallow the only way to reverse under Simple Transmission. + // + // Deliberately only the "stopped" case, not "already in R", since this proc is also reached from + // gear_pre_movement_simple()'s mismatched-direction safety net to kill a stale opposing speed down + // to a clean 0 before flipping direction. Unconditionally reasserting Reverse here would fight that + // safety net's forward-intent case. + // + // Deliberately current_speed <= 0, not <= VEHICLE_MIN_CRAWL_SPEED, matching + // gear_pre_movement_simple()'s identical tightening: is_momentum_reversed() treats any current_speed + // > 0 as real opposing motion, so a looser threshold let a residual speed slip through before ever + // reaching the decisive-zero branch below. + if((driver_prefs & VEHICLE_SIMPLE_TRANSMISSION) && current_speed <= 0) + return gear_accelerate(TRUE) + + if(cruise_control_enabled) + adjust_cruise_control_target(-cruise_control_granularity) + return FALSE + + if(driver_prefs & VEHICLE_SIMPLE_ACCELERATION) + speed_notch = 0 + current_speed = 0 + move_momentum = 0 + next_notch_climb_time = 0 // otherwise a stale timestamp could delay the first notch after accelerating again + brake_held_until = world.time + GEAR_INPUT_GRACE_PERIOD + next_move = world.time + 1 + return FALSE + + brake_held_until = world.time + GEAR_INPUT_GRACE_PERIOD + next_move = world.time + 1 + return FALSE + +/** + * Nudges cruise_control_target_speed by delta, clamped between 0 and the current gear's effective max + * speed. Uses get_gear_max_speed() rather than the raw stats["max_speed"], so Overdrive/fuel performance + * multipliers can actually be requested instead of just capping at the vehicle's stock top speed. + * + * Arguments: + * * delta = Tiles/sec to change the target by. Positive to raise it, negative to lower it. + */ +/obj/vehicle/multitile/proc/adjust_cruise_control_target(delta) + var/list/stats = get_current_gear_stats() + var/traction_scale = get_effective_traction() + var/part_scale = get_part_condition_scale() + var/gear_max_speed = get_gear_max_speed(stats, traction_scale, part_scale) + cruise_control_target_speed = clamp(cruise_control_target_speed + delta, 0, gear_max_speed) + next_move = world.time + 1 + start_momentum_decay_if_needed() + +/** + * Looks up the currently selected gear's stats. + * + * Returns: + * * An assoc list with "max_speed", "torque" and "fuel_use" keys, or all-zero if gear_stats isn't set up. + */ +/obj/vehicle/multitile/proc/get_current_gear_stats() + if(!gear_stats || !gear_stats[current_gear]) + return list("max_speed" = 0, "torque" = 0, "fuel_use" = 0) + return gear_stats[current_gear] + +/** + * Builds this vehicle's full gear_stats table (P/R/N/D/1/2) from top_speed/base_acceleration/ + * base_fuel_use, so every gear's feel stays anchored to a single pair of balance stats. R and D share + * top_speed/base_acceleration. Gear 1 is tuned to reach half of top_speed in one physics tick at 100% + * condition, very punchy with a low ceiling. Gear 2 reaches 3/4 of top_speed within a third of D's own + * stopping distance, moderately punchy with a mid ceiling. fuel_use scales with each gear's own torque. + * + * Returns: + * * The full gear_stats assoc list, ready to assign directly to the gear_stats var. + */ +/obj/vehicle/multitile/proc/build_gear_stats() + var/tick_seconds = GEAR_TICK_INTERVAL / 10 + + var/gear_1_speed = top_speed * GEAR_1_SPEED_FRACTION + var/gear_1_torque = gear_1_speed / tick_seconds + + var/drive_stop_distance = (top_speed ** 2) / (2 * base_acceleration) + var/gear_2_speed = top_speed * GEAR_2_SPEED_FRACTION + var/gear_2_distance = drive_stop_distance * GEAR_2_DISTANCE_FRACTION + var/gear_2_torque = (gear_2_speed ** 2) / (2 * gear_2_distance) + + return list( + "P" = list("max_speed" = 0, "torque" = 0, "fuel_use" = 0.05 * idle_fuel_use_mult, "min_power_fraction" = GEAR_MIN_TORQUE_FRACTION), + "R" = list("max_speed" = gear_2_speed, "torque" = gear_2_torque, "fuel_use" = base_fuel_use * (gear_2_torque / base_acceleration), "min_power_fraction" = GEAR_REVERSE_MIN_POWER_FRACTION), + "N" = list("max_speed" = 0, "torque" = 0, "fuel_use" = 0.08 * idle_fuel_use_mult, "min_power_fraction" = GEAR_MIN_TORQUE_FRACTION), + "D" = list("max_speed" = top_speed, "torque" = base_acceleration, "fuel_use" = base_fuel_use, "min_power_fraction" = GEAR_MIN_TORQUE_FRACTION), + "1" = list("max_speed" = gear_1_speed, "torque" = gear_1_torque, "fuel_use" = base_fuel_use * (gear_1_torque / base_acceleration), "min_power_fraction" = GEAR_1_MIN_POWER_FRACTION), + "2" = list("max_speed" = gear_2_speed, "torque" = gear_2_torque, "fuel_use" = base_fuel_use * (gear_2_torque / base_acceleration), "min_power_fraction" = GEAR_2_MIN_POWER_FRACTION), + ) + +/** + * How much current_speed decays this tick from rolling/aerodynamic drag. Scales with current speed, so + * holding a high cruising speed costs more of the acceleration budget to counteract than a low one. + * Applies continuously whenever there's any momentum, not gated behind a grace period, so the driver + * has to keep feeding the engine to counteract it. + * + * Arguments: + * * tick_dt = Seconds elapsed this tick. + * + * Returns: + * * Tiles/sec of speed to remove this tick. + */ +/obj/vehicle/multitile/proc/get_speed_decay_amount(tick_dt) + return current_speed * SPEED_DECAY_COEFFICIENT * tick_dt + +/** + * Applies one tick's worth of drag decay directly to current_speed, flooring it at 0 below + * VEHICLE_MIN_CRAWL_SPEED. Shared by every gear_cruise_loop() branch that needs plain decay. Deliberately + * not called while a tick is still genuinely climbing toward its target. + * + * Arguments: + * * tick_dt = Seconds elapsed this tick. + */ +/obj/vehicle/multitile/proc/apply_speed_decay(tick_dt) + if(current_speed <= 0) + return + current_speed = max(current_speed - get_speed_decay_amount(tick_dt), 0) + if(current_speed < VEHICLE_MIN_CRAWL_SPEED) + current_speed = 0 + +/** + * Decays drift_speed at plain SPEED_DECAY_COEFFICIENT until the driver gives a fresh gas/brake input + * after the turn that created this drift, then switches to the steeper DRIFT_DECAY_COEFFICIENT. + * Turning the wheel alone doesn't scrub off sideways momentum, only pressing the pedals again does. + * drift_braking resets to FALSE on every fresh drift so each turn gets its own coasting window. + * + * Called unconditionally from gear_cruise_loop() every tick, including under Simple Acceleration and + * with the engine off, since drift is just unpowered momentum bleeding off via rolling friction. + * + * Arguments: + * * tick_dt = Seconds elapsed this tick. + */ +/obj/vehicle/multitile/proc/apply_drift_decay(tick_dt) + if(drift_speed <= 0) + return + var/coefficient = drift_braking ? DRIFT_DECAY_COEFFICIENT : SPEED_DECAY_COEFFICIENT + drift_speed = max(drift_speed - drift_speed * coefficient * tick_dt, 0) + if(drift_speed < VEHICLE_MIN_CRAWL_SPEED) + drift_speed = 0 + drift_direction = 0 + +/** + * Steps the current gear forward or backward through GLOB.vehicle_gear_order, clamped at both ends + * (no wraparound, jumping straight from Park to a driving gear or vice versa isn't allowed). Shifting + * into Park kills all momentum instantly; Neutral leaves current_speed alone and lets the vehicle keep + * rolling (and decaying) on its own. + * + * Also zeroes speed_notch when parking, even for drivers not using Simple Acceleration, since a stale + * nonzero notch would otherwise make the vehicle start moving on its own once the driver goes idle. + * + * Arguments: + * * delta = +1 to shift up, -1 to shift down. + * + * Returns: + * * The new current_gear. + */ +/obj/vehicle/multitile/proc/cycle_gear(delta) + var/current_index = GLOB.vehicle_gear_order.Find(current_gear) + var/new_index = clamp(current_index + delta, 1, length(GLOB.vehicle_gear_order)) + current_gear = GLOB.vehicle_gear_order[new_index] + update_move_direction() // manually shifting into/out of R needs this to stick immediately too + + if(current_gear == "P") + current_speed = 0 + move_momentum = 0 + speed_notch = 0 + next_notch_climb_time = 0 + + return current_gear + +/** + * Looks up the toggles_vehicle preference bitfield of whichever mob is currently in the driver's seat. + * + * Every preference-gated branch in this file calls this fresh at the point of use, never caches it, so + * a driver swap mid-drive picks up the new driver's preferences on the very next tick/input. + * + * Returns: + * * The driver's toggles_vehicle bitfield, or 0 (today's Complex/Complex/Complex behavior) if there's no + * driver or no client. + */ +/obj/vehicle/multitile/proc/get_driver_vehicle_prefs() + var/mob/driver = get_seat_mob(VEHICLE_DRIVER) + if(!driver?.client?.prefs) + return 0 + return driver.client.prefs.toggles_vehicle + +/** + * Picks the gear this vehicle should be in under the VEHICLE_SIMPLE_TRANSMISSION preference. Gear stays + * fully hidden from the player in this mode. + * + * Reversing always selects R. Otherwise walks the forward gears highest-top-speed-first and returns the + * first one that isn't underpowered, so a healthy vehicle drives in D but a damaged or traction-starved + * one drops to a lower, punchier gear. Falls back to "1" if every forward gear is underpowered. + * + * Arguments: + * * reverse_intent = TRUE if the driver's current input means "go backward". + * + * Returns: + * * The gear name to shift into. + */ +/obj/vehicle/multitile/proc/get_best_auto_gear(reverse_intent) + if(reverse_intent) + return "R" + + var/traction_scale = get_effective_traction() + var/part_scale = get_part_condition_scale() + + for(var/gear in list("D", "2", "1")) + var/list/stats = gear_stats?[gear] + if(!stats) + continue + var/computed_torque = get_gear_computed_torque(stats, traction_scale, part_scale) + if(computed_torque >= stats["torque"] * GEAR_UNDERPOWERED_FRACTION) + return gear + + return "1" + +/// How many flamer_fire are mounted atop this vehicle's hull. 0 by default, only the tank tracks on-hull riders. +/obj/vehicle/multitile/proc/get_mounted_flame_tile_count() + return 0 + +/// How many flamer_fire are burning on this vehicle's turfs without being mounted. 0 by default, only the tank tracks its footprint. +/obj/vehicle/multitile/proc/get_underneath_flame_tile_count() + return 0 + +/** + * Applies continuous acid damage from any lingering_acid mounted atop this vehicle's hull. + * + * A no-op on the base class, only the tank tracks on-hull riders. Called every tick from both + * gear_cruise_loop() and handle_simple_accel_idle_tick(). + * + * Arguments: + * * tick_dt = Delta time for this physics tick. + */ +/obj/vehicle/multitile/proc/apply_mounted_acid_damage(tick_dt) + return + +/// Finds this vehicle's installed engine hardpoint, if any. +/obj/vehicle/multitile/proc/get_engine_hardpoint() + return locate(/obj/item/hardpoint/engine) in hardpoints + +/// Finds this vehicle's installed treads/wheels hardpoint, if any. +/obj/vehicle/multitile/proc/get_locomotion_hardpoint() + return locate(/obj/item/hardpoint/locomotion) in hardpoints + +/// Finds this vehicle's installed battery hardpoint, if any. Must be present and health > 0 to start the engine. +/obj/vehicle/multitile/proc/get_battery_hardpoint() + return locate(/obj/item/hardpoint/battery) in hardpoints + +/// Finds this vehicle's installed fuel tank hardpoint, if any. +/obj/vehicle/multitile/proc/get_fuel_tank_hardpoint() + return locate(/obj/item/hardpoint/fuel_tank) in hardpoints + +/// Finds this vehicle's installed radiator hardpoint, if any. +/obj/vehicle/multitile/proc/get_radiator_hardpoint() + return locate(/obj/item/hardpoint/radiator) in hardpoints + +/// Current fuel blend's top speed/acceleration multiplier. 1 (no effect) if no fuel tank is installed. +/obj/vehicle/multitile/proc/get_fuel_performance_mult() + var/obj/item/hardpoint/fuel_tank/tank = get_fuel_tank_hardpoint() + return tank ? tank.get_fuel_performance_mult() : 1 + +/** + * Whether this vehicle currently has power for onboard systems beyond raw mechanical operation. True if + * the engine is running, or a battery hardpoint has any charge left. Used to fall the turret's rotation + * back to a slow manual-crank rate when neither is true. + * + * Arguments: + * * ignore_battery = TRUE to skip the battery check entirely, used while a battery hardpoint is on its + * way out but hasn't been removed from `hardpoints` yet. + */ +/obj/vehicle/multitile/proc/has_vehicle_power(ignore_battery = FALSE) + if(engine_on) + return TRUE + if(ignore_battery) + return FALSE + var/obj/item/hardpoint/battery/battery = get_battery_hardpoint() + // health > 0 matters separately from current_charge > 0: a destroyed battery keeps its stored charge + // but can't actually deliver it anymore. + return battery && battery.health > 0 && battery.current_charge > 0 + +/** + * Re-evaluates the installed IFF module's (if any) online/offline state. Call this whenever + * has_vehicle_power()'s result could change (engine on/off, battery install/uninstall/depletion), so a + * power loss shuts IFF down as promptly as a wound or destruction would. No-op if no IFF module. + * + * Arguments: + * * ignore_battery = Passed through to has_vehicle_power(). TRUE while a battery is on its way out. + */ +/obj/vehicle/multitile/proc/recheck_iff_module(ignore_battery = FALSE) + var/obj/item/hardpoint/iff_module/module = locate() in get_hardpoints_copy() + module?.check_functional_transition(ignore_battery = ignore_battery) + +/** + * Re-evaluates the installed support module's (if any) functional state. Same triggers as + * recheck_iff_module() above, so a power loss silences a passive buff as promptly as a wound would. + * No-op if no support module is installed. + * + * Arguments: + * * ignore_battery = Passed through to is_functional()/has_vehicle_power(). + */ +/obj/vehicle/multitile/proc/recheck_support_modules(ignore_battery = FALSE) + var/obj/item/hardpoint/support/module = locate() in get_hardpoints_copy() + module?.refresh_functional_state(ignore_battery = ignore_battery) + +/** + * Re-evaluates the installed Visual Sensors module's (if any) seated vision-impair overlay. Unlike Air + * Filter, this is a standing effect that needs an explicit refresh whenever has_vehicle_power() could + * change, same as recheck_iff_module()/recheck_support_modules() above. No-op if no module or no one seated. + * + * Arguments: + * * ignore_battery = Unused here, kept only to match the recheck_*() family's shared call signature. + */ +/obj/vehicle/multitile/proc/recheck_visual_sensors(ignore_battery = FALSE) + var/obj/item/hardpoint/visual_sensors/sensors = locate() in get_hardpoints_copy() + sensors?.refresh_seated_overlays() + +/** + * Total power (units/sec) currently being drawn by this vehicle's onboard systems, summed from every + * installed, undamaged hardpoint's power_draw. A destroyed hardpoint draws nothing. + * + * Returns: + * * Units/sec. + */ +/obj/vehicle/multitile/proc/get_module_power_draw() + var/total_draw = 0 + for(var/obj/item/hardpoint/installed_hardpoint in get_hardpoints_copy()) + if(installed_hardpoint.health <= 0) + continue + total_draw += installed_hardpoint.power_draw + return total_draw + +/** + * Continuous background loop managing this vehicle's battery charge. Recharges while the engine is on, + * drains while it's off. Runs for the vehicle's entire lifetime once started, independent of + * movement/input, since modules keep drawing power whether or not anyone's touching the controls. + * + * Once charge reaches 0 while parked: the turret falls back to manual-crank turn rate, the IFF module + * shuts down, any Support module's buff drops, and Visual Sensors' overlay jumps to fully obscured. Air + * Filter isn't recalculated here since it's only ever read live when gas/smoke touches the vehicle. + */ +/obj/vehicle/multitile/proc/battery_power_loop() + var/tick_dt = BATTERY_TICK_INTERVAL / 10 + + while(!QDELETED(src)) + sleep(BATTERY_TICK_INTERVAL) + + var/obj/item/hardpoint/battery/battery = get_battery_hardpoint() + if(!battery) + continue + + if(engine_on) + battery.current_charge = min(battery.current_charge + BATTERY_RECHARGE_RATE * tick_dt, battery.max_charge) + else + var/was_charged = battery.current_charge > 0 + battery.current_charge = max(battery.current_charge - get_module_power_draw() * tick_dt, 0) + if(was_charged && battery.current_charge <= 0) + var/obj/item/hardpoint/holder/tank_turret/turret = locate() in hardpoints + if(turret) + turret.recalculate_turn_rate() + recheck_iff_module() + recheck_support_modules() + recheck_turn_signals() + recheck_visual_sensors() + +/// Average traction of every turf this vehicle currently occupies. 1 if it occupies no turfs somehow. +/obj/vehicle/multitile/proc/get_avg_turf_traction() + var/total = 0 + var/count = 0 + for(var/turf/occupied_turf as anything in locs) + total += occupied_turf.get_traction() + count++ + if(!count) + return 1 + return total / count + +/// Average occupied-turf traction, dampened by the locomotion hardpoint. 1 if no locomotion hardpoint installed. +/obj/vehicle/multitile/proc/get_effective_traction() + var/obj/item/hardpoint/locomotion/loco = get_locomotion_hardpoint() + if(!loco) + return 1 + return loco.get_effective_traction(get_avg_turf_traction()) + +/** + * Ramps a hardpoint's raw integrity% into a 0-1 performance scale. Performance only degrades below + * `threshold_pct`, ramping linearly down to 0 at 0% integrity. + * + * Arguments: + * * integrity_pct = get_integrity_percent() of the part being scaled. + * * threshold_pct = Defaults to DEGRADE_GRACE_THRESHOLD_PCT. Weapon/turret-ring callers pass + * WEAPON_DEGRADE_GRACE_THRESHOLD_PCT instead, which starts biting a little sooner. + */ +/obj/vehicle/multitile/proc/get_health_scale_with_grace(integrity_pct, threshold_pct = DEGRADE_GRACE_THRESHOLD_PCT) + if(integrity_pct >= threshold_pct) + return 1 + return max(0, integrity_pct / threshold_pct) + +/** + * Combined engine/locomotion condition scale for the gear-transmission acceleration model. + * + * Two refinements over straight health%: each hardpoint's scale ramps in via + * get_health_scale_with_grace(), and the two hardpoints combine via a geometric mean instead of a + * straight product, since a product punishes having both parts damaged far harder than either alone + * (50%/50% would multiply down to 25%). sqrt(a*b) keeps 50%/50% at 50%, only lopsided damage pulls the + * combined scale below the healthier part's own value. + * + * Missing either part means 0 (can't drive without both an engine and treads installed). + * + * Returns: + * * 0-1 scale. + */ +/obj/vehicle/multitile/proc/get_part_condition_scale() + var/obj/item/hardpoint/engine/engine_hardpoint = get_engine_hardpoint() + var/obj/item/hardpoint/locomotion/loco = get_locomotion_hardpoint() + var/engine_scale = engine_hardpoint ? get_health_scale_with_grace(engine_hardpoint.get_integrity_percent()) * engine_hardpoint.get_wound_performance_multiplier() : 0 + var/loco_scale = loco ? get_health_scale_with_grace(loco.get_integrity_percent()) * loco.get_wound_performance_multiplier() : 0 + return sqrt(engine_scale * loco_scale) + +/** + * A gear's actual torque this tick, floored at `stats["min_power_fraction"]` of its own nominal torque + * regardless of how low engine/tread damage drags the raw scaled value. Gear 1/2 get a much higher floor + * than D/R/P/N, so they stay dependable low-range crawl gears under heavy damage. Traction isn't part of + * the floor branch, bad terrain is a separate, legitimate reason to be slow. + * + * The floor only applies while part_scale is still positive, so a fully destroyed engine or treads leave + * the vehicle completely unable to move rather than crawling at the floor. + * + * The scaled value, but not the floor, is also multiplied by the fuel blend's performance multiplier, so + * a bad fuel blend never crushes a gear below the hard floor engine/tread damage already respects. + * + * The whole result is then scaled by gear_performance_mult and gear_torque_mult, so a general buff or a + * torque-only malus both raise/lower the floor along with everything else. + */ +/obj/vehicle/multitile/proc/get_gear_computed_torque(list/stats, traction_scale, part_scale) + if(part_scale <= 0) + return 0 + return max(stats["torque"] * traction_scale * part_scale * get_fuel_performance_mult(), stats["torque"] * stats["min_power_fraction"]) * gear_performance_mult * gear_torque_mult + +/** + * A gear's actual reachable speed ceiling this tick. Same floor shape as get_gear_computed_torque(), just + * applied to max_speed instead of torque, so the "time to reach ceiling" ratio stays consistent once the + * floor is active. + */ +/obj/vehicle/multitile/proc/get_gear_max_speed(list/stats, traction_scale, part_scale) + if(part_scale <= 0) + return 0 + return max(stats["max_speed"] * traction_scale * part_scale * get_fuel_performance_mult(), stats["max_speed"] * stats["min_power_fraction"]) * gear_performance_mult + +/** + * Temporarily boosts gear_performance_mult by overdrive_speed_mult, then reverts after overdrive_duration. + * No-ops if overdrive_speed_mult is 0 (vehicle doesn't have this ability) or still on cooldown. + * + * Arguments: + * * user = Whoever triggered it, for cooldown/feedback messages. + */ +/obj/vehicle/multitile/proc/activate_overdrive(mob/user) + if(!overdrive_speed_mult) + return + if(overdrive_next > world.time) + to_chat(user, SPAN_WARNING("You can't activate overdrive yet! Wait [round((overdrive_next - world.time) / 10, 0.1)] seconds.")) + return + + gear_performance_mult *= (1 + overdrive_speed_mult) + addtimer(CALLBACK(src, PROC_REF(reset_overdrive)), overdrive_duration) + + overdrive_next = world.time + overdrive_cooldown + to_chat(user, SPAN_NOTICE("You activate overdrive.")) + playsound(src, overdrive_sound, 75, FALSE) + +/// Reverts activate_overdrive()'s temporary gear_performance_mult boost. +/obj/vehicle/multitile/proc/reset_overdrive() + gear_performance_mult /= (1 + overdrive_speed_mult) + +/** + * Whether the current gear is producing meaningfully less torque than its nominal value right now + * (bad traction, damaged engine/treads). Exposed for the future HUD's red-flash indicator. + * + * Routed through get_gear_computed_torque() so it picks up both the per-gear min_power_fraction floor + * and gear_performance_mult, since a gear resting on its floor guarantee isn't genuinely "underpowered." + * + * Returns: + * * TRUE if computed torque is under GEAR_UNDERPOWERED_FRACTION of nominal, FALSE otherwise. + */ +/obj/vehicle/multitile/proc/is_underpowered() + var/list/stats = get_current_gear_stats() + if(!stats["torque"]) + return FALSE + var/computed_torque = get_gear_computed_torque(stats, get_effective_traction(), get_part_condition_scale()) + return computed_torque < stats["torque"] * GEAR_UNDERPOWERED_FRACTION + +/** + * Advances this vehicle's engine temperature by one physics tick. Heat builds up under load and bleeds + * off toward the current area's ambient temperature at a rate set by the Radiator hardpoint's condition + * and remaining coolant. No radiator installed falls back to a much weaker passive cooling rate. + * + * Crossing the overheat threshold drips condition damage onto the engine itself. + * + * engine_temperature can never rise past ENGINE_OVERHEAT_SHUTDOWN_THRESHOLD. Once hit, the engine + * force-shuts-off via set_engine_on() rather than climbing further. + * + * Also the single shared per-tick choke point for the engine's other auto-shutdown case (running out of + * fuel), exhaust smoke, and the track-rattle sound loop. + * + * Arguments: + * * tick_dt = Seconds elapsed this physics tick. + * * consumed_fraction = How much of this tick's acceleration budget was spent, 0 (idle/coasting) to 1 + * (full accel, or fully revving in Park/Neutral). Scales load heat on top of an idle baseline. + */ +/obj/vehicle/multitile/proc/update_engine_temperature(tick_dt, consumed_fraction) + var/obj/item/hardpoint/engine/engine_hardpoint = get_engine_hardpoint() + if(!engine_hardpoint) + return + + var/obj/item/hardpoint/radiator/radiator = locate(/obj/item/hardpoint/radiator) in hardpoints + var/cooling_effectiveness = radiator ? radiator.get_cooling_effectiveness() : 0 + var/cooling_coefficient = ENGINE_PASSIVE_COOLING_COEFFICIENT + cooling_effectiveness * (ENGINE_RADIATOR_COOLING_COEFFICIENT - ENGINE_PASSIVE_COOLING_COEFFICIENT) + + var/heat_generated = 0 + if(engine_on) + var/list/stats = get_current_gear_stats() + // Heat scales off base_acceleration (Drive's nominal torque), not this gear's own torque, since + // low-range gears carry a much larger torque number that has nothing to do with actual engine + // effort per tick. Falls back to ENGINE_REV_TORQUE_EQUIVALENT in Park/Neutral. + var/load_torque = stats["torque"] ? base_acceleration : ENGINE_REV_TORQUE_EQUIVALENT + heat_generated = ENGINE_HEAT_IDLE + consumed_fraction * ENGINE_HEAT_PER_LOAD * load_torque + heat_generated *= tick_dt + + // External heat from fire mounted on the hull or burning ground applies regardless of engine_on, + // since a burning hull heats the engine compartment either way. 0 for every non-tank vehicle. + heat_generated += get_mounted_flame_tile_count() * ENGINE_HEAT_PER_MOUNTED_FLAME_TILE * tick_dt + heat_generated += get_underneath_flame_tile_count() * ENGINE_HEAT_PER_UNDERNEATH_FLAME_TILE * tick_dt + + var/area/current_area = get_area(src) + var/ambient_temperature = current_area ? current_area.return_temperature() : T20C + var/heat_removed = (engine_hardpoint.engine_temperature - ambient_temperature) * cooling_coefficient * tick_dt + + engine_hardpoint.engine_temperature = min(engine_hardpoint.engine_temperature + heat_generated - heat_removed, ENGINE_OVERHEAT_SHUTDOWN_THRESHOLD) + + // Condition damage only applies while the engine is running. A shut-off engine can still be sitting + // above the overheat threshold while it cools back down, and shouldn't keep taking damage for it. + if(engine_on && engine_hardpoint.is_overheating()) + engine_hardpoint.take_damage(ENGINE_OVERHEAT_DAMAGE_PER_SEC * tick_dt) + + engine_hardpoint.check_overheat_wound_trigger(src) + + if(engine_on && engine_hardpoint.engine_temperature >= ENGINE_OVERHEAT_SHUTDOWN_THRESHOLD) + set_engine_on(FALSE) + var/mob/driver = get_seat_mob(VEHICLE_DRIVER) + if(driver) + to_chat(driver, SPAN_WARNING("The engine automatically shuts down as it redlines past the danger zone!")) + + // consume_fuel() already silently no-ops once the tank's dry. has_fuel() here is what actually stops + // the engine from running on a removed/empty tank, mirroring the overheat auto-shutdown above. + if(engine_on && !has_fuel()) + set_engine_on(FALSE) + var/mob/driver_mob = get_seat_mob(VEHICLE_DRIVER) + if(driver_mob) + to_chat(driver_mob, SPAN_WARNING("The engine sputters and dies - it's out of fuel!")) + + check_engine_exhaust_smoke() + update_track_sound() + +/** + * Whether the engine is already close enough to ambient that gear_cruise_loop() can stop keeping itself + * alive just to cool it. The cooling formula is exponential decay, so it never reaches ambient exactly, + * only within ENGINE_TEMPERATURE_SETTLE_THRESHOLD degrees. + * + * Returns: + * * TRUE if there's no engine hardpoint, or its temperature is within the settle threshold. + */ +/obj/vehicle/multitile/proc/is_engine_temperature_settled() + var/obj/item/hardpoint/engine/engine_hardpoint = get_engine_hardpoint() + if(!engine_hardpoint) + return TRUE + var/area/current_area = get_area(src) + var/ambient_temperature = current_area ? current_area.return_temperature() : T20C + return abs(engine_hardpoint.engine_temperature - ambient_temperature) < ENGINE_TEMPERATURE_SETTLE_THRESHOLD + +/** + * Starts/stops the track-rattle loop to match whether this vehicle is moving right now, distinct from + * engine_soundloop which plays whenever the engine's on regardless of movement. start()/stop() are + * already no-ops in the requested state, so this can just run unconditionally every tick. + */ +/obj/vehicle/multitile/proc/update_track_sound() + if(current_speed > 0) + track_soundloop?.start() + else + track_soundloop?.stop() + +/** + * Starts/stops the skid-loop sound to match whether the vehicle currently has any drift_speed. Same + * reasoning as update_track_sound() above, runs unconditionally every gear_cruise_loop() tick. Replaces + * what used to be a plain playsound() fired on every drift_movement_loop() step, which read as spammy. + */ +/obj/vehicle/multitile/proc/update_drift_sound() + if(drift_speed > 0) + drift_soundloop?.start() + else + drift_soundloop?.stop() + +/** + * Which tint, how often, and how much this vehicle's exhaust smoke should puff right now, or a null + * color if nothing warrants any. Returns list(color, interval, amount) since wound-driven triggers puff + * more often and thicker than off-label-fuel triggers. + * + * Every currently-relevant engine wound tint gets collected and blended together via BlendRGB(), so a + * tank with both a cracked block and a fouled injector puffs a genuine mix of both. + * + * Returns a 4th list element, is_large_cloud, TRUE if either engine wound family is at its worst tier. + */ +/obj/vehicle/multitile/proc/get_engine_exhaust_smoke_state() + var/obj/item/hardpoint/engine/engine_hardpoint = get_engine_hardpoint() + if(engine_hardpoint) + var/list/active_colors = list() + var/is_large_cloud = FALSE + + // engine_cracked_block: either tier is white/clear smoke. Only tier 2 counts as large. + var/block_tier = LAZYACCESS(engine_hardpoint.wound_tiers, /datum/hardpoint_wound_family/engine_cracked_block) + if(block_tier) + active_colors += ENGINE_SMOKE_COLOR_BRUTE_WOUND + if(block_tier >= 2) + is_large_cloud = TRUE + + // engine_fouled_injector: tier 1 is sooty black, tier 2 is the darkest of the bunch. Only tier 2 + // counts as large. + var/injector_tier = LAZYACCESS(engine_hardpoint.wound_tiers, /datum/hardpoint_wound_family/engine_fouled_injector) + if(injector_tier == 1) + active_colors += ENGINE_SMOKE_COLOR_FOULED_CARBURATOR + else if(injector_tier >= 2) + active_colors += ENGINE_SMOKE_COLOR_ACID_WOUND + is_large_cloud = TRUE + + if(length(active_colors)) + var/blended_color = active_colors[1] + for(var/i in 2 to length(active_colors)) + blended_color = BlendRGB(blended_color, active_colors[i], 0.5) + return list(blended_color, ENGINE_SMOKE_INTERVAL_WOUND, ENGINE_SMOKE_AMOUNT_WOUND, is_large_cloud) + + // Gated on how dominant a single off-label reagent is, not just whether the blend is off-label at + // all, so a light splash of welding fuel topped off with mostly JP-8 shouldn't visibly smoke. + var/obj/item/hardpoint/fuel_tank/tank = get_fuel_tank_hardpoint() + if(tank) + if(tank.get_reagent_fraction("fuel") > VEHICLE_FUEL_SMOKE_THRESHOLD_PCT / 100) + return list(ENGINE_SMOKE_COLOR_OFF_LABEL_FUEL, ENGINE_SMOKE_INTERVAL_WELDING_FUEL, ENGINE_SMOKE_AMOUNT_DEFAULT, FALSE) + if(tank.get_reagent_fraction("cornoil") > VEHICLE_FUEL_SMOKE_THRESHOLD_PCT / 100) + return list(ENGINE_SMOKE_COLOR_OFF_LABEL_FUEL, ENGINE_SMOKE_INTERVAL_COOKING_OIL, ENGINE_SMOKE_AMOUNT_DEFAULT, FALSE) + + return list(null, ENGINE_SMOKE_INTERVAL_COOKING_OIL, ENGINE_SMOKE_AMOUNT_DEFAULT, FALSE) + +/** + * Puffs a smoke effect from behind the vehicle while the engine's running and either an exhaust-smoke- + * worthy engine wound is active, or the tank's running on off-label fuel. Reuses the same smoke effect + * the M40 HSDP grenade spawns, tinted per trigger via get_engine_exhaust_smoke_state(). Throttled to one + * puff per that trigger's own interval. Also puffs a matching cloud inside the fighting compartment if + * the air filter can't keep it out. + * + * A Tier 2 engine wound puffs a full cross (the exhaust turf plus all 4 cardinal-adjacent turfs) instead + * of just the single exhaust tile every other trigger uses, so a wrecked engine billows out wider. + */ +/obj/vehicle/multitile/proc/check_engine_exhaust_smoke() + if(!engine_on || world.time < next_exhaust_smoke_time) + return + + var/list/smoke_state = get_engine_exhaust_smoke_state() + var/smoke_color = smoke_state[1] + if(!smoke_color) + return + + next_exhaust_smoke_time = world.time + smoke_state[2] + var/smoke_amount = smoke_state[3] + var/is_large_cloud = smoke_state[4] + + var/turf/exhaust_turf = get_step(get_turf(src), turn(dir, 180)) + if(exhaust_turf) + spawn_exhaust_smoke_puff(exhaust_turf, smoke_color, smoke_amount) + if(is_large_cloud) + for(var/cardinal_dir in GLOB.cardinals) + var/turf/cross_turf = get_step(exhaust_turf, cardinal_dir) + if(cross_turf) + spawn_exhaust_smoke_puff(cross_turf, smoke_color, smoke_amount) + + puff_interior_exhaust_smoke(smoke_color, smoke_amount) + +/// Spawns one exhaust-smoke puff at `puff_turf`, tinted `smoke_color`. Factored out for the Tier 2 cross spread. +/obj/vehicle/multitile/proc/spawn_exhaust_smoke_puff(turf/puff_turf, smoke_color, smoke_amount) + var/obj/effect/particle_effect/smoke/bad/exhaust_smoke = new(puff_turf, smoke_amount) + exhaust_smoke.color = smoke_color + // Default smoke layer sits below a tank's own riders, hiding the exhaust puff entirely. + exhaust_smoke.layer = TANK_ABOVE_RIDER_LAYER + +/** + * Puffs a matching smoke cloud inside the vehicle's fighting compartment, scaled by how compromised the + * air filter is. Delegates to bridge_smoke_into_interior(), the same mechanic a Boiler glob or Acid + * Runner cloud uses, so any smoke touching the tank gets identical containment behavior. + * + * Arguments: + * * smoke_color = The tint to apply, matching the exterior puff. + * * smoke_amount = The amount to spawn with, matching the exterior puff. + */ +/obj/vehicle/multitile/proc/puff_interior_exhaust_smoke(smoke_color, smoke_amount) + bridge_smoke_into_interior(/obj/effect/particle_effect/smoke/bad, smoke_color, smoke_amount) + +/** + * Bridges a smoke_path cloud into this vehicle's fighting compartment, scaled by how compromised its air + * filter currently is. Shared by every source of exterior smoke that touches the vehicle, so they all + * get identical containment behavior. A healthy filter blocks it outright, a compromised one lets it in + * with alpha/opacity scaled by leak_fraction. + * + * Loops the interior's actual room and reuses any existing smoke_path instance already on a given turf + * instead of stacking duplicates, so a sustained leak visibly fills the whole compartment. + * + * Arguments: + * * smoke_path = The exact smoke subtype to bridge in, reused turf-by-turf across calls. + * * smoke_color = Tint to apply, or null to leave whatever color smoke_path's own type already sets. + * * smoke_amount = The amount to construct a new instance with, or null to use smoke_path's own default. + */ +/obj/vehicle/multitile/proc/bridge_smoke_into_interior(smoke_path, smoke_color, smoke_amount) + if(!interior) + return + var/obj/item/hardpoint/air_filter/filter = locate() in get_hardpoints_copy() + var/leak_fraction = 1 // No filter installed at all - fully unfiltered, same as a destroyed one. + if(filter) + leak_fraction = filter.get_gas_leak_fraction() + if(leak_fraction <= 0) + return + + var/turf/center = interior.get_middle_turf() + if(!center) + return + var/h_radius = max(0, round((interior.width - 1) / 2)) + var/v_radius = max(0, round((interior.height - 1) / 2)) + for(var/turf/car_turf as anything in RECT_TURFS(h_radius, v_radius, center)) + var/obj/effect/particle_effect/smoke/existing = locate(smoke_path) in car_turf + if(!existing) + existing = smoke_amount ? new smoke_path(car_turf, smoke_amount) : new smoke_path(car_turf) + existing.bridged_vehicle = src + if(smoke_color) + existing.color = smoke_color + existing.gas_leak_fraction = leak_fraction + existing.alpha = max(20, round(255 * leak_fraction)) + existing.set_opacity(existing.alpha >= AIR_FILTER_LEAK_OPACITY_ALPHA_THRESHOLD) // a faint leak doesn't block sight, only a thick one does + +/// Whether this vehicle has a fuel tank installed with any fuel left in it at all. +/obj/vehicle/multitile/proc/has_fuel() + var/obj/item/hardpoint/fuel_tank/tank = locate(/obj/item/hardpoint/fuel_tank) in hardpoints + return tank && tank.reagents && tank.reagents.total_volume > 0 + +/** + * Burns fuel for one physics tick. For driving gears, the baseline cost scales with consumed_fraction, + * so accelerating from a stop costs far more than holding an already-reached speed. For Park/Neutral, a + * small idle burn always applies while the engine's on, plus a minimal extra cost while revving. On top + * of either, an extra cost scales with how much the speed actually changed this tick. Blends in the + * off-label fuel penalty. No-ops entirely while the engine is off. + * + * ENGINE_IDLE_FUEL_USE is added unconditionally whenever the engine's on, covering the case of sitting + * still in a driving gear with no throttle input at all. Every idle-related term (ENGINE_IDLE_FUEL_USE, + * ENGINE_REV_FUEL_USE, and Park/Neutral's own base fuel_use from build_gear_stats()) is scaled by this + * vehicle's own idle_fuel_use_mult. + * + * Arguments: + * * dt = Seconds elapsed this tick, for the baseline cruising/idle cost. + * * speed_delta = Tiles/sec current_speed actually changed by this tick, for the extra accel cost. + * * consumed_fraction = How much of this tick's acceleration budget was spent, 0 (idle/coasting) to 1 + * (full accel, or fully revving in Park/Neutral). + */ +/obj/vehicle/multitile/proc/consume_fuel(dt, speed_delta = 0, consumed_fraction = 0) + if(!engine_on) + return + var/obj/item/hardpoint/fuel_tank/tank = locate(/obj/item/hardpoint/fuel_tank) in hardpoints + if(!tank || !tank.reagents || tank.reagents.total_volume <= 0) + return + + var/list/stats = get_current_gear_stats() + var/cruise_amount + + if(stats["max_speed"] > 0) + cruise_amount = stats["fuel_use"] * consumed_fraction * dt + else + cruise_amount = (stats["fuel_use"] + consumed_fraction * ENGINE_REV_FUEL_USE * idle_fuel_use_mult) * dt + + var/accel_amount = ACCEL_FUEL_PER_SPEED_DELTA * speed_delta + var/idle_amount = ENGINE_IDLE_FUEL_USE * idle_fuel_use_mult * dt + tank.consume_fuel((cruise_amount + accel_amount + idle_amount) * tank.get_fuel_blend_penalty()) diff --git a/code/modules/vehicles/multitile/multitile_overwatch.dm b/code/modules/vehicles/multitile/multitile_overwatch.dm new file mode 100644 index 000000000000..1aa93ccdcb3a --- /dev/null +++ b/code/modules/vehicles/multitile/multitile_overwatch.dm @@ -0,0 +1,52 @@ +/// Dedicated overwatch camera, +2 tiles of view range over a standard marine's helmet camera. +/obj/structure/machinery/camera/overwatch/vehicle + name = "vehicle overwatch camera" + view_range = 9 + +/** + * Creates and wires up this vehicle's dedicated CIC overwatch camera. + * + * Opt-in: call this from a vehicle's own Initialize() to enable overwatch for it. + */ +/obj/vehicle/multitile/proc/setup_overwatch_camera() + overwatch_camera = new /obj/structure/machinery/camera/overwatch/vehicle(src) + overwatch_camera.c_tag = name + overwatch_camera.status = FALSE + overwatch_camera.AddComponent(/datum/component/overwatch_console_control) + refresh_overwatch_camera_state() + +/// Returns this vehicle's dedicated overwatch camera, if any. Null for a vehicle that never set one up. +/obj/vehicle/multitile/proc/get_camera() + return overwatch_camera + +/** + * Called on every real IFF online/offline transition, letting a vehicle keep its IFF-gated systems + * in sync. check_eye() already disconnects any console mid-watch once offline, so no extra cleanup is + * needed. No-op if this vehicle never set up an overwatch camera. + */ +/obj/vehicle/multitile/proc/refresh_overwatch_camera_state() + if(!overwatch_camera) + return + var/obj/item/hardpoint/iff_module/iff = locate() in get_hardpoints_copy() + overwatch_camera.status = !!(iff && iff.is_functional()) + +/** + * Moves this vehicle's overwatch to a different squad. Sticky by design: leaving the driver's seat + * doesn't clear the assignment, only a new crewmember from a different squad reassigns it. + * + * Also force-releases any console currently watching this vehicle's camera, so an officer can't get + * stuck staring at a feed with no way to back out. + * + * Arguments: + * * new_squad = Squad to assign this vehicle's overwatch to. Null is a no-op. + */ +/obj/vehicle/multitile/proc/update_overwatch_squad(datum/squad/new_squad) + if(!new_squad || new_squad == overwatch_squad) + return + overwatch_squad?.remove_overwatch_vehicle(src) + overwatch_squad = new_squad + overwatch_squad.add_overwatch_vehicle(src) + + var/obj/structure/machinery/camera/own_cam = get_camera() + for(var/obj/structure/machinery/computer/overwatch/console as anything in GLOB.active_overwatch_consoles) + console.force_release_if_watching(own_cam) diff --git a/code/modules/vehicles/multitile/multitile_riding.dm b/code/modules/vehicles/multitile/multitile_riding.dm new file mode 100644 index 000000000000..2740e47f0e44 --- /dev/null +++ b/code/modules/vehicles/multitile/multitile_riding.dm @@ -0,0 +1,831 @@ +//----------------------------- +// On-hull riding. Lets a mob or obj stand atop a multitile vehicle's hull, tracked +// via the /datum/component/vehicle_rider component. +//----------------------------- + +/// TRUE while `user` should be granted an instant climb onto this vehicle. No-op except for the tank's Praetorian Dancer interaction. +/obj/vehicle/multitile/proc/has_instant_dancer_climb(mob/living/user) + return FALSE + +/** + * Marks a mob as riding atop this vehicle's hull. + * + * Adds the mob to on_top_mobs, attaches a /datum/component/vehicle_rider, and applies rider visuals. + * Un-hides a hidden xeno, if applicable. + * + * Arguments: + * * mob/living/rider_mob = The mob being marked ontop. + */ +/obj/vehicle/multitile/proc/mark_on_top(mob/living/rider_mob) + if(!istype(rider_mob)) + return + if(rider_mob.z != z) + return + if(!(get_turf(rider_mob) in locs)) + return + if(rider_mob.get_tank_on_top_of() == src) + return + on_top_mobs |= rider_mob + rider_mob.AddComponent(/datum/component/vehicle_rider, src) + _apply_rider_visuals(rider_mob) + + if(isxeno(rider_mob)) + var/mob/living/carbon/xenomorph/X = rider_mob + if(X.layer == XENO_HIDING_LAYER) + var/datum/action/xeno_action/onclick/xenohide/hide = get_action(X, /datum/action/xeno_action/onclick/xenohide) + if(hide) + hide.remove_hide_status() // prevents cheesing the layer system + +/** + * mark_on_top() for a mob mounted via a collision rather than a consensual climb_onto(). + * Also brings along whichever of `rider_mob`'s two held items ended up dropped on the ground. + * + * Arguments: + * * mob/living/rider_mob = The mob being mounted. + * * obj/item/prev_l_hand = Whatever was in rider_mob's left hand right before the collision, or null. + * * obj/item/prev_r_hand = Whatever was in rider_mob's right hand right before the collision, or null. + */ +/obj/vehicle/multitile/proc/mark_on_top_after_ram(mob/living/rider_mob, obj/item/prev_l_hand, obj/item/prev_r_hand) + mark_on_top(rider_mob) + for(var/obj/item/dropped_item in list(prev_l_hand, prev_r_hand)) + if(!dropped_item || QDELETED(dropped_item)) + continue + if(dropped_item.loc == rider_mob) // still held - travels with rider_mob automatically, nothing to do + continue + obj_mark_on_top(dropped_item) + +/** + * mark_on_top(), but for an obj/ instead of a mob. + * + * Arguments: + * * obj/rider_obj = The obj being marked ontop. + */ +/obj/vehicle/multitile/proc/obj_mark_on_top(obj/rider_obj) + if(!istype(rider_obj)) + return + if(!rider_obj.is_allowed_atop_vehicle) + return + if(rider_obj.z != z) + return + if(!(get_turf(rider_obj) in locs)) + return + if(rider_obj.get_tank_on_top_of() == src) + rider_obj.layer = TANK_RIDER_OBJ_LAYER // prevents a visual bug with layering + return + on_top_obj |= rider_obj + rider_obj.AddComponent(/datum/component/vehicle_rider, src) + rider_obj.layer = TANK_RIDER_OBJ_LAYER + if(istype(rider_obj, /obj/structure/closet/bodybag)) + var/obj/structure/closet/bodybag/BB = rider_obj + if(BB.roller_buckled) + BB.pixel_y = BB.buckle_offset + 12 + else + BB.pixel_y = initial(rider_obj.pixel_y) + 12 + else + rider_obj.pixel_y = initial(rider_obj.pixel_y) + 12 + +/** + * Removes rider effects from a mob who was previously atop this vehicle. + * Resets layer/plane/pixel_y, removes from on_top_mobs, removes the vehicle_rider component. + * + * Arguments: + * * mob/living/rider_mob = The mob who has disembarked. + */ +/obj/vehicle/multitile/proc/clear_on_top(mob/living/rider_mob) + if(!istype(rider_mob)) + return + on_top_mobs -= rider_mob + qdel(rider_mob.GetComponent(/datum/component/vehicle_rider)) + rider_mob.layer = initial(rider_mob.layer) + rider_mob.plane = initial(rider_mob.plane) + rider_mob.pixel_y = initial(rider_mob.pixel_y) + +/// clear_on_top(), but for an obj/ instead of a mob. +/obj/vehicle/multitile/proc/obj_clear_on_top(obj/rider_obj) + if(!istype(rider_obj)) + return + on_top_obj -= rider_obj + qdel(rider_obj.GetComponent(/datum/component/vehicle_rider)) + rider_obj.layer = initial(rider_obj.layer) + if(istype(rider_obj, /obj/structure/closet/bodybag)) + var/obj/structure/closet/bodybag/BB = rider_obj + if(BB.roller_buckled) + BB.pixel_y = BB.buckle_offset + else + BB.pixel_y = initial(rider_obj.pixel_y) + else + rider_obj.pixel_y = initial(rider_obj.pixel_y) + +/// Removes any rider that's no longer actually standing on this vehicle's footprint, refreshes visuals for the rest. Called defensively after a crash or forced move. +/obj/vehicle/multitile/proc/revalidate_on_top() + for(var/mob/living/M in on_top_mobs.Copy()) + if(!M || M.z != z || !(get_turf(M) in locs)) + clear_on_top(M) + else + _apply_rider_visuals(M) + +/// Whether AM is a mob/living currently riding on top of this specific vehicle. +/obj/vehicle/multitile/proc/_is_our_rider(atom/movable/AM) + if(!ismob(AM)) + return FALSE + var/mob/living/rider_mob = AM + return istype(rider_mob) && rider_mob.get_tank_on_top_of() == src + +/** + * Lets a mob climb onto this vehicle's hull. Called by Collided() (multitile_bump.dm) or a mob's own climb verb. + * Runs edge case checks, waits out a climb delay, then commits, pulling any grabbed mob/obj along. + * + * Arguments: + * * mob/living/user = The mob trying to get atop the vehicle. + * * turf/preferred = The exact turf of the vehicle being climbed onto. + */ +/obj/vehicle/multitile/proc/climb_onto(mob/living/user, turf/preferred) + if(!istype(user)) + return + + if(user.action_busy) + return + + if(exceeds_desant_cap()) + to_chat(user, SPAN_WARNING("[src] is moving too fast to climb onto!")) + return + + if(!_validate_climb_target(user, preferred, TRUE)) + to_chat(user, SPAN_WARNING("You can't climb there.")) + return + + user.visible_message( + SPAN_WARNING("[user] starts climbing onto [src]."), + SPAN_WARNING("You start climbing onto [src].")) + + var/climb_speed_factor = (move_momentum / move_max_momentum) // % of our max speed attained. + + if(!has_instant_dancer_climb(user)) // Dodge's evasive stance skips every delay branch below entirely. + + if(health == 0) // When broken, everyone takes 0.20 seconds to climb up. Keep in mind that the real perceived value is around 2-3x higher when you take in account lag. + if(!do_after (user, 0.20 SECONDS, INTERRUPT_MOVED, BUSY_ICON_CLIMBING, numticks = 1)) + to_chat(user, SPAN_WARNING("You stop climbing onto [src].")) + return + + else if(isxeno(user)) // Xenos take longer to climb to avoid accidentally getting on top while slashing inside boiler gas. + // takes between 0.8 seconds when still and 2 seconds when at full speed. (You'd have to climb through one of the sides) OR pounce on it. + if(!do_after(user, max((2 * climb_speed_factor), 0.80) SECONDS, INTERRUPT_MOVED, BUSY_ICON_CLIMBING, numticks = 4)) + to_chat(user, SPAN_WARNING("You stop climbing onto [src].")) + return + + // Humans and preds climb up faster. Humans can't climb down instantly. + // takes between 0.25 seconds when still and 1.20 second when at full speed (You'd have to climb through one of the sides) + else if(!do_after(user, max((1.20 * climb_speed_factor), 0.30) SECONDS, INTERRUPT_MOVED, BUSY_ICON_CLIMBING, numticks = 3)) + to_chat(user, SPAN_WARNING("You stop climbing onto [src].")) + return + + if(!_validate_climb_target(user, preferred, TRUE)) + to_chat(user, SPAN_WARNING("The spot on [src] is no longer reachable.")) + return + + _carry_move_with_grabs(user, preferred) + user.visible_message( + SPAN_WARNING("[user] climbs onto [src]."), + SPAN_WARNING("You climb onto [src].")) + return + +/** + * Lets a mob climb down off this vehicle's hull onto an adjacent turf. Only interrupts on + * incapacitation, so disembarking still works while the vehicle is moving. + * + * Arguments: + * * mob/living/user = The mob trying to get down. + * * turf/target = The turf being climbed down onto. + */ +/obj/vehicle/multitile/proc/climb_down(mob/living/user, turf/target) + if(user.action_busy) + return + + if(!_validate_climb_target(user, target, FALSE)) + return + + var/climb_speed_factor = (move_momentum / move_max_momentum) // % of our max speed attained. + + user.visible_message( + SPAN_WARNING("[user] starts climbing down from [src]."), + SPAN_WARNING("You start climbing down from [src].")) + + if(!has_instant_dancer_climb(user)) // Dodge's evasive stance skips the delay entirely. + + if(!ishuman(user)) // Xenos and preds can always climb down with almost no delay to avoid situations where they get stuck atop the vehicle. + // The tiny delay here is meant to prevent fast castes from accidentally disembarking from a misinput + if(!do_after(user, 0.20 SECONDS, INTERRUPT_INCAPACITATED, BUSY_ICON_GENERIC, numticks = 1)) + to_chat(user, SPAN_WARNING("You stop climbing down from [src].")) + return + + // Humans take at most 0.9 seconds to climb down at full speed, and 0.30 when standing still. + else if(!do_after(user, max((0.9 * climb_speed_factor), 0.30) SECONDS, INTERRUPT_INCAPACITATED, BUSY_ICON_GENERIC, numticks = 3)) + to_chat(user, SPAN_WARNING("You stop climbing down from [src].")) + return + + // edge case where the vehicle moves while we're climbing down + if(!_validate_climb_target(user, target, FALSE)) + to_chat(user, SPAN_WARNING("The spot is no longer reachable.")) + return + _carry_remove_with_grabs(user, target) + user.visible_message( + SPAN_WARNING("[user] climbs down from [src]."), + SPAN_WARNING("You climb down from [src].")) + return + +/** + * Allows a mob to move freely atop this vehicle, or into/out of it while riding. + * Overrides /atom's BlockedPassDirs. + * + * Arguments: + * * atom/movable/mover = The atom attempting to move on this vehicle's turf. + * * target_dir = The direction being moved towards. + */ +/obj/vehicle/multitile/BlockedPassDirs(atom/movable/mover, target_dir) + if(ismob(mover)) + var/mob/living/mover_mob = mover + if(istype(mover_mob) && mover_mob.get_tank_on_top_of() == src) + var/turf/start = get_turf(mover_mob) + var/turf/target = get_step(start, target_dir) + if(target && (target in src.locs)) + return NO_BLOCKED_MOVEMENT + else if(isobj(mover)) + var/obj/mover_obj = mover + if(mover_obj.is_atop_vehicle()) + var/turf/start = get_turf(mover_obj) + var/turf/target = get_step(start, target_dir) + if(target && (target in src.locs)) + return NO_BLOCKED_MOVEMENT + var/ret = ..() + if(ret != null) + return ret + return null + +/// Brings a rider's layer atop the vehicle's own. +/obj/vehicle/multitile/proc/_apply_rider_visuals(mob/living/M) + M.update_layer() + M.pixel_y = M.old_y + 12 + +/** + * Pulls a grabbed mob/obj up onto the vehicle along with `user` once their climb finishes. + * The order of clearing, marking, and moving is not arbitrary. + * + * Arguments: + * * mob/living/user = Mob doing the pulling. + * * turf/dest = Which turf on the vehicle we're moving to. + */ +/obj/vehicle/multitile/proc/_carry_move_with_grabs(mob/living/user, turf/dest) + var/list/grabbed_things = list() + for(var/obj/item/grab/G in list(user.l_hand, user.r_hand)) + if(G?.grabbed_thing) + grabbed_things += G.grabbed_thing + + for(var/atom/movable/thing as anything in grabbed_things) + if(isliving(thing)) + var/mob/living/L = thing + L.apply_effect(2, WEAKEN) + _add_to_top_buckled_to(L, dest) + L.forceMove(dest) + mark_on_top(L) + _reignite_mounted_flame(L) + + else if(isobj(thing)) + var/obj/O = thing + if(O.is_allowed_atop_vehicle) + _add_to_top_buckled_entity(O, dest) + + user.forceMove(dest) + mark_on_top(user) + _reignite_mounted_flame(user) + +/// Re-fires Crossed() on any flame at a rider's tile, since forceMove() runs before mark_on_top(). +/obj/vehicle/multitile/proc/_reignite_mounted_flame(mob/living/rider) + var/obj/flamer_fire/mounted_fire = locate() in get_turf(rider) + if(mounted_fire) + mounted_fire.Crossed(rider) + +/// Brings a roller bed `L` is buckled to up onto the vehicle alongside them. +/obj/vehicle/multitile/proc/_add_to_top_buckled_to(mob/living/L, turf/dest) + if(L.buckled && istype(L.buckled, /obj/structure/bed/roller)) + var/obj/structure/bed/roller/R = L.buckled + R.forceMove(dest) + obj_mark_on_top(R) + +/// Brings whatever's buckled to `object` (a roller bed's mob/bodybag) up onto the vehicle alongside it. +/obj/vehicle/multitile/proc/_add_to_top_buckled_entity(obj/object, turf/dest) + if(istype(object, /obj/structure/bed/roller)) + var/obj/structure/bed/roller/R = object + + R.forceMove(dest) + obj_mark_on_top(R) + if(R.buckled_mob) + var/mob/living/L = R.buckled_mob + L.forceMove(dest) + mark_on_top(L) + else if (R.buckled_bodybag) + var/obj/structure/closet/bodybag/BB = R.buckled_bodybag + BB.forceMove(dest) + obj_mark_on_top(BB) + else if (istype(object, /obj/structure/closet/bodybag)) // Grab is on a bodybag, possibly atop a roller. + var/obj/structure/closet/bodybag/BB = object + + if (BB.roller_buckled) + var/obj/structure/bed/roller/R = BB.roller_buckled + R.forceMove(dest) + obj_mark_on_top(R) // mark ASAP to prevent bodybag from unbuckling + BB.forceMove(dest) + obj_mark_on_top(BB) + + else // lone bodybag + BB.forceMove(dest) + obj_mark_on_top(BB) + + else // any other obj + object.forceMove(dest) + obj_mark_on_top(object) + +/** + * Pulls a grabbed mob/obj down off the vehicle along with `user` once their climb-down finishes. + * The order of clearing, marking, and moving is not arbitrary. + * + * Arguments: + * * mob/living/user = Mob doing the pulling. + * * turf/dest = Which turf off the vehicle we're moving to. + */ +/obj/vehicle/multitile/proc/_carry_remove_with_grabs(mob/living/user, turf/dest) + var/list/grabbed_things = list() + for(var/obj/item/grab/G in list(user.l_hand, user.r_hand)) + if(G?.grabbed_thing) + grabbed_things += G.grabbed_thing + + for(var/atom/movable/thing as anything in grabbed_things) + if(isliving(thing)) // GRAB is on MOB + var/mob/living/L = thing + _remove_from_top_buckled_to(L, dest) + clear_on_top(L) + L.forceMove(dest) + + else if(isobj(thing)) // GRAB is on OBJ + var/obj/O = thing + if(O.is_allowed_atop_vehicle) + _remove_from_top_buckled_entity(O, dest) + + clear_on_top(user) + user.forceMove(dest) + +/// Brings a roller bed `L` is buckled to down off the vehicle alongside them. +/obj/vehicle/multitile/proc/_remove_from_top_buckled_to(mob/living/L, turf/dest) + if(L.buckled && istype(L.buckled, /obj/structure/bed/roller)) + var/obj/structure/bed/roller/R = L.buckled + R.forceMove(dest) + obj_clear_on_top(R) + +/// Brings whatever's buckled to `object` (a roller bed's mob/bodybag) down off the vehicle alongside it. +/obj/vehicle/multitile/proc/_remove_from_top_buckled_entity(obj/object, turf/dest) + if(istype(object, /obj/structure/bed/roller)) + var/obj/structure/bed/roller/R = object + + if(R.buckled_mob) + var/mob/living/L = R.buckled_mob + obj_clear_on_top(object) // clear first then move to prevent buckled mobs from getting debuffed for improperly dismounting. + object.forceMove(dest) + clear_on_top(L) + + else if (R.buckled_bodybag) + var/obj/structure/closet/bodybag/BB = R.buckled_bodybag + object.forceMove(dest) + obj_clear_on_top(object) + BB.forceMove(dest) // must be moved last to avoid unbuckling + obj_clear_on_top(BB) + + else // empty roller bed + object.forceMove(dest) + obj_clear_on_top(object) + + else if (istype(object, /obj/structure/closet/bodybag)) // Grab is on a bodybag, possibly atop a roller. + var/obj/structure/closet/bodybag/BB = object + + if (BB.roller_buckled) + var/obj/structure/bed/roller/R = BB.roller_buckled + R.forceMove(dest) + obj_clear_on_top(R) + BB.forceMove(dest) // must be moved last to avoid unbuckling + obj_clear_on_top(BB) + + else // lone bodybag + object.forceMove(dest) + obj_clear_on_top(object) + + else // any other item + object.forceMove(dest) + obj_clear_on_top(object) + +/** + * Whether turf T has anything dense on it, other than a mob. If user and from are both + * supplied, directional obstacles like barricades get a real directional check. Falls back + * to a blanket density scan otherwise. + * + * Arguments: + * * T = The turf being checked for dense atoms. + * * user = Whoever is trying to move there, needed for the directional check. + * * from = The turf the mover is actually coming from, if known. + * + * Returns: + * * TRUE = Something blocks this direction, or something dense that isn't a mob is present. + * * FALSE = The turf is clear, only mobs are present, or the obstacle doesn't block. + */ +/obj/vehicle/multitile/proc/_blocked_except_mobs(turf/T, mob/living/user, turf/from) + if(!istype(T)) + return TRUE + + if(user && from && from != T) + var/direct = get_dir(from, T) + if(direct) + if(from.BlockedExitDirs(user, direct)) + return TRUE + if(T.BlockedPassDirs(user, direct)) + return TRUE + for(var/atom/A in from) + if(A == src || ismob(A)) + continue + if(A.BlockedExitDirs(user, direct)) + return TRUE + for(var/atom/A in T) + if(A == src || ismob(A)) + continue + if(A.BlockedPassDirs(user, direct)) + return TRUE + return FALSE + + if(T.density) + return TRUE + for(var/atom/A in T) + if(A == src) + continue + if(ismob(A)) + continue + if(A.density) + return TRUE + return FALSE + +/** + * Whether spot is a valid climb target: same z, adjacent to user, and not blocked by + * anything other than a mob. + * + * Arguments: + * * user = Whoever wants to climb onto/down from spot. + * * spot = The turf being climbed onto or down from. + * + * Returns: + * * TRUE = Nothing is blocking the turf. + * * FALSE = Something is blocking the turf. + */ +/obj/vehicle/multitile/proc/_validate_climb_target(mob/living/user, turf/spot) + if(!istype(user) || !istype(spot)) + return FALSE + if(user.z != z) + return FALSE + var/turf/user_turf = get_turf(user) + if(get_dist(spot, user_turf) != 1) + return FALSE + if(_blocked_except_mobs(spot, user, user_turf)) + return FALSE + return TRUE + +//----------------------------- +// Rider tracking across movement. Keeps riders correctly positioned atop the hull whenever +// this vehicle moves, rotates, crashes, or gets knocked back. +//----------------------------- + +/** + * Joustles mobs riding atop this vehicle in a random direction when it crashes with enough speed. + * A mob thrown clear of the hull gets a weaken; a mob thrown into a wall or off an edge falls off entirely. + * Objs scatter the same way, except any flagged immune_to_tank_crash_scatter. + * + * Arguments: + * * force_momentum = Momentum value to gate and scale this scatter on. + * * min_force = force_momentum must reach at least this to trigger anything. + * * exclude_xenos = If TRUE, xeno riders are left alone entirely. + */ +/obj/vehicle/multitile/proc/_scatter_riders_on_crash(force_momentum = move_momentum, min_force = 1.6, exclude_xenos = FALSE) + if(abs(force_momentum) < min_force) + return + + var/sweep_range = 3 + var/src_z = src.z + var/list/src_locs = src.locs + + for(var/obj/O in on_top_obj.Copy()) + if(!O || O.z != src_z) + obj_clear_on_top(O) + continue + + if(O.immune_to_tank_crash_scatter) + continue + + var/turf/start = get_turf(O) + var/throw_dir = get_dir(src, start) + if(!throw_dir) + throw_dir = pick(GLOB.cardinals) + + var/turf/target = get_step(start, throw_dir) + if(!target || (target in src_locs) || target.density) + step_away(O, src, sweep_range, 3) + var/turf/cur = get_turf(O) + target = get_step(cur, throw_dir) + + if(target && !(target in src_locs) && !target.density) + obj_clear_on_top(O) + O.throw_atom(target, sweep_range, SPEED_FAST) + else if(!(start in src_locs)) + obj_clear_on_top(O) + + for(var/mob/living/M in on_top_mobs.Copy()) + if(!M || M.z != src_z) + clear_on_top(M) + continue + + if(exclude_xenos && isxeno(M)) + continue + + var/turf/start = get_turf(M) + var/throw_dir = get_dir(src, start) + if(!throw_dir) + throw_dir = pick(GLOB.cardinals) + + var/turf/next_out = get_step(start, throw_dir) + + if(next_out && !(next_out in src_locs) && !next_out.density) + M.forceMove(next_out) + clear_on_top(M) + else + step_away(M, src, sweep_range, 3) + var/turf/cur = get_turf(M) + var/turf/next2 = get_step(cur, throw_dir) + if(next2 && !(next2 in src_locs) && !next2.density) + M.forceMove(next2) + clear_on_top(M) + + to_chat(M, SPAN_WARNING("You're thrown from [src]!")) + playsound(M, "punch", 25, TRUE) + shake_camera(M, 2, 1) + + var/turf/final_pos = get_turf(M) + + if(!(final_pos in src_locs)) + M.apply_effect(3, WEAKEN) + clear_on_top(M) + continue + + var/list/hull_neighbors = list() + for(var/d in GLOB.cardinals) + var/turf/H = get_step(final_pos, d) + if(H && (H in src_locs) && !H.density) + hull_neighbors += H + + if(hull_neighbors.len) + var/turf/spot = pick(hull_neighbors) + M.forceMove(spot) + M.apply_effect(1.5, WEAKEN) + mark_on_top(M) + +/// Whether this vehicle is currently moving too fast to safely carry riders, per desant_momentum_cap. +/obj/vehicle/multitile/proc/exceeds_desant_cap() + if(!desant_momentum_cap || !move_max_momentum) + return FALSE + return (abs(move_momentum) / move_max_momentum) >= desant_momentum_cap + +/** + * Throws every non-xeno rider and item off this vehicle once it's moving too fast to carry them + * safely, per desant_momentum_cap. No-ops if the cap isn't currently exceeded. Reuses + * _scatter_riders_on_crash()'s own throw physics, minus its crash-force floor and with xenos exempt. + */ +/obj/vehicle/multitile/proc/_joust_riders_over_desant_cap() + if(!exceeds_desant_cap()) + return + if(!length(on_top_mobs) && !length(on_top_obj)) + return + _scatter_riders_on_crash(move_momentum, min_force = 0, exclude_xenos = TRUE) + +/** + * Builds a matrix of this vehicle's occupied tiles and returns a consistent anchor coordinate pair + * from it. Not necessarily the true geometric center for an even-width footprint, but consistent + * between an old and new position, which is all the rider-translation math needs. + * + * Returns: + * * A list of two integers, {x, y}. + */ +/obj/vehicle/multitile/proc/_current_center() + var/first = TRUE + var/minx + var/miny + var/maxx + var/maxy + for(var/turf/T in locs) + if(first) + minx = maxx = T.x + miny = maxy = T.y + first = FALSE + else + if(T.x < minx) + minx = T.x + if(T.x > maxx) + maxx = T.x + if(T.y < miny) + miny = T.y + if(T.y > maxy) + maxy = T.y + + return list(minx + 1, miny + 1) + +/** + * Calculates how many 90-degree rotations are needed to go from one cardinal direction to another. + * + * Arguments: + * * old_dir = The direction we are initially at. + * * new_dir = The direction we want to be at. + * + * Returns: + * * 0 = No rotation needed. + * * 1 = 90 degree clockwise rotation. + * * -1 = 90 degree counter-clockwise rotation. + * * 2 = 180 degree rotation. + */ +/obj/vehicle/multitile/proc/_quarter_turns(old_dir, new_dir) + if(old_dir == new_dir) + return 0 + switch(old_dir) + if(NORTH) + if(new_dir == EAST) + return 1 + if(new_dir == WEST) + return -1 + if(new_dir == SOUTH) + return 2 + if(SOUTH) + if(new_dir == WEST) + return 1 + if(new_dir == EAST) + return -1 + if(new_dir == NORTH) + return 2 + if(EAST) + if(new_dir == SOUTH) + return 1 + if(new_dir == NORTH) + return -1 + if(new_dir == WEST) + return 2 + if(WEST) + if(new_dir == NORTH) + return 1 + if(new_dir == SOUTH) + return -1 + if(new_dir == EAST) + return 2 + return 0 + +/** + * Rotates an offset (dx, dy) by 90-degree increments, per _quarter_turns()'s result. + * + * Arguments: + * * dx = X offset relative to the vehicle. + * * dy = Y offset relative to the vehicle. + * * k = Rotation amount from _quarter_turns(). + * + * Returns: + * * A list of two integers, the rotated {dx, dy}. + */ +/obj/vehicle/multitile/proc/_rotated_offset(dx, dy, k) + if(!k) + return list(dx, dy) + if(k == 2) + return list(-dx, -dy) // 180° + if(k == 1) + return list(dy, -dx) // CW 90° + // k == -1 (CCW 90°) + return list(-dy, dx) + +/** + * Coordinates moving every rider atop this vehicle whenever it moves or rotates. Combines + * translation, rotation, and collision detection to keep riders correctly positioned on the hull. + * + * Arguments: + * * old_cx = X position this vehicle occupied before this move. + * * old_cy = Y position this vehicle occupied before this move. + * * old_z = Z-level this vehicle was on before this move. + * * old_dir = Direction this vehicle was facing before this move. + * * new_cx = X position this vehicle is moving to. + * * new_cy = Y position this vehicle is moving to. + * * new_dir = Direction this vehicle is facing now. + * * fallback_direction = Travel direction to use when from/target aren't a plain adjacent step. + */ +/obj/vehicle/multitile/proc/_update_riders_after_motion(old_cx, old_cy, old_z, old_dir, new_cx, new_cy, new_dir, fallback_direction = 0) + var/k = _quarter_turns(old_dir, new_dir) // -1,0,1,2 + var/src_z = src.z + var/list/src_locs = src.locs + + // simpler version for objs. Checked against old_z so a stairs z-transition doesn't drop riders. + for(var/obj/O in on_top_obj.Copy()) + if(!O || O.z != old_z) + obj_clear_on_top(O) + continue + + // Don't move body bags atop roller beds. We will handle those when we move the roller beds themselves. + if(istype(O, /obj/structure/closet/bodybag)) + var/obj/structure/closet/bodybag/BB = O + if(BB.roller_buckled) + continue + + var/turf/from = get_turf(O) + if(!from) + obj_clear_on_top(O) + continue + + var/list/rd = _rotated_offset(from.x - old_cx, from.y - old_cy, k) + var/turf/target = locate(new_cx + rd[1], new_cy + rd[2], src_z) + + if(target && (target in src_locs) && target != from) + O.forceMove(target) + obj_mark_on_top(O) + if(istype(O, /obj/structure/bed/roller)) + var/obj/structure/bed/roller/R = O + if(R.buckled_bodybag) + R.buckled_bodybag.forceMove(target) + obj_mark_on_top(R.buckled_bodybag) + else if(from in src_locs) + obj_mark_on_top(O) + else + obj_clear_on_top(O) + + for(var/mob/living/M in on_top_mobs.Copy()) + if(!M || M.z != old_z) + clear_on_top(M) + continue + + // If the mob is buckled to something, we'll skip it, because the buckled obj has already moved with it. + if(M.buckled) + continue + var/turf/from = get_turf(M) + if(!from) + clear_on_top(M) + continue + + // Offset from OLD center -> rotate by k -> translate to NEW center + var/list/rd = _rotated_offset(from.x - old_cx, from.y - old_cy, k) + var/turf/target = locate(new_cx + rd[1], new_cy + rd[2], src_z) + + // this shouldn't happen, but just to be safe. + if(!target || !(target in src_locs)) + if(from in src_locs) + mark_on_top(M) + else + clear_on_top(M) + continue + + // If target is blocked by a non-rider dense atom, keep if possible. Direction-aware for a single + // step, falls back to a blanket density check otherwise (e.g. a rotation). + // + // A stairs transition is a special case, so we use fallback_direction instead of get_dir(). + var/direct = (from.z == target.z && get_dist(from, target) <= 1) ? get_dir(from, target) : fallback_direction + var/blocked = FALSE + if(direct) + if(target.BlockedPassDirs(M, direct) || from.BlockedExitDirs(M, direct)) + blocked = TRUE + if(!blocked) + for(var/atom/A in target) + if(A == src || ismob(A)) + // Mobs never block a rider from moving into their own relative slot, riders can + // share a tank tile now. + continue + if(A.BlockedPassDirs(M, direct)) + blocked = TRUE + break + if(!blocked) + for(var/atom/A in from) + if(A == src || ismob(A)) + continue + if(A.BlockedExitDirs(M, direct)) + blocked = TRUE + break + else + blocked = target.density // Start with turf density check + if(!blocked) + for(var/atom/A in target) + if(A != src && !ismob(A) && A.density) + blocked = TRUE + break + + if(blocked) + if(from in src_locs) + mark_on_top(M) + else + clear_on_top(M) + continue + + // Move and keep elevation + if(target != from) + M.forceMove(target) + mark_on_top(M) + + addtimer(CALLBACK(src, PROC_REF(_joust_riders_over_desant_cap)), 0) diff --git a/code/modules/vehicles/multitile/multitile_stairs.dm b/code/modules/vehicles/multitile/multitile_stairs.dm new file mode 100644 index 000000000000..9e4808c9b35b --- /dev/null +++ b/code/modules/vehicles/multitile/multitile_stairs.dm @@ -0,0 +1,151 @@ +/* + * Lets a multitile vehicle climb or descend a matching multiz staircase, sized to its own + * footprint. Dispatched from try_move() (multitile_movement.dm) via get_stairs_edge_direction(). + */ + +/// How many tiles deep this vehicle's own footprint is along `direction`, the travel axis. +/obj/vehicle/multitile/proc/get_stairs_travel_distance(direction) + var/bound_width_tiles = bound_width / world.icon_size + var/bound_height_tiles = bound_height / world.icon_size + return (direction & (NORTH|SOUTH)) ? bound_height_tiles : bound_width_tiles + +/** + * Checks whether this vehicle's edge facing `diretction` is a full row of matching multiz stairs, + * sized to its own footprint width. + * + * Returns UP, DOWN, or null if there's no full match. + */ +/obj/vehicle/multitile/proc/get_stairs_edge_direction(direction) + var/bound_width_tiles = bound_width / world.icon_size + var/bound_height_tiles = bound_height / world.icon_size + var/expected_edge_width = (direction & (NORTH|SOUTH)) ? bound_width_tiles : bound_height_tiles + + var/list/turf/edge_turfs = list() + for(var/turf/T in locs) + var/turf/next = get_step(T, direction) + if(!(next in locs)) + edge_turfs += T + + if(length(edge_turfs) != expected_edge_width) + // Geometrically shouldn't happen for an intact footprint, but bail safely if it does. + return + + var/up_match = TRUE + var/down_match = TRUE + for(var/turf/T in edge_turfs) + var/obj/structure/stairs/multiz/up/up_stairs = locate() in T + var/obj/structure/stairs/multiz/down/down_stairs = locate() in T + if(!up_stairs || up_stairs.dir != direction) + up_match = FALSE + if(!down_stairs || down_stairs.dir != REVERSE_DIR(direction)) + down_match = FALSE + + if(up_match) + return UP + if(down_match) + return DOWN + +/** + * Moves this vehicle up or down a validated stairs edge, its own footprint depth forward so it + * clears the support wall. Returns TRUE on success, FALSE on a crash. + */ +/obj/vehicle/multitile/proc/attempt_stairs_transition(direction, travel) + var/list/old_center_xy = _current_center() + var/turf/old_center = locate(old_center_xy[1], old_center_xy[2], z) + var/travel_distance = get_stairs_travel_distance(direction) + var/turf/same_z_target = old_center + for(var/i in 1 to travel_distance) + same_z_target = get_step(same_z_target, direction) + if(!same_z_target) + return crash_stairs_transition() + + var/turf/actual_center = (travel == UP) ? SSmapping.get_turf_above(same_z_target) : SSmapping.get_turf_below(same_z_target) + if(!actual_center) + return crash_stairs_transition() + + var/bound_x_tiles = bound_x / world.icon_size + var/bound_y_tiles = bound_y / world.icon_size + var/bound_width_tiles = bound_width / world.icon_size + var/bound_height_tiles = bound_height / world.icon_size + var/turf/dest_min_turf = locate(actual_center.x + bound_x_tiles, actual_center.y + bound_y_tiles, actual_center.z) + var/list/dest_turfs = CORNER_BLOCK(dest_min_turf, bound_width_tiles, bound_height_tiles) + + if(length(dest_turfs) != bound_width_tiles * bound_height_tiles) + return crash_stairs_transition() + + // validate + for(var/turf/T as anything in dest_turfs) + if(!T || T.density) + return crash_stairs_transition() + for(var/atom/A in T) + if(!A.density) + continue // harmless clutter, including stairs anti build markers + if(istype(A, /obj/vehicle)) + return crash_stairs_transition() + if(isliving(A)) + var/mob/living/blocker = A + if(blocker.would_block_tank_stairs(src)) + return crash_stairs_transition() + continue + if(istype(A, /obj/structure/platform)) + // Platform railings block head on but let the vehicle pass from behind. + if(A.dir == direction) + continue + if(REVERSE_DIR(A.dir) == direction) + return crash_stairs_transition() + continue + if(is_blocking_structure(A)) + return crash_stairs_transition() + + // commit + for(var/turf/T as anything in dest_turfs) + for(var/atom/A in T) + if(QDELETED(A) || !A.density) + continue + if(istype(A, /obj/structure/platform)) + continue // never touched, Pass 1 already crashed if this one was facing us + if(isliving(A)) + var/mob/living/mover = A + // Push the mob forward, forceMove so platform railings don't block it. + var/turf/farthest_clear = get_turf(mover) + for(var/i in 1 to travel_distance) + var/turf/next_step = get_step(farthest_clear, direction) + if(!next_step || next_step.density) + break + farthest_clear = next_step + if(farthest_clear != get_turf(mover)) + shake_camera(mover, steps = 10, strength = 1) + mover.forceMove(farthest_clear) + playsound(mover, "punch", 25, 1) // same cue as a normal ram shove + else + A.handle_vehicle_bump(src) + + var/turf/old_turf = get_turf(src) + var/list/old_locs = locs.Copy() + forceMove(actual_center) + var/turf/current_loc = get_turf(src) + + update_covered_barricades(old_locs) + for(var/obj/item/hardpoint/H in hardpoints) + H.on_move(old_turf, current_loc, direction) + + if(movement_sound && world.time > move_next_sound_play) + playsound(src, movement_sound, vol = 20, sound_range = 30) + move_next_sound_play = world.time + 10 + + last_move_dir = direction + + return TRUE + +/// Feedback for a failed stairs climb, mirrors a normal blocked move. +/obj/vehicle/multitile/proc/crash_stairs_transition() + var/already_resolved = skip_generic_crash_response + skip_generic_crash_response = FALSE + if(world.time >= next_crash_effect) + next_crash_effect = world.time + 5 + if(!already_resolved) + on_crash() + interior_crash_effect() + if(!already_resolved) + halve_speed() + return FALSE diff --git a/code/modules/vehicles/multitile/multitile_verbs.dm b/code/modules/vehicles/multitile/multitile_verbs.dm index df53803f9f78..78be3087e598 100644 --- a/code/modules/vehicles/multitile/multitile_verbs.dm +++ b/code/modules/vehicles/multitile/multitile_verbs.dm @@ -4,7 +4,7 @@ //Used to swap which module a position is using -//e.g. swapping primary gunner from the minigun to the smoke launcher +//e.g. swapping primary gunner from the minigun to the flare launcher /obj/vehicle/multitile/proc/switch_hardpoint() set name = "Change Active Hardpoint" set category = "Vehicle" @@ -39,13 +39,14 @@ if(HP.ammo) msg += " Ammo: [SPAN_HELPFUL(HP.ammo.current_rounds)]/[SPAN_HELPFUL(HP.ammo.max_rounds)] | Mags: [SPAN_HELPFUL(LAZYLEN(HP.backup_clips))]/[SPAN_HELPFUL(HP.max_clips)]" to_chat(M, SPAN_WARNING(msg)) + V.refresh_hardpoint_actions() //cycles through hardpoints in a activatable hardpoints list without asking anything -/obj/vehicle/multitile/proc/cycle_hardpoint() +/obj/vehicle/multitile/proc/cycle_hardpoint(mob/toggler) set name = "Cycle Active Hardpoint" set category = "Vehicle" - var/mob/M = usr + var/mob/M = toggler || usr if(!M || !istype(M)) return @@ -79,13 +80,14 @@ if(HP.ammo) msg += " Ammo: [SPAN_HELPFUL(HP.ammo.current_rounds)]/[SPAN_HELPFUL(HP.ammo.max_rounds)] | Mags: [SPAN_HELPFUL(LAZYLEN(HP.backup_clips))]/[SPAN_HELPFUL(HP.max_clips)]" to_chat(M, SPAN_WARNING(msg)) + V.refresh_hardpoint_actions() // Used to lock/unlock the vehicle doors to anyone without proper access -/obj/vehicle/multitile/proc/toggle_door_lock() +/obj/vehicle/multitile/proc/toggle_door_lock(mob/toggler) set name = "Toggle Door Locks" set category = "Vehicle" - var/mob/M = usr + var/mob/M = toggler || usr if(!M || !istype(M)) return @@ -101,6 +103,27 @@ V.door_locked = !V.door_locked to_chat(M, SPAN_NOTICE("You [V.door_locked ? "lock" : "unlock"] the vehicle doors.")) + V.refresh_hardpoint_actions() + +/// Manually toggles this vehicle's IFF module online/offline. Actual gating lives on the module itself. +/obj/vehicle/multitile/proc/toggle_iff_module(mob/toggler) + set name = "Toggle IFF" + set category = "Vehicle" + + var/mob/user = toggler || usr + if(!istype(user)) + return + + var/obj/vehicle/multitile/vehicle = user.interactee + if(!istype(vehicle)) + return + + var/obj/item/hardpoint/iff_module/module = locate() in vehicle.get_hardpoints_copy() + if(!module) + to_chat(user, SPAN_WARNING("No IFF module installed.")) + return + + module.toggle_online(user) //opens vehicle status window with HP and ammo of hardpoints /obj/vehicle/multitile/proc/get_status_info() @@ -211,22 +234,32 @@ 2. \"G: Toggle Door Locks\" - toggles vehicle's access restrictions. Crewman, Brig and Command accesses bypass these restrictions.
\ Gunner verbs:
1. \"A: Cycle Active Hardpoint\" - works similarly to one above, except it automatically switches to next hardpoint in a list allowing you to switch faster.
\ 2. \"G: Toggle Middle/Shift Clicking\" - toggles between using Middle Mouse Button click and Shift + Click to fire not currently selected weapon if possible.
\ - 3. \"G: Toggle Turret Gyrostabilizer\" - toggles Turret Gyrostabilizer allowing it to keep current direction ignoring hull turning. (Exists only on vehicles with rotating turret, e.g. M34A2 Longstreet Light Tank)
\ + 3. \"G: Toggle Turret Rotation and Hardpoint Safety\" - locks the turret to the hull's own facing, blocks mouse-driven aim, and prevents any mounted hardpoint from firing. The turret is gyrostabilized (holds its own facing independent of hull rotation) automatically whenever the gunner's seat is occupied, unless this is on. (Exists only on vehicles with rotating turret, e.g. M34A2 Longstreet Light Tank)
\ Support Gunner verbs:
1. \"Reload Firing Port Weapon\" - initiates automated reloading process for M56 FPW. Requires a confirmation.
\ Driver shortcuts:
1. \"CTRL + Click\" - activates vehicle horn.
\ - Gunner shortcuts:
1. \"ALT + Click\" - toggles Turret Gyrostabilizer. (Exists only on vehicles with rotating turret, e.g. M34A2 Longstreet Light Tank)
" + Gunner shortcuts:
1. \"ALT + Click\" - toggles Turret Rotation and Hardpoint Safety. (Exists only on vehicles with rotating turret, e.g. M34A2 Longstreet Light Tank)
" show_browser(user, dat, "Vehicle Controls Guide", "vehicle_help", width = 900, height = 500) onclose(user, "vehicle_help") return -//toggles gyrostabilizer for vehicles that have turret, allowing it to keep direction regardless hull rotations -/obj/vehicle/multitile/proc/toggle_gyrostabilizer() - set name = "Toggle Turret Gyrostabilizer" - set desc = "Toggles Turret Gyrostabilizer allowing it independent movement regardless of hull direction." +/// This vehicle's own gyro-capable hardpoint: its turret holder, or a rotation-tracked top-level hardpoint. +/obj/vehicle/multitile/proc/get_gyro_hardpoint() as /obj/item/hardpoint + var/obj/item/hardpoint/holder/tank_turret/turret = locate() in hardpoints + if(turret) + return turret + for(var/obj/item/hardpoint/H in hardpoints) + if(H.uses_live_rotation_tracking) + return H + return null + +// Toggles the turret's (or a top-level tracked weapon's) safety lock. +/obj/vehicle/multitile/proc/toggle_turret_safety(mob/toggler) + set name = "Toggle Turret Rotation and Hardpoint Safety" + set desc = "Toggles the turret's rotation/hardpoint safety lock - stops it from following the mouse or firing, and returns it to the hull's own facing." set category = "Vehicle" - var/mob/M = usr + var/mob/M = toggler || usr if(!M || !istype(M)) return @@ -234,13 +267,96 @@ if(!istype(V)) return - var/obj/item/hardpoint/holder/tank_turret/T = null - for(var/obj/item/hardpoint/holder/tank_turret/TT in V.hardpoints) - T = TT - break + var/obj/item/hardpoint/T = V.get_gyro_hardpoint() if(!T) return - T.toggle_gyro(usr) + T.toggle_turret_safety(M) + V.refresh_hardpoint_actions() + +//toggles the artillery module's magnified optics for your own view - available to both driver and gunner +/obj/vehicle/multitile/proc/toggle_artillery_optics(mob/toggler) + set name = "Toggle Artillery Optics" + set desc = "Toggles the artillery module's magnified optics for your own view." + set category = "Vehicle" + + var/mob/living/user = toggler || usr + if(!user || !istype(user)) + return + + var/obj/vehicle/multitile/vehicle = user.interactee + if(!istype(vehicle)) + return + + var/obj/item/hardpoint/support/artillery_module/AM = locate() in vehicle.hardpoints + if(!AM) + to_chat(user, SPAN_WARNING("No artillery module installed.")) + return + + AM.toggle_optics(user) + +//toggles the artillery module's night vision overlay for your own view - available to both driver and gunner +/obj/vehicle/multitile/proc/toggle_artillery_nvg(mob/toggler) + set name = "Toggle Artillery Night Vision" + set desc = "Toggles the artillery module's night vision overlay for your own view." + set category = "Vehicle" + + var/mob/living/user = toggler || usr + if(!user || !istype(user)) + return + + var/obj/vehicle/multitile/vehicle = user.interactee + if(!istype(vehicle)) + return + + var/obj/item/hardpoint/support/artillery_module/AM = locate() in vehicle.hardpoints + if(!AM) + to_chat(user, SPAN_WARNING("No artillery module installed.")) + return + + AM.toggle_nvg(user) + +//hands control of the secondary hardpoint (aim + fire) between the gunner and the driver +/obj/vehicle/multitile/proc/toggle_slave_secondary_to_driver(mob/toggler) + set name = "Slave Secondary to Driver" + set desc = "Toggles handing control of the secondary weapon's aim and fire between yourself and the driver." + set category = "Vehicle" + + var/mob/user = toggler || usr + if(!user || !istype(user)) + return + + var/obj/vehicle/multitile/vehicle = user.interactee + if(!istype(vehicle)) + return + + var/obj/item/hardpoint/secondary/secondary_weapon = vehicle.get_slavable_secondary() + if(!secondary_weapon) + to_chat(user, SPAN_WARNING("No secondary weapon installed.")) + return + + secondary_weapon.toggle_slaved_to_driver(user) + vehicle.refresh_hardpoint_actions() + +//toggles the fire mode for hardpoints. currently only used for the primary and secondary flamers to switch between glob and stream +/obj/vehicle/multitile/proc/toggle_hardpoint_fire_mode() + set name = "Toggle Hardpoint Fire Mode" + set desc = "Toggles the fire mode of your currently selected hardpoint, if it supports one." + set category = "Vehicle" + + var/mob/user = usr + if(!user || !istype(user)) + return + + var/obj/vehicle/multitile/vehicle = user.interactee + if(!istype(vehicle)) + return + + var/obj/item/hardpoint/selected_hardpoint = vehicle.get_mob_hp(user) + if(!selected_hardpoint) + to_chat(user, SPAN_WARNING("You have no hardpoint selected.")) + return + + selected_hardpoint.toggle_fire_mode(usr) //single use verb that allows VCs to add a nickname in "" at the end of their vehicle name /obj/vehicle/multitile/proc/name_vehicle() @@ -324,6 +440,229 @@ to_chat(user, SPAN_NOTICE("You activate vehicle's horn.")) V.perform_honk() +//shifts up through the gear-transmission's GLOB.vehicle_gear_order, driver only +/obj/vehicle/multitile/proc/cycle_gear_up() + set name = "Shift Gear Up" + set category = "Vehicle" + + var/mob/user = usr + if(!istype(user)) + return + + var/obj/vehicle/multitile/vehicle = user.interactee + if(!istype(vehicle) || !vehicle.uses_gear_transmission) + return + + // The matching keybinding calls this directly, so a gunner could otherwise shift gears via hotkey. + if(vehicle.get_mob_seat(user) != VEHICLE_DRIVER) + return + + if(vehicle.get_driver_vehicle_prefs() & VEHICLE_SIMPLE_TRANSMISSION) + to_chat(user, SPAN_WARNING("Your vehicle preferences have simple transmission enabled - the vehicle shifts gears on its own.")) + return + + to_chat(user, SPAN_NOTICE("You shift into [vehicle.cycle_gear(1)].")) + +//shifts down through the gear-transmission's GLOB.vehicle_gear_order, driver only +/obj/vehicle/multitile/proc/cycle_gear_down() + set name = "Shift Gear Down" + set category = "Vehicle" + + var/mob/user = usr + if(!istype(user)) + return + + var/obj/vehicle/multitile/vehicle = user.interactee + if(!istype(vehicle) || !vehicle.uses_gear_transmission) + return + + // Needed because the keybinding calls this directly, see cycle_gear_up()'s matching check. + if(vehicle.get_mob_seat(user) != VEHICLE_DRIVER) + return + + if(vehicle.get_driver_vehicle_prefs() & VEHICLE_SIMPLE_TRANSMISSION) + to_chat(user, SPAN_WARNING("Your vehicle preferences have simple transmission enabled - the vehicle shifts gears on its own.")) + return + + to_chat(user, SPAN_NOTICE("You shift into [vehicle.cycle_gear(-1)].")) + +//toggles cruise control, driver only. While on, gas/brake adjust the target speed instead. +/obj/vehicle/multitile/proc/toggle_cruise_control() + set name = "Toggle Cruise Control" + set category = "Vehicle" + + var/mob/user = usr + if(!istype(user)) + return + + var/obj/vehicle/multitile/vehicle = user.interactee + if(!istype(vehicle) || !vehicle.uses_gear_transmission) + return + + // Needed because the keybinding calls this directly, see cycle_gear_up()'s matching check. + if(vehicle.get_mob_seat(user) != VEHICLE_DRIVER) + return + + if(!vehicle.cruise_control_enabled && (vehicle.get_driver_vehicle_prefs() & VEHICLE_SIMPLE_ACCELERATION)) + to_chat(user, SPAN_WARNING("Cruise control isn't available with simple vehicle acceleration enabled.")) + return + + vehicle.cruise_control_enabled = !vehicle.cruise_control_enabled + if(vehicle.cruise_control_enabled) + vehicle.cruise_control_target_speed = vehicle.current_speed + vehicle.start_momentum_decay_if_needed() + // Drifting is only available with cruise control off, cancel one in progress instead of decaying it. + vehicle.drift_speed = 0 + vehicle.drift_direction = 0 + to_chat(user, SPAN_NOTICE("Cruise control engaged, holding [round(vehicle.current_speed, 0.1)] tiles/s. Gas/brake now adjust the target speed.")) + else + to_chat(user, SPAN_WARNING("Cruise control disengaged.")) + +//adjusts how much a single gas/brake press changes cruise control target speed, driver only. +/obj/vehicle/multitile/proc/set_cruise_control_granularity() + set name = "Set Cruise Control Granularity" + set category = "Vehicle" + + var/mob/user = usr + if(!istype(user)) + return + + var/obj/vehicle/multitile/vehicle = user.interactee + if(!istype(vehicle) || !vehicle.uses_gear_transmission) + return + + var/new_granularity = input(user, "How much should each gas/brake press change the cruise control target speed by, in tiles/sec?", "Cruise Control Granularity", vehicle.cruise_control_granularity) as num|null + if(isnull(new_granularity) || new_granularity <= 0) + return + if(vehicle.gear_stats && vehicle.gear_stats["D"] && new_granularity > vehicle.gear_stats["D"]["max_speed"]) + to_chat(user, SPAN_WARNING("That's more than the vehicle's top speed - pick something smaller.")) + return + + vehicle.cruise_control_granularity = new_granularity + to_chat(user, SPAN_NOTICE("Cruise control granularity set to [new_granularity] tiles/s per press.")) + +//toggles the engine on/off, driver only. Off blocks driving and stops the engine sound loop. +/obj/vehicle/multitile/proc/toggle_engine(mob/toggler) + set name = "Toggle Engine" + set category = "Vehicle" + + var/mob/user = toggler || usr + if(!istype(user)) + return + + var/obj/vehicle/multitile/vehicle = user.interactee + if(!istype(vehicle) || !vehicle.uses_gear_transmission) + return + + // Explicit seat check, an action button can call this directly unlike the driver-only verb list. + if(vehicle.get_mob_seat(user) != VEHICLE_DRIVER) + return + + if(world.time < vehicle.next_engine_toggle_time) + to_chat(user, SPAN_WARNING("The ignition needs a moment to reset before you can toggle it again.")) + return + + // Only the starting transition needs gating, engine_on being TRUE already means a healthy engine. + var/obj/item/hardpoint/engine/engine_hp = vehicle.get_engine_hardpoint() + if(!vehicle.engine_on && (!engine_hp || engine_hp.health <= 0)) + to_chat(user, SPAN_WARNING("There's no working engine installed - the engine can't start.")) + return + + // A busted battery can't deliver power to the starter regardless of leftover current_charge. + var/obj/item/hardpoint/battery/battery = vehicle.get_battery_hardpoint() + if(!vehicle.engine_on && (!battery || battery.health <= 0)) + to_chat(user, SPAN_WARNING("There's no working battery installed - the engine can't start.")) + return + + if(!vehicle.engine_on && !vehicle.has_fuel()) + to_chat(user, SPAN_WARNING("There's no fuel - the engine can't start.")) + return + + vehicle.next_engine_toggle_time = world.time + 5 SECONDS + vehicle.set_engine_on(!vehicle.engine_on) + to_chat(user, SPAN_NOTICE("You [vehicle.engine_on ? "start" : "shut off"] the engine.")) + vehicle.refresh_hardpoint_actions() + +//toggles the north blinker signal, driver only. +/obj/vehicle/multitile/proc/toggle_turn_signal_north() + set name = "North Blinker Signal" + set category = "Vehicle" + + var/mob/user = usr + if(!istype(user)) + return + + var/obj/vehicle/multitile/vehicle = user.interactee + if(!istype(vehicle) || !vehicle.uses_gear_transmission) + return + + if(!(vehicle.turn_signal_flags & NORTH) && !vehicle.can_use_turn_signals()) + to_chat(user, SPAN_WARNING("The turn signals need a charged battery or a running engine.")) + return + + vehicle.toggle_turn_signal(NORTH) + to_chat(user, SPAN_NOTICE("North blinker signal [(vehicle.turn_signal_flags & NORTH) ? "on" : "off"].")) + +//toggles the south blinker signal, driver only. +/obj/vehicle/multitile/proc/toggle_turn_signal_south() + set name = "South Blinker Signal" + set category = "Vehicle" + + var/mob/user = usr + if(!istype(user)) + return + + var/obj/vehicle/multitile/vehicle = user.interactee + if(!istype(vehicle) || !vehicle.uses_gear_transmission) + return + + if(!(vehicle.turn_signal_flags & SOUTH) && !vehicle.can_use_turn_signals()) + to_chat(user, SPAN_WARNING("The turn signals need a charged battery or a running engine.")) + return + + vehicle.toggle_turn_signal(SOUTH) + to_chat(user, SPAN_NOTICE("South blinker signal [(vehicle.turn_signal_flags & SOUTH) ? "on" : "off"].")) + +//toggles the east blinker signal, driver only. +/obj/vehicle/multitile/proc/toggle_turn_signal_east() + set name = "East Blinker Signal" + set category = "Vehicle" + + var/mob/user = usr + if(!istype(user)) + return + + var/obj/vehicle/multitile/vehicle = user.interactee + if(!istype(vehicle) || !vehicle.uses_gear_transmission) + return + + if(!(vehicle.turn_signal_flags & EAST) && !vehicle.can_use_turn_signals()) + to_chat(user, SPAN_WARNING("The turn signals need a charged battery or a running engine.")) + return + + vehicle.toggle_turn_signal(EAST) + to_chat(user, SPAN_NOTICE("East blinker signal [(vehicle.turn_signal_flags & EAST) ? "on" : "off"].")) + +//toggles the west blinker signal, driver only. +/obj/vehicle/multitile/proc/toggle_turn_signal_west() + set name = "West Blinker Signal" + set category = "Vehicle" + + var/mob/user = usr + if(!istype(user)) + return + + var/obj/vehicle/multitile/vehicle = user.interactee + if(!istype(vehicle) || !vehicle.uses_gear_transmission) + return + + if(!(vehicle.turn_signal_flags & WEST) && !vehicle.can_use_turn_signals()) + to_chat(user, SPAN_WARNING("The turn signals need a charged battery or a running engine.")) + return + + vehicle.toggle_turn_signal(WEST) + to_chat(user, SPAN_NOTICE("West blinker signal [(vehicle.turn_signal_flags & WEST) ? "on" : "off"].")) + /obj/vehicle/multitile/proc/perform_honk() if(honk_sound) playsound(loc, honk_sound, 75, TRUE, 15) //heard within ~15 tiles diff --git a/code/modules/vehicles/powerloader.dm b/code/modules/vehicles/powerloader.dm index 4936de036cb1..d3335e472ce4 100644 --- a/code/modules/vehicles/powerloader.dm +++ b/code/modules/vehicles/powerloader.dm @@ -302,6 +302,16 @@ return load_target_tag = "floodlight" + else if(istype(target, /obj/item/hardpoint)) + var/obj/item/hardpoint/H = target + if(H.owner) + to_chat(user, SPAN_WARNING("\The [src] can't grab \the [target], it's still installed on something.")) + return + if(H.w_class != SIZE_MASSIVE) + to_chat(user, SPAN_WARNING("\The [target] is light enough to carry by hand.")) + return + load_target_tag = "vehicle_module" + if(!load_target_tag) return grab_object(user, target, load_target_tag) diff --git a/code/modules/vehicles/tank/tank.dm b/code/modules/vehicles/tank/tank.dm index 2a8c15f3e338..2c29e9605f18 100644 --- a/code/modules/vehicles/tank/tank.dm +++ b/code/modules/vehicles/tank/tank.dm @@ -25,17 +25,31 @@ "back" = list(0, 2) ) - movement_sound = 'sound/vehicles/tank_driving.ogg' + // Superseded by the tank_engine looping sound. Other vehicles keep this. + movement_sound = null + engine_soundloop_type = /datum/looping_sound/tank_engine honk_sound = 'sound/vehicles/honk_3_ambulence.ogg' + uses_tank_track_sound = TRUE + + engine_on = FALSE required_skill = SKILL_VEHICLE_LARGE vehicle_flags = VEHICLE_CLASS_MEDIUM - move_max_momentum = 3 + move_max_momentum = 2.63 move_momentum_build_factor = 1.8 move_turn_momentum_loss_factor = 0.6 + uses_gear_transmission = TRUE + can_enter_open_space = TRUE + has_own_crusher_charge_handling = TRUE + current_gear = "P" + top_speed = 2.63 + base_acceleration = 0.88 + // 0.5 * 0.67, part of the across-the-board fuel consumption reduction. + base_fuel_use = 0.335 + vehicle_light_range = 7 // Rest (all the guns) is handled by the tank turret hardpoint @@ -48,9 +62,15 @@ /obj/item/hardpoint/armor/caustic, /obj/item/hardpoint/armor/concussive, /obj/item/hardpoint/armor/paladin, - /obj/item/hardpoint/armor/snowplow, + /obj/item/hardpoint/snowplow, /obj/item/hardpoint/locomotion/treads, /obj/item/hardpoint/locomotion/treads/robust, + /obj/item/hardpoint/engine/tank, + /obj/item/hardpoint/fuel_tank/uscm, + /obj/item/hardpoint/radiator/uscm, + /obj/item/hardpoint/battery/uscm, + /obj/item/hardpoint/hatch/armored/uscm, + // IFF module/visual sensors/turret ring/air filter mount on the turret itself, so removing it takes them with it. ) seats = list( @@ -63,6 +83,8 @@ VEHICLE_GUNNER = null, ) + health = 1000 + dmg_multipliers = list( "all" = 1, "acid" = 1.5, // Acid melts the tank @@ -76,6 +98,19 @@ explosive_resistance = 400 minimap_icon_state = "tank" + var/on_top_mobs_shooting_inaccuracy_time = 0 /// world_time must be bigger than this so mobs don't get penalized for shooting while atop the tank. + +/obj/vehicle/multitile/tank/Initialize() + . = ..() + gear_stats = build_gear_stats() + cruise_control_granularity = gear_stats["D"]["max_speed"] * CRUISE_CONTROL_DEFAULT_GRANULARITY_FRACTION + setup_overwatch_camera() + +/obj/vehicle/multitile/tank/update_next_move() + var/anti_build_factor = 1/((max(abs(move_momentum), 1)/move_max_momentum) * move_momentum_build_factor) + on_top_mobs_shooting_inaccuracy_time = world.time + move_delay * move_momentum_build_factor * anti_build_factor * misc_multipliers["move"] * 5 + . = ..() + /obj/vehicle/multitile/tank/initialize_cameras(change_tag = FALSE) if(!camera) camera = new /obj/structure/machinery/camera/vehicle(src) @@ -99,6 +134,12 @@ add_hardpoint(new /obj/item/hardpoint/holder/tank_turret) /obj/vehicle/multitile/tank/add_seated_verbs(mob/living/M, seat) + // Reassign this tank's CIC overwatch to the new driver's squad, if they're a Tank Crewman with one. + // driver.mind is required so a map-placed dummy/mannequin can never trigger an assignment just by existing pre-buckled. + if(seat == VEHICLE_DRIVER && ishuman(M)) + var/mob/living/carbon/human/driver = M + if(driver.mind && driver.job == JOB_TANK_CREW && driver.assigned_squad) + update_overwatch_squad(driver.assigned_squad) if(!M.client) return add_verb(M.client, list( @@ -111,12 +152,58 @@ add_verb(M.client, list( /obj/vehicle/multitile/proc/toggle_door_lock, /obj/vehicle/multitile/proc/activate_horn, + /obj/vehicle/multitile/proc/cycle_hardpoint, + /obj/vehicle/multitile/proc/toggle_hardpoint_fire_mode, + /obj/vehicle/multitile/proc/cycle_gear_up, + /obj/vehicle/multitile/proc/cycle_gear_down, + /obj/vehicle/multitile/proc/toggle_cruise_control, + /obj/vehicle/multitile/proc/set_cruise_control_granularity, + /obj/vehicle/multitile/proc/toggle_turn_signal_north, + /obj/vehicle/multitile/proc/toggle_turn_signal_south, + /obj/vehicle/multitile/proc/toggle_turn_signal_east, + /obj/vehicle/multitile/proc/toggle_turn_signal_west, + /obj/vehicle/multitile/proc/toggle_engine, )) + give_action(M, /datum/action/human_action/vehicle_action/toggle_door_lock) + give_action(M, /datum/action/human_action/vehicle_action/toggle_engine) + if(!(M.client.prefs.toggles_vehicle & VEHICLE_SIMPLE_ACCELERATION)) + give_action(M, /datum/action/human_action/vehicle_action/toggle_cruise_control) + give_action(M, /datum/action/human_action/vehicle_action/set_cruise_control_granularity) + RegisterSignal(M, COMSIG_MOB_VEHICLE_PREFS_CHANGED, PROC_REF(on_driver_prefs_changed)) + start_crew_hud(M, VEHICLE_DRIVER) else if(seat == VEHICLE_GUNNER) add_verb(M.client, list( /obj/vehicle/multitile/proc/cycle_hardpoint, - /obj/vehicle/multitile/proc/toggle_gyrostabilizer, + /obj/vehicle/multitile/proc/toggle_turret_safety, + /obj/vehicle/multitile/proc/toggle_slave_secondary_to_driver, + /obj/vehicle/multitile/proc/toggle_hardpoint_fire_mode, + /obj/vehicle/multitile/proc/toggle_iff_module, )) + give_action(M, /datum/action/human_action/vehicle_action/toggle_gyro) + give_action(M, /datum/action/human_action/vehicle_action/toggle_iff) + // seats[VEHICLE_GUNNER] isn't written yet at this point, so gunner_seated is passed explicitly as TRUE. + var/obj/item/hardpoint/holder/tank_turret/turret = locate() in hardpoints + turret?.recalculate_gyro(TRUE) + if(get_slavable_secondary()) + give_action(M, /datum/action/human_action/vehicle_action/slave_secondary) + start_crew_hud(M, VEHICLE_GUNNER) + if(seat == VEHICLE_DRIVER || seat == VEHICLE_GUNNER) + if(get_artillery_module()?.is_functional()) + add_verb(M.client, list( + /obj/vehicle/multitile/proc/toggle_artillery_optics, + /obj/vehicle/multitile/proc/toggle_artillery_nvg, + )) + give_action(M, /datum/action/human_action/vehicle_action/toggle_nvg) + give_action(M, /datum/action/human_action/vehicle_action/toggle_artillery_range) + if(length(get_activatable_hardpoints(seat)) >= 2) + give_action(M, /datum/action/human_action/vehicle_action/cycle_hardpoint) + if(active_hp[seat]?.get_flame_mode()) + give_action(M, /datum/action/human_action/vehicle_action/toggle_hardpoint_fire_mode) + give_action(M, /datum/action/human_action/vehicle_action/use_phone) + // seats[seat] isn't written yet, so the conditional grants above use M directly instead of delegating to this. + refresh_hardpoint_actions() + // Auto-select a default weapon for this seat if it doesn't already have a valid one. + ensure_active_hardpoint(seat) /obj/vehicle/multitile/tank/remove_seated_verbs(mob/living/M, seat) @@ -133,74 +220,406 @@ remove_verb(M.client, list( /obj/vehicle/multitile/proc/toggle_door_lock, /obj/vehicle/multitile/proc/activate_horn, + /obj/vehicle/multitile/proc/cycle_hardpoint, + /obj/vehicle/multitile/proc/toggle_hardpoint_fire_mode, + /obj/vehicle/multitile/proc/cycle_gear_up, + /obj/vehicle/multitile/proc/cycle_gear_down, + /obj/vehicle/multitile/proc/toggle_cruise_control, + /obj/vehicle/multitile/proc/set_cruise_control_granularity, + /obj/vehicle/multitile/proc/toggle_turn_signal_north, + /obj/vehicle/multitile/proc/toggle_turn_signal_south, + /obj/vehicle/multitile/proc/toggle_turn_signal_east, + /obj/vehicle/multitile/proc/toggle_turn_signal_west, + /obj/vehicle/multitile/proc/toggle_engine, )) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_door_lock) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_engine) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_cruise_control) + remove_action(M, /datum/action/human_action/vehicle_action/set_cruise_control_granularity) + UnregisterSignal(M, COMSIG_MOB_VEHICLE_PREFS_CHANGED) + stop_crew_hud(M, VEHICLE_DRIVER) else if(seat == VEHICLE_GUNNER) remove_verb(M.client, list( /obj/vehicle/multitile/proc/cycle_hardpoint, - /obj/vehicle/multitile/proc/toggle_gyrostabilizer, + /obj/vehicle/multitile/proc/toggle_turret_safety, + /obj/vehicle/multitile/proc/toggle_slave_secondary_to_driver, + /obj/vehicle/multitile/proc/toggle_hardpoint_fire_mode, + /obj/vehicle/multitile/proc/toggle_iff_module, )) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_gyro) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_iff) + remove_action(M, /datum/action/human_action/vehicle_action/slave_secondary) + // This seat is about to be vacated, so gunner_seated is passed explicitly as FALSE. + var/obj/item/hardpoint/holder/tank_turret/turret = locate() in hardpoints + turret?.recalculate_gyro(FALSE) + stop_crew_hud(M, VEHICLE_GUNNER) + if(seat == VEHICLE_DRIVER || seat == VEHICLE_GUNNER) + remove_verb(M.client, list( + /obj/vehicle/multitile/proc/toggle_artillery_optics, + /obj/vehicle/multitile/proc/toggle_artillery_nvg, + )) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_nvg) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_artillery_range) + remove_action(M, /datum/action/human_action/vehicle_action/cycle_hardpoint) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_hardpoint_fire_mode) + remove_action(M, /datum/action/human_action/vehicle_action/use_phone) + var/obj/item/hardpoint/support/artillery_module/AM = get_artillery_module() + AM?.clear_user_effects(M) + +/obj/vehicle/multitile/tank/proc/get_artillery_module() as /obj/item/hardpoint/support/artillery_module + for(var/obj/item/hardpoint/support/artillery_module/AM in hardpoints) + return AM + return null + +/** + * Ensures active_hp[seat] holds a valid, activatable hardpoint whenever one exists. + * Leaves an existing valid selection alone. + * + * Default priority when a new pick is needed: + * * Gunner: mounted Primary, else mounted Secondary, else null. + * * Driver: the turret's own flare launcher, else a Secondary slaved to the driver, else null. + */ +/obj/vehicle/multitile/tank/ensure_active_hardpoint(seat) + if(seat != VEHICLE_DRIVER && seat != VEHICLE_GUNNER) + return + + var/list/usable_hps = get_activatable_hardpoints(seat) + var/obj/item/hardpoint/current = active_hp[seat] + if(current && (current in usable_hps)) + return + + var/obj/item/hardpoint/new_active = null + if(seat == VEHICLE_GUNNER) + for(var/obj/item/hardpoint/primary/candidate in usable_hps) + new_active = candidate + break + if(!new_active) + for(var/obj/item/hardpoint/secondary/candidate in usable_hps) + new_active = candidate + break + else + for(var/obj/item/hardpoint/holder/tank_turret/candidate in usable_hps) + new_active = candidate + break + if(!new_active) + for(var/obj/item/hardpoint/secondary/candidate in usable_hps) + new_active = candidate + break + + if(current && !QDELETED(current)) + SEND_SIGNAL(current, COMSIG_GUN_INTERRUPT_FIRE) + active_hp[seat] = new_active + // Keeps action buttons that depend on the specific active hardpoint correct across every caller of this proc. + refresh_hardpoint_actions() + +/** + * Re-evaluates which hardpoint-dependent action buttons each seated driver/gunner should have. + * Also refreshes the icon of every tank action button that mob currently holds. + */ +/obj/vehicle/multitile/tank/refresh_hardpoint_actions() + for(var/seat in list(VEHICLE_DRIVER, VEHICLE_GUNNER)) + var/mob/living/M = seats[seat] + if(!istype(M) || !M.client) + continue + + var/obj/item/hardpoint/support/artillery_module/AM = get_artillery_module() + if(AM?.is_functional()) + give_action(M, /datum/action/human_action/vehicle_action/toggle_nvg) + give_action(M, /datum/action/human_action/vehicle_action/toggle_artillery_range) + add_verb(M.client, list( + /obj/vehicle/multitile/proc/toggle_artillery_optics, + /obj/vehicle/multitile/proc/toggle_artillery_nvg, + )) + else + remove_action(M, /datum/action/human_action/vehicle_action/toggle_nvg) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_artillery_range) + remove_verb(M.client, list( + /obj/vehicle/multitile/proc/toggle_artillery_optics, + /obj/vehicle/multitile/proc/toggle_artillery_nvg, + )) + + if(length(get_activatable_hardpoints(seat)) >= 2) + give_action(M, /datum/action/human_action/vehicle_action/cycle_hardpoint) + // give_action() no-ops when this seat already has the action, so force the icon refresh here too. + for(var/datum/action/human_action/vehicle_action/cycle_hardpoint/action in M.actions) + action.update_button_icon() + else + remove_action(M, /datum/action/human_action/vehicle_action/cycle_hardpoint) + + if(active_hp[seat]?.get_flame_mode()) + give_action(M, /datum/action/human_action/vehicle_action/toggle_hardpoint_fire_mode) + else + remove_action(M, /datum/action/human_action/vehicle_action/toggle_hardpoint_fire_mode) + + if(seat == VEHICLE_GUNNER) + if(get_slavable_secondary()) + give_action(M, /datum/action/human_action/vehicle_action/slave_secondary) + else + remove_action(M, /datum/action/human_action/vehicle_action/slave_secondary) + + if(seat == VEHICLE_DRIVER) + if(!(M.client.prefs.toggles_vehicle & VEHICLE_SIMPLE_ACCELERATION)) + give_action(M, /datum/action/human_action/vehicle_action/toggle_cruise_control) + give_action(M, /datum/action/human_action/vehicle_action/set_cruise_control_granularity) + else + remove_action(M, /datum/action/human_action/vehicle_action/toggle_cruise_control) + remove_action(M, /datum/action/human_action/vehicle_action/set_cruise_control_granularity) + + for(var/datum/action/human_action/vehicle_action/action in M.actions) + action.update_button_icon() //Called when players try to move vehicle //Another wrapper for try_move() /obj/vehicle/multitile/tank/relaymove(mob/user, direction) + if(movement_locked) + return FALSE + if(user == seats[VEHICLE_DRIVER]) // Check if treads are installed if(!(locate(/obj/item/hardpoint/locomotion/treads) in hardpoints)) return FALSE - return ..() - - if(user != seats[VEHICLE_GUNNER]) - return FALSE - - var/obj/item/hardpoint/holder/tank_turret/T = null - for(var/obj/item/hardpoint/holder/tank_turret/TT in hardpoints) - T = TT - break - if(!T) - return FALSE - - if(direction == GLOB.reverse_dir[T.dir] || direction == T.dir) - return FALSE - - T.user_rotation(user, turning_angle(T.dir, direction)) - update_icon() + // Rider tracking now lives directly in try_move()/try_rotate(), so it fires for every caller. + var/success = ..() + if(success) + revalidate_on_top() + return TRUE + + // Gunner turret aiming is mouse-driven now (see crew_mousemove() in multitile_interaction.dm). + return FALSE + +// !!!! No point in keeping this now that you can freely climb onto the tank. !!!! +// Unless, maybe, you end up stuck in the center (turret) tile. + +// /obj/vehicle/multitile/tank/MouseDrop_T(mob/dropped, mob/user) +// . = ..() +// if((dropped != user) || !isxeno(user)) +// return + +// if(health > 0) +// to_chat(user, SPAN_XENO("We can't jump over [src] until it is destroyed!")) +// return + +// var/turf/current_turf = get_turf(user) +// var/dir_to_go = get_dir(current_turf, src) +// for(var/i in 1 to 3) +// current_turf = get_step(current_turf, dir_to_go) +// if(!(current_turf in locs)) +// break + +// if(current_turf.density) +// to_chat(user, SPAN_XENO("The path over [src] is obstructed!")) +// return + +// // Now we check to make sure the turf on the other side of the tank isn't dense too +// current_turf = get_step(current_turf, dir_to_go) +// if(current_turf.density) +// to_chat(user, SPAN_XENO("The path over [src] is obstructed!")) +// return + +// to_chat(user, SPAN_XENO("We begin to jump over [src]...")) +// if(!do_after(user, 3 SECONDS, INTERRUPT_ALL, BUSY_ICON_HOSTILE)) +// to_chat(user, SPAN_XENO("We stop jumping over [src].")) +// return + +// user.forceMove(current_turf) +// to_chat(user, SPAN_XENO("We jump to the other side of [src].")) + +/** + * Applies a jousting effect when crashing above a certain speed. + * + * Because momentum defines how close a vehicle is to its top speed, 0.5 should mean that the tank will joust its riders... + * ...After it crashes at anything while above half of its max speed. + * + * Keep in mind that momentum decays while standing still, and it can be negative in case we are moving backwards. + * + * Checked against whichever of move_momentum or drift_speed is larger, since a sideways skid can be real speed too. + */ +/obj/vehicle/multitile/tank/on_crash() + if(max(abs(move_momentum), drift_speed) < move_max_momentum * 0.5) + return + _scatter_riders_on_crash(max(abs(move_momentum), drift_speed)) + revalidate_on_top() +/// The tank's own turret holder is the only accepted_hardpoints list anywhere that includes Visual Sensors. +/obj/vehicle/multitile/tank/supports_visual_sensors() return TRUE -/obj/vehicle/multitile/tank/MouseDrop_T(mob/dropped, mob/user) - . = ..() - if((dropped != user) || !isxeno(user)) - return +/// Rangefinding is a tank-exclusive gunner feature. +/obj/vehicle/multitile/tank/has_gunner_rangefinder() + return TRUE - if(health > 0) - to_chat(user, SPAN_XENO("We can't jump over [src] until it is destroyed!")) +/// Counts every flame mounted atop this tank's own hull. Drives extra engine heat. +/obj/vehicle/multitile/tank/get_mounted_flame_tile_count() + . = 0 + for(var/obj/flamer_fire/mounted_flame in on_top_obj) + . += 1 + +/// Counts every flame burning on this tank's footprint without being mounted, e.g. fire it's driving over. +/obj/vehicle/multitile/tank/get_underneath_flame_tile_count() + . = 0 + for(var/turf/occupied_turf in locs) + for(var/obj/flamer_fire/underneath_flame in occupied_turf) + if(!(underneath_flame in on_top_obj)) + . += 1 + +/** + * Applies continuous acid damage from any lingering_acid puddle(s) mounted atop this tank's own hull. + * + * Unlike expose_to_lingering_acid()'s own cooldown-throttled hit, this applies every physics tick, but + * only while the tank is moving. Scaled so the average damage-per-second stays comparable. + * + * Arguments: + * * tick_dt = Delta time for this physics tick. + */ +/obj/vehicle/multitile/tank/apply_mounted_acid_damage(tick_dt) + if(current_speed <= 0) return + for(var/obj/effect/lingering_acid/puddle in on_top_obj) + apply_weighted_module_hit(puddle.damage * tick_dt / (LINGERING_ACID_VEHICLE_DAMAGE_COOLDOWN / 10), "acid", null, ignore_aim = TRUE) - var/turf/current_turf = get_turf(user) - var/dir_to_go = get_dir(current_turf, src) - for(var/i in 1 to 3) - current_turf = get_step(current_turf, dir_to_go) - if(!(current_turf in locs)) - break - - if(current_turf.density) - to_chat(user, SPAN_XENO("The path over [src] is obstructed!")) - return - - // Now we check to make sure the turf on the other side of the tank isn't dense too - current_turf = get_step(current_turf, dir_to_go) - if(current_turf.density) - to_chat(user, SPAN_XENO("The path over [src] is obstructed!")) +/// TRUE while `user` is a Praetorian Dancer with its Dodge ability active. Grants instant tank climbing. +/obj/vehicle/multitile/tank/has_instant_dancer_climb(mob/living/user) + if(!isxeno(user)) + return FALSE + var/mob/living/carbon/xenomorph/xeno_user = user + var/datum/behavior_delegate/praetorian_dancer/dancer_behavior = xeno_user.behavior_delegate + return istype(dancer_behavior) && dancer_behavior.dodge_activated + +/** + * How compromised the Hatch hardpoint currently is, from 0 (barely wounded) to 1 (about to be destroyed). + * Takes whichever is further along: active wound tier count or raw integrity loss. + * + * Arguments: + * * installed_hatch = The tank's Hatch hardpoint. Caller already confirmed it exists and isn't destroyed. + */ +/obj/vehicle/multitile/tank/proc/get_hatch_instability_scale(obj/item/hardpoint/hatch/installed_hatch) + var/wound_fraction = LAZYLEN(installed_hatch.wound_tiers) / 2 // hatch_sticky_latch + hatch_bent_frame, one tier each today + var/integrity_loss_fraction = 1 - (installed_hatch.get_integrity_percent() / 100) + return clamp(max(wound_fraction, integrity_loss_fraction), 0, 1) + +/** + * Tank-specific override of get_hatch_lock_result(). Reads the Hatch's wound state instead of the base access rule. + * + * * Hatch destroyed, hull destroyed, or unlocked: same as unlocked and healthy, nobody blocked. + * * Locked and Hatch healthy: entry blocked for anyone without valid access, xenos included. Exit is never blocked. + * * Locked and Hatch wounded: never blocks, only scales delay. + * Marines get progressively slower, xenos faster, and an unauthorized marine is let through slowly instead of denied. + */ +/obj/vehicle/multitile/tank/get_hatch_lock_result(mob/M, entering = TRUE) + if(!door_locked || health <= 0) + return list("blocked" = FALSE, "delay_mult" = 1) + + var/obj/item/hardpoint/hatch/installed_hatch = get_hardpoint_by_slot(HDPT_HATCH) + if(!installed_hatch || installed_hatch.health <= 0) + return list("blocked" = FALSE, "delay_mult" = 1) + + var/is_authorized = ishuman(M) && allowed(M) && get_target_lock(M.faction_group) + + if(!LAZYLEN(installed_hatch.wound_tiers)) + if(entering && !is_authorized) + to_chat(M, SPAN_WARNING("\The [src] is locked!")) + return list("blocked" = TRUE, "delay_mult" = 1) + return list("blocked" = FALSE, "delay_mult" = 1) + + if(is_authorized) + var/marine_instability_scale = get_hatch_instability_scale(installed_hatch) + return list("blocked" = FALSE, "delay_mult" = 1 + marine_instability_scale * (HATCH_WOUNDED_MARINE_MAX_DELAY_MULT - 1)) + + if(ishuman(M)) + return list("blocked" = FALSE, "delay_mult" = HATCH_WOUNDED_NON_USCM_DELAY_MULT) + + if(isxeno(M)) + var/xeno_instability_scale = get_hatch_instability_scale(installed_hatch) + return list("blocked" = FALSE, "delay_mult" = HATCH_WOUNDED_XENO_START_MULT - xeno_instability_scale * (HATCH_WOUNDED_XENO_START_MULT - HATCH_WOUNDED_XENO_MIN_MULT)) + + if(entering) + to_chat(M, SPAN_WARNING("\The [src] is locked!")) + return list("blocked" = TRUE, "delay_mult" = 1) + return list("blocked" = FALSE, "delay_mult" = 1) + +//----------------------------- +// ammo cookoff. +//----------------------------- + +/// Kicks off the shared cookoff sequence (multitile_cookoff.dm) the instant hull health first reaches 0. +/obj/vehicle/multitile/tank/on_hull_destroyed() + start_hull_cookoff_sequence() + +/// The tank's one "something special" to eject once the hull cooks off: its turret, with every nested weapon. +/obj/vehicle/multitile/tank/launch_special_wreckage() + launch_turret_wreckage() + +/** + * Physically launches the Turret holder into the air as wreckage. Every nested weapon comes along for free since they live inside it. + * Bypasses remove_hardpoint() (which would qdel() it outright) and hand-rolls the reparenting instead, so the wreckage persists and lands somewhere. + */ +/obj/vehicle/multitile/tank/proc/launch_turret_wreckage() + var/obj/item/hardpoint/holder/tank_turret/turret = locate() in hardpoints + if(!turret) return - to_chat(user, SPAN_XENO("We begin to jump over [src]...")) - if(!do_after(user, 3 SECONDS, INTERRUPT_ALL, BUSY_ICON_HOSTILE)) - to_chat(user, SPAN_XENO("We stop jumping over [src].")) - return + // The exterior explosion is still lingering here, so mark the wreckage immune before moving it onto this turf. + turret.explo_proof = TRUE + + for(var/obj/item/hardpoint/nested in turret.hardpoints) + nested.explo_proof = TRUE + + hardpoints -= turret + turret.owner = null + + var/turf/launch_turf = get_turf(src) + turret.forceMove(launch_turf) + turret.pixel_y = initial(turret.pixel_y) + render_turret_wreck_visuals(turret) + + playsound(launch_turf, 'sound/effects/metal_crash.ogg', 60, TRUE) + var/datum/effect_system/spark_spread/launch_sparks = new + launch_sparks.set_up(5, 0, launch_turf) + launch_sparks.start() + + // Brief "shoots up" beat before drop_turret_wreckage() wraps it in a droppod and drops it at the landing turf. + animate(turret, pixel_y = 48, time = 0.3 SECONDS, easing = QUAD_EASING) + addtimer(CALLBACK(src, PROC_REF(drop_turret_wreckage), turret, launch_turf), 0.3 SECONDS) + +/// Builds the turret's own standalone appearance now that it's a loose object again, instead of the vehicle's usual overlay compositing. +/obj/vehicle/multitile/tank/proc/render_turret_wreck_visuals(obj/item/hardpoint/holder/tank_turret/turret) + turret.update_icon() + +/// Wraps the airborne turret in a droppod and sends it falling toward a random turf near where it launched. +/obj/vehicle/multitile/tank/proc/drop_turret_wreckage(obj/item/hardpoint/holder/tank_turret/turret, turf/launch_turf) + turret.pixel_y = initial(turret.pixel_y) + var/turf/landing_turf = get_turret_wreckage_landing_turf(launch_turf) + var/obj/structure/droppod/wreck/pod = new(launch_turf, turret) + pod.launch(landing_turf) + +/** + * Picks a random valid, open turf near `origin` for a piece of turret wreckage to land on. + * Widens the search outward if the normal ring is entirely blocked. Only returns `origin` itself as a last resort. + */ +/obj/vehicle/multitile/tank/proc/get_turret_wreckage_landing_turf(turf/origin) + var/list/candidates = get_valid_turret_landing_turfs(origin, HULL_COOKOFF_TURRET_LAUNCH_MIN_RANGE, HULL_COOKOFF_TURRET_LAUNCH_MAX_RANGE) + if(length(candidates)) + return pick(candidates) + + for(var/search_range = HULL_COOKOFF_TURRET_LAUNCH_MAX_RANGE + 1, search_range <= 10, search_range++) + candidates = get_valid_turret_landing_turfs(origin, search_range, search_range) + if(length(candidates)) + return pick(candidates) + + return origin + +/// Every genuinely open, non-dense, non-space turf between min_range and max_range tiles of origin. +/obj/vehicle/multitile/tank/proc/get_valid_turret_landing_turfs(turf/origin, min_range, max_range) + . = list() + for(var/turf/open/candidate_turf in range(max_range, origin)) + if(istype(candidate_turf, /turf/open/space) || candidate_turf.density) + continue + var/distance = get_dist(origin, candidate_turf) + if(distance < min_range || distance > max_range) + continue + . += candidate_turf - user.forceMove(current_turf) - to_chat(user, SPAN_XENO("We jump to the other side of [src].")) /* ** PRESETS SPAWNERS */ @@ -229,11 +648,31 @@ /obj/effect/vehicle_spawner/tank/load_hardpoints(obj/vehicle/multitile/tank/V) V.add_hardpoint(new /obj/item/hardpoint/holder/tank_turret) + load_logistics_hardpoints(V) + +/** + * Installs the baseline logistics parts every non-bare-hull tank preset spawns with, plus the modules mounted on the turret. + * Requires a turret to already be installed on `V`. + */ +/obj/effect/vehicle_spawner/tank/proc/load_logistics_hardpoints(obj/vehicle/multitile/tank/V) + V.add_hardpoint(new /obj/item/hardpoint/engine/tank) + V.add_hardpoint(new /obj/item/hardpoint/fuel_tank/uscm) + V.add_hardpoint(new /obj/item/hardpoint/radiator/uscm) + V.add_hardpoint(new /obj/item/hardpoint/battery/uscm) + V.add_hardpoint(new /obj/item/hardpoint/hatch/armored/uscm) + + for(var/obj/item/hardpoint/holder/tank_turret/turret in V.hardpoints) + turret.add_hardpoint(new /obj/item/hardpoint/iff_module/uscm) + turret.add_hardpoint(new /obj/item/hardpoint/visual_sensors/uscm) + turret.add_hardpoint(new /obj/item/hardpoint/turret_ring/uscm) + turret.add_hardpoint(new /obj/item/hardpoint/air_filter/uscm) + break //PRESET: turret, treads installed /obj/effect/vehicle_spawner/tank/plain/load_hardpoints(obj/vehicle/multitile/tank/V) V.add_hardpoint(new /obj/item/hardpoint/holder/tank_turret) V.add_hardpoint(new /obj/item/hardpoint/locomotion/treads) + load_logistics_hardpoints(V) //PRESET: no hardpoints /obj/effect/vehicle_spawner/tank/hull/load_hardpoints(obj/vehicle/multitile/tank/V) @@ -260,6 +699,7 @@ V.add_hardpoint(new /obj/item/hardpoint/armor/paladin) V.add_hardpoint(new /obj/item/hardpoint/locomotion/treads) V.add_hardpoint(new /obj/item/hardpoint/holder/tank_turret) + load_logistics_hardpoints(V) for(var/obj/item/hardpoint/holder/tank_turret/TT in V.hardpoints) TT.add_hardpoint(new /obj/item/hardpoint/primary/cannon) TT.add_hardpoint(new /obj/item/hardpoint/secondary/m56cupola) @@ -271,6 +711,7 @@ V.add_hardpoint(new /obj/item/hardpoint/armor/paladin) V.add_hardpoint(new /obj/item/hardpoint/locomotion/treads) V.add_hardpoint(new /obj/item/hardpoint/holder/tank_turret) + load_logistics_hardpoints(V) for(var/obj/item/hardpoint/holder/tank_turret/TT in V.hardpoints) TT.add_hardpoint(new /obj/item/hardpoint/primary/cannon) TT.add_hardpoint(new /obj/item/hardpoint/secondary/m56cupola) @@ -282,6 +723,7 @@ V.add_hardpoint(new /obj/item/hardpoint/armor/ballistic) V.add_hardpoint(new /obj/item/hardpoint/locomotion/treads) V.add_hardpoint(new /obj/item/hardpoint/holder/tank_turret) + load_logistics_hardpoints(V) for(var/obj/item/hardpoint/holder/tank_turret/TT in V.hardpoints) TT.add_hardpoint(new /obj/item/hardpoint/primary/minigun) TT.add_hardpoint(new /obj/item/hardpoint/secondary/small_flamer) @@ -293,6 +735,7 @@ V.add_hardpoint(new /obj/item/hardpoint/armor/ballistic) V.add_hardpoint(new /obj/item/hardpoint/locomotion/treads) V.add_hardpoint(new /obj/item/hardpoint/holder/tank_turret) + load_logistics_hardpoints(V) for(var/obj/item/hardpoint/holder/tank_turret/TT in V.hardpoints) TT.add_hardpoint(new /obj/item/hardpoint/primary/flamer) TT.add_hardpoint(new /obj/item/hardpoint/secondary/grenade_launcher) @@ -304,6 +747,7 @@ V.add_hardpoint(new /obj/item/hardpoint/armor/ballistic) V.add_hardpoint(new /obj/item/hardpoint/locomotion/treads) V.add_hardpoint(new /obj/item/hardpoint/holder/tank_turret) + load_logistics_hardpoints(V) for(var/obj/item/hardpoint/holder/tank_turret/TT in V.hardpoints) TT.add_hardpoint(new /obj/item/hardpoint/primary/autocannon) TT.add_hardpoint(new /obj/item/hardpoint/secondary/towlauncher) diff --git a/code/modules/vehicles/tank/tank_actions.dm b/code/modules/vehicles/tank/tank_actions.dm new file mode 100644 index 000000000000..26048cc4ba72 --- /dev/null +++ b/code/modules/vehicles/tank/tank_actions.dm @@ -0,0 +1,47 @@ +/// Tank-only variant of get_vehicle(), typed and filtered down to the tank subtype. +/datum/action/human_action/vehicle_action/proc/get_tank() + var/obj/vehicle/multitile/tank/vehicle = get_vehicle() + return istype(vehicle) ? vehicle : null + +// 1) Artillery Module: Night Vision ------------------------------------------------- + +/datum/action/human_action/vehicle_action/toggle_nvg + name = "Toggle Night Vision" + action_icon_state = "nvg" + +/datum/action/human_action/vehicle_action/toggle_nvg/update_button_icon() + var/obj/vehicle/multitile/tank/vehicle = get_tank() + var/obj/item/hardpoint/support/artillery_module/AM = vehicle?.get_artillery_module() + action_icon_state = (AM && (owner in AM.nvg_users)) ? "nvg_off" : "nvg" + button.overlays.Cut() + button.overlays += image('icons/mob/hud/actions.dmi', button, action_icon_state) + +/datum/action/human_action/vehicle_action/toggle_nvg/action_activate() + . = ..() + var/obj/vehicle/multitile/tank/vehicle = get_tank() + if(!vehicle) + return + vehicle.toggle_artillery_nvg(owner) + update_button_icon() + +// 2) Artillery Module: Range (magnified optics) -------------------------------------- + +/datum/action/human_action/vehicle_action/toggle_artillery_range + name = "Toggle Artillery Range" + action_icon_state = "zoom_scope" + +/datum/action/human_action/vehicle_action/toggle_artillery_range/update_button_icon() + var/obj/vehicle/multitile/tank/vehicle = get_tank() + var/obj/item/hardpoint/support/artillery_module/AM = vehicle?.get_artillery_module() + action_icon_state = (AM && (owner in AM.optics_users)) ?"unzoom_scope" : "zoom_scope" + button.overlays.Cut() + button.overlays += image('icons/mob/hud/actions.dmi', button, action_icon_state) + +/datum/action/human_action/vehicle_action/toggle_artillery_range/action_activate() + . = ..() + var/obj/vehicle/multitile/tank/vehicle = get_tank() + if(!vehicle) + return + vehicle.toggle_artillery_optics(owner) + update_button_icon() + diff --git a/code/modules/vehicles/tank/tank_helpers.dm b/code/modules/vehicles/tank/tank_helpers.dm new file mode 100644 index 000000000000..d8c2808395da --- /dev/null +++ b/code/modules/vehicles/tank/tank_helpers.dm @@ -0,0 +1,249 @@ +// --- Tail Stab hook + +/** + * Tank's version of handle_tail_stab(). Deals the same damage as a mob target would take. + */ +/obj/vehicle/multitile/tank/handle_tail_stab(mob/living/carbon/xenomorph/xeno, blunt_stab) + var/damage = (xeno.melee_damage_upper + xeno.frenzy_aura * FRENZY_DAMAGE_MULTIPLIER) * TAILSTAB_MOB_DAMAGE_MULTIPLIER + + // Play our own attack flavor since we skip ability_act() entirely. + xeno.animation_attack_on(src) + xeno.flick_attack_overlay(src, blunt_stab ? "slam" : "tail") + if(blunt_stab) + xeno.visible_message(SPAN_XENOWARNING("\The [xeno] swipes its tail into \the [src], bashing it!"), SPAN_XENOWARNING("We swipe our tail into [get_attack_desc(xeno)], bashing it!")) + playsound(src, "punch", 50, TRUE) + else + xeno.visible_message(SPAN_XENOWARNING("\The [xeno] skewers \the [src] with its razor sharp tail!"), SPAN_XENOWARNING("We skewer [get_attack_desc(xeno)] with our razor sharp tail!")) + playsound(src, "alien_bite", 50, TRUE) + + // Mitigated like a normal claw slash, not a bypass hit. + take_damage_type(damage, blunt_stab ? "blunt" : "slash", xeno) + // Fire resource-gain hooks manually since we skip ability_act(). + if(xeno.behavior_delegate) + xeno.behavior_delegate.melee_attack_modify_burn_damage(0, src) + xeno.behavior_delegate.melee_attack_additional_effects_target(src) + xeno.behavior_delegate.melee_attack_additional_effects_self() + return TAILSTAB_COOLDOWN_NORMAL + +// --- Weighted random-module acid exposure (Spray Acid, Despoiler's lingering acid puddles) + +/** + * Tank version of handle_acid_damage(). Hits a weighted random part instead of always Treads. + */ +/obj/vehicle/multitile/tank/proc/expose_to_weighted_acid(atom/A) + var/damage = 0 + var/mob/attacker = null + + if(istype(A, /obj/effect/xenomorph/spray)) + var/obj/effect/xenomorph/spray/acid = A + damage = acid.damage_amount + if(acid.cause_data?.cause_name == "resin acid trap") + damage = floor(damage / 3) + attacker = acid.cause_data?.resolve_mob() + + else if(istype(A, /obj/effect/lingering_acid)) + var/obj/effect/lingering_acid/puddle = A + damage = puddle.damage + // No cause_data here, falls back to an unweighted pick. + + if(damage <= 0) + return + // ignore_aim = TRUE, a spray/puddle isn't a directed attack. + apply_weighted_module_hit(damage, "acid", attacker, ignore_aim = TRUE) + +// --- Boiler glob AOE exposure (acid/neurotoxin gas over the tank's footprint) + +/** + * Boiler acid gas sitting over the tank's footprint. Hits every part at once as a percent of its + * own remaining health, unmitigated. + * + * Arguments: + * * covered_fraction = Fraction of the tank's 9 tiles currently gassed, 0 to 1. + * * attacker = Whoever's glob this is, if known. + */ +/obj/vehicle/multitile/tank/proc/apply_glob_acid_tick(covered_fraction, atom/attacker) + if(covered_fraction <= 0) + return + + apply_hull_damage(max(TANK_GLOB_ACID_FLOOR, health * TANK_GLOB_ACID_HEALTH_PCT / 100) * covered_fraction, "acid", attacker, unmitigated = TRUE) + // get_hardpoints_copy() already flattens nested hardpoints into this list. + for(var/obj/item/hardpoint/H in get_hardpoints_copy()) + if(H.health <= 0) + continue + H.take_damage(max(TANK_GLOB_ACID_FLOOR, H.health * TANK_GLOB_ACID_HEALTH_PCT / 100) * covered_fraction, "acid", attacker, unmitigated = TRUE) + +/** + * Neuro-glob counterpart to apply_glob_acid_tick(). Rolls a boosted chance at a gunked wound for + * each neuro-foulable slot. + * + * Arguments: + * * covered_fraction = Fraction of the tank's 9 tiles currently gassed with neurotoxin, 0 to 1. + */ +/obj/vehicle/multitile/tank/proc/apply_glob_neuro_tick(covered_fraction) + if(covered_fraction <= 0) + return + var/chance_scale = covered_fraction * TANK_GLOB_NEURO_CHANCE_MULT * get_neuro_wound_chance_scale() + for(var/slot in GLOB.neuro_foulable_slots) + roll_neuro_wound_at_slot(slot, chance_scale) + +/** + * Reads the installed Armor hardpoint's neuro_wound_chance_mult. + */ +/obj/vehicle/multitile/tank/get_neuro_wound_chance_scale() + var/obj/item/hardpoint/armor/installed_armor = get_hardpoint_by_slot(HDPT_ARMOR) + return installed_armor ? installed_armor.neuro_wound_chance_mult : 1 + +/** + * Shockwave damage to every hardpoint mounted on the turret. Unmitigated, ignores turret armor. + * + * Arguments: + * * xeno = The mob to check for atop-a-tank status. + * * damage = Damage to deal to every hardpoint mounted on the turret, if any. + */ +/proc/apply_atop_tank_turret_damage(mob/living/carbon/xenomorph/xeno, damage) + var/turf/xeno_turf = get_turf(xeno) + if(!xeno_turf) + return + var/obj/vehicle/multitile/vehicle = get_multitile_vehicle_at(xeno_turf) + if(!vehicle) + return + var/obj/item/hardpoint/holder/tank_turret/turret = locate() in vehicle.hardpoints + if(!turret) + return + for(var/obj/item/hardpoint/mounted_weapon in turret.hardpoints) + mounted_weapon.take_damage(damage, "blunt", xeno, unmitigated = TRUE) + +// --- Forced turret wrench (shared by Warrior Punch and Praetorian Oppressor's Dislocate) + +/** + * Snap-turns a tank's turret away from the attacker's side. Guards against overlapping wrenches + * corrupting the turret's real turn rate baseline. + * + * Arguments: + * * attacker = Whoever is doing the wrenching. + * * tank = The tank being wrenched. + * * degrees = How far to turn the turret, away from `attacker`'s own side. + * + * Returns: + * * TRUE if a turret was found and wrenched, FALSE otherwise. + */ +/proc/force_turret_wrench(mob/living/carbon/xenomorph/attacker, obj/vehicle/multitile/vehicle, degrees) + var/obj/item/hardpoint/holder/tank_turret/turret = locate() in vehicle.hardpoints + if(!turret) + return FALSE + + var/attacker_bearing = Get_Angle_Grounded(turret, attacker) + var/angle_diff = angle_delta(attacker_bearing, turret.current_angle) + var/turn_amount = (angle_diff >= 0) ? -degrees : degrees + // %% not %, current_angle is often fractional. + var/new_desired = ((turret.current_angle + turn_amount) %% 360 + 360) %% 360 + + playsound(turret, "punch", 25, TRUE) + playsound(turret, 'sound/vehicles/turretdamaged.ogg', 50, TRUE) + play_wound_gain_effects(turret, WOUND_DAMTYPE_BRUTE, attacker) + var/mob/living/gunner = vehicle.get_seat_mob(VEHICLE_GUNNER) + if(gunner?.client) + shake_camera(gunner, 2, 1) + + // Boosted turn rate, restored once the forced turn settles. Only the first of any overlapping + // wrenches snapshots the real baseline. + if(!turret.forced_wrench_active) + turret.forced_wrench_original_velocity = turret.max_angular_velocity + turret.forced_wrench_original_accel = turret.angular_accel + turret.forced_wrench_active = TRUE + turret.max_angular_velocity = WARRIOR_PUNCH_TURRET_ANGULAR_VELOCITY + turret.angular_accel = WARRIOR_PUNCH_TURRET_ANGULAR_ACCEL + + turret.lock_rotation(TRUE) + turret.desired_angle = new_desired + turret.start_rotation_if_needed() + INVOKE_ASYNC(turret, TYPE_PROC_REF(/obj/item/hardpoint/holder/tank_turret, release_forced_wrench_lock)) + return TRUE + +/** + * Shared aim-dependent damage resolution for Warrior Punch and Oppressor Dislocate. + * + * * Primary: forces the turn, unmitigated hit to Primary with a small bleed to Turret Ring. + * * Turret: forces the turn, unmitigated hit to Turret Ring with a small bleed to mounted modules. + * * Hull: no turn, guaranteed unmitigated hit to a random hull module. + * * Anything else: no turn, bigger unmitigated hit to that module. + * + * Arguments: + * * tank = The tank being hit. + * * attacker = Whoever's aim to read. + * * damage = Base damage. + * * type = Damage type. + * * ring_damage_mult = Extra multiplier for the Turret Ring case, defaults to 1. + * + * Returns: + * * TRUE if the turret was forcibly turned, FALSE otherwise. + */ +/proc/resolve_turret_wrench_damage(obj/vehicle/multitile/vehicle, mob/living/carbon/xenomorph/attacker, damage, type, ring_damage_mult = 1) + var/target_slot = vehicle.get_attack_target_slot(attacker) + + if(target_slot == HDPT_PRIMARY) + // No turret holder to physically wrench (e.g. APC/ARC/van), so this just lands as plain damage. + var/wrenched = force_turret_wrench(attacker, vehicle, WARRIOR_PUNCH_TURRET_TURN_DEGREES) + var/obj/item/hardpoint/primary = vehicle.resolve_targeted_hardpoint(HDPT_PRIMARY) + var/hit_damage = damage * GUARANTEED_EXTERNAL_HIT_DAMAGE_MULT + primary?.take_damage(hit_damage, type, attacker, unmitigated = TRUE) + if(wrenched) + var/obj/item/hardpoint/ring = vehicle.get_hardpoint_by_slot(HDPT_TURRET_RING) + ring?.deal_raw_damage(hit_damage * HARDPOINT_BLEED_THROUGH_FRACTION) + return wrenched + + if(target_slot == HDPT_TURRET) + var/wrenched = force_turret_wrench(attacker, vehicle, WARRIOR_PUNCH_TURRET_TURN_DEGREES) + var/obj/item/hardpoint/ring = vehicle.get_hardpoint_by_slot(HDPT_TURRET_RING) + var/hit_damage = damage * ring_damage_mult + ring?.take_damage(hit_damage, type, attacker, unmitigated = TRUE) + vehicle.bleed_external_modules(HDPT_TURRET, hit_damage) + return wrenched + + if(target_slot == WOUND_SLOT_HULL) + vehicle.force_internal_module_damage(WOUND_SLOT_HULL, damage, type, attacker) + else + var/obj/item/hardpoint/target_hardpoint = vehicle.resolve_targeted_hardpoint(target_slot) + target_hardpoint?.take_damage(damage * GUARANTEED_EXTERNAL_HIT_DAMAGE_MULT, type, attacker, unmitigated = TRUE) + return FALSE + +/** + * Restores the turret's turn rate once a forced turn settles. Safe to call more than once. + */ +/obj/item/hardpoint/holder/tank_turret/proc/release_forced_wrench_lock() + var/safety = 0 + while(!QDELETED(src) && rotation_active && safety < 100) + sleep(1) + safety++ + if(QDELETED(src)) + return + if(!forced_wrench_active) + return + max_angular_velocity = forced_wrench_original_velocity + angular_accel = forced_wrench_original_accel + forced_wrench_active = FALSE + lock_rotation(FALSE) + +/** + * Elevates any barricade the tank just drove onto above its sprite and riders. Drops any barricade + * the tank just left back to its normal layer. + * + * Only applies to a South facing barricade south of the tank's anchor tile. + * + * Arguments: + * * old_locs = The list of turfs the tank occupied immediately before this move. + */ +/obj/vehicle/multitile/tank/update_covered_barricades(list/old_locs) + for(var/turf/T as anything in old_locs) + if(T in locs) + continue + for(var/obj/structure/barricade/B in T) + B.covered_by_vehicle = FALSE + B.update_icon() + + for(var/turf/T as anything in locs) + if(T in old_locs) + continue + for(var/obj/structure/barricade/B in T) + B.covered_by_vehicle = (B.dir == SOUTH) && (B.y < y) + B.update_icon() diff --git a/code/modules/vehicles/van/box_van.dm b/code/modules/vehicles/van/box_van.dm index 39b98b6b480c..f3f74252e420 100644 --- a/code/modules/vehicles/van/box_van.dm +++ b/code/modules/vehicles/van/box_van.dm @@ -44,8 +44,25 @@ move_max_momentum = 3 + engine_on = FALSE + + uses_gear_transmission = TRUE + current_gear = "P" + top_speed = 3.5 + base_acceleration = 1 + // Matches the ARC's own fuel consumption. + base_fuel_use = 0.175 + idle_fuel_use_mult = 0.2 + overdrive_speed_mult = 0.3 + overdrive_sound = 'sound/vehicles/box_van_overdrive.ogg' + desant_momentum_cap = 0.35 + hardpoints_allowed = list( /obj/item/hardpoint/locomotion/van_wheels, + /obj/item/hardpoint/engine/van, + /obj/item/hardpoint/fuel_tank/van, + /obj/item/hardpoint/radiator/civilian, + /obj/item/hardpoint/battery/civilian, ) move_turn_momentum_loss_factor = 1 @@ -57,11 +74,6 @@ mob_size_required_to_hit = MOB_SIZE_XENO - var/overdrive_next = 0 - var/overdrive_cooldown = 15 SECONDS - var/overdrive_duration = 3 SECONDS - var/overdrive_speed_mult = 0.3 // Additive (30% more speed, adds to 80% more speed) - var/momentum_loss_on_weeds_factor = 0.2 move_on_turn = TRUE @@ -87,6 +99,66 @@ for(var/icon in GLOB.player_list) add_default_image(SSdcs, icon) + gear_stats = build_gear_stats() + cruise_control_granularity = gear_stats["D"]["max_speed"] * CRUISE_CONTROL_DEFAULT_GRANULARITY_FRACTION + +/obj/vehicle/multitile/box_van/add_seated_verbs(mob/living/M, seat) + if(!M.client) + return + add_verb(M.client, list( + /obj/vehicle/multitile/proc/get_status_info, + )) + if(seat == VEHICLE_DRIVER) + add_verb(M.client, list( + /obj/vehicle/multitile/proc/toggle_door_lock, + /obj/vehicle/multitile/proc/activate_horn, + /obj/vehicle/multitile/proc/name_vehicle, + /obj/vehicle/multitile/proc/toggle_cruise_control, + /obj/vehicle/multitile/proc/set_cruise_control_granularity, + /obj/vehicle/multitile/proc/toggle_turn_signal_north, + /obj/vehicle/multitile/proc/toggle_turn_signal_south, + /obj/vehicle/multitile/proc/toggle_turn_signal_east, + /obj/vehicle/multitile/proc/toggle_turn_signal_west, + /obj/vehicle/multitile/proc/toggle_engine, + )) + give_action(M, /datum/action/human_action/vehicle_action/toggle_door_lock) + give_action(M, /datum/action/human_action/vehicle_action/toggle_engine) + give_action(M, /datum/action/human_action/vehicle_action/use_phone) + if(!(M.client.prefs.toggles_vehicle & VEHICLE_SIMPLE_ACCELERATION)) + give_action(M, /datum/action/human_action/vehicle_action/toggle_cruise_control) + give_action(M, /datum/action/human_action/vehicle_action/set_cruise_control_granularity) + RegisterSignal(M, COMSIG_MOB_VEHICLE_PREFS_CHANGED, PROC_REF(on_driver_prefs_changed)) + start_crew_hud(M, VEHICLE_DRIVER) + refresh_hardpoint_actions() + +/obj/vehicle/multitile/box_van/remove_seated_verbs(mob/living/M, seat) + if(!M.client) + return + remove_verb(M.client, list( + /obj/vehicle/multitile/proc/get_status_info, + )) + if(seat == VEHICLE_DRIVER) + remove_verb(M.client, list( + /obj/vehicle/multitile/proc/toggle_door_lock, + /obj/vehicle/multitile/proc/activate_horn, + /obj/vehicle/multitile/proc/name_vehicle, + /obj/vehicle/multitile/proc/toggle_cruise_control, + /obj/vehicle/multitile/proc/set_cruise_control_granularity, + /obj/vehicle/multitile/proc/toggle_turn_signal_north, + /obj/vehicle/multitile/proc/toggle_turn_signal_south, + /obj/vehicle/multitile/proc/toggle_turn_signal_east, + /obj/vehicle/multitile/proc/toggle_turn_signal_west, + /obj/vehicle/multitile/proc/toggle_engine, + )) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_door_lock) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_engine) + remove_action(M, /datum/action/human_action/vehicle_action/use_phone) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_cruise_control) + remove_action(M, /datum/action/human_action/vehicle_action/set_cruise_control_granularity) + UnregisterSignal(M, COMSIG_MOB_VEHICLE_PREFS_CHANGED) + stop_crew_hud(M, VEHICLE_DRIVER) + SStgui.close_user_uis(M, src) + /obj/vehicle/multitile/box_van/crew_mousedown(datum/source, atom/object, turf/location, control, params) var/list/modifiers = params2list(params) if(modifiers[SHIFT_CLICK] || modifiers[MIDDLE_CLICK] || modifiers[RIGHT_CLICK] || modifiers[BUTTON4] || modifiers[BUTTON5]) //don't step on examine, point, etc @@ -207,23 +279,11 @@ /obj/vehicle/multitile/box_van/handle_click(mob/living/user, atom/A, list/mods) if(mods[SHIFT_CLICK] && !mods[ALT_CLICK]) - if(overdrive_next > world.time) - to_chat(user, SPAN_WARNING("You can't activate overdrive yet! Wait [round((overdrive_next - world.time) / 10, 0.1)] seconds.")) - return - - misc_multipliers["move"] -= overdrive_speed_mult - addtimer(CALLBACK(src, PROC_REF(reset_overdrive)), overdrive_duration) - - overdrive_next = world.time + overdrive_cooldown - to_chat(user, SPAN_NOTICE("You activate overdrive.")) - playsound(src, 'sound/vehicles/box_van_overdrive.ogg', 75, FALSE) + activate_overdrive(user) return return ..() -/obj/vehicle/multitile/box_van/proc/reset_overdrive() - misc_multipliers["move"] += overdrive_speed_mult - /obj/vehicle/multitile/box_van/get_projectile_hit_boolean(obj/projectile/P) if(src == P.original) //clicking on the van itself will hit it. var/hitchance = P.get_effective_accuracy() @@ -282,6 +342,10 @@ /obj/effect/vehicle_spawner/box_van/decrepit/load_hardpoints(obj/vehicle/multitile/box_van/V) V.add_hardpoint(new /obj/item/hardpoint/locomotion/van_wheels) + V.add_hardpoint(new /obj/item/hardpoint/engine/van) + V.add_hardpoint(new /obj/item/hardpoint/fuel_tank/van) + V.add_hardpoint(new /obj/item/hardpoint/radiator/civilian) + V.add_hardpoint(new /obj/item/hardpoint/battery/civilian) //PRESET: wheels installed /obj/effect/vehicle_spawner/box_van/fixed/spawn_vehicle() @@ -294,3 +358,7 @@ /obj/effect/vehicle_spawner/box_van/fixed/load_hardpoints(obj/vehicle/multitile/box_van/V) V.add_hardpoint(new /obj/item/hardpoint/locomotion/van_wheels) + V.add_hardpoint(new /obj/item/hardpoint/engine/van) + V.add_hardpoint(new /obj/item/hardpoint/fuel_tank/van) + V.add_hardpoint(new /obj/item/hardpoint/radiator/civilian) + V.add_hardpoint(new /obj/item/hardpoint/battery/civilian) diff --git a/code/modules/vehicles/van/clf_van.dm b/code/modules/vehicles/van/clf_van.dm index 16f75135e051..d0a08bd41057 100644 --- a/code/modules/vehicles/van/clf_van.dm +++ b/code/modules/vehicles/van/clf_van.dm @@ -44,15 +44,31 @@ "abstract" = 1 ) - movement_sound = 'sound/vehicles/tank_driving.ogg' + movement_sound = 'sound/vehicles/box_van_driving.ogg' honk_sound = 'sound/vehicles/honk_2_truck.ogg' vehicle_light_range = 8 move_max_momentum = 3 + engine_on = FALSE + + uses_gear_transmission = TRUE + current_gear = "P" + top_speed = 3.5 + base_acceleration = 1 + // Matches the ARC's own fuel consumption. + base_fuel_use = 0.175 + idle_fuel_use_mult = 0.2 + overdrive_speed_mult = 0.3 + desant_momentum_cap = 0.35 + hardpoints_allowed = list( /obj/item/hardpoint/locomotion/van_wheels, + /obj/item/hardpoint/engine/van, + /obj/item/hardpoint/fuel_tank/van, + /obj/item/hardpoint/radiator/civilian, + /obj/item/hardpoint/battery/civilian, ) move_turn_momentum_loss_factor = 1 @@ -64,11 +80,6 @@ mob_size_required_to_hit = MOB_SIZE_XENO - var/overdrive_next = 0 - var/overdrive_cooldown = 15 SECONDS - var/overdrive_duration = 3 SECONDS - var/overdrive_speed_mult = 0.3 // Additive (30% more speed, adds to 80% more speed) - var/momentum_loss_on_weeds_factor = 0.2 move_on_turn = TRUE @@ -94,6 +105,66 @@ for(var/I in GLOB.player_list) add_default_image(SSdcs, I) + gear_stats = build_gear_stats() + cruise_control_granularity = gear_stats["D"]["max_speed"] * CRUISE_CONTROL_DEFAULT_GRANULARITY_FRACTION + +/obj/vehicle/multitile/clf_van/add_seated_verbs(mob/living/M, seat) + if(!M.client) + return + add_verb(M.client, list( + /obj/vehicle/multitile/proc/get_status_info, + )) + if(seat == VEHICLE_DRIVER) + add_verb(M.client, list( + /obj/vehicle/multitile/proc/toggle_door_lock, + /obj/vehicle/multitile/proc/activate_horn, + /obj/vehicle/multitile/proc/name_vehicle, + /obj/vehicle/multitile/proc/toggle_cruise_control, + /obj/vehicle/multitile/proc/set_cruise_control_granularity, + /obj/vehicle/multitile/proc/toggle_turn_signal_north, + /obj/vehicle/multitile/proc/toggle_turn_signal_south, + /obj/vehicle/multitile/proc/toggle_turn_signal_east, + /obj/vehicle/multitile/proc/toggle_turn_signal_west, + /obj/vehicle/multitile/proc/toggle_engine, + )) + give_action(M, /datum/action/human_action/vehicle_action/toggle_door_lock) + give_action(M, /datum/action/human_action/vehicle_action/toggle_engine) + give_action(M, /datum/action/human_action/vehicle_action/use_phone) + if(!(M.client.prefs.toggles_vehicle & VEHICLE_SIMPLE_ACCELERATION)) + give_action(M, /datum/action/human_action/vehicle_action/toggle_cruise_control) + give_action(M, /datum/action/human_action/vehicle_action/set_cruise_control_granularity) + RegisterSignal(M, COMSIG_MOB_VEHICLE_PREFS_CHANGED, PROC_REF(on_driver_prefs_changed)) + start_crew_hud(M, VEHICLE_DRIVER) + refresh_hardpoint_actions() + +/obj/vehicle/multitile/clf_van/remove_seated_verbs(mob/living/M, seat) + if(!M.client) + return + remove_verb(M.client, list( + /obj/vehicle/multitile/proc/get_status_info, + )) + if(seat == VEHICLE_DRIVER) + remove_verb(M.client, list( + /obj/vehicle/multitile/proc/toggle_door_lock, + /obj/vehicle/multitile/proc/activate_horn, + /obj/vehicle/multitile/proc/name_vehicle, + /obj/vehicle/multitile/proc/toggle_cruise_control, + /obj/vehicle/multitile/proc/set_cruise_control_granularity, + /obj/vehicle/multitile/proc/toggle_turn_signal_north, + /obj/vehicle/multitile/proc/toggle_turn_signal_east, + /obj/vehicle/multitile/proc/toggle_turn_signal_west, + /obj/vehicle/multitile/proc/toggle_turn_signal_south, + /obj/vehicle/multitile/proc/toggle_engine, + )) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_door_lock) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_engine) + remove_action(M, /datum/action/human_action/vehicle_action/use_phone) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_cruise_control) + remove_action(M, /datum/action/human_action/vehicle_action/set_cruise_control_granularity) + UnregisterSignal(M, COMSIG_MOB_VEHICLE_PREFS_CHANGED) + stop_crew_hud(M, VEHICLE_DRIVER) + SStgui.close_user_uis(M, src) + /obj/vehicle/multitile/clf_van/BlockedPassDirs(atom/movable/mover, target_dir) if(mover in mobs_under) //can't collide with the thing you're buckled to return NO_BLOCKED_MOVEMENT @@ -204,23 +275,11 @@ /obj/vehicle/multitile/clf_van/handle_click(mob/living/user, atom/A, list/mods) if(mods[SHIFT_CLICK] && !mods[ALT_CLICK]) - if(overdrive_next > world.time) - to_chat(user, SPAN_WARNING("You can't activate overdrive yet! Wait [round((overdrive_next - world.time) / 10, 0.1)] seconds.")) - return - - misc_multipliers["move"] -= overdrive_speed_mult - addtimer(CALLBACK(src, PROC_REF(reset_overdrive)), overdrive_duration) - - overdrive_next = world.time + overdrive_cooldown - to_chat(user, SPAN_NOTICE("You activate overdrive.")) - playsound(src, 'sound/vehicles/overdrive_activate.ogg', 75, FALSE) + activate_overdrive(user) return return ..() -/obj/vehicle/multitile/clf_van/proc/reset_overdrive() - misc_multipliers["move"] += overdrive_speed_mult - /obj/vehicle/multitile/clf_van/get_projectile_hit_boolean(obj/projectile/P) if(src == P.original) //clicking on the van itself will hit it. var/hitchance = P.get_effective_accuracy() @@ -281,6 +340,10 @@ /obj/effect/vehicle_spawner/clf_van/decrepit/load_hardpoints(obj/vehicle/multitile/clf_van/V) V.add_hardpoint(new /obj/item/hardpoint/locomotion/van_wheels) + V.add_hardpoint(new /obj/item/hardpoint/engine/van) + V.add_hardpoint(new /obj/item/hardpoint/fuel_tank/van) + V.add_hardpoint(new /obj/item/hardpoint/radiator/civilian) + V.add_hardpoint(new /obj/item/hardpoint/battery/civilian) //PRESET: wheels installed /obj/effect/vehicle_spawner/clf_van/fixed/spawn_vehicle() @@ -293,3 +356,7 @@ /obj/effect/vehicle_spawner/clf_van/fixed/load_hardpoints(obj/vehicle/multitile/clf_van/V) V.add_hardpoint(new /obj/item/hardpoint/locomotion/van_wheels) + V.add_hardpoint(new /obj/item/hardpoint/engine/van) + V.add_hardpoint(new /obj/item/hardpoint/fuel_tank/van) + V.add_hardpoint(new /obj/item/hardpoint/radiator/civilian) + V.add_hardpoint(new /obj/item/hardpoint/battery/civilian) diff --git a/code/modules/vehicles/van/pizza_van.dm b/code/modules/vehicles/van/pizza_van.dm index e620c0f50b9f..d83ddf884b38 100644 --- a/code/modules/vehicles/van/pizza_van.dm +++ b/code/modules/vehicles/van/pizza_van.dm @@ -34,6 +34,10 @@ /obj/effect/vehicle_spawner/box_van/pizza_van/decrepit/load_hardpoints(obj/vehicle/multitile/box_van/pizza_van/V) V.add_hardpoint(new /obj/item/hardpoint/locomotion/van_wheels) + V.add_hardpoint(new /obj/item/hardpoint/engine/van) + V.add_hardpoint(new /obj/item/hardpoint/fuel_tank/van) + V.add_hardpoint(new /obj/item/hardpoint/radiator/civilian) + V.add_hardpoint(new /obj/item/hardpoint/battery/civilian) //PRESET: wheels installed /obj/effect/vehicle_spawner/box_van/pizza_van/fixed/spawn_vehicle() @@ -46,3 +50,7 @@ /obj/effect/vehicle_spawner/box_van/pizza_van/fixed/load_hardpoints(obj/vehicle/multitile/box_van/pizza_van/V) V.add_hardpoint(new /obj/item/hardpoint/locomotion/van_wheels) + V.add_hardpoint(new /obj/item/hardpoint/engine/van) + V.add_hardpoint(new /obj/item/hardpoint/fuel_tank/van) + V.add_hardpoint(new /obj/item/hardpoint/radiator/civilian) + V.add_hardpoint(new /obj/item/hardpoint/battery/civilian) diff --git a/code/modules/vehicles/van/van.dm b/code/modules/vehicles/van/van.dm index d7ce2397aaa0..bd0bb80d8faf 100644 --- a/code/modules/vehicles/van/van.dm +++ b/code/modules/vehicles/van/van.dm @@ -38,15 +38,31 @@ "cooldown" = 1 ) - movement_sound = 'sound/vehicles/tank_driving.ogg' + movement_sound = 'sound/vehicles/box_van_driving.ogg' honk_sound = 'sound/vehicles/honk_2_truck.ogg' vehicle_light_range = 8 move_max_momentum = 3 + engine_on = FALSE + + uses_gear_transmission = TRUE + current_gear = "P" + top_speed = 3.5 + base_acceleration = 1 + // Matches the ARC's own fuel consumption. + base_fuel_use = 0.175 + idle_fuel_use_mult = 0.2 + overdrive_speed_mult = 0.3 + desant_momentum_cap = 0.35 + hardpoints_allowed = list( /obj/item/hardpoint/locomotion/van_wheels, + /obj/item/hardpoint/engine/van, + /obj/item/hardpoint/fuel_tank/van, + /obj/item/hardpoint/radiator/civilian, + /obj/item/hardpoint/battery/civilian, ) move_turn_momentum_loss_factor = 1 @@ -58,11 +74,6 @@ mob_size_required_to_hit = MOB_SIZE_XENO - var/overdrive_next = 0 - var/overdrive_cooldown = 15 SECONDS - var/overdrive_duration = 3 SECONDS - var/overdrive_speed_mult = 0.3 // Additive (30% more speed, adds to 80% more speed) - var/momentum_loss_on_weeds_factor = 0.2 move_on_turn = TRUE @@ -88,6 +99,66 @@ for(var/I in GLOB.player_list) add_default_image(SSdcs, I) + gear_stats = build_gear_stats() + cruise_control_granularity = gear_stats["D"]["max_speed"] * CRUISE_CONTROL_DEFAULT_GRANULARITY_FRACTION + +/obj/vehicle/multitile/van/add_seated_verbs(mob/living/M, seat) + if(!M.client) + return + add_verb(M.client, list( + /obj/vehicle/multitile/proc/get_status_info, + )) + if(seat == VEHICLE_DRIVER) + add_verb(M.client, list( + /obj/vehicle/multitile/proc/toggle_door_lock, + /obj/vehicle/multitile/proc/activate_horn, + /obj/vehicle/multitile/proc/name_vehicle, + /obj/vehicle/multitile/proc/toggle_cruise_control, + /obj/vehicle/multitile/proc/set_cruise_control_granularity, + /obj/vehicle/multitile/proc/toggle_turn_signal_north, + /obj/vehicle/multitile/proc/toggle_turn_signal_south, + /obj/vehicle/multitile/proc/toggle_turn_signal_east, + /obj/vehicle/multitile/proc/toggle_turn_signal_west, + /obj/vehicle/multitile/proc/toggle_engine, + )) + give_action(M, /datum/action/human_action/vehicle_action/toggle_door_lock) + give_action(M, /datum/action/human_action/vehicle_action/toggle_engine) + give_action(M, /datum/action/human_action/vehicle_action/use_phone) + if(!(M.client.prefs.toggles_vehicle & VEHICLE_SIMPLE_ACCELERATION)) + give_action(M, /datum/action/human_action/vehicle_action/toggle_cruise_control) + give_action(M, /datum/action/human_action/vehicle_action/set_cruise_control_granularity) + RegisterSignal(M, COMSIG_MOB_VEHICLE_PREFS_CHANGED, PROC_REF(on_driver_prefs_changed)) + start_crew_hud(M, VEHICLE_DRIVER) + refresh_hardpoint_actions() + +/obj/vehicle/multitile/van/remove_seated_verbs(mob/living/M, seat) + if(!M.client) + return + remove_verb(M.client, list( + /obj/vehicle/multitile/proc/get_status_info, + )) + if(seat == VEHICLE_DRIVER) + remove_verb(M.client, list( + /obj/vehicle/multitile/proc/toggle_door_lock, + /obj/vehicle/multitile/proc/activate_horn, + /obj/vehicle/multitile/proc/name_vehicle, + /obj/vehicle/multitile/proc/toggle_cruise_control, + /obj/vehicle/multitile/proc/set_cruise_control_granularity, + /obj/vehicle/multitile/proc/toggle_turn_signal_north, + /obj/vehicle/multitile/proc/toggle_turn_signal_south, + /obj/vehicle/multitile/proc/toggle_turn_signal_east, + /obj/vehicle/multitile/proc/toggle_turn_signal_west, + /obj/vehicle/multitile/proc/toggle_engine, + )) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_door_lock) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_engine) + remove_action(M, /datum/action/human_action/vehicle_action/use_phone) + remove_action(M, /datum/action/human_action/vehicle_action/toggle_cruise_control) + remove_action(M, /datum/action/human_action/vehicle_action/set_cruise_control_granularity) + UnregisterSignal(M, COMSIG_MOB_VEHICLE_PREFS_CHANGED) + stop_crew_hud(M, VEHICLE_DRIVER) + SStgui.close_user_uis(M, src) + /obj/vehicle/multitile/van/BlockedPassDirs(atom/movable/mover, target_dir) if(mover in mobs_under) //can't collide with the thing you're buckled to return NO_BLOCKED_MOVEMENT @@ -198,23 +269,11 @@ /obj/vehicle/multitile/van/handle_click(mob/living/user, atom/A, list/mods) if(mods[SHIFT_CLICK] && !mods[ALT_CLICK]) - if(overdrive_next > world.time) - to_chat(user, SPAN_WARNING("You can't activate overdrive yet! Wait [round((overdrive_next - world.time) / 10, 0.1)] seconds.")) - return - - misc_multipliers["move"] -= overdrive_speed_mult - addtimer(CALLBACK(src, PROC_REF(reset_overdrive)), overdrive_duration) - - overdrive_next = world.time + overdrive_cooldown - to_chat(user, SPAN_NOTICE("You activate overdrive.")) - playsound(src, 'sound/vehicles/overdrive_activate.ogg', 75, FALSE) + activate_overdrive(user) return return ..() -/obj/vehicle/multitile/van/proc/reset_overdrive() - misc_multipliers["move"] += overdrive_speed_mult - /obj/vehicle/multitile/van/get_projectile_hit_boolean(obj/projectile/P) if(src == P.original) //clicking on the van itself will hit it. var/hitchance = P.get_effective_accuracy() @@ -275,6 +334,10 @@ /obj/effect/vehicle_spawner/van/decrepit/load_hardpoints(obj/vehicle/multitile/van/V) V.add_hardpoint(new /obj/item/hardpoint/locomotion/van_wheels) + V.add_hardpoint(new /obj/item/hardpoint/engine/van) + V.add_hardpoint(new /obj/item/hardpoint/fuel_tank/van) + V.add_hardpoint(new /obj/item/hardpoint/radiator/civilian) + V.add_hardpoint(new /obj/item/hardpoint/battery/civilian) //PRESET: wheels installed /obj/effect/vehicle_spawner/van/fixed/spawn_vehicle() @@ -287,3 +350,7 @@ /obj/effect/vehicle_spawner/van/fixed/load_hardpoints(obj/vehicle/multitile/van/V) V.add_hardpoint(new /obj/item/hardpoint/locomotion/van_wheels) + V.add_hardpoint(new /obj/item/hardpoint/engine/van) + V.add_hardpoint(new /obj/item/hardpoint/fuel_tank/van) + V.add_hardpoint(new /obj/item/hardpoint/radiator/civilian) + V.add_hardpoint(new /obj/item/hardpoint/battery/civilian) diff --git a/colonialmarines.dme b/colonialmarines.dme index 4ba1e4ccaf47..dc2069f388c9 100644 --- a/colonialmarines.dme +++ b/colonialmarines.dme @@ -396,6 +396,7 @@ #include "code\datums\spaceports.dm" #include "code\datums\tacmap_viewer.dm" #include "code\datums\tgs_event_handler.dm" +#include "code\datums\vehicle_hardpoint_hud.dm" #include "code\datums\vehicles.dm" #include "code\datums\weakrefs.dm" #include "code\datums\world_topic.dm" @@ -464,6 +465,7 @@ #include "code\datums\components\toxin_buildup.dm" #include "code\datums\components\tracker_bullets.dm" #include "code\datums\components\tutorial_status.dm" +#include "code\datums\components\vehicle_rider.dm" #include "code\datums\components\weed_damage_reduction.dm" #include "code\datums\components\weed_food.dm" #include "code\datums\components\autofire\_automated_fire.dm" @@ -660,6 +662,7 @@ #include "code\datums\looping_sounds\_looping_sound.dm" #include "code\datums\looping_sounds\item_sounds.dm" #include "code\datums\looping_sounds\misc_sounds.dm" +#include "code\datums\looping_sounds\vehicle_sounds.dm" #include "code\datums\origin\civilian.dm" #include "code\datums\origin\cmb.dm" #include "code\datums\origin\origin.dm" @@ -1321,6 +1324,7 @@ #include "code\game\objects\items\reagent_containers\food.dm" #include "code\game\objects\items\reagent_containers\glass.dm" #include "code\game\objects\items\reagent_containers\hypospray.dm" +#include "code\game\objects\items\reagent_containers\jp8_canister.dm" #include "code\game\objects\items\reagent_containers\pill.dm" #include "code\game\objects\items\reagent_containers\reagent_container.dm" #include "code\game\objects\items\reagent_containers\robodropper.dm" @@ -1864,6 +1868,7 @@ #include "code\modules\cm_tech\droppod\lz_effect.dm" #include "code\modules\cm_tech\droppod\marine.dm" #include "code\modules\cm_tech\droppod\supply.dm" +#include "code\modules\cm_tech\droppod\wreck.dm" #include "code\modules\cm_tech\implements\adv_weapon.dm" #include "code\modules\cm_tech\implements\ammo_kits.dm" #include "code\modules\cm_tech\implements\armor.dm" @@ -2114,6 +2119,7 @@ #include "code\modules\mob\living\brain\MMI.dm" #include "code\modules\mob\living\brain\say.dm" #include "code\modules\mob\living\carbon\carbon.dm" +#include "code\modules\mob\living\carbon\carbon_debug_verbs.dm" #include "code\modules\mob\living\carbon\carbon_defines.dm" #include "code\modules\mob\living\carbon\carbon_helpers.dm" #include "code\modules\mob\living\carbon\give.dm" @@ -2642,17 +2648,23 @@ #include "code\modules\vehicles\arc\interior.dm" #include "code\modules\vehicles\arc\verbs.dm" #include "code\modules\vehicles\hardpoints\hardpoint.dm" +#include "code\modules\vehicles\hardpoints\hardpoint_wound_families.dm" +#include "code\modules\vehicles\hardpoints\hardpoint_wounds.dm" +#include "code\modules\vehicles\hardpoints\air_filter\air_filter.dm" #include "code\modules\vehicles\hardpoints\armor\armor.dm" #include "code\modules\vehicles\hardpoints\armor\ballistic.dm" #include "code\modules\vehicles\hardpoints\armor\caustic.dm" #include "code\modules\vehicles\hardpoints\armor\concussive.dm" #include "code\modules\vehicles\hardpoints\armor\paladin.dm" -#include "code\modules\vehicles\hardpoints\armor\snowplow.dm" +#include "code\modules\vehicles\hardpoints\battery\battery.dm" +#include "code\modules\vehicles\hardpoints\engine\engine.dm" +#include "code\modules\vehicles\hardpoints\fuel_tank\fuel_tank.dm" #include "code\modules\vehicles\hardpoints\hardpoint_ammo\arc_sentry_ammo.dm" #include "code\modules\vehicles\hardpoints\hardpoint_ammo\autocannon_ammo.dm" #include "code\modules\vehicles\hardpoints\hardpoint_ammo\cupola_ammo.dm" #include "code\modules\vehicles\hardpoints\hardpoint_ammo\dualcannon_ammo.dm" #include "code\modules\vehicles\hardpoints\hardpoint_ammo\firing_port_weapon_ammo.dm" +#include "code\modules\vehicles\hardpoints\hardpoint_ammo\flare_ammo.dm" #include "code\modules\vehicles\hardpoints\hardpoint_ammo\flare_launcher_ammo.dm" #include "code\modules\vehicles\hardpoints\hardpoint_ammo\gl_ammo.dm" #include "code\modules\vehicles\hardpoints\hardpoint_ammo\hardpoint_ammo.dm" @@ -2660,10 +2672,11 @@ #include "code\modules\vehicles\hardpoints\hardpoint_ammo\minigun_ammo.dm" #include "code\modules\vehicles\hardpoints\hardpoint_ammo\primary_flamer_ammo.dm" #include "code\modules\vehicles\hardpoints\hardpoint_ammo\secondary_flamer_ammo.dm" -#include "code\modules\vehicles\hardpoints\hardpoint_ammo\smoke_ammo.dm" #include "code\modules\vehicles\hardpoints\hardpoint_ammo\tow_ammo.dm" +#include "code\modules\vehicles\hardpoints\hatch\hatch.dm" #include "code\modules\vehicles\hardpoints\holder\holder.dm" #include "code\modules\vehicles\hardpoints\holder\tank_turret.dm" +#include "code\modules\vehicles\hardpoints\iff_module\iff_module.dm" #include "code\modules\vehicles\hardpoints\primary\arc_sentry.dm" #include "code\modules\vehicles\hardpoints\primary\autocannon.dm" #include "code\modules\vehicles\hardpoints\primary\dual_cannon.dm" @@ -2671,12 +2684,16 @@ #include "code\modules\vehicles\hardpoints\primary\ltb.dm" #include "code\modules\vehicles\hardpoints\primary\minigun.dm" #include "code\modules\vehicles\hardpoints\primary\primary.dm" +#include "code\modules\vehicles\hardpoints\radiator\radiator.dm" +#include "code\modules\vehicles\hardpoints\secondary\brute_launcher.dm" #include "code\modules\vehicles\hardpoints\secondary\cupola.dm" +#include "code\modules\vehicles\hardpoints\secondary\flag.dm" #include "code\modules\vehicles\hardpoints\secondary\flamer.dm" #include "code\modules\vehicles\hardpoints\secondary\frontal_cannon.dm" #include "code\modules\vehicles\hardpoints\secondary\grenade_launcher.dm" #include "code\modules\vehicles\hardpoints\secondary\secondary.dm" #include "code\modules\vehicles\hardpoints\secondary\tow.dm" +#include "code\modules\vehicles\hardpoints\snowplow\snowplow.dm" #include "code\modules\vehicles\hardpoints\special\firing_port_weapon.dm" #include "code\modules\vehicles\hardpoints\special\special.dm" #include "code\modules\vehicles\hardpoints\support\antenna.dm" @@ -2685,6 +2702,8 @@ #include "code\modules\vehicles\hardpoints\support\iwsa.dm" #include "code\modules\vehicles\hardpoints\support\overdrive.dm" #include "code\modules\vehicles\hardpoints\support\support.dm" +#include "code\modules\vehicles\hardpoints\turret_ring\turret_ring.dm" +#include "code\modules\vehicles\hardpoints\visual_sensors\visual_sensors.dm" #include "code\modules\vehicles\hardpoints\wheels\apc_wheels.dm" #include "code\modules\vehicles\hardpoints\wheels\arc_wheels.dm" #include "code\modules\vehicles\hardpoints\wheels\locomotion.dm" @@ -2695,19 +2714,29 @@ #include "code\modules\vehicles\interior\interior_hull.dm" #include "code\modules\vehicles\interior\interior_landmarks.dm" #include "code\modules\vehicles\interior\interactable\doors.dm" +#include "code\modules\vehicles\interior\interactable\intercom.dm" #include "code\modules\vehicles\interior\interactable\seats.dm" #include "code\modules\vehicles\interior\interactable\vehicle_locker.dm" #include "code\modules\vehicles\interior\interactable\vendors.dm" #include "code\modules\vehicles\interior\interactable\viewports.dm" #include "code\modules\vehicles\interior\interactable\weapons_loader.dm" #include "code\modules\vehicles\multitile\multitile.dm" +#include "code\modules\vehicles\multitile\multitile_actions.dm" #include "code\modules\vehicles\multitile\multitile_bump.dm" +#include "code\modules\vehicles\multitile\multitile_cookoff.dm" +#include "code\modules\vehicles\multitile\multitile_hardpoint_hud.dm" #include "code\modules\vehicles\multitile\multitile_hardpoints.dm" +#include "code\modules\vehicles\multitile\multitile_hud.dm" #include "code\modules\vehicles\multitile\multitile_interaction.dm" #include "code\modules\vehicles\multitile\multitile_movement.dm" +#include "code\modules\vehicles\multitile\multitile_overwatch.dm" +#include "code\modules\vehicles\multitile\multitile_riding.dm" +#include "code\modules\vehicles\multitile\multitile_stairs.dm" #include "code\modules\vehicles\multitile\multitile_verbs.dm" #include "code\modules\vehicles\tank\interior.dm" #include "code\modules\vehicles\tank\tank.dm" +#include "code\modules\vehicles\tank\tank_actions.dm" +#include "code\modules\vehicles\tank\tank_helpers.dm" #include "code\modules\vehicles\van\box_van.dm" #include "code\modules\vehicles\van\clf_van.dm" #include "code\modules\vehicles\van\interior.dm" diff --git a/icons/effects/Targeted.dmi b/icons/effects/Targeted.dmi index 33922d93b6b9..fec130b01885 100644 Binary files a/icons/effects/Targeted.dmi and b/icons/effects/Targeted.dmi differ diff --git a/icons/mob/hud/actions.dmi b/icons/mob/hud/actions.dmi index 4a754815243b..e9fae9d695db 100644 Binary files a/icons/mob/hud/actions.dmi and b/icons/mob/hud/actions.dmi differ diff --git a/icons/mob/hud/alien_standard.dmi b/icons/mob/hud/alien_standard.dmi index f20d8200aba6..a1fae4b92867 100644 Binary files a/icons/mob/hud/alien_standard.dmi and b/icons/mob/hud/alien_standard.dmi differ diff --git a/icons/mob/hud/human_bronze.dmi b/icons/mob/hud/human_bronze.dmi index 057dd99f5bd3..c13588cef89c 100644 Binary files a/icons/mob/hud/human_bronze.dmi and b/icons/mob/hud/human_bronze.dmi differ diff --git a/icons/mob/hud/human_dark.dmi b/icons/mob/hud/human_dark.dmi index 7d2d635a734b..99fdd303df47 100644 Binary files a/icons/mob/hud/human_dark.dmi and b/icons/mob/hud/human_dark.dmi differ diff --git a/icons/mob/hud/human_glass.dmi b/icons/mob/hud/human_glass.dmi index 60d89f448761..d1902330d7ab 100644 Binary files a/icons/mob/hud/human_glass.dmi and b/icons/mob/hud/human_glass.dmi differ diff --git a/icons/mob/hud/human_green.dmi b/icons/mob/hud/human_green.dmi index 6ebf2bf1db38..1404cbf79f82 100644 Binary files a/icons/mob/hud/human_green.dmi and b/icons/mob/hud/human_green.dmi differ diff --git a/icons/mob/hud/human_grey.dmi b/icons/mob/hud/human_grey.dmi index fbd044bc7be7..a56833896e7a 100644 Binary files a/icons/mob/hud/human_grey.dmi and b/icons/mob/hud/human_grey.dmi differ diff --git a/icons/mob/hud/human_holo.dmi b/icons/mob/hud/human_holo.dmi index 9cd3bff960ba..647f35165202 100644 Binary files a/icons/mob/hud/human_holo.dmi and b/icons/mob/hud/human_holo.dmi differ diff --git a/icons/mob/hud/human_midnight.dmi b/icons/mob/hud/human_midnight.dmi index e83ac45420f8..dbaa0b4522ed 100644 Binary files a/icons/mob/hud/human_midnight.dmi and b/icons/mob/hud/human_midnight.dmi differ diff --git a/icons/mob/hud/human_old.dmi b/icons/mob/hud/human_old.dmi index 2185c37bb296..59cc15305954 100644 Binary files a/icons/mob/hud/human_old.dmi and b/icons/mob/hud/human_old.dmi differ diff --git a/icons/mob/hud/human_orange.dmi b/icons/mob/hud/human_orange.dmi index 421ccd789dc6..975ae18c74b5 100644 Binary files a/icons/mob/hud/human_orange.dmi and b/icons/mob/hud/human_orange.dmi differ diff --git a/icons/mob/hud/human_red.dmi b/icons/mob/hud/human_red.dmi index ec55a34d5a01..6791bd3596ff 100644 Binary files a/icons/mob/hud/human_red.dmi and b/icons/mob/hud/human_red.dmi differ diff --git a/icons/mob/hud/human_white.dmi b/icons/mob/hud/human_white.dmi index c3181aeb2d89..68261bc8d13c 100644 Binary files a/icons/mob/hud/human_white.dmi and b/icons/mob/hud/human_white.dmi differ diff --git a/icons/mob/hud/screen1.dmi b/icons/mob/hud/screen1.dmi index e262363e87c7..9efdf570c3e9 100644 Binary files a/icons/mob/hud/screen1.dmi and b/icons/mob/hud/screen1.dmi differ diff --git a/icons/mob/hud/zone_sel.dmi b/icons/mob/hud/zone_sel.dmi index 25ace3720c74..b994ddec9bbd 100644 Binary files a/icons/mob/hud/zone_sel.dmi and b/icons/mob/hud/zone_sel.dmi differ diff --git a/icons/mob/humans/onmob/clothing/helmet_garb/huds.dmi b/icons/mob/humans/onmob/clothing/helmet_garb/huds.dmi index 748846dbe013..0394de23cdc8 100644 Binary files a/icons/mob/humans/onmob/clothing/helmet_garb/huds.dmi and b/icons/mob/humans/onmob/clothing/helmet_garb/huds.dmi differ diff --git a/icons/mob/humans/onmob/clothing/helmet_garb/walkman.dmi b/icons/mob/humans/onmob/clothing/helmet_garb/walkman.dmi index bde11685f623..e05924c62cff 100644 Binary files a/icons/mob/humans/onmob/clothing/helmet_garb/walkman.dmi and b/icons/mob/humans/onmob/clothing/helmet_garb/walkman.dmi differ diff --git a/icons/obj/items/clothing/helmet_visors.dmi b/icons/obj/items/clothing/helmet_visors.dmi index a134369e1b4b..3aa95307d436 100644 Binary files a/icons/obj/items/clothing/helmet_visors.dmi and b/icons/obj/items/clothing/helmet_visors.dmi differ diff --git a/icons/obj/items/tank.dmi b/icons/obj/items/tank.dmi index cc23d95eea63..571eb1e7c42f 100644 Binary files a/icons/obj/items/tank.dmi and b/icons/obj/items/tank.dmi differ diff --git a/icons/obj/items/walkman.dmi b/icons/obj/items/walkman.dmi index 08d76ccc30c4..3fc92bc3ed25 100644 Binary files a/icons/obj/items/walkman.dmi and b/icons/obj/items/walkman.dmi differ diff --git a/icons/obj/items/weapons/guns/ammo_by_faction/USCM/vehicles.dmi b/icons/obj/items/weapons/guns/ammo_by_faction/USCM/vehicles.dmi index 612d4cc9f43a..ed1df2927236 100644 Binary files a/icons/obj/items/weapons/guns/ammo_by_faction/USCM/vehicles.dmi and b/icons/obj/items/weapons/guns/ammo_by_faction/USCM/vehicles.dmi differ diff --git a/icons/obj/items/weapons/projectiles.dmi b/icons/obj/items/weapons/projectiles.dmi index b2a257366979..f1ec76e2311c 100644 Binary files a/icons/obj/items/weapons/projectiles.dmi and b/icons/obj/items/weapons/projectiles.dmi differ diff --git a/icons/obj/vehicles/apc.dmi b/icons/obj/vehicles/apc.dmi index dfa76f52063e..67da0e384faf 100644 Binary files a/icons/obj/vehicles/apc.dmi and b/icons/obj/vehicles/apc.dmi differ diff --git a/icons/obj/vehicles/apc_pmc.dmi b/icons/obj/vehicles/apc_pmc.dmi index de2b8a538e71..99b35013377d 100644 Binary files a/icons/obj/vehicles/apc_pmc.dmi and b/icons/obj/vehicles/apc_pmc.dmi differ diff --git a/icons/obj/vehicles/arc.dmi b/icons/obj/vehicles/arc.dmi index c13153072ee2..45552ac7342b 100644 Binary files a/icons/obj/vehicles/arc.dmi and b/icons/obj/vehicles/arc.dmi differ diff --git a/icons/obj/vehicles/hardpoints/shared.dmi b/icons/obj/vehicles/hardpoints/shared.dmi new file mode 100644 index 000000000000..c086683d3707 Binary files /dev/null and b/icons/obj/vehicles/hardpoints/shared.dmi differ diff --git a/icons/obj/vehicles/hardpoints/tank.dmi b/icons/obj/vehicles/hardpoints/tank.dmi index c9af30849eb8..eb6512e61629 100644 Binary files a/icons/obj/vehicles/hardpoints/tank.dmi and b/icons/obj/vehicles/hardpoints/tank.dmi differ diff --git a/icons/obj/vehicles/hud/tankhud.dmi b/icons/obj/vehicles/hud/tankhud.dmi new file mode 100644 index 000000000000..e92490604829 Binary files /dev/null and b/icons/obj/vehicles/hud/tankhud.dmi differ diff --git a/icons/obj/vehicles/tank.dmi b/icons/obj/vehicles/tank.dmi index 1991bc9caa70..9ec706743234 100644 Binary files a/icons/obj/vehicles/tank.dmi and b/icons/obj/vehicles/tank.dmi differ diff --git a/maps/interiors/apc.dmm b/maps/interiors/apc.dmm index f03d673d99b0..c60f33d293ec 100644 --- a/maps/interiors/apc.dmm +++ b/maps/interiors/apc.dmm @@ -150,6 +150,15 @@ /area/space) "u" = ( /obj/effect/landmark/interior/spawn/weapons_loader, +/obj/effect/landmark/interior/spawn/intercom{ + pixel_x = 9; + pixel_y = -5; + drag_delay = 3.05 + }, +/obj/effect/landmark/interior/spawn/telephone{ + pixel_x = -2; + pixel_y = -5 + }, /turf/open/shuttle/vehicle/floor_3_6, /area/interior/vehicle/apc) "v" = ( @@ -254,10 +263,6 @@ /obj/structure/bed/chair/vehicle{ pixel_x = 8 }, -/obj/effect/landmark/interior/spawn/telephone{ - pixel_x = 5; - pixel_y = 30 - }, /obj/structure/extinguisher_cabinet/lifeboat{ icon = 'icons/obj/vehicles/interiors/general.dmi'; pixel_x = -8; diff --git a/maps/interiors/apc_command.dmm b/maps/interiors/apc_command.dmm index 0e14e3e48bca..68c190b1191a 100644 --- a/maps/interiors/apc_command.dmm +++ b/maps/interiors/apc_command.dmm @@ -372,6 +372,10 @@ /area/space) "Y" = ( /obj/effect/landmark/interior/spawn/weapons_loader, +/obj/effect/landmark/interior/spawn/intercom{ + pixel_x = 9; + pixel_y = -5 + }, /turf/open/shuttle/vehicle/floor_3_6, /area/interior/vehicle/apc/command) diff --git a/maps/interiors/apc_med.dmm b/maps/interiors/apc_med.dmm index 9ca29500eafa..04392b3b08c0 100644 --- a/maps/interiors/apc_med.dmm +++ b/maps/interiors/apc_med.dmm @@ -335,6 +335,14 @@ /area/interior/vehicle/apc/med) "R" = ( /obj/effect/landmark/interior/spawn/weapons_loader, +/obj/effect/landmark/interior/spawn/telephone{ + pixel_x = -1; + pixel_y = -5 + }, +/obj/effect/landmark/interior/spawn/intercom{ + pixel_x = 10; + pixel_y = -5 + }, /turf/open/shuttle/vehicle/floor_3_6, /area/interior/vehicle/apc/med) "S" = ( @@ -370,10 +378,6 @@ /obj/structure/bed/chair/vehicle{ pixel_x = 8 }, -/obj/effect/landmark/interior/spawn/telephone{ - pixel_x = 5; - pixel_y = 30 - }, /obj/structure/extinguisher_cabinet/lifeboat{ icon = 'icons/obj/vehicles/interiors/general.dmi'; pixel_x = -8; diff --git a/maps/interiors/apc_no_fpw.dmm b/maps/interiors/apc_no_fpw.dmm index a387a19e79e8..73f4a7dc6b54 100644 --- a/maps/interiors/apc_no_fpw.dmm +++ b/maps/interiors/apc_no_fpw.dmm @@ -185,6 +185,14 @@ /area/space) "y" = ( /obj/effect/landmark/interior/spawn/weapons_loader, +/obj/effect/landmark/interior/spawn/intercom{ + pixel_x = 10; + pixel_y = -5 + }, +/obj/effect/landmark/interior/spawn/telephone{ + pixel_x = -1; + pixel_y = -5 + }, /turf/open/shuttle/vehicle/floor_3_6, /area/interior/vehicle/apc) "z" = ( @@ -361,10 +369,6 @@ /obj/structure/bed/chair/vehicle{ pixel_x = 8 }, -/obj/effect/landmark/interior/spawn/telephone{ - pixel_x = 5; - pixel_y = 30 - }, /obj/structure/extinguisher_cabinet/lifeboat{ icon = 'icons/obj/vehicles/interiors/general.dmi'; pixel_x = -8; diff --git a/maps/interiors/apc_pmc.dmm b/maps/interiors/apc_pmc.dmm index 7ac70e8469ef..1ce4dc7655bd 100644 --- a/maps/interiors/apc_pmc.dmm +++ b/maps/interiors/apc_pmc.dmm @@ -150,6 +150,14 @@ /area/space) "u" = ( /obj/effect/landmark/interior/spawn/weapons_loader/wy, +/obj/effect/landmark/interior/spawn/intercom{ + pixel_x = 9; + pixel_y = -6 + }, +/obj/effect/landmark/interior/spawn/telephone/wy{ + pixel_x = -2; + pixel_y = -6 + }, /turf/open/shuttle/vehicle/floor_3_6, /area/interior/vehicle/apc) "v" = ( @@ -254,10 +262,6 @@ /obj/structure/bed/chair/vehicle/white{ pixel_x = 8 }, -/obj/effect/landmark/interior/spawn/telephone/wy{ - pixel_x = 5; - pixel_y = 30 - }, /obj/structure/extinguisher_cabinet/lifeboat{ icon = 'icons/obj/vehicles/interiors/general_wy.dmi'; pixel_x = -8; diff --git a/maps/interiors/arc.dmm b/maps/interiors/arc.dmm index f1a7f11a8f34..428bdfc1d5d1 100644 --- a/maps/interiors/arc.dmm +++ b/maps/interiors/arc.dmm @@ -21,6 +21,10 @@ dir = 4 }, /obj/item/device/megaphone, +/obj/effect/landmark/interior/spawn/intercom{ + pixel_x = -1; + pixel_y = 15 + }, /turf/open/floor, /area/interior/vehicle/arc) "Dn" = ( diff --git a/maps/interiors/tank.dmm b/maps/interiors/tank.dmm index e5c019e01993..4d3c28676189 100644 --- a/maps/interiors/tank.dmm +++ b/maps/interiors/tank.dmm @@ -6,14 +6,18 @@ name = "back entrance marker"; tag = "back" }, -/obj/structure/transmitter{ +/obj/effect/landmark/interior/spawn/telephone{ dir = 8; layer = 3.1; name = "Tank Telephone"; - phone_category = "Vehicles"; - phone_id = "M34A2 Longstreet Light Tank"; pixel_x = 22; - pixel_y = -14 + pixel_y = -6 + }, +/obj/effect/landmark/interior/spawn/intercom{ + pixel_x = 22; + pixel_y = -15; + dir = 8; + drag_delay = 3.05 }, /turf/open/shuttle/vehicle/floor_1_12, /area/interior/vehicle/tank) @@ -67,7 +71,8 @@ alpha = 50; icon_state = "exterior_2"; layer = 5.2; - pixel_y = 32 + pixel_y = 32; + pixel_x = 0 }, /turf/open/void/vehicle, /area/space) diff --git a/maps/map_files/Tyrargo_Rift/Tyrargo_Rift.dmm b/maps/map_files/Tyrargo_Rift/Tyrargo_Rift.dmm index f45c62828598..33328e1981c2 100644 --- a/maps/map_files/Tyrargo_Rift/Tyrargo_Rift.dmm +++ b/maps/map_files/Tyrargo_Rift/Tyrargo_Rift.dmm @@ -56373,8 +56373,8 @@ /area/tyrargo/oob/outdoors) "cKo" = ( /obj/structure/surface/rack, -/obj/item/ammo_magazine/hardpoint/turret_smoke, -/obj/item/ammo_magazine/hardpoint/turret_smoke, +/obj/item/storage/box/packet/flare, +/obj/item/storage/box/packet/flare, /obj/item/ammo_magazine/hardpoint/m56_cupola, /obj/item/ammo_magazine/hardpoint/m56_cupola, /turf/open/auto_turf/tyrargo_grass/layer0_mud, diff --git a/maps/map_files/USS_Almayer/USS_Almayer.dmm b/maps/map_files/USS_Almayer/USS_Almayer.dmm index e0234b532ae4..2cad41e30718 100644 --- a/maps/map_files/USS_Almayer/USS_Almayer.dmm +++ b/maps/map_files/USS_Almayer/USS_Almayer.dmm @@ -223,10 +223,6 @@ /obj/structure/cargo_container/wy/mid, /turf/open/floor/plating/plating_catwalk, /area/almayer/hallways/hangar) -"aaI" = ( -/obj/structure/blocker/forcefield/multitile_vehicles, -/turf/open/floor/almayer/no_build/plate, -/area/almayer/stair_clone/lower/starboard_fore) "aaJ" = ( /turf/closed/wall/almayer, /area/almayer/living/starboard_emb) @@ -4218,7 +4214,6 @@ /turf/open/floor/almayer/no_build/plate, /area/almayer/middeck/hanger) "ajS" = ( -/obj/structure/blocker/forcefield/multitile_vehicles, /turf/open/floor/plating/almayer/no_build, /area/almayer/hallways/upper/fore_hallway) "ajT" = ( @@ -13987,7 +13982,6 @@ /obj/structure/stairs/multiz/down{ dir = 1 }, -/obj/structure/blocker/forcefield/multitile_vehicles, /turf/open/floor/plating/almayer/no_build, /area/almayer/stair_clone/upper/port_aft) "aKB" = ( @@ -14171,7 +14165,6 @@ /area/almayer/maint/hull/upper/u_a_s) "aLg" = ( /obj/structure/stairs/multiz/down, -/obj/structure/blocker/forcefield/multitile_vehicles, /turf/open/floor/almayer/no_build/plating, /area/almayer/stair_clone/upper/starboard_aft) "aLh" = ( @@ -34334,10 +34327,6 @@ /obj/structure/machinery/power/apc/almayer/south, /turf/open/floor/almayer/plate, /area/almayer/middeck/maintenance/saft) -"bFF" = ( -/obj/structure/blocker/forcefield/multitile_vehicles, -/turf/open/floor/almayer/plate, -/area/almayer/hallways/upper/fore_hallway) "bFG" = ( /obj/structure/largecrate/random, /obj/structure/prop/hybrisa/factory/robotic_arm/flipped{ @@ -50881,6 +50870,10 @@ pixel_y = 5; pixel_x = 4 }, +/obj/item/device/cassette_tape/liberation{ + pixel_x = 7; + pixel_y = -5 + }, /turf/open/floor/wood/ship, /area/almayer/living/tankerbunks) "cEi" = ( @@ -53008,10 +53001,6 @@ /obj/structure/platform_decoration/metal/almayer/southwest, /turf/open/floor/almayer/red/southwest, /area/almayer/lifeboat_pumps/south2) -"dBg" = ( -/obj/structure/blocker/forcefield/multitile_vehicles, -/turf/open/floor/almayer/plating_striped/east, -/area/almayer/stair_clone/lower/port_aft) "dBi" = ( /obj/structure/platform/metal/almayer/north, /obj/structure/platform/metal/almayer/east, @@ -67641,7 +67630,6 @@ /turf/open/floor/plating, /area/almayer/maint/upper/u_m_s) "jXR" = ( -/obj/structure/blocker/forcefield/multitile_vehicles, /obj/structure/sign/safety/stairs{ pixel_x = 8; pixel_y = 25 @@ -70755,10 +70743,6 @@ }, /turf/open/floor/almayer/bluecorner, /area/almayer/squads/delta) -"loV" = ( -/obj/structure/blocker/forcefield/multitile_vehicles, -/turf/open/floor/almayer/plating_striped/west, -/area/almayer/stair_clone/lower/starboard_aft) "loY" = ( /turf/open/floor/almayer/green/west, /area/almayer/living/grunt_rnr) @@ -73460,10 +73444,6 @@ /obj/structure/platform/metal/almayer/east, /turf/open/floor/almayer/plating_striped/west, /area/almayer/hallways/lower/repair_bay) -"mFL" = ( -/obj/structure/blocker/forcefield/multitile_vehicles, -/turf/open/floor/plating/almayer/no_build, -/area/almayer/stair_clone/lower/port_aft) "mFO" = ( /obj/structure/pipes/standard/simple/hidden/supply{ dir = 4 @@ -83704,10 +83684,6 @@ /obj/effect/decal/cleanable/cobweb, /turf/open/floor/plating/plating_catwalk, /area/almayer/maint/upper/mess) -"qVS" = ( -/obj/structure/blocker/forcefield/multitile_vehicles, -/turf/open/floor/almayer/plating_striped/west, -/area/almayer/stair_clone/lower/port_aft) "qWt" = ( /obj/structure/disposalpipe/segment{ dir = 8; @@ -86343,10 +86319,6 @@ }, /turf/open/floor/plating/plating_catwalk, /area/almayer/maint/upper/u_a_p) -"sai" = ( -/obj/structure/blocker/forcefield/multitile_vehicles, -/turf/open/floor/almayer/no_build/plate, -/area/almayer/stair_clone/lower/port_fore) "saX" = ( /obj/structure/disposalpipe/down/almayer{ dir = 8; @@ -87294,10 +87266,6 @@ }, /turf/open/floor/almayer/test_floor4, /area/almayer/engineering/lower/engine_core) -"suU" = ( -/obj/structure/blocker/forcefield/multitile_vehicles, -/turf/open/floor/almayer/plating_striped/east, -/area/almayer/stair_clone/lower/starboard_aft) "svf" = ( /obj/structure/machinery/light{ dir = 1 @@ -89962,7 +89930,14 @@ }, /obj/structure/surface/rack, /obj/item/device/radio, -/obj/item/tool/weldpack, +/obj/item/tool/weldpack{ + pixel_x = -4; + pixel_y = 0 + }, +/obj/item/reagent_container/glass/beaker/jp8_canister/full{ + pixel_x = 6; + pixel_y = -2 + }, /turf/open/floor/almayer/plating/northeast, /area/almayer/hallways/lower/vehiclehangar) "tFF" = ( @@ -93132,10 +93107,6 @@ }, /turf/open/floor/almayer/plate, /area/almayer/hallways/lower/port_fore_hallway) -"uZm" = ( -/obj/structure/blocker/forcefield/multitile_vehicles, -/turf/open/floor/plating/almayer/no_build, -/area/almayer/stair_clone/lower/starboard_aft) "uZo" = ( /obj/structure/bed/stool, /obj/structure/pipes/standard/simple/hidden/supply{ @@ -96640,7 +96611,6 @@ dir = 1; name = "ship-grade camera" }, -/obj/structure/blocker/forcefield/multitile_vehicles, /obj/structure/sign/safety/stairs{ pixel_x = 8; pixel_y = -32 @@ -147121,8 +147091,8 @@ wDr wDr wDr jXR -aaI -aaI +sNO +sNO wDr kgt hKe @@ -147152,8 +147122,8 @@ tzw jZe qPU wDr -sai -sai +alg +alg wrN wDr wDr @@ -152048,7 +152018,7 @@ lSJ oWE lSJ kNO -loV +lSJ atF atF atl @@ -152114,7 +152084,7 @@ vra akN fqA fqA -qVS +cbg dVn cbg buY @@ -152151,7 +152121,7 @@ qJj qJj qJj qJj -uZm +qJj atF atF atl @@ -152217,7 +152187,7 @@ bSJ akN fqA fqA -mFL +aqL aqL aqL aqL @@ -152254,7 +152224,7 @@ wNz wNz wNz wNz -suU +wNz atC atC atp @@ -152320,7 +152290,7 @@ bSJ akO akK akK -dBg +iyE iyE iyE iyE @@ -208958,9 +208928,9 @@ bmT bmT bmT bmT -bFF -bFF -bFF +aKH +aKH +aKH bmT vIo vIo diff --git a/sound/ATTRIBUTION.txt b/sound/ATTRIBUTION.txt index 64583c62489b..a2728f8a7889 100644 --- a/sound/ATTRIBUTION.txt +++ b/sound/ATTRIBUTION.txt @@ -4,4 +4,22 @@ giant_lizard_hiss1.ogg, giant_lizard_hiss2.ogg by ZapSplat -- https://www.zapspl ghost_whisper_12.ogg by ondrosik -- https://freesound.org/s/207797/ -- License: Creative Commons 0 hit_punch.ogg and hit_kick.ogg are made by Taira Komori -(https://taira-komori.jpn.org/freesounden.html) \ No newline at end of file +(https://taira-komori.jpn.org/freesounden.html) + +vehicles/trackrattling.ogg: tank bulldozer tracked vehicle sound effect atmo by Garuda1982 -- https://freesound.org/s/541240/ -- License: Attribution 4.0 +vehicles/turretdamaged.ogg: Rotating tank turret planetary electric motor by lorefold -- https://freesound.org/s/607311/ -- License: Creative Commons 0 +vehicles/metalimpact1.ogg: Car Crash Sound Effect -- https://pixabay.com/sound-effects/film-special-effects-sound-effect-car-crash-394903/ -- License: Pixabay Content License +vehicles/metalimpact2.ogg: Car Crash Impact Head on Smash Metal and Glass Breaking -- https://www.zapsplat.com/music/car-crash-impact-head-on-smash-metal-and-glass-breaking/ -- License: ZapSplat Standard License +vehicles/metalimpact3.ogg: Bicycle Impact Crash Hits Metal Fence or Wall -- https://www.zapsplat.com/music/bicycle-impact-crash-hits-metal-fence-or-wall/ -- License: ZapSplat Standard License +vehicles/metalimpact4.ogg: Car Crash Sound Effect by DRAGON-STUDIO -- https://pixabay.com/sound-effects/film-special-effects-car-crash-sound-effect-376874/ -- License: Pixabay Content License +vehicles/metalimpact5.ogg: Clank Car Crash Collision by qubodup -- https://pixabay.com/sound-effects/film-special-effects-clank-car-crash-collision-6206/ -- License: Pixabay Content License +vehicles/metalimpact6.ogg: Car Crash Sound by DRAGON-STUDIO -- https://pixabay.com/sound-effects/film-special-effects-car-crash-sound-376882/ -- License: Pixabay Content License +vehicles/iffbeepactive.ogg: 5 beep c.wav by jobro -- https://freesound.org/s/33789/ -- License: Attribution 3.0 +vehicles/iffbeepoff.ogg: warning by EtherAudio -- https://freesound.org/s/856382/ -- License: Attribution 4.0 +vehicles/acidsizzle1.ogg: Food Sizzling.wav by bennathanras -- https://freesound.org/s/607430/ -- License: Attribution NonCommercial 4.0 +vehicles/acidsizzle2.ogg, vehicles/acidsizzle3.ogg, vehicles/acidsizzle4.ogg, vehicles/acidsizzle5.ogg, vehicles/acidsizzle6.ogg, vehicles/acidsizzle7.ogg: Sizzle / Acid Burn by bottles -- https://freesound.org/s/382313/ -- License: Creative Commons 0 +vehicles/turbinestartup.ogg, vehicles/turbineidle.ogg, vehicles/turbineoff.ogg: Start-up Sound - U.S. Army M1A1 Tank - Gas Turbine Engine.mp3 by adr1911 -- https://freesound.org/s/542582/ -- License: Creative Commons 0 +vehicles/treadedDrift.ogg: Floor Scrape 11 by Department64 -- https://freesound.org/s/681435/ -- License: Attribution 4.0 +vehicles/arcstart.ogg, vehicles/arcidle.ogg, vehicles/arcstop.ogg: Tank M2 Bradley Start And Stop -- https://quicksounds.com/sound/21044/tank-m2-bradley-start-and-stop -- License: Quick Sounds Standard License +vehicles/apcstart.ogg, vehicles/apcidle.ogg, vehicles/apcstop.ogg: Tank M551 Start Stop -- https://quicksounds.com/sound/21067/tank-m551-start-stop -- License: Quick Sounds Standard License +vehicles/humveestart.ogg, vehicles/humveeidle.ogg, vehicles/humveestop.ogg: Ford LTL-9000 Engine Start and Idle -- https://pixabay.com/sound-effects/technology-ford-ltl-9000-engine-start-and-idle-30042/ -- License: Pixabay Content License \ No newline at end of file diff --git a/sound/effects/pipe_hissing10s.ogg b/sound/effects/pipe_hissing10s.ogg new file mode 100644 index 000000000000..7579be2af29e Binary files /dev/null and b/sound/effects/pipe_hissing10s.ogg differ diff --git a/sound/vehicles/acidsizzle1.ogg b/sound/vehicles/acidsizzle1.ogg new file mode 100644 index 000000000000..0d682ea747e9 Binary files /dev/null and b/sound/vehicles/acidsizzle1.ogg differ diff --git a/sound/vehicles/acidsizzle2.ogg b/sound/vehicles/acidsizzle2.ogg new file mode 100644 index 000000000000..6abdbc1543dd Binary files /dev/null and b/sound/vehicles/acidsizzle2.ogg differ diff --git a/sound/vehicles/acidsizzle3.ogg b/sound/vehicles/acidsizzle3.ogg new file mode 100644 index 000000000000..372eafc8f9ba Binary files /dev/null and b/sound/vehicles/acidsizzle3.ogg differ diff --git a/sound/vehicles/acidsizzle4.ogg b/sound/vehicles/acidsizzle4.ogg new file mode 100644 index 000000000000..6c3b65111c86 Binary files /dev/null and b/sound/vehicles/acidsizzle4.ogg differ diff --git a/sound/vehicles/acidsizzle5.ogg b/sound/vehicles/acidsizzle5.ogg new file mode 100644 index 000000000000..d1326f7f15f8 Binary files /dev/null and b/sound/vehicles/acidsizzle5.ogg differ diff --git a/sound/vehicles/acidsizzle6.ogg b/sound/vehicles/acidsizzle6.ogg new file mode 100644 index 000000000000..40da7efc0fe6 Binary files /dev/null and b/sound/vehicles/acidsizzle6.ogg differ diff --git a/sound/vehicles/acidsizzle7.ogg b/sound/vehicles/acidsizzle7.ogg new file mode 100644 index 000000000000..1bd9db35a4a5 Binary files /dev/null and b/sound/vehicles/acidsizzle7.ogg differ diff --git a/sound/vehicles/apcidle.ogg b/sound/vehicles/apcidle.ogg new file mode 100644 index 000000000000..18ba9c23d5ad Binary files /dev/null and b/sound/vehicles/apcidle.ogg differ diff --git a/sound/vehicles/apcstart.ogg b/sound/vehicles/apcstart.ogg new file mode 100644 index 000000000000..585d66e7aed4 Binary files /dev/null and b/sound/vehicles/apcstart.ogg differ diff --git a/sound/vehicles/apcstop.ogg b/sound/vehicles/apcstop.ogg new file mode 100644 index 000000000000..4b27c319abaf Binary files /dev/null and b/sound/vehicles/apcstop.ogg differ diff --git a/sound/vehicles/arcidle.ogg b/sound/vehicles/arcidle.ogg new file mode 100644 index 000000000000..4386f6b58bf0 Binary files /dev/null and b/sound/vehicles/arcidle.ogg differ diff --git a/sound/vehicles/arcstart.ogg b/sound/vehicles/arcstart.ogg new file mode 100644 index 000000000000..dbbfcf607c08 Binary files /dev/null and b/sound/vehicles/arcstart.ogg differ diff --git a/sound/vehicles/arcstop.ogg b/sound/vehicles/arcstop.ogg new file mode 100644 index 000000000000..f0bfc03a2058 Binary files /dev/null and b/sound/vehicles/arcstop.ogg differ diff --git a/sound/vehicles/blinkerloop.ogg b/sound/vehicles/blinkerloop.ogg new file mode 100644 index 000000000000..15eb78e202ed Binary files /dev/null and b/sound/vehicles/blinkerloop.ogg differ diff --git a/sound/vehicles/humveeidle.ogg b/sound/vehicles/humveeidle.ogg new file mode 100644 index 000000000000..7c3bfdc3ddf6 Binary files /dev/null and b/sound/vehicles/humveeidle.ogg differ diff --git a/sound/vehicles/humveestart.ogg b/sound/vehicles/humveestart.ogg new file mode 100644 index 000000000000..c67a9c0c5096 Binary files /dev/null and b/sound/vehicles/humveestart.ogg differ diff --git a/sound/vehicles/humveestop.ogg b/sound/vehicles/humveestop.ogg new file mode 100644 index 000000000000..a5ff2bdabb81 Binary files /dev/null and b/sound/vehicles/humveestop.ogg differ diff --git a/sound/vehicles/iffbeepactive.ogg b/sound/vehicles/iffbeepactive.ogg new file mode 100644 index 000000000000..abf902a837d0 Binary files /dev/null and b/sound/vehicles/iffbeepactive.ogg differ diff --git a/sound/vehicles/iffbeepoff.ogg b/sound/vehicles/iffbeepoff.ogg new file mode 100644 index 000000000000..d93998cb182f Binary files /dev/null and b/sound/vehicles/iffbeepoff.ogg differ diff --git a/sound/vehicles/metalimpact1.ogg b/sound/vehicles/metalimpact1.ogg new file mode 100644 index 000000000000..8825d99c77a4 Binary files /dev/null and b/sound/vehicles/metalimpact1.ogg differ diff --git a/sound/vehicles/metalimpact2.ogg b/sound/vehicles/metalimpact2.ogg new file mode 100644 index 000000000000..9d6dac26cb21 Binary files /dev/null and b/sound/vehicles/metalimpact2.ogg differ diff --git a/sound/vehicles/metalimpact3.ogg b/sound/vehicles/metalimpact3.ogg new file mode 100644 index 000000000000..d5cf97607fc8 Binary files /dev/null and b/sound/vehicles/metalimpact3.ogg differ diff --git a/sound/vehicles/metalimpact4.ogg b/sound/vehicles/metalimpact4.ogg new file mode 100644 index 000000000000..f4cdd7bac668 Binary files /dev/null and b/sound/vehicles/metalimpact4.ogg differ diff --git a/sound/vehicles/metalimpact5.ogg b/sound/vehicles/metalimpact5.ogg new file mode 100644 index 000000000000..d6739ab5a430 Binary files /dev/null and b/sound/vehicles/metalimpact5.ogg differ diff --git a/sound/vehicles/metalimpact6.ogg b/sound/vehicles/metalimpact6.ogg new file mode 100644 index 000000000000..629c5e7301f5 Binary files /dev/null and b/sound/vehicles/metalimpact6.ogg differ diff --git a/sound/vehicles/tankturret.ogg b/sound/vehicles/tankturret.ogg new file mode 100644 index 000000000000..10b65acb427a Binary files /dev/null and b/sound/vehicles/tankturret.ogg differ diff --git a/sound/vehicles/trackrattling.ogg b/sound/vehicles/trackrattling.ogg new file mode 100644 index 000000000000..ec80a01606be Binary files /dev/null and b/sound/vehicles/trackrattling.ogg differ diff --git a/sound/vehicles/treadedDrift.ogg b/sound/vehicles/treadedDrift.ogg new file mode 100644 index 000000000000..92fa862790b5 Binary files /dev/null and b/sound/vehicles/treadedDrift.ogg differ diff --git a/sound/vehicles/turbineidle.ogg b/sound/vehicles/turbineidle.ogg new file mode 100644 index 000000000000..3f23e87eedfd Binary files /dev/null and b/sound/vehicles/turbineidle.ogg differ diff --git a/sound/vehicles/turbineoff.ogg b/sound/vehicles/turbineoff.ogg new file mode 100644 index 000000000000..b34a5c06c588 Binary files /dev/null and b/sound/vehicles/turbineoff.ogg differ diff --git a/sound/vehicles/turbinestartup.ogg b/sound/vehicles/turbinestartup.ogg new file mode 100644 index 000000000000..28a842fd5b77 Binary files /dev/null and b/sound/vehicles/turbinestartup.ogg differ diff --git a/sound/vehicles/turretdamaged.ogg b/sound/vehicles/turretdamaged.ogg new file mode 100644 index 000000000000..cbd93a9229b3 Binary files /dev/null and b/sound/vehicles/turretdamaged.ogg differ diff --git a/tgui/packages/tgui/interfaces/OverwatchConsole.tsx b/tgui/packages/tgui/interfaces/OverwatchConsole.tsx index a67324cb0ba9..edd2733f7f0c 100644 --- a/tgui/packages/tgui/interfaces/OverwatchConsole.tsx +++ b/tgui/packages/tgui/interfaces/OverwatchConsole.tsx @@ -29,6 +29,7 @@ type MarineData = { distance: string; area_name: string; ref: string; + no_camera_label?: string; }; type Data = { @@ -503,7 +504,8 @@ const SquadMonitor = (props) => { const [marineSearch, setMarineSearch] = useSharedState('marinesearch', ''); let determine_status_color = (status) => { - let conscious = status.includes('Conscious'); + let conscious = + status.includes('Conscious') || status.includes('Operational'); let unconscious = status.includes('Unconscious'); let state_color = 'red'; @@ -672,7 +674,12 @@ const SquadMonitor = (props) => { > {marine.name} - )) || {marine.name} (NO CAMERA)} + )) || ( + + {marine.name} ({marine.no_camera_label || 'NO CAMERA'} + ) + + )} {marine.role} { + const { data } = useBackend(); + const { slots, hull, selected } = data; + + return ( + + +
+ + +
+
+ +
+ {selected && ( +
+ + +
+ )} +
+
+ ); +}; + +const HealthEditor = (props: { + readonly health: number; + readonly maxHealth: number; + readonly setAction: string; +}) => { + const { act } = useBackend(); + const { health, maxHealth, setAction } = props; + + return ( + + Health:{' '} + act(setAction, { value })} + /> + + ); +}; + +const WoundFamiliesEditor = (props: { + readonly wounds: WoundRow[]; + readonly setAction: string; +}) => { + const { act } = useBackend(); + const { wounds, setAction } = props; + + if (!wounds.length) { + return No wound families for this slot.; + } + + return ( + + {wounds.map((wound) => { + const options = ['Undamaged', ...wound.tier_names]; + const selectedOption = options[wound.current_tier]; + return ( + + {wound.family_label} + + { + const tier = options.indexOf(value); + act(setAction, { + family_type: wound.family_type, + tier, + }); + }} + /> + + + ); + })} +
+ ); +}; + +const SlotsTable = (props: { readonly slots: SlotRow[] }) => { + const { act } = useBackend(); + const { slots } = props; + const [chosenType, setChosenType] = useState>({}); + + return ( + + + Slot + Location + Installed + Install + + + {slots.map((row) => { + const options = row.candidates.map((candidate) => candidate.name); + const selectedType = chosenType[row.slot] || row.candidates[0]?.type; + const selectedName = row.candidates.find( + (candidate) => candidate.type === selectedType, + )?.name; + + return ( + + {row.slot} + {row.location} + + {row.installed ? ( + <> + {row.installed.name} ({row.installed.health_pct}%) + + act('remove', { ref: row.installed!.ref })} + > + Remove + + + ) : ( + Empty + )} + + + {!!options.length && ( + { + const candidate = row.candidates.find( + (c) => c.name === value, + ); + if (candidate) { + setChosenType({ + ...chosenType, + [row.slot]: candidate.type, + }); + } + }} + /> + )} + + + {!!options.length && ( + + )} + + + ); + })} +
+ ); +}; diff --git a/tgui/packages/tgui/interfaces/TankPivotTuner.tsx b/tgui/packages/tgui/interfaces/TankPivotTuner.tsx new file mode 100644 index 000000000000..d5c6eff4bdbc --- /dev/null +++ b/tgui/packages/tgui/interfaces/TankPivotTuner.tsx @@ -0,0 +1,117 @@ +import type { BooleanLike } from 'common/react'; +import { useBackend } from 'tgui/backend'; +import { Button, LabeledList, Section, Stack } from 'tgui/components'; +import { Window } from 'tgui/layouts'; + +type Weapon = { + ref: string; + name: string; + pivot_x: number; + pivot_y: number; + selected: BooleanLike; +}; + +type Data = { + dir_name: string; + weapons: Weapon[]; + selected_name: string; + tuning_label: string; + pivot_x: number; + pivot_y: number; + offset_x: number; + offset_y: number; + is_gimballed: BooleanLike; + gimbal_pivot_x: number; + gimbal_pivot_y: number; +}; + +const AXIS_STEPS = [-10, -1, 1, 10]; + +const AxisRow = (props: { + readonly axis: 'x' | 'y'; + readonly value: number; + readonly target: 'rotation' | 'gimbal' | 'offset'; +}) => { + const { act } = useBackend(); + const { axis, value, target } = props; + return ( + + + {AXIS_STEPS.map((step) => ( + + + + ))} + + + ); +}; + +export const TankPivotTuner = (props) => { + const { act, data } = useBackend(); + const { + dir_name, + weapons = [], + selected_name, + tuning_label, + pivot_x, + pivot_y, + offset_x, + offset_y, + is_gimballed, + gimbal_pivot_x, + gimbal_pivot_y, + } = data; + + return ( + + +
+ + {weapons.map((weaponEntry) => ( + + + + ))} + +
+ {selected_name && ( +
+ + + + +
+ )} + {selected_name && ( +
+ + + + +
+ )} + {selected_name && is_gimballed && ( +
+ + + + +
+ )} +
+
+ ); +}; diff --git a/tgui/packages/tgui/interfaces/VehicleStatus.tsx b/tgui/packages/tgui/interfaces/VehicleStatus.tsx index d6aa064f2d67..222eae1ea15e 100644 --- a/tgui/packages/tgui/interfaces/VehicleStatus.tsx +++ b/tgui/packages/tgui/interfaces/VehicleStatus.tsx @@ -28,6 +28,7 @@ type Data = { mags?: number; max_mags?: number; fpw: BooleanLike; + wounds: { name: string; tier: number }[]; }[]; }; @@ -123,6 +124,15 @@ const HardpointsView = (props) => { ) : ( Hardpoint destroyed! )} + {hardpoint.wounds.length ? ( + + {hardpoint.wounds.map((wound, woundIndex) => ( + + {wound.name} (Tier {wound.tier}) + + ))} + + ) : null} {hardpoint.uses_ammo ? (