981 lines
37 KiB
Lua
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
|