From 5d80a33e9bfb6006a0cc0f9b630d28eeaa72da13 Mon Sep 17 00:00:00 2001 From: Torhan Bartel Date: Sun, 18 Oct 2026 03:44:16 +0200 Subject: [PATCH] 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) --- docs/chain-lightning.md | 22 +++++++ src/mods/chainlightning.cpp | 128 +++++++++++++++++++++++++++++++----- 2 files changed, 134 insertions(+), 16 deletions(-) diff --git a/docs/chain-lightning.md b/docs/chain-lightning.md index ab120e2..27446ae 100644 --- a/docs/chain-lightning.md +++ b/docs/chain-lightning.md @@ -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 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) 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 use `JumpRange()` = half the cast range (450). The tooltip shows both ("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). diff --git a/src/mods/chainlightning.cpp b/src/mods/chainlightning.cpp index ccc34af..0fdc6a1 100644 --- a/src/mods/chainlightning.cpp +++ b/src/mods/chainlightning.cpp @@ -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 +#include #include #include #include @@ -60,10 +66,10 @@ constexpr int kRaySegments = 20; double CastRange() { return static_cast(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 claimed; + std::vector starts; +}; + struct Chain { + std::shared_ptr cast; std::vector 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& avoid) { - return game::NearestHostile(caster, from, JumpRange(), current, avoid.data(), avoid.size()); +bool Contains(const std::vector& 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->claimed.push_back(target); auto chain = std::make_shared(); + 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 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(); + 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 = 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 }