diff --git a/binaries/data/mods/public/simulation/components/Attack.js b/binaries/data/mods/public/simulation/components/Attack.js index a73158bfa5..9ea860167e 100644 --- a/binaries/data/mods/public/simulation/components/Attack.js +++ b/binaries/data/mods/public/simulation/components/Attack.js @@ -277,11 +277,6 @@ Attack.prototype.CanAttack = function(target, wantedTypes) const cmpCapturable = QueryMiragedInterface(target, IID_Capturable); const cmpDiplomacy = QueryPlayerIDInterface(entityOwner, IID_Diplomacy); - // Check if the relative height difference is larger than the attack range - // If the relative height is bigger, it means they will never be able to - // reach each other, no matter how close they come. - const heightDiff = Math.abs(cmpThisPosition.GetHeightOffset() - cmpTargetPosition.GetHeightOffset()); - for (const type of types) { if (type != "Capture" && (!cmpDiplomacy?.IsEnemy(targetOwner) || !cmpHealth || !cmpHealth.GetHitpoints())) @@ -290,7 +285,8 @@ Attack.prototype.CanAttack = function(target, wantedTypes) if (type == "Capture" && (!cmpCapturable || !cmpCapturable.CanCapture(entityOwner))) continue; - if (heightDiff > this.GetRange(type).max) + // Check if the target is currently in range, or could ever be reached + if (!this.IsTargetInRange(target, type) && !this.CanEverReachTarget(target, type)) continue; const restrictedClasses = this.GetRestrictedClasses(type); @@ -304,6 +300,77 @@ Attack.prototype.CanAttack = function(target, wantedTypes) return false; }; +/** + * Check if the target could potentially ever be reached with the given attack type, + * as an optimistic estimate. This assumes the attacker can move to the closest + * possible position to the target — ignoring obstructions and terrain features + * (e.g., hills) that might help or hinder. + * + * This is a best-effort guess: + * - It may return true even when the target is actually unreachable (e.g., turreted + * units on walls with a height offset too large for the projectile to overcome). + * - It may return false even when the target is reachable (e.g., a nearby hill could + * provide enough elevation to hit a "too high" target, but we don't check for that). + * + * Currently these checks are mostly useful to determine if we can reach turreted units + * (e.g. on a wall, outpost...). + * + * @param {number} targetId - The target entity ID. + * @param {string} type - The attack type. + * @return {boolean} - Whether the target is estimated to be reachable (see caveats above). + */ +Attack.prototype.CanEverReachTarget = function(targetId, type) +{ + const cmpThisPosition = Engine.QueryInterface(this.entity, IID_Position); + const cmpTargetPosition = Engine.QueryInterface(targetId, IID_Position); + + const thisHeightOffset = cmpThisPosition.GetHeightOffset(); + const targetHeightOffset = cmpTargetPosition.GetHeightOffset(); + + const range = this.GetRange(type); + + // Find the closest horizontal distance we could ever get to the target. + // We first determine the closest horizontal distance we could ever get to the target, + // accounting for turreted units inside buildings: + // - If the building blocks movement, we can only reach its exterior edge. + // - If the building is passable, we can walk right up to the turret point. + const cmpTurretable = Engine.QueryInterface(targetId, IID_Turretable); + const holderId = cmpTurretable?.HolderID(); + let closestDistance = 0; + if (holderId && holderId != INVALID_ENTITY) + { + const cmpTurretHolder = Engine.QueryInterface(holderId, IID_TurretHolder); + if (cmpTurretHolder) + { + const turretPoint = cmpTurretHolder.GetOccupiedTurretPoint(targetId); + closestDistance = cmpTurretHolder.GetClosestApproachDistanceToTurretPoint(turretPoint); + } + } + + if (!range.parabolic) + { + // For non-parabolic attacks (e.g., "Melee" attack type), we check if the height offset + // is within max range at the closest possible horizontal distance (simple 3D distance check). + const heightDiff = Math.abs(targetHeightOffset - thisHeightOffset); + return Math.sqrt(closestDistance * closestDistance + heightDiff * heightDiff) <= range.max; + } + + // For parabolic attacks (generally "Ranged" attack type), we use the parabolic formula + // to determine if the height offset is surmountable at the closest possible distance. + // Typical scenario: units on walls/towers may be unreachable if the attacker's + // projectiles can't arc high enough, even at point-blank range. + const cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); + if (!cmpRangeManager) + return true; + + const yOrigin = this.GetAttackYOrigin(type); + + const maxReachableHeightDiff = cmpRangeManager.GetMaxParabolicHeightDiff( + range.max, yOrigin, closestDistance); + + return targetHeightOffset - thisHeightOffset <= maxReachableHeightDiff; +}; + /** * Returns undefined if we have no preference or the lowest index of a preferred class. */ diff --git a/binaries/data/mods/public/simulation/components/TurretHolder.js b/binaries/data/mods/public/simulation/components/TurretHolder.js index 110be4ab23..f0843b5b37 100644 --- a/binaries/data/mods/public/simulation/components/TurretHolder.js +++ b/binaries/data/mods/public/simulation/components/TurretHolder.js @@ -240,6 +240,39 @@ class TurretHolder return turret ? turret.name : ""; } + /** + * Calculate the closest horizontal distance an external entity could ever get + * to the specified turret point. If the holder is passable, returns 0. + * Otherwise returns the perpendicular distance from the turret point to the + * nearest edge of the holder's obstruction. + * + * @param {string|Object} turretPoint - The turret point name or object. + * @return {number} - The minimum possible horizontal distance. + */ + GetClosestApproachDistanceToTurretPoint(turretPoint) + { + if (typeof turretPoint === "string") + turretPoint = this.TurretPointByName(turretPoint); + if (!turretPoint) + return 0; + + const cmpObstruction = Engine.QueryInterface(this.entity, IID_Obstruction); + if (!cmpObstruction || !cmpObstruction.GetBlockMovementFlag(false)) + return 0; + + const dxLocal = turretPoint.offset.x; + const dzLocal = turretPoint.offset.z; + + const halfSizes = cmpObstruction.GetObstructionHalfSizes(); + const hw = halfSizes.X ?? halfSizes.x; + const hh = halfSizes.Y ?? halfSizes.y; + + if (hw == null || hh == null || hw < 0 || hh < 0) + return 0; + + return Math.max(0, Math.min(hw - Math.abs(dxLocal), hh - Math.abs(dzLocal))); + } + /** * @return {number[]} - The turretted entityIDs. */ diff --git a/binaries/data/mods/public/simulation/components/tests/test_Attack.js b/binaries/data/mods/public/simulation/components/tests/test_Attack.js index f4530f834d..cac8a99d87 100644 --- a/binaries/data/mods/public/simulation/components/tests/test_Attack.js +++ b/binaries/data/mods/public/simulation/components/tests/test_Attack.js @@ -32,6 +32,8 @@ Engine.LoadComponentScript("interfaces/Formation.js"); Engine.LoadComponentScript("interfaces/Health.js"); Engine.LoadComponentScript("interfaces/Resistance.js"); Engine.LoadComponentScript("interfaces/TechnologyManager.js"); +Engine.LoadComponentScript("interfaces/Turretable.js"); +Engine.LoadComponentScript("interfaces/TurretHolder.js"); Engine.LoadComponentScript("Attack.js"); let entityID = 903; @@ -416,3 +418,4 @@ function testAttackPreference() TS_ASSERT_EQUALS(cmpAttack.GetPreference(attacker+4), undefined); } testAttackPreference(); + diff --git a/source/simulation2/components/CCmpRangeManager.cpp b/source/simulation2/components/CCmpRangeManager.cpp index 3d0ddf5dd1..b58a8107f9 100644 --- a/source/simulation2/components/CCmpRangeManager.cpp +++ b/source/simulation2/components/CCmpRangeManager.cpp @@ -1365,6 +1365,22 @@ public: } } + /** + * Compute effective horizontal range given a reference range and height difference. + */ + static entity_pos_t ComputeParabolicRange(entity_pos_t range, entity_pos_t heightDiff) + { + if (heightDiff < -range / 2) + return NEVER_IN_RANGE; + + entity_pos_t effectiveRange; + effectiveRange.SetInternalValue(static_cast(isqrt64( + SQUARE_U64_FIXED(range) + + static_cast(heightDiff.GetInternalValue()) * static_cast(range.GetInternalValue()) * 2 + ))); + return effectiveRange; + } + entity_pos_t GetEffectiveParabolicRange(entity_id_t source, entity_id_t target, entity_pos_t range, entity_pos_t yOrigin) const override { // For non-positive ranges, just return the range. @@ -1383,14 +1399,28 @@ public: CFixedVector3D sourcePos = cmpSourcePosition->GetPosition(); CFixedVector3D targetPos = cmpTargetPosition->GetPosition(); - entity_pos_t heightDifference = sourcePos.Y - targetPos.Y + yOrigin; + entity_pos_t heightDiff = sourcePos.Y - targetPos.Y + yOrigin; + return ComputeParabolicRange(range, heightDiff); + } - if (heightDifference < -range / 2) - return NEVER_IN_RANGE; + entity_pos_t GetMaxParabolicHeightDiff(entity_pos_t range, entity_pos_t yOrigin, entity_pos_t horizDistance) const override + { + // EffectiveRange² = range² + 2 * range * heightDiff + // Solve for heightDiff when effectiveRange = horizDistance: + // heightDiff = (horizDistance² - range²) / (2 * range) + // Max target height above source = yOrigin - heightDiff + // = yOrigin + (range² - horizDistance²) / (2 * range) + // + // If horizDistance > range, the result is less than yOrigin (can be negative), + // meaning the source must be above the target to compensate for the extra horizontal distance. + // The caller can decide if that's acceptable. + i64 rangeSq = SQUARE_U64_FIXED(range); + i64 distSq = SQUARE_U64_FIXED(horizDistance); + i64 numerator = rangeSq - distSq; - entity_pos_t effectiveRange; - effectiveRange.SetInternalValue(static_cast(isqrt64(SQUARE_U64_FIXED(range) + static_cast(heightDifference.GetInternalValue()) * static_cast(range.GetInternalValue()) * 2))); - return effectiveRange; + entity_pos_t result; + result.SetInternalValue(static_cast(numerator / static_cast(range.GetInternalValue() * 2))); + return yOrigin + result; } entity_pos_t GetElevationAdaptedRange(const CFixedVector3D& pos1, const CFixedVector3D& rot, entity_pos_t range, entity_pos_t yOrigin, entity_pos_t angle) const override diff --git a/source/simulation2/components/ICmpObstruction.cpp b/source/simulation2/components/ICmpObstruction.cpp index 333caeb7d9..6e8c750c51 100644 --- a/source/simulation2/components/ICmpObstruction.cpp +++ b/source/simulation2/components/ICmpObstruction.cpp @@ -1,4 +1,4 @@ -/* Copyright (C) 2025 Wildfire Games. +/* Copyright (C) 2026 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -45,6 +45,14 @@ std::string ICmpObstruction::CheckFoundation_wrapper(const std::string& classNam } } +CFixedVector2D ICmpObstruction::GetObstructionHalfSizes_wrapper() const +{ + ICmpObstructionManager::ObstructionSquare square; + if (!GetObstructionSquare(square)) + return CFixedVector2D(entity_pos_t::FromInt(-1), entity_pos_t::FromInt(-1)); + return CFixedVector2D(square.hw, square.hh); +} + BEGIN_INTERFACE_WRAPPER(Obstruction) DEFINE_INTERFACE_METHOD("GetSize", ICmpObstruction, GetSize) DEFINE_INTERFACE_METHOD("CheckShorePlacement", ICmpObstruction, CheckShorePlacement) @@ -55,6 +63,7 @@ DEFINE_INTERFACE_METHOD("GetEntitiesBlockingConstruction", ICmpObstruction, GetE DEFINE_INTERFACE_METHOD("GetEntitiesDeletedUponConstruction", ICmpObstruction, GetEntitiesDeletedUponConstruction) DEFINE_INTERFACE_METHOD("SetActive", ICmpObstruction, SetActive) DEFINE_INTERFACE_METHOD("SetDisableBlockMovementPathfinding", ICmpObstruction, SetDisableBlockMovementPathfinding) +DEFINE_INTERFACE_METHOD("GetObstructionHalfSizes", ICmpObstruction, GetObstructionHalfSizes_wrapper) DEFINE_INTERFACE_METHOD("GetBlockMovementFlag", ICmpObstruction, GetBlockMovementFlag) DEFINE_INTERFACE_METHOD("SetControlGroup", ICmpObstruction, SetControlGroup) DEFINE_INTERFACE_METHOD("GetControlGroup", ICmpObstruction, GetControlGroup) diff --git a/source/simulation2/components/ICmpObstruction.h b/source/simulation2/components/ICmpObstruction.h index 2c233635a9..1eca08878d 100644 --- a/source/simulation2/components/ICmpObstruction.h +++ b/source/simulation2/components/ICmpObstruction.h @@ -1,4 +1,4 @@ -/* Copyright (C) 2025 Wildfire Games. +/* Copyright (C) 2026 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -104,6 +104,12 @@ public: */ virtual std::string CheckFoundation_wrapper(const std::string& className, bool onlyCenterPoint) const; + /** + * GetObstructionSquare wrapper for script calls. + * @return [hw, hh] half-sizes of the obstruction square, or empty array on failure. + */ + virtual CFixedVector2D GetObstructionHalfSizes_wrapper() const; + /** * Test whether this entity is colliding with any obstructions that share its * control groups and block the creation of foundations. diff --git a/source/simulation2/components/ICmpRangeManager.cpp b/source/simulation2/components/ICmpRangeManager.cpp index a39aa2783c..64986212b0 100644 --- a/source/simulation2/components/ICmpRangeManager.cpp +++ b/source/simulation2/components/ICmpRangeManager.cpp @@ -67,6 +67,7 @@ DEFINE_INTERFACE_METHOD("GetLosRevealWholeMap", ICmpRangeManager, GetLosRevealWh DEFINE_INTERFACE_METHOD("SetLosRevealWholeMapForAll", ICmpRangeManager, SetLosRevealWholeMapForAll) DEFINE_INTERFACE_METHOD("GetLosRevealWholeMapForAll", ICmpRangeManager, GetLosRevealWholeMapForAll) DEFINE_INTERFACE_METHOD("GetEffectiveParabolicRange", ICmpRangeManager, GetEffectiveParabolicRange) +DEFINE_INTERFACE_METHOD("GetMaxParabolicHeightDiff", ICmpRangeManager, GetMaxParabolicHeightDiff) DEFINE_INTERFACE_METHOD("GetElevationAdaptedRange", ICmpRangeManager, GetElevationAdaptedRange) DEFINE_INTERFACE_METHOD("ActivateScriptedVisibility", ICmpRangeManager, ActivateScriptedVisibility) DEFINE_INTERFACE_METHOD("GetLosVisibility", ICmpRangeManager, GetLosVisibility_wrapper) diff --git a/source/simulation2/components/ICmpRangeManager.h b/source/simulation2/components/ICmpRangeManager.h index 4ed58b91e5..6ee666cdb1 100644 --- a/source/simulation2/components/ICmpRangeManager.h +++ b/source/simulation2/components/ICmpRangeManager.h @@ -195,6 +195,18 @@ public: */ virtual entity_pos_t GetEffectiveParabolicRange(entity_id_t source, entity_id_t target, entity_pos_t range, entity_pos_t yOrigin) const = 0; + /** + * Get the max height (relative to the source) a parabolic projectile can reach + * at a given horizontal distance. + * @param source the entity at the origin. + * @param range the maximum parabolic range on flat terrain. + * @param yOrigin height bonus for the source. + * @param horizDistance the horizontal distance to check. + * @return the maximum reachable height difference (target height - source height), + * or a very negative value if the horizontal distance exceeds the range. + */ + virtual entity_pos_t GetMaxParabolicHeightDiff(entity_pos_t range, entity_pos_t yOrigin, entity_pos_t horizDistance) const = 0; + /** * Get the average elevation over 8 points on distance range around the entity * @param id the entity id to look around