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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user