---@diagnostic disable: inject-field ENGINEER_MVM_BUILDING_HEALTH_MULTIPLIER = 2 ENGINEER_MVM_TELEPORTER_UBER_DURATION = 5 local activeEngieBotTeleporters = { [TEAM_RED] = {}, [TEAM_BLUE] = {}, } local lastTeleportTime = -1 function CheckSpawnTeleport(botSpawn, whereName) local teleportLocation local chosenTeleporter for _, teleporterInfo in pairs(activeEngieBotTeleporters[TEAM_BLUE]) do local canTeleport if not whereName then canTeleport = true else canTeleport = TableContains(teleporterInfo.TeleportWhereList, "All") or TableContains(teleporterInfo.TeleportWhereList, whereName) end if canTeleport then teleportLocation = teleporterInfo.TeleportLocation chosenTeleporter = teleporterInfo.TeleporterEnt end end if not teleportLocation then return end botSpawn:SetAbsOrigin(teleportLocation) if CurTime() - lastTeleportTime > 0.1 then chosenTeleporter:PlaySound("MVM.Robot_Teleporter_Deliver") if botSpawn.m_iClass ~= TF_CLASS_SPY then local uberTime = ENGINEER_MVM_TELEPORTER_UBER_DURATION botSpawn:AddCond(TF_COND_INVULNERABLE, uberTime) botSpawn:AddCond(TF_COND_INVULNERABLE_WEARINGOFF, uberTime) end lastTeleportTime = CurTime() end end function GetThirdHintOrigin(nestName) local sentryNestEnt local teleNestEnt for _, entity in pairs(ents.FindAllByName(nestName)) do local classname = entity:GetClassname() if classname == "bot_hint_sentrygun" then sentryNestEnt = entity elseif classname == "bot_hint_teleporter_exit" then teleNestEnt = entity end end if not sentryNestEnt or not teleNestEnt then print("couldn't find sentry nest or tele nest") return end local pos1 = sentryNestEnt:GetAbsOrigin() local pos2 = teleNestEnt:GetAbsOrigin() local mid = (pos1 + pos2) * 0.5 local edge = pos2 - pos1 local perpendicularVector = Vector(-edge.y, edge.x, 0):Normalize() local offsetDist = 70 local pos3 = mid + (perpendicularVector * offsetDist) return pos3 end local function getClosestDispenserScreens(origin) local closestScreen local secondClosestScreen local closestDistance = math.huge local secondClosestDistance = math.huge for _, screen in pairs(ents.FindAllByClass("vgui_screen")) do if screen.m_nOverlayMaterial == 2 then local dist = origin:Distance(screen:GetAbsOrigin()) if dist < closestDistance then -- Shift the current closest down to second place secondClosestScreen = closestScreen secondClosestDistance = closestDistance closestScreen = screen closestDistance = dist elseif dist < secondClosestDistance then secondClosestScreen = screen secondClosestDistance = dist end end end return closestScreen, secondClosestScreen end function BuildDispenser(botSpawn, botTemplate, buildPos, buildAngles) local INITIAL_LVL1_CONSTRUCTION = 21 local LVL2_UPGRADE = 1.2 local LVL3_UPGRADE = 1.1 print("build dispenser") local dispenser = ents.CreateWithKeys("obj_dispenser", { TeamNum = botSpawn.m_iTeamNum, defaultupgrade = 2, }) dispenser.customSpawn = true dispenser:AcceptInput("SetBuilder", nil, botSpawn) dispenser:SetAbsOrigin(buildPos) dispenser:SetAbsAngles(buildAngles) dispenser.m_iState = 0 dispenser:RunScriptCode("self.SetModel(`models/buildables/dispenser.mdl`)") dispenser:RunScriptCode("self.ResetSequence(1)", dispenser) dispenser:AcceptInput("Disable", 1) dispenser["$radiusmult"] = 10 local screen1, screen2 = getClosestDispenserScreens(buildPos) if screen1 then screen1:AcceptInput("SetInactive", 1) end if screen2 then screen2:AcceptInput("SetInactive", 1) end local playbackUpdateTimer playbackUpdateTimer = timer.Create(0, function() if not IsValid(dispenser) then timer.Stop(playbackUpdateTimer) playbackUpdateTimer = nil return end dispenser.m_flPlaybackRate = 0.4 end, 0) timer.Simple(INITIAL_LVL1_CONSTRUCTION, function() if not IsValid(dispenser) then return end if playbackUpdateTimer then timer.Stop(playbackUpdateTimer) playbackUpdateTimer = nil end dispenser:RunScriptCode("self.SetModel(`models/buildables/dispenser_lvl2.mdl`)") dispenser:RunScriptCode("self.ResetSequence(0)", dispenser) timer.Simple(LVL2_UPGRADE, function() if not IsValid(dispenser) then return end dispenser:RunScriptCode("self.SetModel(`models/buildables/dispenser_lvl3.mdl`)") dispenser:RunScriptCode("self.ResetSequence(0)", dispenser) timer.Simple(LVL3_UPGRADE, function() if not IsValid(dispenser) then return end dispenser:AcceptInput("Enable", 1) if screen1 then screen1:AcceptInput("SetActive", 1) end if screen2 then screen2:AcceptInput("SetActive", 1) end dispenser.m_iState = 1 dispenser:RunScriptCode("self.SetModel(`models/buildables/dispenser_lvl3_light.mdl`)") end) end) end) end function BuildExtraSentry(botSpawn, botTemplate, buildPos, buildAngles) local CONSTRUCTION_TIME = 5.25 print("build disposable") local sentry = ents.CreateWithKeys("obj_sentrygun", { TeamNum = botSpawn.m_iTeamNum, defaultupgrade = 0, spawnflags = 64, }) sentry.customSpawn = true sentry:AcceptInput("SetBuilder", nil, botSpawn) sentry:SetAbsOrigin(buildPos) sentry:SetAbsAngles(buildAngles) sentry.m_iState = 0 sentry:RunScriptCode("self.SetModel(`models/buildables/sentry1_heavy.mdl`); self.SetBodygroup(3, 1)") sentry:RunScriptCode("self.SetModelScale(0.6, 0.0)") timer.Simple(0.15, function() for _ = 1, 2 do sentry:RunScriptCode("self.ResetSequence(2)", sentry) end end) sentry.m_bDisposableBuilding = 1 local constructionTimer local destroyedCallback destroyedCallback = sentry:AddCallback(ON_REMOVE, function() pcall(function() timer.Stop(constructionTimer) end) ---@diagnostic disable-next-line: missing-parameter sentry:RemoveCallback(destroyedCallback) end) constructionTimer = timer.Simple(CONSTRUCTION_TIME, function() if not IsValid(sentry) then return end if not IsValid(sentry) then return end sentry.m_iState = 1 sentry:RunScriptCode("self.SetModel(`models/buildables/sentry1.mdl`)") end) end -- debug -- timer.Simple(1, function() -- local origin = Vector(-2062, 0, -63) -- local chosenPlayer -- for _, player in pairs(ents.GetAllPlayers()) do -- if not player:IsRealPlayer() then -- chosenPlayer = player -- end -- end -- -- BuildExtraSentry(chosenPlayer, {}, origin, Vector(0, 0, 0)) -- -- BuildTeleporter(chosenPlayer, {}, origin, Vector(0, 0, 0)) -- BuildDispenser(chosenPlayer, {}, origin, Vector(0, 0, 0)) -- end) function BuildTeleporter(owner, teleportWhereList, teleporterOrigin, teleporterAngles, associatedEngineerNest) local CONSTRUCTION_TIME = 21 local TELEPORTER_TEAM = TEAM_BLUE local teleporter = ents.CreateWithKeys("obj_teleporter", { TeamNum = TELEPORTER_TEAM, teleporterType = 2, }) timer.Simple(0.1, function() -- delay to force hide particle teleporter:SetModelSpecial("models/buildables/teleporter.mdl") teleporter:RunScriptCode("self.ResetSequence(1)") end) -- prevent owner's sentry from destroying this teleporter:AddCallback(ON_DAMAGE_RECEIVED_PRE, function(_ent, damageInfo) if not damageInfo.Attacker then return end if damageInfo.Attacker == owner then damageInfo.Damage = 0 return true end end) -- prevent bots from being stuck in teleporter teleporter:AddCallback(ON_SHOULD_COLLIDE, function(_ent, other) if not other:IsPlayer() then return end if other.m_iTeamNum == TELEPORTER_TEAM then return false end end) teleporter:SetAbsOrigin(teleporterOrigin) teleporter:SetAbsAngles(teleporterAngles) teleporter.m_hBuilder = owner teleporter:PlaySound("Engineer.MVM_AutoBuildingTeleporter02") -- teleporter going up ---@diagnostic disable-next-line: undefined-field local maxHealth = teleporter.m_iMaxHealth * ENGINEER_MVM_BUILDING_HEALTH_MULTIPLIER timer.Simple(0.1, function() teleporter:AcceptInput("SetHealth", maxHealth) end) local constructionTimer local destroyedCallback destroyedCallback = teleporter:AddCallback(ON_REMOVE, function() pcall(function() timer.Stop(constructionTimer) end) ---@diagnostic disable-next-line: missing-parameter teleporter:RemoveCallback(destroyedCallback) end) local playbackUpdateTimer playbackUpdateTimer = timer.Create(0, function() if not IsValid(teleporter) then timer.Stop(playbackUpdateTimer) playbackUpdateTimer = nil return end -- if curCycle >= 1 then -- teleporter:RunScriptCode(("self.SetCycle(%f)"):format(1)) -- timer.Stop(playbackUpdateTimer) -- playbackUpdateTimer = nil -- return -- end -- curCycle = curCycle + 0.001 -- teleporter:RunScriptCode(("self.SetCycle(%f)"):format(curCycle)) teleporter.m_flPlaybackRate = 0.4 end, 0) constructionTimer = timer.Simple(CONSTRUCTION_TIME, function() if not IsValid(teleporter) then return end if playbackUpdateTimer then timer.Stop(playbackUpdateTimer) playbackUpdateTimer = nil end -- (royal): right now, if the RTR is on the tele after construction finishes, it will not work even if sapper is then removed -- as engie bot will never remove the sapper if teleporter.m_flPercentageConstructed ~= 0 then print("teleporter is currently being sapped by red tape recorder") return end teleporter:Activate() teleporter:SetModelSpecial("models/buildables/teleporter_light.mdl") teleporter:RunScriptCode("self.ResetSequence(2)") local tfGamerules = ents.FindByClass("tf_gamerules") if tfGamerules then tfGamerules:AcceptInput("PlayVO", "mvm/mvm_tele_activate.wav") end if associatedEngineerNest then StartEngieNestParticle(associatedEngineerNest, teleporter) end -- force trigger teleporter particles teleporter.m_iState = 6 local teleporterHandle = teleporter:GetHandleIndex() activeEngieBotTeleporters[TELEPORTER_TEAM][teleporterHandle] = { TeleportWhereList = teleportWhereList, TeleportLocation = teleporter:GetAbsOrigin() + Vector(0, 0, 5), TeleporterEnt = teleporter, } local teleporterTemplate = { ClassIcon = "teleporter", } -- (royal): valve teleporter icon doesn't have a transparent background AddToExtraIcon(teleporterTemplate, false) teleporter:AddCallback(ON_REMOVE, function() activeEngieBotTeleporters[TELEPORTER_TEAM][teleporterHandle] = nil RemoveFromExtraIcon(teleporterTemplate) end) end) return teleporter end local activeNestParticles = {} function StartEngieNestParticle(nestEntity, teleporter) local nestHandle = nestEntity:GetHandleIndex() if activeNestParticles[nestHandle] then return end local particleSystem = ents.CreateWithKeys("info_particle_system", { start_active = false, effect_name = "teleporter_mvm_bot_persist", }) particleSystem:SetAbsOrigin(nestEntity:GetAbsOrigin()) particleSystem:AcceptInput("Start") activeNestParticles[nestHandle] = particleSystem if teleporter then teleporter:AddCallback(ON_REMOVE, function() StopEngieNestParticle(nestHandle) end) end end function StopEngieNestParticle(nestHandle) local particleSystem = activeNestParticles[nestHandle] if not particleSystem then return end if not IsValid(particleSystem) then activeNestParticles[nestHandle] = nil return end particleSystem:AcceptInput("Stop") timer.Simple(1, function() if IsValid(particleSystem) then particleSystem:Remove() end end) activeNestParticles[nestHandle] = nil end function WalkToAndTryBuildTeleporter(botSpawn, botTemplate, builtBuildings) local teleHints = ents.FindAllByClass("bot_hint_teleporter_exit") if #teleHints <= 0 then return end local origin = botSpawn:GetAbsOrigin() local closestTeleHint local closestDistance = math.huge for _, teleporterHint in pairs(teleHints) do local distSqr = origin:Distance(teleporterHint:GetAbsOrigin()) if distSqr < closestDistance then closestDistance = distSqr closestTeleHint = teleporterHint end end local reachedTimer reachedTimer = timer.Create(0.015, function() if not botSpawn:IsAlive() then timer.Stop(reachedTimer) return end local distance = botSpawn:GetAbsOrigin():Distance(closestTeleHint:GetAbsOrigin()) if distance <= 50 then botSpawn:BotCommand("stop interrupt action") timer.Simple(0.1, function() -- retry to make sure botSpawn:BotCommand("stop interrupt action") end) timer.Stop(reachedTimer) local engineerNest for _, entity in pairs(ents.FindAllByName(closestTeleHint:GetName())) do if entity:GetClassname() == "bot_hint_engineer_nest" then engineerNest = entity break end end local teleOrigin = closestTeleHint:GetAbsOrigin() local traceResult = util.Trace({ start = teleOrigin, mask = MASK_SOLID, collisiongroup = COLLISION_GROUP_DEBRIS, distance = 500, angles = Vector(90, 0, 0), mins = Vector(0, 0, 0), maxs = Vector(0, 0, 0), }) if traceResult.Hit then teleOrigin = traceResult.HitPos end -- spawn teleporter local teleporter = BuildTeleporter( botSpawn, botTemplate.TeleportWhereMultiple or { botTemplate.TeleportWhere }, teleOrigin, closestTeleHint:GetAbsAngles(), engineerNest ) builtBuildings[teleporter] = true teleporter:AddCallback(ON_REMOVE, function() builtBuildings[teleporter] = nil end) end end, 0) -- (royal): $OnActionDone doesn't seem like it ever fires local teleHintPos = closestTeleHint:GetAbsOrigin() -- local interruptAction = ("interrupt_action -posent %s -lookposent %s -waituntildone"):format( -- closestTeleHint:GetName(), -- closestTeleHint:GetName() -- ) -- timeout after 3 seconds local interruptAction = ("interrupt_action -pos %s %s %s -lookpos %s %s %s -duration 3"):format( teleHintPos[1], teleHintPos[2], teleHintPos[3], teleHintPos[1], teleHintPos[2], teleHintPos[3] ) botSpawn:BotCommand(interruptAction) end -- (royal): this is done instead of using "building max level" 1 as that comes with visual issues on the mini function HandleMiniSentry(botSpawn, sentry) sentry["$rangemult"] = 0 local waitingForDeployed waitingForDeployed = timer.Create(0.015, function() if not IsValid(sentry) then timer.Stop(waitingForDeployed) return end if sentry.m_bBuilding == 1 then return end timer.Stop(waitingForDeployed) sentry["$cannotbesapped"] = 1 sentry.m_hBuilder = nil sentry.m_bMiniBuilding = 0 local replacement = ents.CreateWithKeys("obj_sentrygun", { TeamNum = botSpawn.m_iTeamNum, defaultupgrade = 0, spawnflags = 64, }) replacement.customSpawn = true replacement:AcceptInput("SetBuilder", nil, botSpawn) replacement:SetAbsOrigin(sentry:GetAbsOrigin()) replacement:SetAbsAngles(sentry:GetAbsAngles()) local lastHealth = sentry.m_iHealth replacement:AcceptInput("SetHealth", sentry.m_iMaxHealth) replacement.m_iHealth = lastHealth sentry:AcceptInput("Hide", 1) sentry:RunScriptCode("activator.SetSolid(0)", sentry) sentry:RunScriptCode("activator.SetModelScale(0.0, 0.0)", sentry) sentry:AddCallback(ON_DAMAGE_RECEIVED_PRE, function() return false end) replacement:AddCallback(ON_REMOVE, function() sentry:Remove() end) end, 0) end function EngieBotSpawned(botSpawn, botTemplate, callbacks) local builtBuildings = {} -- doesn't include sentry local teleBuilt = false local wrench = botSpawn:GetPlayerItemBySlot(LOADOUT_POSITION_MELEE) local shouldBuildMiniSentry = wrench:GetAttributeValue("mod wrench builds minisentry", true) ~= nil local sentryBuiltCallback sentryBuiltCallback = ents.AddCreateCallback("obj_sentrygun", function(sentry) timer.Simple(0.1, function() if not IsValid(sentry) then return end if sentry.customSpawn then return end local owner = sentry.m_hBuilder if not owner or not IsValid(owner) or not owner:IsAlive() then ents.RemoveCreateCallback(sentryBuiltCallback) return end if owner ~= botSpawn then return end -- if shouldBuildMiniSentry then -- -- (royal): as this sentry will be hidden, we disable its mini flag so that the model won't have the associated particle -- sentry.m_bMiniBuilding = 0 -- end local waitingForPlacedTimer waitingForPlacedTimer = timer.Create(0.015, function() if not IsValid(sentry) then timer.Stop(waitingForPlacedTimer) return end if sentry.m_bPlacing == 1 then return end -- placed timer.Stop(waitingForPlacedTimer) if shouldBuildMiniSentry then HandleMiniSentry(botSpawn, sentry) end if botTemplate.TeleportWhere then if not teleBuilt and botSpawn:IsAlive() then WalkToAndTryBuildTeleporter(botSpawn, botTemplate, builtBuildings) teleBuilt = true end end local shouldGetThirdHint = false if botTemplate.BuildDisposableSentry or botTemplate.BuildDispenser then shouldGetThirdHint = true end -- TODO: queue movement if shouldGetThirdHint then local origin = botSpawn:GetAbsOrigin() local closestHint local closestDistance = math.huge for _, teleporterHint in pairs(ents.FindAllByClass("bot_hint_engineer_nest")) do local distSqr = origin:Distance(teleporterHint:GetAbsOrigin()) if distSqr < closestDistance then closestDistance = distSqr closestHint = teleporterHint end end if closestHint then local thirdHintPos = GetThirdHintOrigin(closestHint:GetName()) if thirdHintPos then if botTemplate.BuildDisposableSentry then BuildExtraSentry(botSpawn, botTemplate, thirdHintPos, closestHint:GetAbsAngles()) elseif botTemplate.BuildDispenser then BuildDispenser(botSpawn, botTemplate, thirdHintPos, closestHint:GetAbsAngles()) end end end end end, 0) end) end) callbacks.engieBotDied = botSpawn:AddCallback(ON_DEATH, function() local tfGamerules = ents.FindByClass("tf_gamerules") if not tfGamerules then return end local hasTeleporter = false for building in pairs(builtBuildings) do if building:GetClassname() == "obj_teleporter" and IsValid(building) then hasTeleporter = true break end end if hasTeleporter then tfGamerules:AcceptInput("PlayVO", "Announcer.MVM_An_Engineer_Bot_Is_Dead_But_Not_Teleporter") else tfGamerules:AcceptInput("PlayVO", "Announcer.MVM_An_Engineer_Bot_Is_Dead") end if sentryBuiltCallback then ents.RemoveCreateCallback(sentryBuiltCallback) sentryBuiltCallback = nil end end) end local function waveInit(_wave) for _, nestHandle in pairs(activeNestParticles) do StopEngieNestParticle(nestHandle) end activeNestParticles = {} activeEngieBotTeleporters = { [TEAM_RED] = {}, [TEAM_BLUE] = {}, } end if OnWaveInitFuncs then table.insert(OnWaveInitFuncs, waveInit) else function OnWaveInit(wave) waveInit(wave) end end