Skills, auto-attack, aura and chain lightning rework, Esc, cache fix
- Skill bar: no art selection; slot 0 is the right-click art, keys 0-6 cast slots directly; cooldown labels in exact seconds; slot 0 set apart. - Arts regenerate on their own (casting no longer refills the group). - Auto-attack after targeting or casting at a hostile creature, at the speed of a held button. - Auras can be recast while active (renew, no second visual). - Blicke wie Klingen hits every enemy in range, physical, with life leech. - Blitzschlag steers the game's own chain: 3 + level/5 hits, 1 + level/10 chains, re-hits a lone enemy. - Weapon sets: click selects, drag only from the selected set; the leftmost set is clickable (signed hit tests). - Esc closes open windows before opening the game menu. - HUD: standalone buttons and texts are centred. - Record cache sized for widened views (town lag while walking). - Shared game code: orders, fx events, text drawing, cooldowns, effects. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
f721d2a486
commit
1fd89524d3
41 files changed
+2347
-214
No files matched your search
@@ -0,0 +1,263 @@
|
||||
// Blitzschlag as chain lightning (rebalancing request of 2026-10-15).
|
||||
//
|
||||
// The game's Blitzschlag already chains (measured, docs/chain-lightning.md):
|
||||
// the caster's art code creates a bolt object (type 872) at the target and
|
||||
// sets it up with a cEventFX 0x60 whose [14] is the number of hits left in the
|
||||
// chain (3 at level 1). The bolt hits its target; then, with hits left, its
|
||||
// update (0x5b4640..) looks for the next target (0x6005d0 at 0x5b469f: near
|
||||
// the current target, not the caster, not the current target), draws the arc
|
||||
// (segment objects) and creates the next bolt with [14] - 1. Without a next
|
||||
// target the chain ends, and between two enemies it bounces back and forth.
|
||||
//
|
||||
// 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 the game's range that this
|
||||
// chain has not hit yet; else another enemy; else the current target again
|
||||
// (a lone enemy takes all N hits);
|
||||
// - 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 <cstdint>
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include "game/engine/arts.h"
|
||||
#include "game/engine/fx.h"
|
||||
#include "game/engine/objects.h"
|
||||
#include "game/engine/orders.h"
|
||||
#include "re/log.h"
|
||||
#include "re/patch.h"
|
||||
|
||||
namespace mods::chainlightning {
|
||||
|
||||
uint32_t __fastcall NextTarget(void* manager, void*, const int32_t* position, uint32_t range, uint32_t caster,
|
||||
uint32_t current, uint8_t* bolt);
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint32_t kBoltObjectType = 0x368; // 872
|
||||
constexpr uint32_t kJumpSearchCall = 0x005b469f; // in the bolt update
|
||||
constexpr uint32_t kFindJumpTarget = 0x006005d0; // cObjectManager::FindJumpTarget
|
||||
constexpr uint32_t kJumpRange = 100000; // the game's argument (a squared distance)
|
||||
constexpr size_t kObjectOwnerPlayer = 0x39; // u8, copied from the caster like the game does
|
||||
constexpr size_t kCreaturePosition = 0x18; // 4 dwords, as effects are placed
|
||||
constexpr size_t kEventHitsLeft = 14;
|
||||
constexpr DWORD kChainLifetimeMs = 10000;
|
||||
|
||||
struct Chain {
|
||||
std::vector<uint32_t> hit; // targets in order
|
||||
DWORD started = 0;
|
||||
};
|
||||
|
||||
struct Bolt {
|
||||
std::shared_ptr<Chain> chain;
|
||||
uint32_t hitsLeft = 0;
|
||||
};
|
||||
|
||||
// The art the hero cast last (cast orders), for its level; a cast order arms
|
||||
// the next bolt setup as a chain start.
|
||||
uint16_t g_heroArt = 0;
|
||||
bool g_armed = false;
|
||||
// Bolt objects of our chains, by object id; and the chain the next bolt set
|
||||
// up at a given target belongs to (filled by NextTarget and branch spawning).
|
||||
std::map<uint32_t, Bolt> g_bolts;
|
||||
struct Pending {
|
||||
std::shared_ptr<Chain> chain;
|
||||
uint32_t target;
|
||||
};
|
||||
std::vector<Pending> g_pending;
|
||||
|
||||
void OnOrder(void* creature, const game::CreatureOrder& order, void*) {
|
||||
if (order.command == game::kOrderUseArt && creature == game::GetPlayerHero()) {
|
||||
g_heroArt = static_cast<uint16_t>(order.art);
|
||||
g_armed = true;
|
||||
}
|
||||
}
|
||||
|
||||
unsigned ArtLevel(void* creature, uint16_t art) {
|
||||
uint8_t* entry = game::FindCooldownEntry(static_cast<uint8_t*>(creature) + game::kCreatureArts, art);
|
||||
return entry ? entry[game::kCooldownLevel] + entry[game::kCooldownBonusLevels] : 0;
|
||||
}
|
||||
|
||||
bool Contains(const std::vector<uint32_t>& ids, uint32_t id) {
|
||||
for (uint32_t i : ids) {
|
||||
if (i == id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool IsTarget(void* caster, uint32_t id) {
|
||||
return !game::IsPlayerHeroId(id) && game::IsHostileTo(caster, game::GetCreature(id));
|
||||
}
|
||||
|
||||
// The nearest living enemy of `caster` within `range` (squared) of `from`, not
|
||||
// `current`; one not in `avoid` if there is any. 0 if none.
|
||||
uint32_t Nearest(void* caster, void* from, uint32_t range, uint32_t current, const std::vector<uint32_t>& avoid) {
|
||||
uint32_t best = 0;
|
||||
double bestDistance = 0.0;
|
||||
bool bestFresh = false;
|
||||
const int count = game::NearbyCreatureCount();
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const uint32_t id = game::NearbyCreatureId(i);
|
||||
if (id == current || !IsTarget(caster, id)) {
|
||||
continue;
|
||||
}
|
||||
const double d = static_cast<double>(game::DistanceTo(from, id));
|
||||
if (d * d > static_cast<double>(range)) {
|
||||
continue;
|
||||
}
|
||||
const bool fresh = !Contains(avoid, id);
|
||||
if (best && ((bestFresh && !fresh) || (bestFresh == fresh && d >= bestDistance))) {
|
||||
continue;
|
||||
}
|
||||
best = id;
|
||||
bestDistance = d;
|
||||
bestFresh = fresh;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
void Forget() {
|
||||
const DWORD now = GetTickCount();
|
||||
for (auto it = g_bolts.begin(); it != g_bolts.end();) {
|
||||
it = now - it->second.chain->started > kChainLifetimeMs ? g_bolts.erase(it) : std::next(it);
|
||||
}
|
||||
g_pending.clear();
|
||||
}
|
||||
|
||||
// A bolt object at `target`, set up with a copy of `event` (while its strings
|
||||
// are alive) with `hitsLeft`.
|
||||
void SpawnBolt(const uint8_t* original, const uint32_t* event, uint32_t target, uint32_t hitsLeft) {
|
||||
auto* creature = static_cast<uint8_t*>(game::GetCreature(target));
|
||||
if (!creature) {
|
||||
return;
|
||||
}
|
||||
auto* bolt = static_cast<uint8_t*>(
|
||||
game::GetObjectById(game::CreateObjectAt(kBoltObjectType, creature + kCreaturePosition)));
|
||||
if (!bolt) {
|
||||
return;
|
||||
}
|
||||
bolt[kObjectOwnerPlayer] = original[kObjectOwnerPlayer];
|
||||
uint32_t copy[game::kFxEventDwords];
|
||||
std::memcpy(copy, event, sizeof(copy));
|
||||
copy[3] = target;
|
||||
std::memcpy(©[4], creature + kCreaturePosition, 16);
|
||||
copy[kEventHitsLeft] = hitsLeft;
|
||||
game::SendEvent(bolt, copy);
|
||||
}
|
||||
|
||||
void BeforeFxEvent(uint8_t* fx, uint32_t* event) {
|
||||
if (event[1] != game::kFxEventBoltHit || *reinterpret_cast<uint32_t*>(fx + game::kObjectType) != kBoltObjectType ||
|
||||
!game::IsPlayerHeroId(event[2])) {
|
||||
return;
|
||||
}
|
||||
const uint32_t id = *reinterpret_cast<uint32_t*>(fx + game::kCreatureId);
|
||||
const uint32_t target = event[3];
|
||||
if (g_bolts.count(id)) {
|
||||
return; // set up again later: already ours
|
||||
}
|
||||
// A jump of one of our chains (NextTarget or a branch said where it goes).
|
||||
for (auto it = g_pending.begin(); it != g_pending.end(); ++it) {
|
||||
if (it->target == target) {
|
||||
g_bolts[id] = {it->chain, event[kEventHitsLeft]};
|
||||
it->chain->hit.push_back(target);
|
||||
g_pending.erase(it);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!g_armed) {
|
||||
return;
|
||||
}
|
||||
// The first bolt of a cast.
|
||||
g_armed = false;
|
||||
void* caster = game::GetCreature(event[2]);
|
||||
void* first = game::GetCreature(target);
|
||||
if (!caster || !first) {
|
||||
return;
|
||||
}
|
||||
Forget();
|
||||
const unsigned level = ArtLevel(caster, g_heroArt);
|
||||
const uint32_t hits = 3 + level / 5;
|
||||
const unsigned chains = 1 + level / 10;
|
||||
event[kEventHitsLeft] = hits;
|
||||
auto chain = std::make_shared<Chain>();
|
||||
chain->hit.push_back(target);
|
||||
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, kJumpRange, target, taken);
|
||||
const uint32_t branchTarget = next ? next : target;
|
||||
taken.push_back(branchTarget);
|
||||
auto branch = std::make_shared<Chain>();
|
||||
branch->hit.push_back(target);
|
||||
branch->started = chain->started;
|
||||
g_pending.push_back({branch, branchTarget});
|
||||
SpawnBolt(fx, event, branchTarget, hits - 1);
|
||||
}
|
||||
re::Log("chainlightning: art %x level %u: %u chain(s) of %u hits from %x", g_heroArt, level, chains, hits,
|
||||
target);
|
||||
}
|
||||
|
||||
// Called instead of the jump search (ecx = the object manager, four stack
|
||||
// arguments); the bolt is in ebp.
|
||||
__declspec(naked) void NextTargetThunk() {
|
||||
__asm {
|
||||
push ebp // the bolt
|
||||
push dword ptr [esp+14h] // current target
|
||||
push dword ptr [esp+14h] // caster
|
||||
push dword ptr [esp+14h] // range
|
||||
push dword ptr [esp+14h] // position
|
||||
call NextTarget
|
||||
ret 10h
|
||||
}
|
||||
}
|
||||
|
||||
void Startup() {
|
||||
const bool ok = re::RedirectCall(kJumpSearchCall, kFindJumpTarget, reinterpret_cast<const void*>(&NextTargetThunk));
|
||||
re::Log("chainlightning: jump targets%s", ok ? "" : " (patch FAILED)");
|
||||
}
|
||||
RE_STARTUP(ChainLightningStartup, Startup);
|
||||
|
||||
struct Registration {
|
||||
Registration() {
|
||||
game::OnCreatureOrder(&OnOrder);
|
||||
game::BeforeFxEvent(&BeforeFxEvent);
|
||||
}
|
||||
} g_registration;
|
||||
|
||||
} // namespace
|
||||
|
||||
// The next target of `bolt`'s chain (the game's search for bolts that are
|
||||
// not ours).
|
||||
uint32_t __fastcall NextTarget(void* manager, void*, const int32_t* position, uint32_t range, uint32_t caster,
|
||||
uint32_t current, uint8_t* bolt) {
|
||||
const uint32_t id = bolt ? *reinterpret_cast<uint32_t*>(bolt + game::kCreatureId) : 0;
|
||||
const auto it = g_bolts.find(id);
|
||||
if (it == g_bolts.end()) {
|
||||
using FindFn = uint32_t(__fastcall*)(void*, void*, const int32_t*, uint32_t, uint32_t, uint32_t);
|
||||
return reinterpret_cast<FindFn>(kFindJumpTarget)(manager, nullptr, position, range, caster, current);
|
||||
}
|
||||
void* casterCreature = game::GetCreature(caster);
|
||||
void* from = game::GetCreature(current);
|
||||
if (!casterCreature || !from || it->second.hitsLeft <= 1) {
|
||||
return 0;
|
||||
}
|
||||
const std::shared_ptr<Chain> chain = it->second.chain;
|
||||
uint32_t next = Nearest(casterCreature, from, range, current, chain->hit);
|
||||
if (!next) {
|
||||
next = current; // nobody else: hit the same enemy again
|
||||
}
|
||||
g_pending.push_back({chain, next});
|
||||
return next;
|
||||
}
|
||||
|
||||
} // namespace mods::chainlightning
|
||||
Reference in new issue
Block a user