landsandboat/scripts/globals/barge.lua
2026-07-18 08:21:33 -05:00

524 lines
20 KiB
Lua

-----------------------------------
-- Carpenters' Landing Barge
-- https://www.bg-wiki.com/ffxi/Phanauet_Channel
-----------------------------------
xi = xi or {}
xi.barge = xi.barge or {}
local channelID = zones[xi.zone.PHANAUET_CHANNEL]
local landingID = zones[xi.zone.CARPENTERS_LANDING]
-- OOS = out of service
-- ARRIVE action is given until the boat has fully docked
-- DEPART action is given until the boat has fully disppaered in the distance
local actions =
{
ARRIVE = 0,
DEPART = 1,
ARRIVE_OOS = 2,
DEPART_OOS = 3,
}
-- Barge destinations
local destinations =
{
SOUTH_LANDING = 0,
CENTRAL_LANDING_EMFEA = 1,
NORTH_LANDING = 2,
CENTRAL_LANDING = 3,
}
-- Locations for timekeeper NPCs
xi.barge.location =
{
NORTH_LANDING = 1,
CENTRAL_LANDING = 2,
SOUTH_LANDING = 3,
PHANAUET_CHANNEL = 4,
}
-- times are minutes past midnight, since the cycle is every 1440 minutes, and aligns with the transports.sql entries
-- time for arrivalStart: time_offset
-- time for arrivalEnd: time_offset + time_anim_arrive
-- time for departStart: time_offset + time_anim_arrive + time_waiting
-- time for departEnd: time_offset + time_anim_arrive + time_waiting + time_anim_depart
-- time for ride in the channel to end: time_offset + time_anim_arrive - 10
local bargeSchedule =
{
[xi.barge.location.NORTH_LANDING] =
{
{ endTime = utils.timeStringToMinutes('16:00'), action = actions.ARRIVE_OOS, route = destinations.CENTRAL_LANDING },
{ endTime = utils.timeStringToMinutes('16:21'), action = actions.DEPART_OOS, route = destinations.CENTRAL_LANDING },
{ endTime = utils.timeStringToMinutes('16:45'), action = actions.ARRIVE, route = destinations.CENTRAL_LANDING },
{ endTime = utils.timeStringToMinutes('17:40'), action = actions.DEPART, route = destinations.CENTRAL_LANDING },
},
[xi.barge.location.CENTRAL_LANDING] =
{
{ endTime = utils.timeStringToMinutes('04:35'), action = actions.ARRIVE, route = destinations.SOUTH_LANDING },
{ endTime = utils.timeStringToMinutes('05:25'), action = actions.DEPART, route = destinations.SOUTH_LANDING },
{ endTime = utils.timeStringToMinutes('19:15'), action = actions.ARRIVE, route = destinations.SOUTH_LANDING },
{ endTime = utils.timeStringToMinutes('20:05'), action = actions.DEPART, route = destinations.SOUTH_LANDING },
},
[xi.barge.location.SOUTH_LANDING] =
{
{ endTime = utils.timeStringToMinutes('00:15'), action = actions.ARRIVE, route = destinations.CENTRAL_LANDING_EMFEA },
{ endTime = utils.timeStringToMinutes('01:05'), action = actions.DEPART, route = destinations.CENTRAL_LANDING_EMFEA },
{ endTime = utils.timeStringToMinutes('08:55'), action = actions.ARRIVE_OOS, route = destinations.NORTH_LANDING },
{ endTime = utils.timeStringToMinutes('09:06'), action = actions.DEPART_OOS, route = destinations.NORTH_LANDING },
{ endTime = utils.timeStringToMinutes('09:35'), action = actions.ARRIVE, route = destinations.NORTH_LANDING },
{ endTime = utils.timeStringToMinutes('10:25'), action = actions.DEPART, route = destinations.NORTH_LANDING },
{ endTime = utils.timeStringToMinutes('23:35'), action = actions.ARRIVE_OOS, route = destinations.CENTRAL_LANDING_EMFEA },
{ endTime = utils.timeStringToMinutes('23:56'), action = actions.DEPART_OOS, route = destinations.CENTRAL_LANDING_EMFEA },
},
[xi.barge.location.PHANAUET_CHANNEL] =
{
{ endTime = utils.timeStringToMinutes('04:35'), route = destinations.CENTRAL_LANDING_EMFEA },
{ endTime = utils.timeStringToMinutes('08:55'), route = destinations.SOUTH_LANDING },
{ endTime = utils.timeStringToMinutes('16:00'), route = destinations.NORTH_LANDING },
{ endTime = utils.timeStringToMinutes('19:15'), route = destinations.CENTRAL_LANDING },
{ endTime = utils.timeStringToMinutes('23:35'), route = destinations.SOUTH_LANDING },
},
}
local getNextEvent = function(currentTime, schedule)
local nextEvent = schedule[1]
if
schedule[#schedule].endTime <= currentTime or -- currentTime after the last event
schedule[1].endTime > currentTime -- currentTime before first event
then
-- next event is first of the day
return schedule[1]
end
local prevEventEndTime = 0
for i, currEvent in ipairs(schedule) do
if
prevEventEndTime <= currentTime and
currEvent.endTime > currentTime
then
nextEvent = currEvent
break
end
prevEventEndTime = currEvent.endTime
end
return nextEvent
end
xi.barge.timekeeperOnTrigger = function(player, location, eventId)
local schedule = bargeSchedule[location]
if not schedule then
printf('[warning] bad location %i in xi.barge.timekeeperOnTrigger', location)
return
end
local currentTime = VanadielHour() * 60 + VanadielMinute()
local nextEvent = getNextEvent(currentTime, schedule)
local gameMins = nextEvent.endTime - currentTime
if nextEvent.endTime < currentTime then
-- next event is before current time because it's near the end of the day, add a cycle
-- nextEvent.endTime - currentTime underflows so add 1440 first
gameMins = 1440 + nextEvent.endTime - currentTime
end
local earthSecs = gameMins * 60 / 25 -- one earth second is 25 game seconds
if location == xi.barge.location.PHANAUET_CHANNEL then
-- earthSecs is passed to dock timekeepers and they floor to report minutes, do the same here
local earthMins = math.floor(earthSecs / 60)
local gameHours = math.floor(gameMins / 60)
if earthMins ~= 0 then
player:startEvent(eventId, earthMins, gameHours, nextEvent.route)
else
-- arriving shortly
player:startEvent(eventId - 1, 0, 0, nextEvent.route)
end
else
player:startEvent(eventId, earthSecs, nextEvent.action, 0, nextEvent.route)
end
end
-- defines which NPCs to display for a route
-- # is the offset from each ID.npc entry
local npcList =
{
[destinations.NORTH_LANDING] =
{
-- Roi, Luquillaue, Ineuteniace
tonberry = 1,
timekeeper = 0,
rider = 1,
},
[destinations.CENTRAL_LANDING] =
{
-- Roi, Luquillaue, Eunirange
tonberry = 1,
timekeeper = 1,
rider = 1,
},
[destinations.SOUTH_LANDING] =
{
-- Riche, Laiteconce, Ineuteniace
tonberry = 0,
timekeeper = 0,
rider = 0,
},
[destinations.CENTRAL_LANDING_EMFEA] =
{
-- Riche, Laiteconce, Eunirange
tonberry = 0,
timekeeper = 1,
rider = 0,
},
}
-- The timekeeper narrates the trip while the barge is in the channel. Keyed by route rather
-- than by leg, because both South Landing legs run the same Newtpool script at the same
-- offsets. `before` is minutes ahead of the leg's endTime in bargeSchedule. Lines flagged
-- `emote` are shown without a speaker name. From captures.
local routeDialogue =
{
[destinations.NORTH_LANDING] =
{
{ before = 323, messageId = channelID.text.NORTH_GREETING, emote = false },
{ before = 305, messageId = channelID.text.NORTH_MAIN_CANAL, emote = false },
{ before = 281, messageId = channelID.text.NORTH_WATERFALL, emote = false },
{ before = 215, messageId = channelID.text.NORTH_LINE_CAUGHT, emote = true },
{ before = 125, messageId = channelID.text.NORTH_CURRENTS_MEET, emote = false },
{ before = 75, messageId = channelID.text.NORTH_CHILLY_AIR, emote = false },
{ before = 14, messageId = channelID.text.NORTH_ARRIVING_NORTH, emote = false },
},
[destinations.CENTRAL_LANDING] =
{
{ before = 88, messageId = channelID.text.CENTRAL_GREETING },
{ before = 74, messageId = channelID.text.CENTRAL_CRISP_BREEZE },
{ before = 41, messageId = channelID.text.CENTRAL_FORK_IN_CANAL },
{ before = 32, messageId = channelID.text.CENTRAL_SCRUMPTIOUS_LIZARDS },
{ before = 18, messageId = channelID.text.CENTRAL_ARRIVING_CENTRAL },
},
[destinations.SOUTH_LANDING] =
{
{ before = 190, messageId = channelID.text.NEWTPOOL_GREETING },
{ before = 180, messageId = channelID.text.NEWTPOOL_CONVERGE },
{ before = 164, messageId = channelID.text.NEWTPOOL_LIZARDS },
{ before = 118, messageId = channelID.text.NEWTPOOL_PASSING_THROUGH },
{ before = 53, messageId = channelID.text.NEWTPOOL_EDGE_OF_POOL },
{ before = 14, messageId = channelID.text.NEWTPOOL_ARRIVING_SOUTH },
},
[destinations.CENTRAL_LANDING_EMFEA] =
{
{ before = 190, messageId = channelID.text.EMFEA_GREETING },
{ before = 175, messageId = channelID.text.EMFEA_AMPHIBIANS },
{ before = 149, messageId = channelID.text.EMFEA_NARROW_ROUTE },
{ before = 135, messageId = channelID.text.EMFEA_VARIETY_OF_LIFE },
{ before = 96, messageId = channelID.text.EMFEA_DANGEROUS_BRIDGE },
{ before = 52, messageId = channelID.text.EMFEA_NEWTS_AND_FROGS },
{ before = 21, messageId = channelID.text.EMFEA_ARRIVING_CENTRAL },
},
}
-- Each landing's timekeeper announces the barge docking and departing. From captures.
local dockAnnouncers =
{
{ location = xi.barge.location.NORTH_LANDING, npcId = landingID.npc.FELOURIE },
{ location = xi.barge.location.CENTRAL_LANDING, npcId = landingID.npc.RATOULLE },
{ location = xi.barge.location.SOUTH_LANDING, npcId = landingID.npc.CHUAIE },
}
local handleEntityVisibility = function(destination, zoneId)
local zone = GetZone(zoneId)
-- offset by one to avoid zero
if not zone or zone:getLocalVar('destinationEntities') == destination + 1 then
return
end
zone:setLocalVar('destinationEntities', destination + 1)
local ID = zones[zoneId]
local npcData = npcList[destination]
if not npcData then
-- should not be possible
return
end
local offsets =
{
tonberry = ID.npc.TONBERRY_OFFSET,
timekeeper = ID.npc.TIMEKEEPER_OFFSET,
rider = ID.npc.RIDER_OFFSET,
}
for k, v in pairs(npcData) do
for i = 0, 1 do
local npc = GetNPCByID(offsets[k] + i)
if npc then
if v == i then
npc:setStatus(xi.status.NORMAL)
else
npc:setStatus(xi.status.DISAPPEAR)
end
end
end
end
end
local dockEventData =
{
[destinations.NORTH_LANDING] =
{
-- transport ids from DB
transports = { 25 },
arrivalCsId = 11,
arrivalPos = { x = -303.166, y = -1.971, z = 504.964, rotation = 96 },
departEvent = 16,
kickEvent = 34,
triggerAreaId = 1,
},
[destinations.CENTRAL_LANDING] =
{
-- transport ids from DB
transports = { 21, 26 },
arrivalCsId = 10,
arrivalPos = { x = -137.275, y = -1.964, z = 60.265, rotation = 96 },
departEvent = 40,
kickEvent = 42,
triggerAreaId = 3,
},
[destinations.SOUTH_LANDING] =
{
-- transport ids from DB
transports = { 20, 23 },
arrivalCsId = 38,
arrivalPos = { x = 230.621, y = -1.987, z = -530.240, rotation = 129 },
departEvent = 14,
kickEvent = 33,
triggerAreaId = 2,
},
}
-- Aboard, the timekeeper narrates the current leg. At the landings, their timekeeper
-- announces the barge docking and departing. Nothing fires while a zone is empty.
xi.barge.onZoneTick = function(zone)
local currentTime = VanadielHour() * 60 + VanadielMinute()
-- A Vana'diel minute is 2.4 earth seconds, so only look once per minute rather than on
-- every tick. Offset by one to avoid zero, so midnight is not mistaken for unset.
if zone:getLocalVar('[barge]lastMessage') == currentTime + 1 then
return
end
zone:setLocalVar('[barge]lastMessage', currentTime + 1)
if zone:getID() == xi.zone.PHANAUET_CHANNEL then
local nextEvent = getNextEvent(currentTime, bargeSchedule[xi.barge.location.PHANAUET_CHANNEL])
local dialogue = routeDialogue[nextEvent.route]
if not dialogue then
return
end
for _, line in ipairs(dialogue) do
if nextEvent.endTime - line.before == currentTime then
local timekeeper = GetNPCByID(channelID.npc.TIMEKEEPER_OFFSET + npcList[nextEvent.route].timekeeper)
if timekeeper then
if line.emote then
timekeeper:messageText(timekeeper, line.messageId, false, 4)
else
timekeeper:messageText(timekeeper, line.messageId, true)
end
end
return
end
end
else
-- Out of service arrivals and departures are not announced.
for _, dock in ipairs(dockAnnouncers) do
for _, event in ipairs(bargeSchedule[dock.location]) do
local messageId = nil
if event.action == actions.ARRIVE and event.endTime == currentTime then
messageId = landingID.text.BARGE_HAS_ARRIVED
elseif event.action == actions.DEPART and event.endTime - 15 == currentTime then
messageId = landingID.text.BARGE_ABOUT_TO_DEPART
end
if messageId then
local timekeeper = GetNPCByID(dock.npcId)
if timekeeper then
timekeeper:messageText(timekeeper, messageId, true)
end
return
end
end
end
end
end
xi.barge.onZoneIn = function(player, prevZone)
local zoneId = player:getZoneID()
if zoneId == xi.zone.PHANAUET_CHANNEL then
-- Zoning into Phanauet Channel. Set destination charvar based on schedule.
local schedule = bargeSchedule[xi.barge.location.PHANAUET_CHANNEL]
local currentTime = VanadielHour() * 60 + VanadielMinute()
local nextEvent = getNextEvent(currentTime, schedule)
-- Saving as a charvar for use when zoning back into carpenter's landing
-- TODO should we reset the charvar to negative 1 when leaving a dock and only set this if the charvar is -1? This would block skipping legs by logging out
player:setCharVar('[barge]destinationDockId', nextEvent.route)
-- entity visibility only matters if a player zones in, set it now
handleEntityVisibility(nextEvent.route, player:getZoneID())
elseif
zoneId == xi.zone.CARPENTERS_LANDING and
prevZone == xi.zone.PHANAUET_CHANNEL
then
-- Zoning into Carpenter's Landing. Play the cs saved when we started the ride.
local bargeDestinationId = player:getCharVar('[barge]destinationDockId')
player:setCharVar('[barge]destinationDockId', 0)
-- get destinationData with failback to destinations.CENTRAL_LANDING in case of mangled data or route == destinations.CENTRAL_LANDING_EMFEA
local bargeDestinationData = dockEventData[bargeDestinationId] or dockEventData[destinations.CENTRAL_LANDING]
player:setPos(bargeDestinationData.arrivalPos)
return { bargeDestinationData.arrivalCsId, -1, bit.bor(xi.cutsceneFlag.UNKNOWN_1, xi.cutsceneFlag.NO_PCS) }
end
return -1
end
xi.barge.onTransportEvent = function(player, zoneId, transportId)
local aboard = player:getLocalVar('[barge]aboard')
local destData = nil
for _, bargeDestinationData in pairs(dockEventData) do
if bargeDestinationData.triggerAreaId == aboard then
destData = bargeDestinationData
break
end
end
if not destData then -- was not in trigger area for the transport event
return -1
end
if not utils.contains(transportId, destData.transports) then
-- Normal game client won't let you enter the boat trigger area unless the boat is there, but check this transport event aligns with the schedule to avoid shenanigans
-- was in trigger area but no in-service boat was departing from this dock
player:startEvent(destData.kickEvent)
elseif player:hasKeyItem(xi.ki.BARGE_TICKET) then
-- This is technically done on event end but does not matter
player:delKeyItem(xi.ki.BARGE_TICKET)
player:startEvent(destData.departEvent,
{
[0] = 0,
[1] = xi.ki.BARGE_TICKET,
[2] = 0, -- Important to be set to 0
isHidden = true,
flags = bit.bor(
xi.cutsceneFlag.UNKNOWN_1,
xi.cutsceneFlag.NO_PCS,
xi.cutsceneFlag.UNKNOWN_4,
xi.cutsceneFlag.UNKNOWN_7
),
})
elseif player:hasKeyItem(xi.ki.BARGE_MULTI_TICKET) then
local usesLeft = player:getCharVar('[barge]multiTicket') - 1
if usesLeft <= 0 then
usesLeft = 0
player:delKeyItem(xi.ki.BARGE_MULTI_TICKET)
end
player:setCharVar('[barge]multiTicket', usesLeft)
-- was in trigger area and had a ticket
player:startEvent(destData.departEvent,
{
[0] = usesLeft, -- Not actually used by CS but passed in
[1] = xi.ki.BARGE_MULTI_TICKET,
[2] = usesLeft + 1, -- This expects the number of uses before it get decremented
isHidden = true,
flags = bit.bor(
xi.cutsceneFlag.UNKNOWN_1,
xi.cutsceneFlag.NO_PCS,
xi.cutsceneFlag.UNKNOWN_4,
xi.cutsceneFlag.UNKNOWN_7
),
})
else
-- was in trigger area but didn't have a ticket
player:startEvent(destData.kickEvent)
end
end
-- index is the event option from menu
local tickets =
{
[1] =
{
keyItem = xi.ki.BARGE_TICKET,
cost = 50,
},
[2] =
{
keyItem = xi.ki.BARGE_MULTI_TICKET,
cost = 300,
charges = 10,
},
}
xi.barge.onTicketShopTrigger = function(player, eventId)
local hasKeyItemParam = 0
local numberTicket = player:getCharVar('[barge]multiTicket')
for i, ticketData in pairs(tickets) do
hasKeyItemParam = utils.mask.setBit(hasKeyItemParam, i - 1, player:hasKeyItem(ticketData.keyItem))
end
-- Params (KI1, KI2, Price of KI1, Price of KI2, Multiticket #s left, Which tickets does player have)
player:startEvent(eventId, tickets[1].keyItem, tickets[2].keyItem, tickets[1].cost, tickets[2].cost, numberTicket, hasKeyItemParam, 0, 0)
end
xi.barge.onTicketShopEventFinish = function(player, csid, option, npc)
local ID = zones[player:getZoneID()]
local numberTicket = player:getCharVar('[barge]multiTicket')
local ticketData = tickets[option]
if
ticketData and
(csid == 31 or
csid == 32 or
csid == 43)
then
local gil = player:getGil()
-- Event auto plays the correct message if you have the KI (and multi at max charges)
if
not player:hasKeyItem(ticketData.keyItem) or
(ticketData.charges and numberTicket < ticketData.charges)
then
if gil >= ticketData.cost then
player:delGil(ticketData.cost)
player:addKeyItem(ticketData.keyItem)
player:messageSpecial(ID.text.KEYITEM_OBTAINED, ticketData.keyItem, ticketData.charges or 0)
if ticketData.charges then
player:setCharVar('[barge]multiTicket', ticketData.charges)
end
else
-- TODO find out the cs params to auto-detect not enough gil to avoid incorrect messaging
-- the cs can automatically detect if the player doesn't have enough gil when choosing one to buy (passing no key item shows that): !cs 32 0 0 30 300
local showName = true
local turnToPlayer = false
-- dynamically addresses based on gender, neat!
player:showText(npc, ID.text.TOO_POOR, 0, 0, 0, 0, showName, turnToPlayer)
end
end
end
end