Combat: FIX missile splash over-application -- once per salvo, not per round (task #62)
Live regression: a clustered bystander died in ~2 missile salvos ("suddenly
lethal"). Root cause, not authentic: the arcade fires ONE cluster Missile per
trigger (burstCount=missileCount) doing ONE SplashDamage event with baseBurst =
missileCount, floored at 1 ONCE. The port re-expresses that cluster as N flying
BTProjectile rounds, and task #62 fired splash PER ROUND -- each baseBurst=1,
each floored at 1 -- so the distance floor was applied N times = ~missileCount x
too much splash.
Fix: BTProjectile.splashBurst tags ONLY the salvo-lead round. MissileLauncher::
FireWeapon passes nmiss (the cluster count) on i==0 and 0 on every other round;
the contact + world-impact splash hooks fire only when splashBurst>0, using it as
baseBurst. One splash event per salvo with baseBurst=missileCount -- matches the
single arcade cluster missile. Replicant mirror rounds carry 0 (damage 0 too) so
the master's cross-pod splash isn't doubled. AC unaffected (not a MissileLauncher;
splash_burst defaults 0).
Verified headless (BT_SPAWN_ENEMY=2 clustered rig, 45s): bystander now takes 6
splash events (1/salvo, baseBurst=6) and SURVIVES (was ~2 shots); primary dies to
direct hits in ~3 trigger pulls (36 missiles == single-enemy TTK); AC does not
splash; no crash. Also: BT_SPAWN_ENEMY read as a COUNT (=2 clusters a bystander
within SplashRadius) for eyeballing splash; entity-id logging on [enemy]/[splash].
KB: combat-damage.md documents the N-round cluster trap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
12fbc023a8
commit
145a69f865
@@ -937,79 +937,111 @@ void
|
||||
if (enemy_res)
|
||||
{
|
||||
enemy_res->Lock();
|
||||
const ResourceDescription::ResourceID enemyResID = enemy_res->resourceID;
|
||||
|
||||
// Place it ahead of the player's drop ALONG THE SPAWN FACING (the mech
|
||||
// COUNT: BT_SPAWN_ENEMY is read as a count -- "1" (or any truthy value)
|
||||
// drops one dummy; "2"+ CLUSTERS extra dummies laterally so a bystander
|
||||
// sits within a missile's SplashRadius (30) of the primary -- the rig for
|
||||
// eyeballing splash damage (task #62): shoot ONE, watch the OTHER take
|
||||
// collateral. Clamped to a sane range.
|
||||
int enemyCount = atoi(getenv("BT_SPAWN_ENEMY"));
|
||||
if (enemyCount < 1) enemyCount = 1; // non-numeric truthy -> 1
|
||||
if (enemyCount > 8) enemyCount = 8;
|
||||
|
||||
// Base origin: 120 ahead of the player ALONG THE SPAWN FACING (the mech
|
||||
// faces local -Z; rotate that axis into world by the spawn orientation).
|
||||
// The old fixed "z -= 120" assumed the bring-up drive's forced heading=0;
|
||||
// the real-controls drive keeps the authentic spawn orientation, so the
|
||||
// dummy must follow the actual facing or the auto-walk leaves it behind.
|
||||
Origin enemy_origin = mech_location;
|
||||
Origin base_origin = mech_location;
|
||||
UnitVector spawnZ;
|
||||
{
|
||||
AffineMatrix facing;
|
||||
facing.BuildIdentity();
|
||||
facing = mech_location.angularPosition; // rotation from the spawn pose
|
||||
UnitVector spawnZ;
|
||||
facing.GetFromAxis(Z_Axis, &spawnZ); // local Z basis in world
|
||||
enemy_origin.linearPosition.x -= spawnZ.x * 120.0f; // forward = -Z
|
||||
enemy_origin.linearPosition.y -= spawnZ.y * 120.0f;
|
||||
enemy_origin.linearPosition.z -= spawnZ.z * 120.0f;
|
||||
base_origin.linearPosition.x -= spawnZ.x * 120.0f; // forward = -Z
|
||||
base_origin.linearPosition.y -= spawnZ.y * 120.0f;
|
||||
base_origin.linearPosition.z -= spawnZ.z * 120.0f;
|
||||
// FACE THE PLAYER: copying the spawn pose left BOTH mechs facing
|
||||
// the same way (the player stared at the enemy's back). Flip the
|
||||
// enemy's yaw 180 deg about Y so the two face each other. Engine
|
||||
// yaw convention (MATRIX.cpp:196-209): the Z basis = (sin y, 0,
|
||||
// cos y) -> yaw = atan2(z.x, z.z); enemy yaw = that + pi.
|
||||
Scalar spawnYaw = (Scalar)atan2((double)spawnZ.x, (double)spawnZ.z);
|
||||
enemy_origin.angularPosition =
|
||||
base_origin.angularPosition =
|
||||
EulerAngles(0.0f, spawnYaw + 3.14159265f, 0.0f);
|
||||
}
|
||||
|
||||
Mech::MakeMessage
|
||||
create_enemy(
|
||||
Mech::MakeMessageID,
|
||||
sizeof(Mech::MakeMessage),
|
||||
host_manager->MakeUniqueEntityID(),
|
||||
(Entity::ClassID)Mech::MechClassID,
|
||||
EntityID::Null,
|
||||
enemy_res->resourceID,
|
||||
Mech::DefaultFlags,
|
||||
enemy_origin,
|
||||
Motion::Identity,
|
||||
Motion::Identity,
|
||||
playerMission->GetBadgeName(),
|
||||
playerMission->GetColorName(),
|
||||
((BTMission *)playerMission)->GetPatchName());
|
||||
|
||||
enemy_res->Unlock();
|
||||
|
||||
Mech *enemy = Mech::Make(&create_enemy);
|
||||
if (enemy)
|
||||
// Right vector in the ground plane (perp to the forward XZ): cluster the
|
||||
// extra dummies sideways so a hit on the primary catches the bystander.
|
||||
Vector3D rightv(spawnZ.z, 0.0f, -spawnZ.x);
|
||||
{
|
||||
Register_Object(enemy);
|
||||
// Mark it a VALID master so Entity::Dispatch delivers messages
|
||||
// SYNCHRONOUSLY (Receiver::Receive) instead of posting them as deferred
|
||||
// "entity invalid" events that never fire -- otherwise TakeDamage never
|
||||
// lands. (The player gets validated via MakeViewpointEntity's CheckLoad
|
||||
// handshake; a manually-spawned entity must set it itself. Its resources
|
||||
// are already loaded -- it renders + its zones are built.) Also force
|
||||
// pre-run + interest so it ticks like a live entity.
|
||||
enemy->SetValidFlag();
|
||||
enemy->SetPreRunFlag();
|
||||
if (enemy->interestCount == 0) enemy->interestCount = 1;
|
||||
// GROUND MODEL (task #15): a MASTER mech needs a CollisionAssistant
|
||||
// for GetCurrentCollisions (the engine iterates it unchecked,
|
||||
// MOVER.cpp:894). The player gets one in MakeViewpointEntity
|
||||
// (btl4app.cpp:591); this dummy is a master too, so start its own
|
||||
// -- without it the authentic ground block skips its collision half.
|
||||
enemy->StartCollisionAssistant();
|
||||
gEnemyMech = enemy; // the player's targeting step locks onto this
|
||||
DEBUG_STREAM << "[enemy] spawned target mech at ("
|
||||
<< enemy_origin.linearPosition.x << ", "
|
||||
<< enemy_origin.linearPosition.y << ", "
|
||||
<< enemy_origin.linearPosition.z << ")\n" << std::flush;
|
||||
Scalar rl = (Scalar)sqrt((double)(rightv.x*rightv.x + rightv.z*rightv.z));
|
||||
if (rl < 1e-4f) { rightv.x = 1.0f; rightv.z = 0.0f; }
|
||||
else { rightv.x /= rl; rightv.z /= rl; }
|
||||
}
|
||||
else
|
||||
|
||||
for (int ei = 0; ei < enemyCount; ++ei)
|
||||
{
|
||||
DEBUG_STREAM << "[enemy] Mech::Make returned NULL\n" << std::flush;
|
||||
Origin enemy_origin = base_origin;
|
||||
// #0 at the primary spot; extras step +/-25u (inside SplashRadius 30).
|
||||
Scalar off = (ei == 0) ? 0.0f
|
||||
: 25.0f * (Scalar)(((ei + 1) / 2)) * ((ei & 1) ? 1.0f : -1.0f);
|
||||
enemy_origin.linearPosition.x += rightv.x * off;
|
||||
enemy_origin.linearPosition.z += rightv.z * off;
|
||||
|
||||
Mech::MakeMessage
|
||||
create_enemy(
|
||||
Mech::MakeMessageID,
|
||||
sizeof(Mech::MakeMessage),
|
||||
host_manager->MakeUniqueEntityID(),
|
||||
(Entity::ClassID)Mech::MechClassID,
|
||||
EntityID::Null,
|
||||
enemyResID,
|
||||
Mech::DefaultFlags,
|
||||
enemy_origin,
|
||||
Motion::Identity,
|
||||
Motion::Identity,
|
||||
playerMission->GetBadgeName(),
|
||||
playerMission->GetColorName(),
|
||||
((BTMission *)playerMission)->GetPatchName());
|
||||
|
||||
Mech *enemy = Mech::Make(&create_enemy);
|
||||
if (enemy)
|
||||
{
|
||||
Register_Object(enemy);
|
||||
// Mark it a VALID master so Entity::Dispatch delivers messages
|
||||
// SYNCHRONOUSLY (Receiver::Receive) instead of posting them as deferred
|
||||
// "entity invalid" events that never fire -- otherwise TakeDamage never
|
||||
// lands. (The player gets validated via MakeViewpointEntity's CheckLoad
|
||||
// handshake; a manually-spawned entity must set it itself. Its resources
|
||||
// are already loaded -- it renders + its zones are built.) Also force
|
||||
// pre-run + interest so it ticks like a live entity.
|
||||
enemy->SetValidFlag();
|
||||
enemy->SetPreRunFlag();
|
||||
if (enemy->interestCount == 0) enemy->interestCount = 1;
|
||||
// GROUND MODEL (task #15): a MASTER mech needs a CollisionAssistant
|
||||
// for GetCurrentCollisions (the engine iterates it unchecked,
|
||||
// MOVER.cpp:894). The player gets one in MakeViewpointEntity
|
||||
// (btl4app.cpp:591); this dummy is a master too, so start its own
|
||||
// -- without it the authentic ground block skips its collision half.
|
||||
enemy->StartCollisionAssistant();
|
||||
if (ei == 0)
|
||||
gEnemyMech = enemy; // the player's targeting step locks onto the primary
|
||||
DEBUG_STREAM << "[enemy] spawned dummy #" << ei
|
||||
<< " id=" << enemy->GetEntityID()
|
||||
<< (ei == 0 ? " (PRIMARY/direct-target)" : " (bystander/splash)")
|
||||
<< " at ("
|
||||
<< enemy_origin.linearPosition.x << ", "
|
||||
<< enemy_origin.linearPosition.y << ", "
|
||||
<< enemy_origin.linearPosition.z << ")\n" << std::flush;
|
||||
}
|
||||
else
|
||||
{
|
||||
DEBUG_STREAM << "[enemy] Mech::Make returned NULL (#" << ei << ")\n" << std::flush;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
|
||||
Reference in New Issue
Block a user