Blitzschlag: chains of one cast take separate routes
The chains of a cast share the enemies they have claimed and prefer unclaimed ones. Each chain keeps its heading and ranks candidates by distance and turn; a chain's first jump turns away from the directions the other chains set out in, so they keep fanning out. Jumps reach 75 % of the cast range. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
4018e23e6b
commit
5d80a33e9b
2 files changed
+134
-16
No files matched your search
+112
-16
@@ -11,14 +11,20 @@
|
||||
//
|
||||
// This mod keeps all of that and decides:
|
||||
// - hits per chain N = 3 + level / 5: the first bolt's [14];
|
||||
// - the next target: the nearest enemy within half Blitzschlag's cast range (see
|
||||
// below; the game's own jump search used about 316) that this
|
||||
// chain has not hit yet; else another enemy; else the current target again
|
||||
// (a lone enemy takes all N hits);
|
||||
// - the next target: an enemy within 75 % of Blitzschlag's cast range (see
|
||||
// below; the game's own jump search used about 316) that no chain of this
|
||||
// cast has claimed yet, so parallel chains take separate routes; else one
|
||||
// this chain has not hit yet; else another enemy; else the current target
|
||||
// again (a lone enemy takes all N hits). Among those, near and straight
|
||||
// ahead wins: each chain keeps its heading, and a chain's first jump turns
|
||||
// away from the directions the other chains set out in, so the chains keep
|
||||
// fanning out instead of merging again;
|
||||
// - chains B = 1 + level / 10: B - 1 more chains start at the first target,
|
||||
// each with a bolt at its own first jump target (created while the first
|
||||
// bolt is set up, as copies of its event; no arc for that first hop).
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
@@ -60,10 +66,10 @@ constexpr int kRaySegments = 20;
|
||||
double CastRange() {
|
||||
return static_cast<double>(kRaySegmentLength) * kRaySegments;
|
||||
}
|
||||
// A jump between enemies reaches half as far as the cast (request of
|
||||
// 2026-10-17: 900 felt too far for jumps).
|
||||
// A jump between enemies reaches 75 % of the cast range (requests: 2026-10-17
|
||||
// 900 felt too far, half; 2026-10-18 more room for separate routes, 75 %).
|
||||
double JumpRange() {
|
||||
return CastRange() / 2;
|
||||
return CastRange() * 0.75;
|
||||
}
|
||||
constexpr uint32_t kConstant30 = 0x0088ef18; // float 30.0, shared by 72 instructions
|
||||
constexpr uint32_t kRayRemainingMinusSegment = 0x00526db6; // fsub [30.0]
|
||||
@@ -75,8 +81,37 @@ constexpr size_t kCreaturePosition = 0x18; // 4 dwords, as effects are placed
|
||||
constexpr size_t kEventHitsLeft = 14;
|
||||
constexpr DWORD kChainLifetimeMs = 10000;
|
||||
|
||||
// A direction on the ground (unit vector, world axes).
|
||||
struct Direction {
|
||||
double x = 0.0;
|
||||
double y = 0.0;
|
||||
};
|
||||
|
||||
Direction DirectionBetween(const void* from, const void* to) {
|
||||
const double dx = game::CreatureField(to, game::kCreatureX) - game::CreatureField(from, game::kCreatureX);
|
||||
const double dy = game::CreatureField(to, game::kCreatureY) - game::CreatureField(from, game::kCreatureY);
|
||||
const double length = std::sqrt(dx * dx + dy * dy);
|
||||
return length > 0.0 ? Direction{dx / length, dy / length} : Direction{};
|
||||
}
|
||||
|
||||
double Dot(Direction a, Direction b) {
|
||||
return a.x * b.x + a.y * b.y;
|
||||
}
|
||||
|
||||
// The chains of one cast: the enemies they have hit or are jumping to, and the
|
||||
// directions the chains set out in, so each chain takes its own route
|
||||
// (2026-10-18: "make sure that those multiple chains dont just all follow the
|
||||
// same route"; reported in play: with the nearest free enemy only, chains
|
||||
// spread at the first jump and then mostly merged again).
|
||||
struct Cast {
|
||||
std::vector<uint32_t> claimed;
|
||||
std::vector<Direction> starts;
|
||||
};
|
||||
|
||||
struct Chain {
|
||||
std::shared_ptr<Cast> cast;
|
||||
std::vector<uint32_t> hit; // targets in order
|
||||
Direction heading; // the last jump's direction (0, 0 before the first)
|
||||
DWORD started = 0;
|
||||
};
|
||||
|
||||
@@ -118,10 +153,67 @@ unsigned ArtLevel(void* creature, uint16_t art) {
|
||||
return entry ? entry[game::kCooldownLevel] + entry[game::kCooldownBonusLevels] : 0;
|
||||
}
|
||||
|
||||
// The nearest living enemy of `caster` within the jump range of `from`, not
|
||||
// `current`; one not in `avoid` if there is any.
|
||||
uint32_t Nearest(void* caster, void* from, uint32_t current, const std::vector<uint32_t>& avoid) {
|
||||
return game::NearestHostile(caster, from, JumpRange(), current, avoid.data(), avoid.size());
|
||||
bool Contains(const std::vector<uint32_t>& ids, uint32_t id) {
|
||||
return std::find(ids.begin(), ids.end(), id) != ids.end();
|
||||
}
|
||||
|
||||
// How strongly a jump keeps its direction: an enemy straight behind counts as
|
||||
// three times as far as one straight ahead.
|
||||
constexpr double kTurnWeight = 2.0;
|
||||
|
||||
// The next target of `chain` from `current` (the creature `from`), among the
|
||||
// living enemies of `caster` within the jump range:
|
||||
// - first enemies no chain of the cast has claimed, then ones this chain has
|
||||
// not hit, then any other;
|
||||
// - among those the cheapest: distance * (1 + kTurnWeight * turn), turn 0 for
|
||||
// straight ahead along the chain's heading, 1 for straight back; a chain's
|
||||
// first jump (no heading yet) turns away from the directions the cast's
|
||||
// other chains set out in.
|
||||
// Claims it for the cast and turns the chain's heading towards it; 0 if none.
|
||||
uint32_t NextOf(Chain& chain, void* caster, void* from, uint32_t current) {
|
||||
const bool first = chain.heading.x == 0.0 && chain.heading.y == 0.0;
|
||||
uint32_t best = 0;
|
||||
int bestTier = 3;
|
||||
double bestCost = 0.0;
|
||||
Direction bestDirection;
|
||||
const int count = game::NearbyCreatureCount();
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const uint32_t id = game::NearbyCreatureId(i);
|
||||
void* creature = game::GetCreature(id);
|
||||
if (id == current || !creature || game::IsPlayerHeroId(id) || !game::IsHostileTo(caster, creature)) {
|
||||
continue;
|
||||
}
|
||||
const double distance = game::DistanceTo(from, id);
|
||||
if (distance > JumpRange()) {
|
||||
continue;
|
||||
}
|
||||
const int tier = !Contains(chain.cast->claimed, id) ? 0 : !Contains(chain.hit, id) ? 1 : 2;
|
||||
const Direction direction = DirectionBetween(from, creature);
|
||||
double turn = 0.0;
|
||||
if (!first) {
|
||||
turn = (1.0 - Dot(chain.heading, direction)) / 2.0;
|
||||
} else {
|
||||
for (const Direction start : chain.cast->starts) {
|
||||
turn = std::max(turn, (1.0 + Dot(start, direction)) / 2.0);
|
||||
}
|
||||
}
|
||||
const double cost = distance * (1.0 + kTurnWeight * turn);
|
||||
if (best && (tier > bestTier || (tier == bestTier && cost >= bestCost))) {
|
||||
continue;
|
||||
}
|
||||
best = id;
|
||||
bestTier = tier;
|
||||
bestCost = cost;
|
||||
bestDirection = direction;
|
||||
}
|
||||
if (best) {
|
||||
chain.cast->claimed.push_back(best);
|
||||
if (first) {
|
||||
chain.cast->starts.push_back(bestDirection);
|
||||
}
|
||||
chain.heading = bestDirection;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
void Forget() {
|
||||
@@ -187,19 +279,23 @@ void BeforeFxEvent(uint8_t* fx, uint32_t* event) {
|
||||
const uint32_t hits = Hits(level);
|
||||
const unsigned chains = Chains(level);
|
||||
event[kEventHitsLeft] = hits;
|
||||
auto cast = std::make_shared<Cast>();
|
||||
cast->claimed.push_back(target);
|
||||
auto chain = std::make_shared<Chain>();
|
||||
chain->cast = cast;
|
||||
chain->hit.push_back(target);
|
||||
chain->heading = DirectionBetween(caster, first);
|
||||
cast->starts.push_back(chain->heading); // the other chains fan away from it
|
||||
chain->started = GetTickCount();
|
||||
g_bolts[id] = {chain, hits};
|
||||
// More chains: each starts with a bolt at its own first jump target.
|
||||
std::vector<uint32_t> taken{target};
|
||||
for (unsigned c = 1; c < chains && hits > 1; ++c) {
|
||||
const uint32_t next = Nearest(caster, first, target, taken);
|
||||
const uint32_t branchTarget = next ? next : target;
|
||||
taken.push_back(branchTarget);
|
||||
auto branch = std::make_shared<Chain>();
|
||||
branch->cast = cast;
|
||||
branch->hit.push_back(target);
|
||||
branch->started = chain->started;
|
||||
const uint32_t next = NextOf(*branch, caster, first, target);
|
||||
const uint32_t branchTarget = next ? next : target;
|
||||
g_pending.push_back({branch, branchTarget});
|
||||
SpawnBolt(fx, event, branchTarget, hits - 1);
|
||||
}
|
||||
@@ -264,7 +360,7 @@ uint32_t __fastcall NextTarget(void* manager, void*, const int32_t* position, ui
|
||||
return 0;
|
||||
}
|
||||
const std::shared_ptr<Chain> chain = it->second.chain;
|
||||
uint32_t next = Nearest(casterCreature, from, current, chain->hit);
|
||||
uint32_t next = NextOf(*chain, casterCreature, from, current);
|
||||
if (!next) {
|
||||
next = current; // nobody else: hit the same enemy again
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user