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

+22
View File
@@ -53,6 +53,24 @@ ends (a lone enemy is hit once). Two enemies: it bounces between them.
are created at the first jump targets of the other chains (copies of the are created at the first jump targets of the other chains (copies of the
event with [14] = N - 1); their first hop has no arc. event with [14] = N - 1); their first hop has no arc.
Separate routes (2026-10-18). Request: the chains should not all follow the
same route. Before, each chain only avoided the enemies it had hit itself, and
only the extra chains' first targets differed. After that, parallel chains
picked the same nearest enemies and could swap onto each other's route. Now
the chains of one cast share a list of claimed enemies (the first target, and
every target as soon as a chain picks it). A jump prefers an enemy no chain
of the cast has claimed, then one this chain has not hit, then any other,
then the current one.
Follow-up (2026-10-18): "only on the first spread, then they most times merge
together again". The nearest free enemy of two chains often lies in the same
group. Each chain now keeps a heading (caster -> first target for the main
chain, then the direction of its last jump), and the candidates of a tier are
ranked by distance × (1 + 2 × turn), with turn = 0 straight ahead and 1
straight back. A chain's first jump has no heading yet; there the turn is the
largest closeness to the directions the cast's chains set out in, so the
branches fan away from the main chain and from each other.
First version (bolts copied for every jump, not using the game's chain) First version (bolts copied for every jump, not using the game's chain)
replaced the same day: it doubled the jumps. replaced the same day: it doubled the jumps.
@@ -91,3 +109,7 @@ step of the ray point).
Follow-up (2026-10-17): jumps at 900 felt too far. The cast keeps 900; jumps Follow-up (2026-10-17): jumps at 900 felt too far. The cast keeps 900; jumps
use `JumpRange()` = half the cast range (450). The tooltip shows both use `JumpRange()` = half the cast range (450). The tooltip shows both
("Reichweite", "Sprungweite"). ("Reichweite", "Sprungweite").
Follow-up (2026-10-18): with several chains, half the cast range left too
few separate targets ("maybe chain range needs to increase to 75% of cast
range"). Jumps now reach 75 % of the cast range (675).
+112 -16
View File
@@ -11,14 +11,20 @@
// //
// This mod keeps all of that and decides: // This mod keeps all of that and decides:
// - hits per chain N = 3 + level / 5: the first bolt's [14]; // - 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 // - the next target: an enemy within 75 % of Blitzschlag's cast range (see
// below; the game's own jump search used about 316) that this // below; the game's own jump search used about 316) that no chain of this
// chain has not hit yet; else another enemy; else the current target again // cast has claimed yet, so parallel chains take separate routes; else one
// (a lone enemy takes all N hits); // 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, // - 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 // 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). // bolt is set up, as copies of its event; no arc for that first hop).
#include <algorithm>
#include <cmath>
#include <cstdint> #include <cstdint>
#include <cstring> #include <cstring>
#include <map> #include <map>
@@ -60,10 +66,10 @@ constexpr int kRaySegments = 20;
double CastRange() { double CastRange() {
return static_cast<double>(kRaySegmentLength) * kRaySegments; return static_cast<double>(kRaySegmentLength) * kRaySegments;
} }
// A jump between enemies reaches half as far as the cast (request of // A jump between enemies reaches 75 % of the cast range (requests: 2026-10-17
// 2026-10-17: 900 felt too far for jumps). // 900 felt too far, half; 2026-10-18 more room for separate routes, 75 %).
double JumpRange() { double JumpRange() {
return CastRange() / 2; return CastRange() * 0.75;
} }
constexpr uint32_t kConstant30 = 0x0088ef18; // float 30.0, shared by 72 instructions constexpr uint32_t kConstant30 = 0x0088ef18; // float 30.0, shared by 72 instructions
constexpr uint32_t kRayRemainingMinusSegment = 0x00526db6; // fsub [30.0] 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 size_t kEventHitsLeft = 14;
constexpr DWORD kChainLifetimeMs = 10000; 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 { struct Chain {
std::shared_ptr<Cast> cast;
std::vector<uint32_t> hit; // targets in order std::vector<uint32_t> hit; // targets in order
Direction heading; // the last jump's direction (0, 0 before the first)
DWORD started = 0; DWORD started = 0;
}; };
@@ -118,10 +153,67 @@ unsigned ArtLevel(void* creature, uint16_t art) {
return entry ? entry[game::kCooldownLevel] + entry[game::kCooldownBonusLevels] : 0; return entry ? entry[game::kCooldownLevel] + entry[game::kCooldownBonusLevels] : 0;
} }
// The nearest living enemy of `caster` within the jump range of `from`, not bool Contains(const std::vector<uint32_t>& ids, uint32_t id) {
// `current`; one not in `avoid` if there is any. return std::find(ids.begin(), ids.end(), id) != ids.end();
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());
// 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() { void Forget() {
@@ -187,19 +279,23 @@ void BeforeFxEvent(uint8_t* fx, uint32_t* event) {
const uint32_t hits = Hits(level); const uint32_t hits = Hits(level);
const unsigned chains = Chains(level); const unsigned chains = Chains(level);
event[kEventHitsLeft] = hits; event[kEventHitsLeft] = hits;
auto cast = std::make_shared<Cast>();
cast->claimed.push_back(target);
auto chain = std::make_shared<Chain>(); auto chain = std::make_shared<Chain>();
chain->cast = cast;
chain->hit.push_back(target); 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(); chain->started = GetTickCount();
g_bolts[id] = {chain, hits}; g_bolts[id] = {chain, hits};
// More chains: each starts with a bolt at its own first jump target. // 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) { 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>(); auto branch = std::make_shared<Chain>();
branch->cast = cast;
branch->hit.push_back(target); branch->hit.push_back(target);
branch->started = chain->started; 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}); g_pending.push_back({branch, branchTarget});
SpawnBolt(fx, event, branchTarget, hits - 1); SpawnBolt(fx, event, branchTarget, hits - 1);
} }
@@ -264,7 +360,7 @@ uint32_t __fastcall NextTarget(void* manager, void*, const int32_t* position, ui
return 0; return 0;
} }
const std::shared_ptr<Chain> chain = it->second.chain; 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) { if (!next) {
next = current; // nobody else: hit the same enemy again next = current; // nobody else: hit the same enemy again
} }