landsandboat/scripts/globals/pirates.lua
Kipling 85fe7e31ba Pirates Spawning Behavior
Co-authored-by: Xaver-DaRed <Xaver-DaRed@users.noreply.github.com>
2026-06-11 09:07:12 +02:00

257 lines
9.4 KiB
Lua

-----------------------------------
-- Ship bound for [Mhaura/Selbina] Pirates helpers
-- NOTE: Careful with queues as they don't resolve until a zone wakes from sleep, potentially having mismatched timing. Timers are fine.
-----------------------------------
xi = xi or {}
xi.pirates = xi.pirates or {}
-----------------------------------
local actions =
{
ARRIVING = 0,
ARRIVE = 1,
PIRATES_ARRIVE = 2,
MOBS_SPAWN = 3,
PIRATES_RETREAT = 4,
DEPART = 5,
DEPARTING = 6,
}
-- Times are minutes after midnight for first cycle. Cycle is 480 minutes.
local piratesSchedule =
{
{ endTime = utils.timeStringToMinutes('01:10'), action = actions.ARRIVING },
{ endTime = utils.timeStringToMinutes('01:30'), action = actions.ARRIVE },
{ endTime = utils.timeStringToMinutes('01:32'), action = actions.PIRATES_ARRIVE },
{ endTime = utils.timeStringToMinutes('01:34'), action = actions.MOBS_SPAWN },
{ endTime = utils.timeStringToMinutes('04:20'), action = actions.PIRATES_RETREAT },
{ endTime = utils.timeStringToMinutes('04:27'), action = actions.DEPART },
{ endTime = utils.timeStringToMinutes('04:48'), action = actions.DEPARTING },
}
local piratesData =
{
-- Pirate ship is on left side of boat.
[xi.zone.SHIP_BOUND_FOR_SELBINA_PIRATES] =
{
{
startPos = { x = -33.601, y = -7.16, z = 13.37, rotation = 0 },
standingPos = { x = -21.900, y = -7.16, z = 10.46, rotation = 0 },
},
{
startPos = { x = -29.728, y = -7.16, z = 1.30, rotation = 0 },
standingPos = { x = -21.900, y = -7.16, z = 6.59, rotation = 0 },
},
{
startPos = { x = -29.602, y = -7.16, z = -2.47, rotation = 0 },
standingPos = { x = -21.900, y = -7.16, z = 2.10, rotation = 0 },
},
},
-- Pirate ship is on right side of boat.
[xi.zone.SHIP_BOUND_FOR_MHAURA_PIRATES] =
{
{
startPos = { x = 33.601, y = -7.16, z = 13.37, rotation = 128 },
standingPos = { x = 21.900, y = -7.16, z = 10.46, rotation = 128 },
},
{
startPos = { x = 29.728, y = -7.16, z = 1.30, rotation = 128 },
standingPos = { x = 21.900, y = -7.16, z = 6.59, rotation = 128 },
},
{
startPos = { x = 29.602, y = -7.16, z = -2.47, rotation = 128 },
standingPos = { x = 21.900, y = -7.16, z = 2.10, rotation = 128 },
},
},
}
-- This ride's NM: Blackbeard sails the Selbina route, Silverhook the Mhaura route.
local function getNMId(zoneId)
if zoneId == xi.zone.SHIP_BOUND_FOR_SELBINA_PIRATES then
return zones[zoneId].mob.BLACKBEARD
end
return zones[zoneId].mob.SILVERHOOK
end
-- Clear the deck of pirate mobs: disable respawns, despawn idle ones, and leave any still in combat to be finished off (they won't respawn).
-- Used at retreat and before a fresh ride spawns
local function clearPirates(zoneId)
local ID = zones[zoneId]
local mobIdTable = { ID.mob.SHIP_WIGHT, getNMId(zoneId) }
for _, mobId in ipairs(ID.mob.CROSSBONES) do
table.insert(mobIdTable, mobId)
end
for _, mobId in ipairs(mobIdTable) do
local mob = GetMobByID(mobId)
if mob then
mob:setRespawnTime(0) -- Stop the waves / Cancel any pending respawn.
if mob:isSpawned() and not mob:isEngaged() then
DespawnMob(mobId) -- Engaged mobs stay until killed, then won't return.
end
end
end
end
-- Calls itself via timer until the npc is hidden.
local function summonAnimations(npc, rotation, offset)
if npc:getStatus() == xi.status.DISAPPEAR then
return
end
if not npc:isFollowingPath() then
local pos = npc:getPos()
local currentTime = GetSystemTime()
if npc:getLocalVar('initialNpcState') == 1 then
npc:setLocalVar('initialNpcState', 0)
-- rotate to face the player boat
npc:setPos(pos.x, pos.y, pos.z, rotation)
-- first summoning rotation happens in order of NPC ID
npc:setLocalVar('summonStartTime', currentTime + (offset - 1) * 2)
end
local summonStartTime = npc:getLocalVar('summonStartTime')
if summonStartTime ~= 0 and summonStartTime <= currentTime then
npc:setLocalVar('summonStartTime', 0)
npc:setLocalVar('summonEndTime', currentTime + math.random(1, 2))
npc:entityAnimationPacket(xi.animationString.CAST_SUMMONER_START)
end
local summonEndTime = npc:getLocalVar('summonEndTime')
if summonEndTime ~= 0 and summonEndTime <= currentTime then
npc:setLocalVar('summonStartTime', currentTime + math.random(4 + offset, 10))
npc:setLocalVar('summonEndTime', 0)
npc:entityAnimationPacket(xi.animationString.CAST_SUMMONER_STOP)
end
-- No more animations and npc is done pathing
if summonEndTime == 0 and summonStartTime == 0 then
npc:setStatus(xi.status.DISAPPEAR)
end
end
if npc:getStatus() == xi.status.DISAPPEAR then
return
end
-- check again in 1.2s (pirates summon animation can last from 1s to 2s)
npc:timer(1200, function(npcArg)
summonAnimations(npcArg, rotation, offset)
end)
end
xi.pirates.setupPirateNPCSchedule = function(npc)
npc:initNpcAi()
-- Create triggers for every stage of the encounter on each Pirate NPC.
for _, eventData in ipairs(piratesSchedule) do
npc:addPeriodicTrigger(eventData.action, 480, eventData.endTime)
end
end
-- Called on every NPC periodic trigger, which is mapped 1-1 to the schedule table, with triggerId == action
xi.pirates.pirateNPCTimeTrigger = function(npc, triggerId, zoneKey)
local pirateZone = npc:getZone()
if not pirateZone then
return
end
local pirateNPCs = zones[pirateZone:getID()].npc.PIRATES
local pirateIdx = 0
for i, npcId in ipairs(pirateNPCs) do
if npcId == npc:getID() then
pirateIdx = i
break
end
end
local pirateData = piratesData[zoneKey][pirateIdx]
if not pirateData then
return
end
-- Pirates appear and run to position
if triggerId == actions.PIRATES_ARRIVE then
if pirateIdx == 2 then
-- middle pirate has chance to wear a verm cloak, which then means the pirate encounter _might_ have the NM spawn
local hasVermCloak = math.random(1, 100) <= 10
npc:setModelId(hasVermCloak and 47 or 8195, xi.slot.BODY) -- 47 = verm cloak body, 8195 = default body
pirateZone:setLocalVar('nmCanSpawn', hasVermCloak and 1 or 0) -- 1 = NM still eligible; cleared to 0 once it spawns
end
npc:setPos(pirateData.startPos)
npc:setStatus(xi.status.NORMAL)
npc:clearPath()
npc:pathTo(pirateData.standingPos.x, pirateData.standingPos.y, pirateData.standingPos.z, xi.path.flag.RUN + xi.path.flag.WALLHACK)
-- Indicates we need to rotate NPC after pathing completes
npc:setLocalVar('initialNpcState', 1)
summonAnimations(npc, pirateData.standingPos.rotation, pirateIdx)
-- Retreat.
elseif triggerId == actions.PIRATES_RETREAT then
local summonEndTime = npc:getLocalVar('summonEndTime')
-- No more animations will happen and recursive function self destructs
npc:setLocalVar('summonStartTime', 0)
npc:setLocalVar('summonEndTime', 0)
if summonEndTime > 0 then
npc:entityAnimationPacket(xi.animationString.CAST_SUMMONER_STOP)
end
npc:pathTo(pirateData.startPos.x, pirateData.startPos.y, pirateData.startPos.z, xi.path.flag.RUN + xi.path.flag.WALLHACK)
-- Just in case summonAnimations didn't set status
elseif triggerId == actions.DEPART then
npc:clearPath()
npc:setStatus(xi.status.DISAPPEAR)
end
xi.pirates.zoneStateChange(pirateZone, triggerId)
end
xi.pirates.zoneStateChange = function(zone, action)
-- change the zone's state once per action cycle (this function is called by each NPC)
if zone:getLocalVar('currPiratesAction') == action then
return
end
zone:setLocalVar('currPiratesAction', action)
local zoneId = zone:getID()
local ID = zones[zoneId]
if action == actions.MOBS_SPAWN then
-- clear any mobs lingering from a previous ride before summoning fresh ones
clearPirates(zoneId)
-- the skeletons the pirate NPCs are "summoning" onto the deck
for _, mobId in ipairs(ID.mob.CROSSBONES) do
local crossbones = GetMobByID(mobId)
if crossbones then
crossbones:setRespawnTime(1)
end
end
if zone:getLocalVar('nmCanSpawn') == 1 and math.random(1, 100) <= 75 then
-- HQ ride, 75%: NM appears from the start
local nm = GetMobByID(getNMId(zoneId))
if nm then
nm:setRespawnTime(1)
zone:setLocalVar('nmCanSpawn', 0) -- NM is up; no longer eligible to spawn
end
else
-- normal ride, or the 25% placeholder Wight on an HQ ride
local wight = GetMobByID(ID.mob.SHIP_WIGHT)
if wight then
wight:setRespawnTime(1)
end
end
elseif action == actions.PIRATES_RETREAT then
clearPirates(zoneId)
end
end