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:
khallaandClaude Opus 5.5 committed 2026-10-18 03:44:16 +02:00
1 parent 4018e23e6b
commit 5d80a33e9b
2 files changed
+134 -16

No files matched your search

+112 -16
View File
@@ -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
}