Files
Dota-Zombie-Invasion/scripts/vscripts/ai/nevermore_ai.lua
T
2026-05-29 15:11:31 +07:00

981 lines
37 KiB
Lua

local ____lualib = require("lualib_bundle")
local __TS__NumberToFixed = ____lualib.__TS__NumberToFixed
local __TS__ArrayFind = ____lualib.__TS__ArrayFind
local ____exports = {}
local getPredictedGroundPosition, getTargetHorizontalForward, getAimInFrontOfPredictedTarget, getBossForwardAimPoint, addCoilWaveAimJitter, getNevermoreCoilWavePhase, canCoilWaveHitAnyEnemy, canCoilBeamHitAnyEnemy, canHubCrossburstHitAnyEnemy, debugLog, NevermoreBossThink, tryIdleTripleCoilForward, purgeDebugPrint, removeAllModifiersFromUnit, purgeAndPositionNevermoreForRequiem, tryRequiemFromCenterPattern, tryCastPointAbility, tryComputeTimeWalkJump, tryTimeWalkIntoCoilPattern, trySpellSeries, tryExecuteQueuedCoil, abilities, cachedTarget, nextTargetSearchAt, comboLockUntil, queuedCoilAt, queuedCoilCount, queuedCoilInitial, queuedCoilSkipStreak, lastOrderAt, spellSeries, seriesCooldownUntil, SERIES_ROTATION, seriesRotationIndex, SERIES_TRIPLE_MIN, SERIES_TRIPLE_MAX, SERIES_WAVE_MIN, SERIES_WAVE_MAX, SERIES_BEAM_MIN, SERIES_BEAM_MAX, SERIES_CROSS_MIN, SERIES_CROSS_MAX, BETWEEN_SERIES_PAUSE, BETWEEN_CAST_GAP, WITHIN_SERIES_GAP, TIME_WALK_MIN_DISTANCE, PREDICT_LEAD_SPELLS, PREDICT_LEAD_TIME_WALK, PREDICT_MAX_LEAD_UNITS, HERO_ACQUIRE_RADIUS, COIL_JITTER_DIST_MIN, COIL_JITTER_DIST_MAX, COIL_JITTER_FOLLOW_MIN, COIL_JITTER_FOLLOW_MAX, AIM_FORWARD_FIRST_CAST, AIM_FORWARD_FOLLOW_CAST, MAX_QUEUED_COIL_SKIP_STREAK, TIME_WALK_STANDOFF_MIN, TIME_WALK_STANDOFF_MAX, aiNextCastAt, nextIdleTripleAt, requiemCommitPending, DEBUG_AI, nextDebugStateAt, debugTagNextAt, REQUIEM_RARE_ROLL_MAX, DEBUG_NEVERMORE_PURGE, PURGE_LOG_MAX_PER_CALL, NEVERMORE_PURGE_SKIP_MODIFIER_NAMES
local ____modifier_boss_nevermore_debuff_immune = require("abilities.creep.modifier_boss_nevermore_debuff_immune")
local modifier_boss_nevermore_debuff_immune = ____modifier_boss_nevermore_debuff_immune.modifier_boss_nevermore_debuff_immune
local ____modifier_boss_nevermore_phase_terror_wave = require("abilities.creep.modifier_boss_nevermore_phase_terror_wave")
local applyNevermorePhaseTerrorWave = ____modifier_boss_nevermore_phase_terror_wave.applyNevermorePhaseTerrorWave
local modifier_boss_nevermore_phase_terror_wave = ____modifier_boss_nevermore_phase_terror_wave.modifier_boss_nevermore_phase_terror_wave
local ____modifier_boss_nevermore_requiem_gate = require("abilities.creep.modifier_boss_nevermore_requiem_gate")
local applyNevermoreRequiemGate = ____modifier_boss_nevermore_requiem_gate.applyNevermoreRequiemGate
local modifier_boss_nevermore_requiem_gate = ____modifier_boss_nevermore_requiem_gate.modifier_boss_nevermore_requiem_gate
local ____modifier_boss_hud_health_bar = require("abilities.modifiers.modifier_boss_hud_health_bar")
local BOSS_NEVERMORE_NAME_TOKEN = ____modifier_boss_hud_health_bar.BOSS_NEVERMORE_NAME_TOKEN
local applyBossHudHealthBar = ____modifier_boss_hud_health_bar.applyBossHudHealthBar
local ____nevermore_boss_requiem_bridge = require("ai.nevermore_boss_requiem_bridge")
local nevermoreBumpRequiemAiCooldown = ____nevermore_boss_requiem_bridge.nevermoreBumpRequiemAiCooldown
local nevermoreGetRequiemNextAiTime = ____nevermore_boss_requiem_bridge.nevermoreGetRequiemNextAiTime
local nevermoreNeedsMandatoryRequiem = ____nevermore_boss_requiem_bridge.nevermoreNeedsMandatoryRequiem
local nevermoreRegisterPhaseRequiemHook = ____nevermore_boss_requiem_bridge.nevermoreRegisterPhaseRequiemHook
local ____dota_ts_adapter = require("lib.dota_ts_adapter")
local registerEntityFunction = ____dota_ts_adapter.registerEntityFunction
function getPredictedGroundPosition(self, target, leadSeconds)
local cur = GetGroundPosition(
target:GetAbsOrigin(),
nil
)
local vx = target:GetVelocity().x
local vy = target:GetVelocity().y
local speed2d = math.sqrt(vx * vx + vy * vy)
if speed2d < 25 then
local spd = math.max(
1,
target:GetIdealSpeed()
)
local fwd = target:GetForwardVector()
vx = fwd.x * spd
vy = fwd.y * spd
end
local dx = vx * leadSeconds
local dy = vy * leadSeconds
local leadLen = math.sqrt(dx * dx + dy * dy)
if leadLen > PREDICT_MAX_LEAD_UNITS and leadLen > 0 then
local scale = PREDICT_MAX_LEAD_UNITS / leadLen
dx = dx * scale
dy = dy * scale
end
return GetGroundPosition(
cur + Vector(dx, dy, 0),
nil
)
end
function getTargetHorizontalForward(self, target)
local f = target:GetForwardVector()
local len2d = math.sqrt(f.x * f.x + f.y * f.y)
if len2d < 0.01 then
return Vector(1, 0, 0)
end
return Vector(f.x / len2d, f.y / len2d, 0)
end
function getAimInFrontOfPredictedTarget(self, target, leadSeconds, forwardUnits)
local pred = getPredictedGroundPosition(nil, target, leadSeconds)
local fwd = getTargetHorizontalForward(nil, target)
return GetGroundPosition(pred + fwd * forwardUnits, nil)
end
function getBossForwardAimPoint(self, boss, distance)
local o = GetGroundPosition(
boss:GetAbsOrigin(),
nil
)
local f = boss:GetForwardVector()
local len2d = math.sqrt(f.x * f.x + f.y * f.y)
local dir = len2d < 0.01 and Vector(1, 0, 0) or Vector(f.x / len2d, f.y / len2d, 0)
return GetGroundPosition(o + dir * distance, nil)
end
function addCoilWaveAimJitter(self, anchor, castIndexInSeries)
local sector = castIndexInSeries * 149 % 360
local yaw = sector + RandomInt(0, 89)
local distMin = castIndexInSeries <= 0 and COIL_JITTER_DIST_MIN or COIL_JITTER_FOLLOW_MIN
local distMax = castIndexInSeries <= 0 and COIL_JITTER_DIST_MAX or COIL_JITTER_FOLLOW_MAX
local dist = RandomInt(distMin, distMax)
local offset = RotatePosition(
Vector(0, 0, 0),
QAngle(0, yaw, 0),
Vector(dist, 0, 0)
)
return GetGroundPosition(anchor + offset, nil)
end
function getNevermoreCoilWavePhase(self, boss)
local hp = boss:GetHealthPercent()
if hp <= 25 then
return 4
end
if hp <= 50 then
return 3
end
if hp <= 75 then
return 2
end
return 1
end
function canCoilWaveHitAnyEnemy(self, boss, aimPoint)
local ab = abilities.coilWave
if not ab or ab:IsNull() then
return false
end
local phase = getNevermoreCoilWavePhase(nil, boss)
local origin = boss:GetAbsOrigin()
local dir = aimPoint - origin
dir.z = 0
if dir:Length2D() < 1 then
local f = boss:GetForwardVector()
dir = Vector(f.x, f.y, 0)
end
dir = dir:Normalized()
local radius = ab:GetSpecialValueFor("radius")
if radius <= 0 then
return false
end
local baseSlotsKv = ab:GetSpecialValueFor("lane_slot_count")
local baseSlots = baseSlotsKv > 0 and math.floor(baseSlotsKv) or 5
local slotCount = baseSlots + (phase - 1)
local forwardKv = ab:GetSpecialValueFor("lane_forward_dist")
local forwardDist = (forwardKv > 0 and forwardKv or 360) + (phase - 1) * 35
local spacingKv = ab:GetSpecialValueFor("lane_slot_spacing")
local spacingFactor = spacingKv > 0 and spacingKv or 1.32
local spacing = math.max(radius * spacingFactor, 185)
local right = Vector(-dir.y, dir.x, 0):Normalized()
local centerRow = GetGroundPosition(origin + dir * forwardDist, nil)
local mid = (slotCount - 1) / 2
local searchR = forwardDist + radius + spacing * slotCount + 450
local enemies = FindUnitsInRadius(
boss:GetTeamNumber(),
origin,
nil,
searchR,
DOTA_UNIT_TARGET_TEAM_ENEMY,
bit.bor(DOTA_UNIT_TARGET_HERO, DOTA_UNIT_TARGET_BASIC),
DOTA_UNIT_TARGET_FLAG_NONE,
FIND_ANY_ORDER,
false
)
local hullFudge = 28
for ____, u in ipairs(enemies) do
do
if not u or u:IsNull() or not u:IsAlive() then
goto __continue18
end
local ep = GetGroundPosition(
u:GetAbsOrigin(),
nil
)
do
local i = 0
while i < slotCount do
local lateral = (i - mid) * spacing
local slotPos = GetGroundPosition(centerRow + right * lateral, nil)
if (ep - slotPos):Length2D() <= radius + hullFudge then
return true
end
i = i + 1
end
end
end
::__continue18::
end
return false
end
function canCoilBeamHitAnyEnemy(self, boss, aimPoint)
local ab = abilities.coilBeam
if not ab or ab:IsNull() then
return false
end
local phase = getNevermoreCoilWavePhase(nil, boss)
local origin = boss:GetAbsOrigin()
local dir = aimPoint - origin
dir.z = 0
if dir:Length2D() < 1 then
local f = boss:GetForwardVector()
dir = Vector(f.x, f.y, 0)
end
dir = dir:Normalized()
local radius = ab:GetSpecialValueFor("radius")
if radius <= 0 then
return false
end
local baseSlotsKv = ab:GetSpecialValueFor("lane_slot_count")
local baseSlots = baseSlotsKv > 0 and math.floor(baseSlotsKv) or 12
local bonusKv = ab:GetSpecialValueFor("lane_slot_phase_bonus")
local perPhase = bonusKv > 0 and math.floor(bonusKv) or 2
local slotCount = baseSlots + (phase - 1) * perPhase
local startKv = ab:GetSpecialValueFor("beam_start_dist")
local startDist = startKv > 0 and startKv or 140
local stepKv = ab:GetSpecialValueFor("beam_step")
local step = stepKv > 0 and stepKv or math.max(radius * 1.22, 195)
local endAlong = startDist + (slotCount - 1) * step
local searchR = endAlong + radius + 200
local enemies = FindUnitsInRadius(
boss:GetTeamNumber(),
origin,
nil,
searchR,
DOTA_UNIT_TARGET_TEAM_ENEMY,
bit.bor(DOTA_UNIT_TARGET_HERO, DOTA_UNIT_TARGET_BASIC),
DOTA_UNIT_TARGET_FLAG_NONE,
FIND_ANY_ORDER,
false
)
local hullFudge = 28
for ____, u in ipairs(enemies) do
do
if not u or u:IsNull() or not u:IsAlive() then
goto __continue27
end
local ep = GetGroundPosition(
u:GetAbsOrigin(),
nil
)
do
local i = 0
while i < slotCount do
local along = startDist + i * step
local slotPos = GetGroundPosition(origin + dir * along, nil)
if (ep - slotPos):Length2D() <= radius + hullFudge then
return true
end
i = i + 1
end
end
end
::__continue27::
end
return false
end
function canHubCrossburstHitAnyEnemy(self, boss)
local ab = abilities.hubCrossburst
if not ab or ab:IsNull() then
return false
end
local pickKv = ab:GetSpecialValueFor("spawn_pick_radius")
local pick = pickKv > 0 and pickKv or 1500
local startKv = ab:GetSpecialValueFor("ring_start_dist")
local stepKv = ab:GetSpecialValueFor("ring_step")
local countKv = ab:GetSpecialValueFor("ring_count")
local countBonusKv = ab:GetSpecialValueFor("ring_count_phase_bonus")
local radius = ab:GetSpecialValueFor("radius")
local start = startKv > 0 and startKv or 90
local step = stepKv > 0 and stepKv or 190
local baseCnt = countKv > 0 and math.floor(countKv) or 7
local perPh = countBonusKv > 0 and math.floor(countBonusKv) or 1
local phase = getNevermoreCoilWavePhase(nil, boss)
local cnt = math.max(1, baseCnt + (phase - 1) * perPh)
local r = radius > 0 and radius or 165
local armReach = start + math.max(0, cnt - 1) * step + r
local searchR = pick + armReach + 250
local enemies = FindUnitsInRadius(
boss:GetTeamNumber(),
boss:GetAbsOrigin(),
nil,
searchR,
DOTA_UNIT_TARGET_TEAM_ENEMY,
bit.bor(DOTA_UNIT_TARGET_HERO, DOTA_UNIT_TARGET_BASIC),
DOTA_UNIT_TARGET_FLAG_NONE,
FIND_ANY_ORDER,
false
)
for ____, u in ipairs(enemies) do
if u and not u:IsNull() and u:IsAlive() then
return true
end
end
return false
end
function debugLog(self, tag, message, throttle)
if throttle == nil then
throttle = 0.35
end
if not DEBUG_AI then
return
end
local now = GameRules:GetGameTime()
local nextAt = debugTagNextAt[tag] or 0
if now < nextAt then
return
end
debugTagNextAt[tag] = now + throttle
print((("[NevermoreAI][" .. tag) .. "] ") .. message)
end
function NevermoreBossThink(self)
if not IsServer() or not thisEntity or thisEntity:IsNull() then
return 0.5
end
if not thisEntity:IsAlive() then
return 0.5
end
if thisEntity:IsChanneling() then
return 0.5
end
if thisEntity:GetOwner() ~= nil and thisEntity:GetOwner():IsRealHero() then
return 0.5
end
local now = GameRules:GetGameTime()
if now >= nextDebugStateAt then
nextDebugStateAt = now + 1
local ser = spellSeries and (spellSeries.kind .. "x") .. tostring(spellSeries.remaining) or "idle"
debugLog(
nil,
"state",
(((((("lock=" .. __TS__NumberToFixed(comboLockUntil - now, 2)) .. " target=") .. (cachedTarget and cachedTarget:GetUnitName() or "none")) .. " q=") .. tostring(queuedCoilCount)) .. " series=") .. ser,
0.95
)
end
if tryExecuteQueuedCoil(nil, now) then
return 0.14
end
if now < comboLockUntil then
return 0.14
end
local shouldRefreshTarget = now >= nextTargetSearchAt or not cachedTarget or cachedTarget:IsNull() or not cachedTarget:IsAlive() or (cachedTarget:GetAbsOrigin() - thisEntity:GetAbsOrigin()):Length2D() > HERO_ACQUIRE_RADIUS
if shouldRefreshTarget then
local enemies = FindUnitsInRadius(
thisEntity:GetTeamNumber(),
thisEntity:GetAbsOrigin(),
nil,
HERO_ACQUIRE_RADIUS,
DOTA_UNIT_TARGET_TEAM_ENEMY,
DOTA_UNIT_TARGET_HERO,
DOTA_UNIT_TARGET_FLAG_NO_INVIS,
FIND_CLOSEST,
false
)
if #enemies == 0 then
enemies = FindUnitsInRadius(
thisEntity:GetTeamNumber(),
thisEntity:GetAbsOrigin(),
nil,
HERO_ACQUIRE_RADIUS,
DOTA_UNIT_TARGET_TEAM_ENEMY,
DOTA_UNIT_TARGET_BASIC,
DOTA_UNIT_TARGET_FLAG_NO_INVIS,
FIND_CLOSEST,
false
)
end
local enemyHero = __TS__ArrayFind(
enemies,
function(____, unit) return unit:IsRealHero() and unit:IsAlive() end
)
cachedTarget = enemyHero or enemies[1]
nextTargetSearchAt = now + (cachedTarget ~= nil and 0.55 or 1)
debugLog(
nil,
"target",
(("refresh enemies=" .. tostring(#enemies)) .. " chosen=") .. (cachedTarget ~= nil and cachedTarget:GetUnitName() or "none"),
0.35
)
end
local primaryTarget = cachedTarget
if not primaryTarget then
requiemCommitPending = false
spellSeries = nil
queuedCoilCount = 0
queuedCoilInitial = 0
queuedCoilSkipStreak = 0
if tryIdleTripleCoilForward(nil, now) then
return 0.14
end
return 0.25
end
if tryTimeWalkIntoCoilPattern(nil, primaryTarget, now) then
return 0.14
end
if trySpellSeries(nil, primaryTarget, now) then
return 0.14
end
if tryRequiemFromCenterPattern(nil, primaryTarget, now) then
return 0.14
end
comboLockUntil = now + 0.18
return 0.18
end
function tryIdleTripleCoilForward(self, now)
if now < nextIdleTripleAt then
return false
end
if not abilities.tripleCoil or not abilities.tripleCoil:IsFullyCastable() then
return false
end
if thisEntity:HasModifier("modifier_boss_nevermore_time_walk") then
return false
end
local pos = getBossForwardAimPoint(
nil,
thisEntity,
RandomInt(280, 520)
)
if not tryCastPointAbility(nil, abilities.tripleCoil, pos, now) then
return false
end
nextIdleTripleAt = now + 3.2
comboLockUntil = now + 1
debugLog(nil, "idle_triple", "no enemies — triple forward", 0.4)
return true
end
function purgeDebugPrint(self, message)
if DEBUG_NEVERMORE_PURGE then
print("[NevermorePurge] " .. message)
end
end
function removeAllModifiersFromUnit(self, unit)
if not unit or unit:IsNull() then
purgeDebugPrint(nil, "removeAllModifiersFromUnit: unit пустой")
return
end
local unitLabel = "<?>"
do
pcall(function()
unitLabel = unit:GetUnitName()
end)
end
local startCount = unit:GetModifierCount()
purgeDebugPrint(
nil,
(((("старт: " .. unitLabel) .. " ent=") .. tostring(unit:entindex())) .. " modifiers=") .. tostring(startCount)
)
local guard = 0
local maxIterations = 400
local logged = 0
while unit:GetModifierCount() > 0 and guard < maxIterations do
do
guard = guard + 1
local beforeCount = unit:GetModifierCount()
local lastIdx = beforeCount - 1
local name = unit:GetModifierNameByIndex(lastIdx)
if name == nil or name == "" then
purgeDebugPrint(
nil,
(("прервали: пустое имя по индексу " .. tostring(lastIdx)) .. ", count=") .. tostring(beforeCount)
)
break
end
if NEVERMORE_PURGE_SKIP_MODIFIER_NAMES[name] then
local removedSkipped = false
do
local i = lastIdx - 1
while i >= 0 do
do
local altName = unit:GetModifierNameByIndex(i)
if not altName or NEVERMORE_PURGE_SKIP_MODIFIER_NAMES[altName] then
goto __continue72
end
unit:RemoveModifierByName(altName)
removedSkipped = true
break
end
::__continue72::
i = i - 1
end
end
if not removedSkipped then
purgeDebugPrint(
nil,
"стоп whitelist: остались только защищённые модификаторы, count=" .. tostring(beforeCount)
)
break
end
goto __continue69
end
if logged < PURGE_LOG_MAX_PER_CALL then
purgeDebugPrint(
nil,
((((((" #" .. tostring(guard)) .. " снятие: idx=") .. tostring(lastIdx)) .. " name=\"") .. name) .. "\" count до=") .. tostring(beforeCount)
)
logged = logged + 1
elseif logged == PURGE_LOG_MAX_PER_CALL then
purgeDebugPrint(
nil,
(" … дальше без логов каждого шага (лимит " .. tostring(PURGE_LOG_MAX_PER_CALL)) .. ")"
)
logged = logged + 1
end
unit:RemoveModifierByName(name)
local afterCount = unit:GetModifierCount()
if afterCount >= beforeCount then
purgeDebugPrint(
nil,
(((("СТОП: RemoveModifierByName не уменьшил список (движок не снял?) name=\"" .. name) .. "\" до=") .. tostring(beforeCount)) .. " после=") .. tostring(afterCount)
)
break
end
end
::__continue69::
end
local endCount = unit:GetModifierCount()
if guard >= maxIterations then
purgeDebugPrint(
nil,
(("ВНИМАНИЕ: достигнут лимит итераций " .. tostring(maxIterations)) .. ", осталось модификаторов: ") .. tostring(endCount)
)
end
purgeDebugPrint(
nil,
((((("конец цикла: снятий≈" .. tostring(guard)) .. ", осталось modifiers=") .. tostring(endCount)) .. " (") .. unitLabel) .. ")"
)
end
function purgeAndPositionNevermoreForRequiem(self)
local t0 = IsServer() and GameRules:GetGameTime() or 0
local n0 = thisEntity:GetModifierCount()
purgeDebugPrint(
nil,
(((("purgeAndPosition: до снятия time=" .. __TS__NumberToFixed(t0, 2)) .. " modifiers=") .. tostring(n0)) .. " requiemPending=") .. tostring(requiemCommitPending)
)
removeAllModifiersFromUnit(nil, thisEntity)
local n1 = thisEntity:GetModifierCount()
purgeDebugPrint(
nil,
"purgeAndPosition: после removeAll modifiers=" .. tostring(n1)
)
thisEntity:Purge(
true,
true,
false,
true,
true
)
local n2 = thisEntity:GetModifierCount()
purgeDebugPrint(
nil,
"purgeAndPosition: после Purge modifiers=" .. tostring(n2)
)
applyNevermoreRequiemGate(nil, thisEntity)
local center = Entities:FindByName(nil, "nevermore_center_point")
if center then
debugLog(nil, "requiem", "teleport to nevermore_center_point", 0.2)
FindClearSpaceForUnit(
thisEntity,
center:GetAbsOrigin(),
true
)
local cp = center:GetAbsOrigin()
purgeDebugPrint(
nil,
((((("телепорт: nevermore_center_point ok pos=(" .. __TS__NumberToFixed(cp.x, 0)) .. ",") .. __TS__NumberToFixed(cp.y, 0)) .. ",") .. __TS__NumberToFixed(cp.z, 0)) .. ")"
)
else
purgeDebugPrint(nil, "телепорт: сущность nevermore_center_point НЕ НАЙДЕНА на карте")
debugLog(nil, "requiem", "center point not found", 1)
end
local req = abilities.requiem
if req and not req:IsNull() then
purgeDebugPrint(
nil,
(((((((("requiem ability: castable=" .. tostring(req:IsFullyCastable())) .. " channel=") .. tostring(req:GetChannelTime())) .. " cd=") .. __TS__NumberToFixed(
req:GetCooldownTimeRemaining(),
2
)) .. " mana=") .. __TS__NumberToFixed(
thisEntity:GetMana(),
0
)) .. "/") .. __TS__NumberToFixed(
thisEntity:GetMaxMana(),
0
)
)
else
purgeDebugPrint(nil, "requiem ability: отсутствует")
end
end
function tryRequiemFromCenterPattern(self, target, now)
if not abilities.requiem then
return false
end
if not target or target:IsNull() or not target:IsAlive() then
requiemCommitPending = false
return false
end
local mandatoryRequiem = nevermoreNeedsMandatoryRequiem(nil, thisEntity)
if not requiemCommitPending then
if not mandatoryRequiem and now < nevermoreGetRequiemNextAiTime(nil) then
return false
end
if not abilities.requiem:IsFullyCastable() then
return false
end
local hpPct = thisEntity:GetHealthPercent()
local allowByHp = hpPct <= 38
local allowByRareRoll = RandomInt(1, 100) <= REQUIEM_RARE_ROLL_MAX
if not mandatoryRequiem and not allowByHp and not allowByRareRoll then
debugLog(
nil,
"requiem",
("skip rare ult (hp=" .. __TS__NumberToFixed(hpPct, 0)) .. "%)",
2
)
return false
end
requiemCommitPending = true
if mandatoryRequiem then
debugLog(
nil,
"requiem",
("mandatory ult hp=" .. __TS__NumberToFixed(hpPct, 0)) .. "%",
0.5
)
end
end
purgeAndPositionNevermoreForRequiem(nil)
local castPosition = getAimInFrontOfPredictedTarget(nil, target, PREDICT_LEAD_SPELLS + 0.15, AIM_FORWARD_FIRST_CAST)
if not tryCastPointAbility(nil, abilities.requiem, castPosition, now) then
comboLockUntil = now + 0.12
return true
end
requiemCommitPending = false
debugLog(
nil,
"requiem",
(("cast at " .. __TS__NumberToFixed(castPosition.x, 0)) .. ",") .. __TS__NumberToFixed(castPosition.y, 0),
0.2
)
nevermoreBumpRequiemAiCooldown(nil)
comboLockUntil = now + 1
queuedCoilCount = 0
queuedCoilInitial = 0
spellSeries = nil
seriesCooldownUntil = now + 2
return true
end
function tryCastPointAbility(self, ability, position, now)
if not ability or ability:IsNull() then
debugLog(nil, "cast_fail", "ability missing", 0.5)
return false
end
if not ability:IsFullyCastable() then
debugLog(
nil,
"cast_fail",
ability:GetAbilityName() .. " not castable",
0.25
)
return false
end
if now < lastOrderAt + 0.12 then
debugLog(
nil,
"cast_fail",
ability:GetAbilityName() .. " blocked by order throttle",
0.25
)
return false
end
ExecuteOrderFromTable({
UnitIndex = thisEntity:entindex(),
OrderType = DOTA_UNIT_ORDER_CAST_POSITION,
AbilityIndex = ability:entindex(),
Position = position
})
lastOrderAt = now
debugLog(
nil,
"cast_ok",
(((ability:GetAbilityName() .. " -> ") .. __TS__NumberToFixed(position.x, 0)) .. ",") .. __TS__NumberToFixed(position.y, 0),
0.12
)
return true
end
function tryComputeTimeWalkJump(self, target)
if not abilities.timeWalk then
return nil
end
local bossPos = thisEntity:GetAbsOrigin()
local predicted = getPredictedGroundPosition(nil, target, PREDICT_LEAD_TIME_WALK)
local toPred = predicted - bossPos
local distanceToPredicted = toPred:Length2D()
if distanceToPredicted <= TIME_WALK_MIN_DISTANCE then
return nil
end
local dir = distanceToPredicted < 1 and thisEntity:GetForwardVector() or toPred:Normalized()
local standoff = RandomInt(TIME_WALK_STANDOFF_MIN, TIME_WALK_STANDOFF_MAX)
local jumpPos = GetGroundPosition(predicted - dir * standoff, nil)
local maxRange = abilities.timeWalk:GetCastRange(bossPos, nil)
local toJump = jumpPos - bossPos
local jumpLen = toJump:Length2D()
if jumpLen > maxRange - 30 then
jumpPos = GetGroundPosition(
bossPos + toJump:Normalized() * math.max(50, maxRange - 40),
nil
)
end
return jumpPos
end
function tryTimeWalkIntoCoilPattern(self, target, now)
if not abilities.timeWalk or not abilities.coilWave then
return false
end
if now < aiNextCastAt.timeWalk or now < aiNextCastAt.coilWave then
return false
end
if not abilities.timeWalk:IsFullyCastable() or not abilities.coilWave:IsFullyCastable() then
return false
end
if thisEntity:HasModifier("modifier_boss_nevermore_time_walk") then
return false
end
local jumpPos = tryComputeTimeWalkJump(nil, target)
if not jumpPos then
return false
end
local predictedForLog = getPredictedGroundPosition(nil, target, PREDICT_LEAD_TIME_WALK)
local distBossToPred = (predictedForLog - thisEntity:GetAbsOrigin()):Length2D()
if not tryCastPointAbility(nil, abilities.timeWalk, jumpPos, now) then
return false
end
debugLog(
nil,
"time_walk",
(("distToPred=" .. __TS__NumberToFixed(distBossToPred, 0)) .. " queuedCoils=") .. tostring(queuedCoilCount),
0.2
)
aiNextCastAt.timeWalk = now + 3
aiNextCastAt.coilWave = now + BETWEEN_CAST_GAP
comboLockUntil = now + 0.7
spellSeries = nil
seriesCooldownUntil = now + 4
queuedCoilCount = RandomInt(2, 3)
queuedCoilInitial = queuedCoilCount
queuedCoilAt = now + 0.55
queuedCoilSkipStreak = 0
return true
end
function trySpellSeries(self, target, now)
if thisEntity:HasModifier("modifier_boss_nevermore_time_walk") then
return false
end
if spellSeries == nil then
if now < seriesCooldownUntil then
return false
end
local kind = SERIES_ROTATION[seriesRotationIndex % #SERIES_ROTATION + 1]
seriesRotationIndex = seriesRotationIndex + 1
local count = kind == "triple" and RandomInt(SERIES_TRIPLE_MIN, SERIES_TRIPLE_MAX) or (kind == "wave" and RandomInt(SERIES_WAVE_MIN, SERIES_WAVE_MAX) or (kind == "beam" and RandomInt(SERIES_BEAM_MIN, SERIES_BEAM_MAX) or RandomInt(SERIES_CROSS_MIN, SERIES_CROSS_MAX)))
local ____temp_2
if kind == "triple" then
____temp_2 = abilities.tripleCoil
else
local ____temp_1
if kind == "wave" then
____temp_1 = abilities.coilWave
else
local ____temp_0
if kind == "beam" then
____temp_0 = abilities.coilBeam
else
____temp_0 = abilities.hubCrossburst
end
____temp_1 = ____temp_0
end
____temp_2 = ____temp_1
end
local ab = ____temp_2
if not ab then
return false
end
spellSeries = {kind = kind, remaining = count, totalInSeries = count}
debugLog(
nil,
"series",
(("start " .. kind) .. " x") .. tostring(count),
0.2
)
end
local series = spellSeries
if not series then
return false
end
local ____temp_5
if series.kind == "triple" then
____temp_5 = abilities.tripleCoil
else
local ____temp_4
if series.kind == "wave" then
____temp_4 = abilities.coilWave
else
local ____temp_3
if series.kind == "beam" then
____temp_3 = abilities.coilBeam
else
____temp_3 = abilities.hubCrossburst
end
____temp_4 = ____temp_3
end
____temp_5 = ____temp_4
end
local ability = ____temp_5
if not ability or not ability:IsFullyCastable() then
debugLog(nil, "series", series.kind .. " not castable, wait", 0.3)
comboLockUntil = now + 0.25
return true
end
local castIndex = series.totalInSeries - series.remaining
local forwardUnits = castIndex == 0 and AIM_FORWARD_FIRST_CAST or AIM_FORWARD_FOLLOW_CAST
local anchor = getAimInFrontOfPredictedTarget(nil, target, PREDICT_LEAD_SPELLS, forwardUnits)
local castPos = addCoilWaveAimJitter(nil, anchor, castIndex)
if series.kind == "wave" and not canCoilWaveHitAnyEnemy(nil, thisEntity, castPos) then
debugLog(nil, "series", "wave abort — никого в полосе, сбрасываем серию", 0.28)
spellSeries = nil
seriesCooldownUntil = now + 0.45
comboLockUntil = now + 0.22
return true
end
if series.kind == "beam" and not canCoilBeamHitAnyEnemy(nil, thisEntity, castPos) then
debugLog(nil, "series", "beam abort — никого на луче, сбрасываем серию", 0.28)
spellSeries = nil
seriesCooldownUntil = now + 0.45
comboLockUntil = now + 0.22
return true
end
if series.kind == "cross" and not canHubCrossburstHitAnyEnemy(nil, thisEntity) then
debugLog(nil, "series", "crossburst abort — врагов слишком далеко для зоны хаба", 0.28)
spellSeries = nil
seriesCooldownUntil = now + 0.45
comboLockUntil = now + 0.22
return true
end
if not tryCastPointAbility(nil, ability, castPos, now) then
return false
end
series.remaining = series.remaining - 1
debugLog(
nil,
"series",
(series.kind .. " cast, left=") .. tostring(series.remaining),
0.15
)
if series.remaining <= 0 then
spellSeries = nil
seriesCooldownUntil = now + BETWEEN_SERIES_PAUSE
comboLockUntil = now + WITHIN_SERIES_GAP
debugLog(
nil,
"series",
("end -> pause " .. tostring(BETWEEN_SERIES_PAUSE)) .. "s",
0.2
)
else
comboLockUntil = now + WITHIN_SERIES_GAP
end
return true
end
function tryExecuteQueuedCoil(self, now)
if queuedCoilCount <= 0 or now < queuedCoilAt then
return false
end
if not cachedTarget or cachedTarget:IsNull() or not cachedTarget:IsAlive() then
debugLog(nil, "queued", "target invalid -> clear queue", 0.5)
queuedCoilCount = 0
queuedCoilInitial = 0
queuedCoilSkipStreak = 0
return false
end
if not abilities.coilWave or not abilities.coilWave:IsFullyCastable() then
debugLog(nil, "queued", "coil not castable -> postpone", 0.35)
queuedCoilAt = now + 0.2
return false
end
local castIndex = queuedCoilInitial - queuedCoilCount
local forwardUnits = castIndex == 0 and AIM_FORWARD_FIRST_CAST or AIM_FORWARD_FOLLOW_CAST
local anchor = getAimInFrontOfPredictedTarget(nil, cachedTarget, PREDICT_LEAD_SPELLS, forwardUnits)
local pos = addCoilWaveAimJitter(nil, anchor, castIndex)
if not canCoilWaveHitAnyEnemy(nil, thisEntity, pos) then
queuedCoilSkipStreak = queuedCoilSkipStreak + 1
if queuedCoilSkipStreak >= MAX_QUEUED_COIL_SKIP_STREAK then
debugLog(nil, "queued", "coil abort queue — нет целей в полосе слишком долго", 0.35)
queuedCoilCount = 0
queuedCoilInitial = 0
queuedCoilSkipStreak = 0
return false
end
debugLog(nil, "queued", "coil skip — никого в полосе, откладываем", 0.3)
queuedCoilAt = now + 0.35
return true
end
if not tryCastPointAbility(nil, abilities.coilWave, pos, now) then
return false
end
queuedCoilSkipStreak = 0
queuedCoilCount = queuedCoilCount - 1
queuedCoilAt = now + BETWEEN_CAST_GAP
aiNextCastAt.coilWave = math.max(aiNextCastAt.coilWave, now + BETWEEN_CAST_GAP)
comboLockUntil = now + BETWEEN_CAST_GAP
debugLog(
nil,
"queued",
"coil fired, left=" .. tostring(queuedCoilCount),
0.2
)
return true
end
abilities = {}
nextTargetSearchAt = 0
comboLockUntil = 0
queuedCoilAt = 0
queuedCoilCount = 0
queuedCoilInitial = 0
queuedCoilSkipStreak = 0
lastOrderAt = 0
seriesCooldownUntil = 0
SERIES_ROTATION = {"triple", "wave", "beam", "cross"}
seriesRotationIndex = 0
SERIES_TRIPLE_MIN = 2
SERIES_TRIPLE_MAX = 4
SERIES_WAVE_MIN = 2
SERIES_WAVE_MAX = 4
SERIES_BEAM_MIN = 2
SERIES_BEAM_MAX = 4
SERIES_CROSS_MIN = 1
SERIES_CROSS_MAX = 2
BETWEEN_SERIES_PAUSE = 1.6
BETWEEN_CAST_GAP = 1
WITHIN_SERIES_GAP = BETWEEN_CAST_GAP
TIME_WALK_MIN_DISTANCE = 2000
PREDICT_LEAD_SPELLS = 0.42
PREDICT_LEAD_TIME_WALK = 0.55
PREDICT_MAX_LEAD_UNITS = 480
HERO_ACQUIRE_RADIUS = 12000
COIL_JITTER_DIST_MIN = 60
COIL_JITTER_DIST_MAX = 200
COIL_JITTER_FOLLOW_MIN = 90
COIL_JITTER_FOLLOW_MAX = 260
AIM_FORWARD_FIRST_CAST = 100
AIM_FORWARD_FOLLOW_CAST = 50
MAX_QUEUED_COIL_SKIP_STREAK = 5
TIME_WALK_STANDOFF_MIN = 380
TIME_WALK_STANDOFF_MAX = 560
aiNextCastAt = {timeWalk = 0, tripleCoil = 0, coilWave = 0}
nextIdleTripleAt = 0
requiemCommitPending = false
DEBUG_AI = true
nextDebugStateAt = 0
debugTagNextAt = {}
registerEntityFunction(
nil,
"Spawn",
function()
if not IsServer() or not thisEntity then
return
end
applyBossHudHealthBar(nil, thisEntity, BOSS_NEVERMORE_NAME_TOKEN)
applyNevermorePhaseTerrorWave(nil, thisEntity)
applyNevermoreRequiemGate(nil, thisEntity)
thisEntity:AddNewModifier(
thisEntity,
getModifierSourceAbility(nil, thisEntity),
modifier_boss_nevermore_debuff_immune.name,
{}
)
nevermoreRegisterPhaseRequiemHook(
nil,
function()
requiemCommitPending = false
spellSeries = nil
queuedCoilCount = 0
queuedCoilInitial = 0
queuedCoilSkipStreak = 0
seriesCooldownUntil = GameRules:GetGameTime() + 2
end
)
abilities.timeWalk = thisEntity:FindAbilityByName("boss_nevermore_time_walk") or nil
abilities.requiem = thisEntity:FindAbilityByName("boss_nevermore_requiem_barrage") or nil
abilities.tripleCoil = thisEntity:FindAbilityByName("boss_nevermore_triple_coil_aoe") or nil
abilities.coilWave = thisEntity:FindAbilityByName("boss_nevermore_coil_wave") or nil
abilities.coilBeam = thisEntity:FindAbilityByName("boss_nevermore_coil_beam") or nil
abilities.hubCrossburst = thisEntity:FindAbilityByName("boss_nevermore_hub_crossburst") or nil
thisEntity:SetContextThink("NevermoreBossThink", NevermoreBossThink, 0.25)
end
)
REQUIEM_RARE_ROLL_MAX = 4
DEBUG_NEVERMORE_PURGE = true
PURGE_LOG_MAX_PER_CALL = 40
NEVERMORE_PURGE_SKIP_MODIFIER_NAMES = {[modifier_boss_nevermore_debuff_immune.name] = true, [modifier_boss_nevermore_phase_terror_wave.name] = true, [modifier_boss_nevermore_requiem_gate.name] = true, modifier_no_healthbar = true}
return ____exports