local function lResolveBanterActor(name, unit_list, playOnce, lineId) for i, u in ipairs(unit_list) do if IsKindOf(u, "UnitMarker") then u = u.objects and u.objects[1] or u end local is_unit = IsKindOf(u, "Unit") if playOnce and is_unit and u.banters_played_lines and table.find(u.banters_played_lines, lineId) then elseif name == "" or name == "any" or name == "current unit" then return u elseif is_unit and u:IsDead() then elseif is_unit and IsSetpieceActor(u) and not u.visible then -- skip originals of impostor unit copies used for setpiece testing elseif not UnitTarget.Match(nil, (name == "current unit") and "any" or name, u, empty_table) then elseif not IsValid(u) then elseif not u:IsValidPos() then else return u end end return false end local function lGetActorsUsedInBanter(banterPreset, unit_list, findFirst) -- Radio banters should always be valid if banterPreset.isRadio then return {}, true end local used_units, all_lines_satisfied for i, l in ipairs(banterPreset.Lines) do if l.MultipleTexts then -- MultipleTexts lines are considered optional, but we want to be able to return a banter with only -- such lines as valid therefore we consider them as non-optional in this check used_units = used_units or {} for i, m in ipairs(l.AnyOfThese) do local actor = lResolveBanterActor(m.Character, unit_list) if actor then used_units[#used_units + 1] = actor end end if all_lines_satisfied == nil then all_lines_satisfied = true end goto continue end local lineId = banterPreset.id .. tostring(i) local actor = lResolveBanterActor(l.Character, unit_list, l.playOnce, lineId) if actor then if not findFirst then -- optimize since findFirst users probably dont care about specific units used used_units = used_units or {} used_units[actor] = l.Character table.insert_unique(used_units, actor) end if all_lines_satisfied == nil then all_lines_satisfied = true end elseif not l.Optional then if findFirst then return -- not all the lines are satisfied end all_lines_satisfied = false dbg(BanterDebugLog(banterPreset.id, "couldn't find an actor for " .. l.Character)) end ::continue:: end -- If there aren't any lines (such as all being optional) then don't consider the banter as satisfied. if findFirst or used_units then return used_units, all_lines_satisfied end end local function lBanterToTalkingHeadAdapter(id, text, characterId, attachedTemplate) local synthNotification = PlaceObj("TalkingHeadNotificationBase") synthNotification.id = id synthNotification.Lines = { { Text = text, Character = characterId } } if attachedTemplate then synthNotification.CustomLogic = attachedTemplate end PlayTalkingHead(synthNotification) return synthNotification end -- all banter-related Conditions & Effects inherit this class DefineClass.BanterFunctionObjectBase = { __parents = { "PropertyObject" } } DefineClass.BanterPlayer = { --__parents = { "SyncObject" }, --if it didnt setpos in rtt it could be sync objs so we dont have to serialize manually __parents = { "Object" }, preset = false, associated_units = false, thread = false, ui_closing_thread = false, fallback_actor = false, current_line = false, current_text_window = false, any_actor_override = false, -- Some banter lines are played as talking head notifications. active_talking_head = false, seed = false, --sync stuff id = false, --for manual serialization across net started = false, --sync state done_called = false, -- double del shield } DefineClass.BanterFloatingText = { __parents = { "UnitFloatingText" }, TextStyle = "BanterFloatingText", interpolate_pos = false, interpolate_opacity = false, rand_pos = false, FadeInTime = 150, FadeOutTime = 150, MaxWidth = 450, HAlign = "left", TextHAlign = "center", exclusive_by_type = true, prevent_overlap = false, always_show_on_distance = true, } MapVar("g_IdToBanter", {}) MapVar("g_NextBanterId", 0) MapVar("g_ActiveBanters", function() return {} end) MapVar("g_PlayingBanterEffects", function() return {} end) -- Since the instance itself is attached to an object, we need to track state separately. GameVar("g_BanterCooldowns", {}) -- legacy fixup function OnMsg.LoadDynamicData(dynamic_data) if not next(g_BanterCooldowns) then g_BanterCooldowns = dynamic_data.banterCooldowns or {} end end if FirstLoad then Dbg_BoredBanters = false --used to reduce cd for easier test of func end function OnMsg.BanterStart(banter_preset_id) local banter = Banters[banter_preset_id] if banter.group == "MercBanters" then g_BanterCooldowns["global"] = RealTime() + const.GlobalMercBanterCooldown end -- Record cooldown even if the banter doesn't have one. This is used to check -- if it has ever played. g_BanterCooldowns[banter_preset_id] = Game.CampaignTime + (banter.cooldown or 0) end --[[@@@ Create a banter player which shows the banter text and plays the sounds. @function BanterPlayer PlayBanter(string banter_preset_id, array associated_units, Unit fallback_actor, string any_actor_override) @param string banter_preset_id - Banter to play. @param array associated_units - Units to assign as banter actors. @param Unit fallback_actor - Unit to use if the actor cannot be found within the associated_units. @param string any_actor_override - Banter lines with actor "any" will be played by the first /object/ from this group. @param bool wait_setpiece_end - The banter will wait for any current setpiece to end before playing @returns BanterPlayer - object responsible for playing the banter. ]] function PlayBanter(banter_preset_id, associated_units, fallback_actor, any_actor_override, wait_setpiece_end) NetUpdateHash("PlayBanter", banter_preset_id) CombatLog("debug", "Playing banter " .. banter_preset_id) local banter = Banters[banter_preset_id] if not banter then assert(false and "Unknown banter preset!") return end if any_actor_override then any_actor_override = MapGetFirst("map", function(o) return table.find(o.Groups, any_actor_override) end) end -- Radio banters need a fallback actor for lines which require -- an actor currently on the map (phoning via radio) -- This isn't just a dirty workaround as these lines used to be -- displayed above this unit prior to radio banters going Snype only if banter.isRadio and not fallback_actor then for _, unit in ipairs(g_Units) do if unit:IsPlayerAlly() and not unit:IsDead() then fallback_actor = unit break end end end local init_members = { preset = banter, associated_units = associated_units, fallback_actor = fallback_actor, any_actor_override = any_actor_override, id = g_NextBanterId, wait_setpiece_end = wait_setpiece_end } g_NextBanterId = g_NextBanterId + 1 -- Interrupt banters of the same group. if banter.banterGroup then local banterGroup = banter.banterGroup for i = #g_ActiveBanters, 1, -1 do local b = g_ActiveBanters[i] if b.preset.banterGroup == banterGroup then --b:delete(nil, "sync") DoneBanter(b) --see comments in func if you are wondering why we need an additional sync ev from sync code; end end end NetUpdateHash("PlayBanter_StartPlayer", banter_preset_id) local newBanter = BanterPlayer:new(init_members) g_ActiveBanters[#g_ActiveBanters + 1] = newBanter g_ActiveBanters[newBanter.preset.id] = true assert(g_IdToBanter[newBanter.id] == nil) --print("banter created", newBanter.id, g_NextBanterId) g_IdToBanter[newBanter.id] = newBanter return newBanter end --[[@@@ Play a banter and wait for it to finish. @function BanterPlayer PlayAndWaitBanter(string banter_preset_id, array associated_units, Unit fallback_actor) @param string banter_preset_id - Banter to play. @param array associated_units - Units to assign as banter actors. @param Unit fallback_actor - Unit to use if the actor cannot be found within the associated_units. @returns BanterPlayer - object responsible for playing the banter. ]] function PlayAndWaitBanter(banter_preset_id, associated_units, fallback_actor) local player = PlayBanter(banter_preset_id, associated_units, fallback_actor) if player and player.thread then local ok, finished_banter while not ok or finished_banter ~= banter_preset_id do ok, finished_banter = WaitMsg("BanterDone", 500) end end return player end --[[@@@ Find banters associated with the specified unit and end them. Optionally one banter can be excluded from the search. Optionally the unit can be matched by the string in the banter line which led to this unit being picked. @function void EndBanter(Unit unit, BanterPlayer exclude, String actor_string) ]] function EndBanter(unit, exclude, actor_string) if not unit then return end for i = #g_ActiveBanters, 1, -1 do local b = g_ActiveBanters[i] if not exclude or (exclude and b ~= exclude) and b.associated_units then local unitInBanter = table.find(b.associated_units, unit) or table.has_value(b.associated_units, actor_string) if unitInBanter then DoneBanter(b) end end end end OnMsg.UnitDied = EndBanter --[[@@@ Stop all banter. @function void EndAllBanter() ]] function EndAllBanter() for i = #g_ActiveBanters, 1, -1 do DoneBanter(g_ActiveBanters[i]) end end function SkipBanterFromUI(id) for i = #g_ActiveBanters, 1, -1 do local b = g_ActiveBanters[i] if b.preset.id == id then DoneBanter(b, "skip") end end end function IsBanterAvailable(banterDef, context) if not context or type(context) ~= "table" or not rawget(context, "skipConflictCheck") then if GetSectorConflict() and banterDef.disabledInConflict then BanterDebugLog(banterDef.id, "because conflict.") return false end end if not context or type(context) ~= "table" or not rawget(context, "skip_cooldowns") then if banterDef.group == "MercBanters" and g_BanterCooldowns["global"] then if g_BanterCooldowns["global"] - RealTime() > 0 then BanterDebugLog(banterDef.id, "merc banter global cooldown.") return false end end -- once per campaign if banterDef.Once and g_BanterCooldowns[banterDef.id] then BanterDebugLog(banterDef.id, "marked as once per campaign.") return false end if banterDef.cooldown and g_BanterCooldowns[banterDef.id] then if g_BanterCooldowns[banterDef.id] - Game.CampaignTime > 0 then BanterDebugLog(banterDef.id, "on cooldown.") return false end end end local conditions = EvalConditionList(banterDef.conditions, banterDef, context) if not conditions then BanterDebugLog(banterDef.id, "conditions are false.") return false end return true end local lInterruptableCommands = { "Roam", "Visit", "RoamSingle", "BeingInteracted" } local function lUnitIdleForBanter(u) return u.routine ~= "StandStill" and u:IsValidPos() and u.command ~= "IdleSuspicious" and (u.command ~= "BeingInteracted" or not u.being_interacted_with) and ((u:IsIdleCommand() and u.command ~= "OverheardConversationHeadTo") or table.find(lInterruptableCommands, u.command)) end function FilterAvailableBanters(banters, context, units, fallback, findFirst) local contextuallyValidUnits if context and type(context) == "table" and rawget(context, "require_idle") then contextuallyValidUnits = {} for i, u in ipairs(units) do if lUnitIdleForBanter(u) then contextuallyValidUnits[#contextuallyValidUnits + 1] = u end end else contextuallyValidUnits = units end local bantersFiltered, banterActors, list_unplayed local banters_defs_param = banters and type(banters[1]) == "table" for _, b in ipairs(banters) do local banterDef = banters_defs_param and b or Banters[b] if banterDef then local banter_id = banterDef.id local played = g_BanterCooldowns[banter_id] if played and list_unplayed then -- skip elseif IsBanterAvailable(banterDef, context) then local actors, all_actors_present = lGetActorsUsedInBanter(banterDef, contextuallyValidUnits, findFirst) if all_actors_present or fallback then if findFirst then return true end if not bantersFiltered then bantersFiltered = {} banterActors = {} end if not played and not list_unplayed then list_unplayed = true table.iclear(bantersFiltered) table.iclear(banterActors) end local idx = #bantersFiltered + 1 bantersFiltered[idx] = banter_id banterActors[idx] = actors else dbg(BanterDebugLog(banter_id, "because actors missing.")) end end end end if not bantersFiltered then return end -- If there is at least one unplayed banter, prioritize unplayed banters if list_unplayed then return bantersFiltered, banterActors, "unplayed" end -- Used to filter out the last played banter local filterOutIfOthers = context and type(context) == "table" and rawget(context, "filter_if_other") if filterOutIfOthers then local idx = table.find(bantersFiltered, filterOutIfOthers) if idx and #bantersFiltered > 1 then table.remove(bantersFiltered, idx) table.remove(banterActors, idx) end end return bantersFiltered, banterActors end function ReadDurationFromText(text) local wordsPerMinute = 220.0 local averageWordSize = 5.0 local words = Min(#text/averageWordSize, CountWords(text)) local ms = ((words/wordsPerMinute) * 60) * 1500 return Max(ms,2000) -- Bonus time for the person to notice the text. end function BanterPlayer:Init() CreateBadgeFromPreset("BanterOffScreen", self) self.associated_units = lGetActorsUsedInBanter(self.preset, self.associated_units) or {} if self.any_actor_override then table.insert(self.associated_units, 1, self.any_actor_override) end self.seed = InteractionRand(nil, "Banter") local anySnypeOrVRLine = false for i, l in ipairs(self.preset.Lines) do if l.useSnype or l.asVR then anySnypeOrVRLine = true break end end if self.preset.isRadio or anySnypeOrVRLine then self.thread = CreateMapRealTimeThread(self.Run, self) else assert(not gv_SatelliteView) -- Banter started in satellite view that isnt a radio banter self.thread = CreateGameTimeThread(self.Run, self) end end function BanterPlayer:IsFinished() --note that this is not sync most of the time.. if not self.current_line then return false end return self.current_line > #self.preset.Lines end function IsRadioBanterPlaying() for i, banter in ipairs(g_ActiveBanters) do if banter.started and banter.preset.isRadio then --if we don't wait .started it will freeze due to blocking itself with waitplayercontrol return true end end return false end function BanterPlayer:IsOtherRadioBanterPlaying() for i, banter in ipairs(g_ActiveBanters) do if banter ~= self and banter.preset.isRadio then return banter end end return false end function IsSetpiecePlaying() return GameState.setpiece_playing or IsRadioBanterPlaying() end function WaitPlayingSetpiece() --note that this is not sync; local dlg = GetDialog("XSetpieceDlg") if dlg then dlg:Wait() end dlg = GetDialog("RadioBanterDialog") if dlg then dlg:Wait() end end function BanterPlayer:ClearUI() if IsValidThread(self.ui_closing_thread) then return end local isFinished = self:IsFinished() self.ui_closing_thread = CreateRealTimeThread(function() local dlg = GetDialog("RadioBanterDialog") if dlg then if not isFinished then -- skipped dlg:Close() else dlg:AnimatedClose() end end if self.active_talking_head then self.active_talking_head:Stop() self.active_talking_head = false end if self.current_text_window and self.current_text_window.window_state ~= "destroying" then self.current_text_window:delete() end end) end function BanterPlayer:Run() assert(self.id) BanterDebugLog(self.id, "played") WaitPlayerControl({ skip_setpiece = not self.preset.isRadio and not self.wait_setpiece_end }) -- Dont allow two radio banters to play over each other (mega edge case) if self.preset.isRadio then local otherRadioBanter = self:IsOtherRadioBanterPlaying() while otherRadioBanter do -- Tie breaker when both banters have started at the exact same time if not otherRadioBanter.started and otherRadioBanter.id > self.id then break end Sleep(1000) otherRadioBanter = self:IsOtherRadioBanterPlaying() end end -- Stop voice responses because of banter, this must be done after the banter is added to the queue -- as that will block future voice responses. StopVoiceResponses() FireNetSyncEventOnHost("BanterStartEvent", self.id, self.preset.id, self.preset.group) local fx = self.preset.FX if fx then PlayFX(fx, "start", self.any_actor_override) end self.current_line = self.current_line or 1 while not self:IsFinished() do local preset = self.preset local nextLine = preset.Lines[self.current_line] if nextLine.MultipleTexts then local count = 0 self.seed = BraidRandom(self.seed) local forcePlaySubline = false -- If there is only one AnyOfThese line we need hardcode it as the random doesn't work then. if nextLine.AnyOfThese and #nextLine.AnyOfThese == 1 then forcePlaySubline = 1 -- Check if we have an interjection for the fallback merc. -- This means that we want to prioritize it in the randomness as -- it is hardcoded in the script or was passed through a conversation. elseif self.any_actor_override then local anyActorArr = { self.any_actor_override } for i, m in ipairs(nextLine.AnyOfThese) do local actorName = m.Character local found = lResolveBanterActor(actorName, anyActorArr) if found then forcePlaySubline = i break end end end if forcePlaySubline then local line = nextLine.AnyOfThese[forcePlaySubline] line.Optional = true line.Voiced = true line.playOnce = nextLine.playOnce and "subline1" self:PlayBanterLine(line) else for i, m in random_ipairs(nextLine.AnyOfThese, self.seed) do local line = nextLine.AnyOfThese[i] line.Optional = true line.Voiced = true line.playOnce = nextLine.playOnce and "subline" .. tostring(i) local played = self:PlayBanterLine(line) if played then count = count + 1 end if count == nextLine.AnyOfTheseCount then break end end end else self:PlayBanterLine() end self.current_line = self.current_line + 1 end -- Wait for the last text to expire before Done-ing self.thread = false while self.current_text_window and self.current_text_window.window_state ~= "destroying" do Sleep(100) end -- Clear up visuals for the local player -- Note that this isn't sync, there is another attempt at clearing them in the DoneBanter event -- for when the command only arrives over the network (such as when skipping or interrupting) self:ClearUI() DoneBanter(self) end function NetSyncEvents.BanterStartEvent(banter_id, preset_id, group) CreateMapRealTimeThread(function() local banter_player = g_IdToBanter[banter_id] -- This would mean that the banter was ended before it started. Which is possible if -- multiple banters are started referencing the same actors. Thread would have been killed so its fine. if not banter_player then return end banter_player.started = true if IsValid(banter_player) and banter_player.preset.isRadio then -- open this dlg from sync context or it might only open on one client since it pauses the game; local rbd = GetDialog("RadioBanterDialog") if rbd then rbd:Wait() end OpenDialog("RadioBanterDialog", GetInGameInterface(), banter_player) end Msg("BanterStart", preset_id) end) end function NetSyncEvents.BanterLineStartEvent(id, line_idx) --this gets called twice in coop per lines that are not skipped Msg("BanterLineStart", id, line_idx) end function NetSyncEvents.BanterLineDoneEvent(id, line_idx) --this gets called twice in coop per lines that are not skipped Msg("BanterLineDone", id, line_idx) end function GetSoundDurationGameTime(sound) local duration = GetSoundDuration(sound) if not duration then return false end return duration * GetTimeFactor() / 1000 end function BanterPlayer:PlayBanterLine(line) local preset = self.preset local current_line = line or preset.Lines[self.current_line] local next_line = not line and preset.Lines[self.current_line + 1] local actor if current_line.Optional then local actorName = current_line.Character actor = lResolveBanterActor(actorName, self.associated_units) if not IsValid(actor) then local unitFilter = function(u) if not u:IsDead() and (u.unitdatadef_id == actorName or u:IsInGroup(actorName)) then return u end return false end if not gv_SatelliteView then if self:IsValidPos() then actor = MapGetFirst(self:GetPos(), const.SlabSizeX * 15, "Unit", unitFilter) else actor = MapGetFirst("map", "Unit", unitFilter) end end end else actor = lResolveBanterActor(current_line.Character, self.associated_units) or self.fallback_actor end -- No actor found if not IsValid(actor) then if preset.isRadio then -- if snype or radio, that's fine unless the unit is meant to be a merc speaking in which -- case we need to check if they're hired. local actorName = current_line.Character if UnitDataDefs[actorName].IsMercenary then local ud = gv_UnitData[actorName] if not ud or ud.HireStatus ~= "Hired" then return false end end elseif current_line.Optional then -- if optional, that's fine - just dont play it return false else -- if not optional, then we have an error on our hands print("Banter actor not found - ", current_line.Character, "for banter", self.preset.id) return false end end -- End previous banters with this actor. EndBanter(actor, self, self.associated_units[actor] == "any" and "any") if current_line.playOnce and IsKindOf(actor, "Unit") then local line_id = preset.id .. tostring(self.current_line) if type(current_line.playOnce) == "string" then line_id = line_id .. current_line.playOnce end if actor.banters_played_lines then if table.find(actor.banters_played_lines, line_id) then -- Occurs in multi text lines as they dont check associated actors in advance. -- Also occurs when optional lines fall into the MapGetFirst above. assert(type(current_line.playOnce) == "string" or current_line.Optional) return false end table.insert(actor.banters_played_lines, line_id) else actor.banters_played_lines = { line_id } end end local anim_style = IsKindOf(actor, "Unit") and GetAnimationStyle(actor, current_line.AnimationStyle) if anim_style then actor:SetCommand("BanterIdle", current_line.AnimationStyle) CreateMapRealTimeThread(function(actor) WaitMsg("BanterLineDone") if actor.command == "BanterIdle" then actor:SetCommand("Idle") end end, actor) end NetSyncEvent("BanterLineStartEvent", self.preset.id, self.preset.group, self.current_line) if current_line.useSnype or current_line.asVR or GetDialog("RadioBanterDialog") then DeleteBadgesFromTarget(self) local attachedTemplate = nil local actorUnitDataDefId = actor and rawget(actor, "unitdatadef_id") or current_line.Character if actor and current_line.asVR then local id = IsKindOf(actor, "Unit") and actor.id or rawget(actor, "unitdatadef_id") or actor.session_id or actor.id attachedTemplate = SpawnPartyAttachedTalkingHeadNotification(id) end local talkingHeadCharacter = UnitDataDefs[current_line.Character] and current_line.Character or actorUnitDataDefId local talkingHeadInstance = lBanterToTalkingHeadAdapter( preset.id .. self.current_line, current_line.Text, talkingHeadCharacter, attachedTemplate ) self.active_talking_head = talkingHeadInstance local notTimedOut, obj while obj ~= talkingHeadInstance do notTimedOut, obj = WaitMsg("TalkingHeadEnded", 100) end self.active_talking_head = false Sleep(const.BanterBetweenLineTime) WaitPlayerControl({ skip_setpiece = true }) -- In case game got paused NetSyncEvent("BanterLineDoneEvent", self.preset.id, self.preset.group, self.current_line) return true end assert(actor) -- Should only be missing in radio banters (useSnype) local line = current_line.Text if preset.loggable then if preset.isRadio then local character = UnitDataDefs[current_line.Character] CombatLog(character.id, line) elseif IsKindOf(actor, "Unit") then local unitDef = UnitDataDefs[actor.unitdatadef_id] if unitDef and actor.Name then -- Name was overriden by spawn CombatLog(actor.Name, line) else CombatLog(actor.unitdatadef_id, line) end else CombatLog("short", T{237924284463, "", line = line}) end end local soundDuration local soundName if current_line.Voiced then local seed = IsKindOf(actor, "Unit") and rawget(actor, "session_id") soundName = GetVoiceFilename(line, seed and xxhash(seed)) soundDuration = IsGameTimeThread() and GetSoundDurationGameTime(soundName) or GetSoundDuration(soundName) end -- If the sound file doesn't exist - don't play it and invent a duration. if soundDuration then FadeSoundsForVoiceover(true) self:SetSound(soundName, IsGameTimeThread() and "BanterGameTime" or "Banter") else if current_line.Voiced then soundDuration = ReadDurationFromText(_InternalTranslate(line)) else -- lyubo: we don't want to wait for non-voiced lines -- vlad: unless the next line is also non-voiced -- because then the entire banter will be played at once. if next_line and not next_line.Voiced then soundDuration = ReadDurationFromText(_InternalTranslate(next_line.Text)) else soundDuration = nil end end end local manualFloatingText = soundDuration and "manual" local textElement = ShowBanterFloatingText(actor, line, manualFloatingText, current_line.FloatUp) self.current_text_window = textElement -- We need a short sleep for non-voiced lines or the order of lines gets mixed up soundDuration = soundDuration or 100 for waitTime = 100, soundDuration, 100 do if IsValid(actor) then self:SetPos(actor:GetPos()) end Sleep(100) end FadeSoundsForVoiceover(false) if manualFloatingText and textElement and textElement.window_state ~= "destroying" then textElement:Close() end Sleep(const.BanterBetweenLineTime) NetSyncEvent("BanterLineDoneEvent", self.preset.id, self.preset.group, self.current_line) WaitPlayerControl({ skip_setpiece = true }) return true end function ShowBanterFloatingText(actor, line, expire_time, floatUp) if CheatEnabled("CombatUIHidden") then return end local dlg = IsSetpiecePlaying() and GetDialog("XSetpieceDlg").idSetpieceUI or EnsureDialog("FloatingTextDialog") local textElement = XTemplateSpawn("BanterFloatingText", dlg, actor) textElement.interpolate_pos = floatUp textElement.interpolate_opacity = floatUp if expire_time == "manual" then textElement.expire_time = false else textElement.expire_time = expire_time or Max(ReadDurationFromText(_InternalTranslate(line)), 2000) end CreateCustomFloatingText(textElement, actor, line, "BanterFloatingText", "Headstatic") return textElement end function BanterPlayer:delete(fromC, sync) assert(sync) --this should only be called by callers that know what they are doing (in sync context); Object.delete(self, fromC) end function NetSyncEvents.DoneBanter(banter_id, current_line) local banter = g_IdToBanter[banter_id] if not banter then return end --this happens when SkipBanterFromUI is called synchroniously such as when setpiece gets skipped, idk how to distinguish the two cases, so removing the assert; --assert(banter, "missing banter with id: " .. tostring(banter_id) ) banter.current_line = current_line --this gets touched in async way so we resync on death with requestor state banter:delete(nil, "sync") end --context: nil -> kills banter with netsync from host only -- "skip" -> kills banter with netsync on any client -- "sync" -> kills banter here and now with :delete -> this wont work as long as current_line is modified by a rtt function DoneBanter(banter, context) if banter.done_called then return end banter.done_called = true if not IsAsyncCode() or context == "sync" then --IsAsyncCode() is not always correct --assuming we are in sync context here --stop the banter from doing stuff while deletion message travels and mark it to prevent further attempts at deletion if banter.thread then DeleteThread(banter.thread) end end if context == "sync" then assert(false) --this usage cannot work since banter state (current_line) is modified asynchroniously and needs to be resynced before death; banter:delete(nil, context) return end --by default, host ends banters so we dont get 2 events for each banter --if it's skipped due to user input it needs to fire on any machine though local should_fire = (not context and (not netInGame or netUniqueId == 1)) or context == "skip" local ev_f = not context and FireNetSyncEventOnHost or context == "skip" and NetSyncEvent assert(ev_f) --print("sending done batner", banter.id, g_IdToBanter[banter.id] == banter, should_fire) if should_fire then if not g_IdToBanter[banter.id] or banter ~= g_IdToBanter[banter.id] then DebugPrint("g_IdToBanter[banter.id]", g_IdToBanter[banter.id]) DebugPrint("g_IdToBanter[banter.id].preset.id", g_IdToBanter[banter.id] and g_IdToBanter[banter.id].preset.id) DebugPrint("banter.preset.id", banter.preset.id) DebugPrint("netUniqueId", netUniqueId) DebugPrint("context", context) assert(false, "banter id mismatch") return end end ev_f("DoneBanter", banter.id, banter.current_line) end function BanterPlayer:Done() if self.thread then DeleteThread(self.thread) end self:ClearUI() -- Banter was stopped before finishing, fire off all events. self.current_line = self.current_line or 1 while not self:IsFinished() do Msg("BanterLineStart", self.preset.id, self.preset.group, self.current_line) Msg("BanterLineDone", self.preset.id, self.preset.group, self.current_line) self.current_line = self.current_line + 1 end Msg("BanterDone", self.preset.id, self.preset.group) DeleteBadgesFromTarget(self) g_ActiveBanters.lastPlayedTime = GameTime() g_ActiveBanters[self.preset.id] = nil table.remove_value(g_ActiveBanters, self) --print("Done banter", self.id) g_IdToBanter[self.id] = nil if #g_ActiveBanters <= 0 then --good time to reset ids g_NextBanterId = 0 end FadeSoundsForVoiceover(false) NetUpdateHash("BanterDone", self.preset.id) --as long as it isn't a sync obj we have to check manually end function IsUnitPartOfAnyActiveBanter(unit) for i, b in ipairs(g_ActiveBanters) do local actors = b.associated_units if table.find(actors or empty_table, unit) then --assert(IsValidThread(b.thread)) --thread may be dead while sync death is traveling; return true end end return false end if Platform.developer then function TestRadioBanter() PlayBanter("CorazonRadio_CapturedErnie", g_Units, SelectedObj) end end MapVar("g_VoiceResponses", {}) MapVar("g_voiceRespLastPlayed", false) MapVar("g_suppressAllUntil", false) MapVar("g_LastVRPlayed", false) if FirstLoad then gv_VoiceLinesCD = {} end GameVar("gv_vrLog", {max_lines = 50}) const.DbgVoiceResponses = const.DbgVoiceResponses or false local function DbgVoiceResponse(...) if Platform.developer then local args = {...} local line = string.format("[%d] VoiceResponse:", GameTime()) for _, item in ipairs(args) do line = line .. string.format(" %s", IsT(item) and _InternalTranslate(item) or tostring(item)) end gv_vrLog[#gv_vrLog + 1] = line while gv_vrLog.max_lines and (#gv_vrLog > gv_vrLog.max_lines) do table.remove(gv_vrLog, 1) end if const.DbgVoiceResponses then --print("VoiceResponse: ",...) CombatLog("debug",line) print(line) end end end local function HasValidVoiceResponse(unitName, eventType) -- Resolve responses local responses = VoiceResponses[unitName] if not responses then return end -- Downed units may only perform these VRs local unit = g_Units[unitName] if unit and unit:IsDowned() and eventType ~= "Downed" and eventType ~= "HeavilyWoundedSelection" then return end local lines = responses:ResolveResponses(eventType) -- resolve inheritance according to InheritFrom property return lines and #lines>0 end local function GetUnitLikedArray(unit) local squad = unit.Squad and gv_Squads[unit.Squad] local other = squad and squad.units or empty_table local liked, disliked, general = {}, {}, {} for _, other_unit in ipairs(other) do local ounit = gv_UnitData[other_unit] if unit.session_id~=other_unit then general[#general+1] = other_unit if ounit.Likes and table.find(ounit.Likes, unit.session_id) then liked[#liked+1] = other_unit elseif ounit.Dislikes and table.find(ounit.Dislikes, unit.session_id) then disliked[#disliked+1] = other_unit end end end return liked, disliked, general end local function GetUnitLearnToLikeDislike(unit) local squad = unit.Squad and gv_Squads[unit.Squad] local other = squad and squad.units or empty_table local toBeLiked, toBeDisliked = {}, {} for _, other_unit in ipairs(other) do local ounit = gv_UnitData[other_unit] if unit.session_id~=other_unit then if ounit.LearnToLike and table.find(ounit.LearnToLike, unit.session_id) and not table.find(ounit.Likes, unit.session_id) and not table.find(ounit.Dislikes, unit.session_id) then toBeLiked[#toBeLiked+1] = other_unit end if ounit.LearnToDislike and table.find(ounit.LearnToDislike, unit.session_id) and not table.find(ounit.Likes, unit.session_id) and not table.find(ounit.Dislikes, unit.session_id) then toBeDisliked[#toBeDisliked+1] = other_unit end end end return toBeLiked, toBeDisliked end local function PlayVoiceResponseOpponentKilled(unit, vrContext) --first check for praises logic that cause sync learn to like/dislike of a merc if vrContext.CheckForPraise then local chance = InteractionRand(100, "Praise") local chance_threshold = 50 if chance >= chance_threshold then DbgVoiceResponse("Praise", "didn't play - chance", chance, " > ", chance_threshold) end if chance < chance_threshold then local toBeLiked, toBeDisliked = GetUnitLearnToLikeDislike(unit) if next(toBeLiked) then local randomChoice = table.rand(toBeLiked, InteractionRand(1000000, "Praise")) local idx = table.find(gv_UnitData[randomChoice].LearnToLike, unit.session_id) if unit.gender ~= "N/A" and HasValidVoiceResponse(randomChoice, "Praises" .. unit.gender) then local becomeLikedChance = InteractionRand(100, "Praise") local chance_threshold_becomeLiked = const.LearnToLikeDislike.becomeLikedThreshold if becomeLikedChance < chance_threshold_becomeLiked then -- Saves check for value diff compared to preset and if it has not change it will not be saved. -- This is why we make sure it is a new table. -- (this will currently copy it every time but it's not a big deal as it's quite small) local ud = gv_UnitData[randomChoice] ud.Likes = table.copy(ud.Likes) table.insert(ud.Likes, unit.session_id) -- Since this is initiated by a tactical view event we need to apply the change -- to the Unit as well, otherwise it will be overwritten by the sync. -- It is possible for units to either refer to the template table or have a copy -- of the unit data table (based on whether it has been modified). -- Since it still isn't a big deal we will copy the unit data table to cover both bases. g_Units[randomChoice].Likes = table.copy(ud.Likes) if HasValidVoiceResponse(randomChoice, "LearnToLike" .. idx) then PlayVoiceResponse(randomChoice, "LearnToLike" .. idx) end Msg("BecomeLiked", randomChoice, unit.session_id) MoraleModifierEvent("BecomeLiked", g_Units[randomChoice], unit) CombatLog("important", T{800866942430, " Likes ", observer = gv_UnitData[randomChoice].Nick, actor = unit.Nick}) else CombatLog("debug", T{Untranslated("Praise did not cause LearnToLike. Chance /"), chance = becomeLikedChance, threshold = chance_threshold_becomeLiked}) end PlayVoiceResponse(randomChoice, "Praises" .. unit.gender) end else local liked, disliked, general = GetUnitLikedArray(unit) local weights = { { 2, "General" } } if next(liked) then weights[#weights + 1] = { 4, "Buddy" } end if next(disliked) then weights[#weights + 1] = { 4, "Dislike" } end local praiseType = GetWeightedRandom(weights, InteractionRand(1000000, "Praise")) if praiseType == "Buddy" then local unitPraising, rand = table.rand(liked, InteractionRand(1000000, "Praise")) -- "VoiceResponse_OpponentKilled" local learnToLikeIndex = table.find(gv_UnitData[unitPraising].LearnToLike, unit.session_id) local idx = learnToLikeIndex or table.find(gv_UnitData[unitPraising].Likes, unit.session_id) local voice if learnToLikeIndex then voice = "PraisesLearnToLike" .. idx else voice = "PraisesBuddy" .. idx end if HasValidVoiceResponse(unitPraising, voice) then PlayVoiceResponse(unitPraising, voice) end elseif praiseType == "Dislike" then local unitPraising, rand = table.rand(disliked, InteractionRand(1000000, "Praise")) -- "VoiceResponse_OpponentKilled" local learnToDislikeIndex = table.find(gv_UnitData[unitPraising].LearnToDislike, unit.session_id) local idx = learnToDislikeIndex or table.find(gv_UnitData[unitPraising].Dislikes, unit.session_id) local voice if learnToDislikeIndex then voice = "PraisesLearnToDislike" .. idx else voice = "PraisesDislike"..idx end if HasValidVoiceResponse(unitPraising, voice) then PlayVoiceResponse(unitPraising, voice) end else local unitPraising, rand = table.rand(general, InteractionRand(1000000, "Praise")) -- "VoiceResponse_OpponentKilled" if unit.gender ~= "N/A" and HasValidVoiceResponse(unitPraising, "Praises" .. unit.gender) then PlayVoiceResponse(unitPraising, "Praises" .. unit.gender) end end end end end --after that, check all other vr that could play for _, vrType in ipairs(vrContext) do PlayVoiceResponse(unit, vrType) end --the generic response if nothing else works PlayVoiceResponse(unit, "OpponentKilled") end function PlayVoiceResponseMissHighChance(unit) local chance = InteractionRand(100, "Mock") -- "VoiceResponse_MissHighChance" local chance_threshold = 50 if chance >= chance_threshold then DbgVoiceResponse("Mock", "didn't play - chance", chance, " > ", chance_threshold) end if chance < chance_threshold then local toBeLiked, toBeDisliked = GetUnitLearnToLikeDislike(unit) if next(toBeDisliked) then local randomChoice = table.rand(toBeDisliked, InteractionRand(1000000, "Mock")) local idx = table.find(gv_UnitData[randomChoice].LearnToDislike, unit.session_id) if HasValidVoiceResponse(randomChoice, "MockGeneral") then local becomeDislikedChance = InteractionRand(100, "Mock") local chance_threshold_becomeDisliked = const.LearnToLikeDislike.becomeDislikedThreshold if becomeDislikedChance < chance_threshold_becomeDisliked then -- Saves check for value diff compared to preset and if it has not change it will not be saved. -- This is why we make sure it is a new table. -- (this will currently copy it every time but it's not a big deal as it's quite small) local ud = gv_UnitData[randomChoice] ud.Dislikes = table.copy(ud.Dislikes) table.insert(ud.Dislikes, unit.session_id) -- Since this is initiated by a tactical view event we need to apply the change -- to the Unit as well, otherwise it will be overwritten by the sync. -- It is possible for units to either refer to the template table or have a copy -- of the unit data table (based on whether it has been modified). -- Since it still isn't a big deal we will copy the unit data table to cover both bases. g_Units[randomChoice].Dislikes = table.copy(ud.Dislikes) if HasValidVoiceResponse(randomChoice, "LearnToDislike" .. idx) then PlayVoiceResponse(randomChoice, "LearnToDislike" .. idx) end Msg("BecomeDisliked", randomChoice, unit.session_id) MoraleModifierEvent("BecomeDisliked", g_Units[randomChoice], unit) CombatLog("short", T{327710109480, " Dislikes ", observer = gv_UnitData[randomChoice].Nick, actor = unit.Nick}) else CombatLog("debug", T{Untranslated("Mock did not cause dislike. Chance /"),chance = becomeDislikedChance, threshold = chance_threshold_becomeDisliked}) end PlayVoiceResponse(randomChoice, "MockGeneral") return end else local liked, disliked, general = GetUnitLikedArray(unit) local weights = { { 2, "General" } } if next(liked) then weights[#weights + 1] = { 4, "Like" } end if next(disliked) then weights[#weights + 1] = { 4, "Dislike" } end local mockType = GetWeightedRandom(weights, InteractionRand(1000000, "Mock")) if mockType == "Like" then local unitMocking = table.rand(liked, InteractionRand(1000000, "Mock")) local learnToLikeIndex = table.find(gv_UnitData[unitMocking].LearnToLike, unit.session_id) local idx = learnToLikeIndex or table.find(gv_UnitData[unitMocking].Likes, unit.session_id) local voice if learnToLikeIndex then voice = "MockLearnToLike" .. idx else voice = "MockLike" .. idx end if HasValidVoiceResponse(unitMocking, voice) then PlayVoiceResponse(unitMocking, voice) return end elseif mockType == "Dislike" then local unitMocking = table.rand(disliked, InteractionRand(1000000, "Mock")) local learnToDislikeIndex = table.find(gv_UnitData[unitMocking].LearnToDislike, unit.session_id) local idx = learnToDislikeIndex or table.find(gv_UnitData[unitMocking].Dislikes, unit.session_id) local voice if learnToDislikeIndex then voice = "MockLearnToDislike" .. idx else voice = "MockDislike"..idx end if HasValidVoiceResponse(unitMocking, voice) then PlayVoiceResponse(unitMocking, voice) return end else local unitMocking = table.rand(general, InteractionRand(1000000, "Mock")) if HasValidVoiceResponse(unitMocking,"MockGeneral") then PlayVoiceResponse(unitMocking, "MockGeneral") return end end end end PlayVoiceResponse(unit, "MissHighChance") end local function PlayVoiceResponseFriendlyFire(attacker, target, kill) local vrUnit = kill and attacker or target local vrTarget = kill and target or attacker local vr = kill and "KillFriendlyFire" or "FriendlyFire" local likeIndex = table.find(gv_UnitData[vrUnit.session_id].Likes, vrTarget.session_id) local learnToLikeIndex = table.find(gv_UnitData[vrUnit.session_id].LearnToLike, vrTarget.session_id) local dislikeIndex = table.find(gv_UnitData[vrUnit.session_id].Dislikes, vrTarget.session_id) local learnToDislikeIndex = table.find(gv_UnitData[vrUnit.session_id].LearnToDislike, vrTarget.session_id) local voice if likeIndex and learnToLikeIndex then voice = vr .. "LearnToLike" .. learnToLikeIndex elseif likeIndex and not learnToLikeIndex then voice = vr .. "Buddy" .. likeIndex elseif dislikeIndex and learnToDislikeIndex then voice = vr .. "LearnToDislike" .. learnToDislikeIndex elseif dislikeIndex and not learnToDislikeIndex then voice = vr .. "Dislike" .. dislikeIndex else voice = vr .. "General" end if HasValidVoiceResponse(vrUnit.session_id, voice) then PlayVoiceResponse(vrUnit, voice) return end end local function PlayVoiceResponseAIDead(target, targetPos) local team = target.team and target.team.units or empty_table local firstAvailable = false local unitsCount = 0 targetPos = targetPos or IsValid(target) and target:GetPos() if not targetPos then return end for _, unit in ipairs(team) do if not unit:IsDead() then unitsCount = unitsCount + 1 if unit.species == "Human" and not firstAvailable then local unitPos = unit:GetPos() local manhDist = abs(targetPos:x() - unitPos:x()) + abs(targetPos:y() - unitPos:y()) if manhDist <= const.BanterSlabDistance * const.SlabSizeX then firstAvailable = unit end end end end local voice = target.species == "Human" and "AIDeadAlly" or "AIDeadAnimal" if firstAvailable and unitsCount > 1 then PlayVoiceResponseGroup(firstAvailable, voice) elseif unitsCount == 1 then DbgVoiceResponse("AIDeadAlly", "didn't play - last enemy left") else DbgVoiceResponse("AIDeadAlly", "didn't play - no other nearby enemy") end end function OnMsg.Attack(action, results, attack_args) local attacker = attack_args.obj local hit_units = table.ifilter(results.hit_objs, function(idx, o) return IsValid(o) and IsKindOf(o, "Unit") end) local target_spot_group = attack_args.target_spot_group or "" local attackerIsUnit = IsKindOf(attacker, "Unit") if not attacker or not next(hit_units) then return end local killedCount = results.killed_units and not table.findfirst(results.killed_units, function(idx, unit) return unit:IsPlayerAlly() end) and #results.killed_units or 0 --for each hit unit by the attacker, check conds and play vr's for _, target in ipairs(hit_units) do local spot_group = attack_args.target == target and target_spot_group or "" local targetIsAlly = IsKindOf(target, "Unit") and target:IsPlayerAlly() local skipKillMsg = not IsKindOf(target, "Unit") or not attackerIsUnit local stealth_kill = results.stealth_attack and next(results.killed_units) and table.find(results.killed_units, target) local targetDmg = next(results.unit_damage) and results.unit_damage[target] or 0 local targetIsCivilian = IsKindOf(target, "Unit") and target:IsCivilian() if target:IsDead() and (targetDmg > 0 or next(results.killed_units)) then local vrContext = {} if killedCount > 1 then table.insert(vrContext, "MultiOpponentKilled") end if results.melee_attack and not results.stealth_kill and g_Combat and not g_Combat:ShouldEndCombat(results.killed_units) then table.insert(vrContext, "OpponentKilledMelee") end if not targetIsCivilian and not targetIsAlly then target.team.tactical_situations_vr.deadUnits = target.team.tactical_situations_vr.deadUnits and target.team.tactical_situations_vr.deadUnits + 1 or 1 if attackerIsUnit then attacker.team.tactical_situations_vr.killedUnits = attacker.team.tactical_situations_vr.killedUnits and attacker.team.tactical_situations_vr.killedUnits + 1 or 1 PlayVoiceResponseTacticalSituation(table.find(g_Teams, attacker.team), "now") end end if not IsMerc(target) and spot_group ~= "Head" then if HasValidVoiceResponse(target.unitdatadef_id, "DramaticDeath") then PlayVoiceResponse(target, "DramaticDeath") else PlayVoiceResponse(target, "AIDeath") end end if attackerIsUnit and attacker:IsPlayerAlly() and not targetIsAlly and spot_group == "Head" and (not g_Combat or not g_Combat:ShouldEndCombat()) then table.insert(vrContext, "OpponentKilledHeadshot") PlayVoiceResponseAIDead(target) end if not stealth_kill and (not g_Combat or not g_Combat:ShouldEndCombat()) then if target.species ~= "Human" then table.insert(vrContext, "OpponentKilledAnimal") PlayVoiceResponseAIDead(target) end local targetPos = (IsValid(target) and target:GetPos()) or (attack_args and attack_args.target_pos) if targetIsAlly and attackerIsUnit and attacker:IsPlayerAlly() then PlayVoiceResponseFriendlyFire(attacker, target, "kill") else vrContext.CheckForPraise = true PlayVoiceResponseAIDead(target, targetPos) end if not targetIsCivilian and not targetIsAlly then PlayVoiceResponseOpponentKilled(attacker, vrContext) end end if not IsMerc(target) and spot_group == "Head" then EndBanter(target) end elseif targetDmg > 0 then if targetIsAlly and attackerIsUnit and attacker:IsPlayerAlly() then PlayVoiceResponseFriendlyFire(attacker, target) target:AddStatusEffect("FriendlyFire") else if target.wounded_this_turn and not targetIsAlly then if attackerIsUnit then attacker.team.tactical_situations_vr.woundsInflicted = attacker.team.tactical_situations_vr.woundsInflicted and attacker.team.tactical_situations_vr.woundsInflicted + 1 or 1 PlayVoiceResponseTacticalSituation(table.find(g_Teams, attacker.team), "now") end target.team.tactical_situations_vr.woundsReceived = target.team.tactical_situations_vr.woundsReceived and target.team.tactical_situations_vr.woundsReceived + 1 or 1 end if target.villain and not target:IsDefeatedVillain() and target.HitPoints <= MulDivRound(target.MaxHitPoints, 50, 100) then PlayVoiceResponse(target, "VillainHurt") end if target:HasStatusEffect("Flanked") and not target:IsDefeatedVillain() then if IsMerc(target) then PlayVoiceResponse(target, "SurroundedPain") end end end end end end function OnMsg.DamageDone(attacker, target, damage, hit_desc) if not attacker or not target then return end local targetIsUnit = IsKindOf(target, "Unit") if not target:IsDead() and targetIsUnit then PlayVoiceResponse(target, "Pain") end end local function PlayVoiceResponseUnitDied(unit) local liked, disliked, general = GetUnitLikedArray(unit) if next(liked) then local lunit,rand = table.rand(liked, InteractionRand(1000000, "Death")) -- "VoiceResponse_Death_" local learnToLikeIndex = table.find(gv_UnitData[lunit].LearnToLike, unit.session_id) local idx = learnToLikeIndex or table.find(gv_UnitData[lunit].Likes, unit.session_id) local voice if learnToLikeIndex then voice = "DeathLearnToLike" .. idx else voice = "DeathBuddy"..idx end if HasValidVoiceResponse(lunit, voice) then PlayVoiceResponse(lunit, voice) return end end if next(disliked) then local lunit,rand = table.rand(disliked, InteractionRand(1000000, "Death")) -- "VoiceResponse_Death_" local learnToDislikeIndex = table.find(gv_UnitData[lunit].LearnToDislike, unit.session_id) local idx = learnToDislikeIndex or table.find(gv_UnitData[lunit].Dislikes, unit.session_id) local voice if learnToDislikeIndex then voice = "DeathLearnToDislike" .. idx else voice = "DeathDislike"..idx end if HasValidVoiceResponse(lunit, voice) then PlayVoiceResponse(lunit, voice) return end end if next(general) then local lunit,rand = table.rand(general, InteractionRand(1000000, "Death")) -- "VoiceResponse_Death_" if HasValidVoiceResponse(lunit, "DeathGeneral") then PlayVoiceResponse(lunit, "DeathGeneral") return end end end function OnMsg.UnitDied(unit) if not IsMerc(unit) then return end PlayVoiceResponseUnitDied(unit) end SuppressNextSelectionChangeVR = false function OnMsg.SelectionChange() if #Selection < 1 or GetLoadingScreenDialog() or GetSatelliteDialog() or GetDialog("PDADialog") or IsInventoryOpened() then return end if SuppressNextSelectionChangeVR then SuppressNextSelectionChangeVR = false return end local obj = Selection[1] if IsKindOf(obj, "Unit") and obj:IsPlayerAlly() then if (obj:IsThreatened(nil, "pindown") or obj:IsUnderBombard() or obj:IsUnderTimedTrap()) and PlayVoiceResponse(obj, "ThreatSelection") then return elseif obj:IsThreatened(nil, "overwatch") and PlayVoiceResponse(obj, "OverwatchSelection") then return elseif g_Combat and obj:GetStatusEffect("Wounded") and obj:GetStatusEffect("Wounded").stacks >= 4 and PlayVoiceResponse(obj, "HeavilyWoundedSelection") then return elseif IsNearTrap(obj, "not ally trap") and PlayVoiceResponse(obj, "MineNearbySelection") then return elseif ClearLOFOnEnemiesCount(obj) > 5 and PlayVoiceResponse(obj, "ManyEnemiesSelection") then --more than 5 enemies in lof return elseif obj:HasStatusEffect("Hidden") and PlayVoiceResponse(obj, "SelectionStealth") then return else PlayVoiceResponse(obj, "Selection") end end end function ClearLOFOnEnemiesCount(unit) local lofToEnemiesCount = 0 if g_Combat then local enemies = GetEnemies(unit) for _, enemy in ipairs(enemies) do if HasVisibilityTo(unit, enemy) then lofToEnemiesCount = lofToEnemiesCount +1 end end end return lofToEnemiesCount end function IsNearTrap(unit, notAllyTrap) local traps = g_Traps for _, trap in ipairs(traps) do local isBarrel = IsKindOf(trap, "Explosive_Barrel") if IsValid(trap) and not trap.done and not trap:IsDead() and trap.discovered_trap and not isBarrel and trap.TriggerType ~= "Timed" and not IsKindOf(trap, "BoobyTrappable") then local trapPos = trap:GetPos() local dangerRange = 8 * const.SlabSizeX local enemySide = notAllyTrap and trap.team_side ~= "player1" and trap.team_side ~= "player2" if enemySide and unit:GetDist(trapPos) < dangerRange then return true end end end return false end function OnMsg.EnemySighted(team, enemy) if team and team.player_ally and enemy and SelectedObj and not gv_CombatStartFromConversation then local vr = enemy.species ~= "Human" and "AnimalFound" or "OpponentFound" PlayVoiceResponse(SelectedObj, vr) end end function OnMsg.EnemySightedExploration(enemy) local pov_team = GetPoVTeam() if not pov_team or gv_CombatStartFromConversation then return end local units = table.ifilter(pov_team.units, function(idx, unit) return HasVisibilityTo(unit, enemy) end) if #units == 0 then return end local vr = enemy.species ~= "Human" and "AnimalFound" or "OpponentFound" PlayVoiceResponse(table.rand(units), vr) if gv_CurrentSectorId == "I2" then ShowStealthTutorialPopup() end end function OnMsg.UnitAwarenessChanged(unit) if not IsKindOf(unit, "Unit") then return end -- can be unit data local isEnemy if unit.team then isEnemy = unit.team.side == "enemy1" or unit.team.side == "enemy2" or unit.team.side == "enemyNeutral" end local isMilitiaUnit = isEnemy and g_Combat and g_Combat.starting_unit and g_Combat.starting_unit.team.side == "ally" if not unit:IsAware("pending") then return end local time = AsyncRand(100, 200) CreateGameTimeThread(function() Sleep(time) PlayVoiceResponseGroup(isMilitiaUnit and g_Combat.starting_unit or unit, "BecomeAware") end) end function OnMsg.OperationCompleted(operation, mercs) if operation.id == "Arriving" or operation.id == "RAndR" then return end local voice_merc = table.interaction_rand(mercs, "Operation") if voice_merc then PlayVoiceResponse(voice_merc, "ActivityFinished") local squad = gv_Squads[voice_merc.Squad] if squad and squad.vrForActivity then squad.vrForActivity[operation.id] = nil --reset operation list for vr end end end function OnMsg.CombatEnd(combat) ResolveVoiceResponses("EndOfCombat") end function OnMsg.TurnStart(team) NetUpdateHash("Banter OnMsg.TurnStart") ResetVoiceResponses("OncePerTurn") PlayVoiceResponseTacticalSituation(team, "turnStart") local currTeam = g_Teams[team] or false if currTeam then currTeam.tactical_situations_vr = {} end end function OnMsg.TurnEnded(team) PlayVoiceResponseTacticalSituation(team, "turnEnd") local currTeam = g_Teams[team] or false if currTeam then currTeam.tactical_situations_vr = {} end end function PlayVoiceResponseTacticalSituation(currTeam, event) NetUpdateHash("PlayVoiceResponseTacticalSituation", event) assert(currTeam) --dont play vr if combat has ended if g_Combat and not g_Combat:AreEnemiesAware(currTeam) then return end currTeam = g_Teams[currTeam] or false local teamUnits = currTeam.units --1 or less units shouldn't perform VR local aliveUnitsCount = 0 for _, unit in ipairs(teamUnits) do if not unit:IsDead() and not unit:IsDowned() then aliveUnitsCount = aliveUnitsCount + 1 end end if aliveUnitsCount <= 1 then return end local vrEvents = {} --convert the tactical_situations_vr events to specific VRs if (currTeam.tactical_situations_vr["deadUnits"] and currTeam.tactical_situations_vr["deadUnits"] >= 2) or (currTeam.tactical_situations_vr["downedUnits"] and currTeam.tactical_situations_vr["downedUnits"] >= 2) then table.insert(vrEvents, "TacticalLoss") end if (currTeam.tactical_situations_vr["deadUnits"] and currTeam.tactical_situations_vr["deadUnits"] == 1) or (currTeam.tactical_situations_vr["downedUnits"] and currTeam.tactical_situations_vr["downedUnits"] == 1) then table.insert(vrEvents, "TacticalRevenge") end if currTeam.tactical_situations_vr["woundsReceived"] and currTeam.tactical_situations_vr["woundsReceived"] >= 2 then table.insert(vrEvents, "TacticalCareful") end if (currTeam.tactical_situations_vr["killedUnits"] and currTeam.tactical_situations_vr["killedUnits"] >= 2) or (currTeam.tactical_situations_vr["downedUnitsByTeam"] and currTeam.tactical_situations_vr["downedUnitsByTeam"] >= 2) then table.insert(vrEvents, "TacticalKilling") end if currTeam.tactical_situations_vr["woundsInflicted"] and currTeam.tactical_situations_vr["woundsInflicted"] >= 2 then table.insert(vrEvents, "TacticalPressing") end if currTeam.tactical_situations_vr["missedShots"] and currTeam.tactical_situations_vr["missedShots"] > 3 then table.insert(vrEvents, "TacticalFocus") end if not currTeam.tactical_situations_vr["shotsFired"] or (currTeam.tactical_situations_vr["shotsFired"] and currTeam.tactical_situations_vr["shotsFired"] == 0) then if currTeam.tactical_situations_vr["movementDone"] then table.insert(vrEvents, "TacticalReposition") end end if g_Visibility[currTeam] then local oppositeTeam if currTeam:IsPlayerControlled() then oppositeTeam = {"enemy1", "enemy2", "neutralEnemy"} else oppositeTeam = {"player1", "player2"} end for _, visUnit in ipairs(g_Visibility[currTeam]) do if visUnit.team and table.find(oppositeTeam, visUnit.team.side) and visUnit:IsAware() then table.insert(vrEvents, "TacticalTaunt") break end end end if currTeam:IsPlayerControlled() then local firstMerc = currTeam.units and currTeam.units[1] local enemies = firstMerc and GetAllEnemyUnits(firstMerc) local aliveEnemies = 0 for _, enemy in ipairs(enemies) do if not enemy:IsDead() and not enemy:IsDefeatedVillain() then aliveEnemies = aliveEnemies + 1 end end if aliveEnemies == 1 then table.insert(vrEvents, "TacticalLastEnemy") end end if not next(vrEvents) then return end --for each possible voice response, pick units that can perform it (prio of units with high leadership) for _, vrType in ipairs(vrEvents) do local validUnitVR = {} local leadershipVR = {} for _, unit in ipairs(teamUnits) do if (HasValidVoiceResponse(unit.session_id, vrType) or not unit:IsMerc()) and not unit:IsDead() and not unit:IsDowned() and not unit:HasStatusEffect("Hidden") then table.insert(validUnitVR, unit) if unit.Leadership >= const.Combat.LeadershipThresholdVR then table.insert(leadershipVR, unit) end end end if next(leadershipVR) then vrEvents[vrType] = leadershipVR elseif next(validUnitVR) then vrEvents[vrType] = validUnitVR end end --depending on the event and priority and available unit for that vr, choose one local unitForVR if event == "turnStart" then if table.find(vrEvents, "TacticalLastEnemy") and next(vrEvents["TacticalLastEnemy"]) then unitForVR = table.rand(vrEvents["TacticalLastEnemy"], InteractionRand(1000000, "TacticalSituation")) PlayVoiceResponse(unitForVR, "TacticalLastEnemy") elseif table.find(vrEvents, "TacticalLoss") and next(vrEvents["TacticalLoss"]) then unitForVR = table.rand(vrEvents["TacticalLoss"], InteractionRand(1000000, "TacticalSituation")) PlayVoiceResponse(unitForVR, "TacticalLoss") elseif table.find(vrEvents, "TacticalRevenge") and next(vrEvents["TacticalRevenge"]) then unitForVR = table.rand(vrEvents["TacticalRevenge"], InteractionRand(1000000, "TacticalSituation")) PlayVoiceResponseGroup(unitForVR, "TacticalRevenge") elseif table.find(vrEvents, "TacticalCareful") and next(vrEvents["TacticalCareful"]) then unitForVR = table.rand(vrEvents["TacticalCareful"], InteractionRand(1000000, "TacticalSituation")) PlayVoiceResponseGroup(unitForVR, "TacticalCareful") elseif table.find(vrEvents, "TacticalTaunt") and next(vrEvents["TacticalTaunt"]) then unitForVR = table.rand(vrEvents["TacticalTaunt"], InteractionRand(1000000, "TacticalSituation")) PlayVoiceResponse(unitForVR, "TacticalTaunt") end elseif event == "now" then if table.find(vrEvents, "TacticalKilling") and next(vrEvents["TacticalKilling"]) then unitForVR = table.rand(vrEvents["TacticalKilling"], InteractionRand(1000000, "TacticalSituation")) PlayVoiceResponse(unitForVR, "TacticalKilling") currTeam.tactical_situations_vr["killedUnits"] = 0 currTeam.tactical_situations_vr["downedUnitsByTeam"] = 0 end if table.find(vrEvents, "TacticalPressing") and next(vrEvents["TacticalPressing"]) then unitForVR = table.rand(vrEvents["TacticalPressing"], InteractionRand(1000000, "TacticalSituation")) PlayVoiceResponseGroup(unitForVR, "TacticalPressing") currTeam.tactical_situations_vr["woundsInflicted"] = 0 end if table.find(vrEvents, "TacticalFocus") and next(vrEvents["TacticalFocus"]) then if g_Combat and next(vrEvents["TacticalFocus"]) and #vrEvents["TacticalFocus"] > 1 then local lastUnit = g_Combat:GetActiveUnit() local lastUnitIdx = table.find(vrEvents.TacticalFocus, "session_id", lastUnit and lastUnit.session_id) if lastUnitIdx then table.remove(vrEvents.TacticalFocus, lastUnitIdx) end end unitForVR = table.rand(vrEvents["TacticalFocus"], InteractionRand(1000000, "TacticalSituation")) PlayVoiceResponse(unitForVR, "TacticalFocus") currTeam.tactical_situations_vr["missedShots"] = 0 end elseif event == "turnEnd" and not currTeam:IsPlayerControlled() then local playerTeams = {} for i, team in ipairs(g_Teams) do if team.side == "player1" or team.side == "player2" then if team:IsEnemySide(currTeam) then local outnumbered_units = CombatCollectOutnumbered(i) local voice_unit = table.rand(outnumbered_units) if voice_unit then PlayVoiceResponse(voice_unit, "Outnumbered") end end end end if table.find(vrEvents, "TacticalReposition") and next(vrEvents["TacticalReposition"]) then unitForVR = table.rand(vrEvents["TacticalReposition"], InteractionRand(1000000, "TacticalSituation")) PlayVoiceResponse(unitForVR, "TacticalReposition") end end end function OnMsg.UnitMovementDone(unit) if unit:IsMerc() then gv_MercsLastMoveTime[unit.session_id] = GameTime() end if g_Combat and unit and not unit:IsMerc() then unit.team.tactical_situations_vr.movementDone = true end end MapVar("g_SelectedObjLastActionIsMovement", false) function OnMsg.SelectedObjChange(obj, prev) if not g_SelectedObjLastActionIsMovement then return end local shotsByUnits = prev and prev.team and prev.team.tactical_situations_vr["shotsFiredBy"] or false local unitHasShot = shotsByUnits and prev.session_id and shotsByUnits[prev.session_id] or false if not unitHasShot and prev then PlayVoiceResponse(prev, "TacticalReposition") end g_SelectedObjLastActionIsMovement = false end function OnMsg.CombatActionEnd(unit) --g_Combat.active_unit == unit ResolveVoiceResponses("EndOfCombatAction") end function OnMsg.ItemChangeCondition(item, prev, new, inventory_obj) if not inventory_obj or not new then return end if not IsKindOf(inventory_obj, "Unit") then return end if inventory_obj:HasStatusEffect("ManningEmplacement") then return end local isMerc = g_Units[inventory_obj.session_id]:IsMerc() if new < prev and isMerc then local maxCondition = item:GetMaxCondition() if item.Repairable and not IsConditionType(prev, maxCondition, "NeedRepair") and IsConditionType(new, maxCondition, "NeedRepair") then PlayVoiceResponse(inventory_obj.session_id, "ItemDeteriorates") CombatLog("important", T{783482293776, " is in need of repair.",item_name = item.DisplayName}) end if not IsConditionType(prev, maxCondition, "Broken") and IsConditionType(new, maxCondition, "Broken") then CombatLog("important", T{521183153860, " used by is Broken", item_name = item.DisplayName, merc_name = g_Units[inventory_obj.session_id]:GetDisplayName() } ) CreateFloatingText(g_Units[inventory_obj.session_id], T{860115014912, " Broken",item_name = item.DisplayName}, "FloatingTextMiss") if item:IsWeapon() then PlayVoiceResponse(inventory_obj.session_id, "WeaponBroken") end end end end MapVar("gv_VoiceResponsesQueue",{}) function ResolveVoiceResponses(event_group) local voices = gv_VoiceResponsesQueue[event_group] if not voices then return end CreateMapRealTimeThread(function() for i, voice in ipairs(voices) do if voice then local duration = PlayVoiceResponseInternal(voice.unit, voice.event_type, "skip_globalcd", "PlayedFromQueue") -- Only if this voice response played, suppress others in the queue. if duration then local eventType = voice.event_type local preset = VoiceResponseTypes[eventType] local suppresses = preset and next(preset.Suppresses) and preset.Suppresses or empty_table for j = i, #voices do local otherV = voices[j] if otherV and table.find(suppresses, otherV.event_type) then voices[j] = false DbgVoiceResponse(eventType, "didn't play - suppressed", otherV.event_type) end end end voices[i] = false end end -- reset gv_VoiceResponsesQueue[event_group] = {} end) end function PlayVoiceResponseGroup(unit, defaultEventType, force) local teamUnits = unit.team.units if teamUnits and #teamUnits <= 1 then return end if unit.neutral_retaliate and unit.spawner and unit.spawner.Side == "neutral" then return end if IsMerc(unit) then PlayVoiceResponse(unit, defaultEventType, force) return end local response_data = VoiceResponseTypes[defaultEventType] assert(response_data, string.format("No response data for event type: %s", defaultEventType)) if not response_data then return end local customGroup = response_data.CustomGroup --find all VR that belong to that custom group and pick the one which passes the play condition. --If more than one passes the cond -> assert (by design) --If none passes the cond -> play the defaultEventType if customGroup and customGroup ~= "" then local vrFromGroupCanPlay = false ForEachPresetInCampaign("VoiceResponseType", function(event) if event.CustomGroup == customGroup and EvalConditionList(event.PlayConditions) and event.id ~= defaultEventType then if not vrFromGroupCanPlay then vrFromGroupCanPlay = event.id else assert(false, string.format("More than one custom group VR can be played: %s %s", event.id, vrFromGroupCanPlay)) end end end) if vrFromGroupCanPlay and HasValidVoiceResponse(unit.VoiceResponseId, vrFromGroupCanPlay) then PlayVoiceResponse(unit, vrFromGroupCanPlay, force) else PlayVoiceResponse(unit, defaultEventType, force) end else PlayVoiceResponse(unit, defaultEventType, force) end end function PlayVoiceResponse(unit, eventType, force) local response_data = VoiceResponseTypes[eventType] assert(response_data, string.format("No response data for event type: %s", eventType)) if not response_data then return end if (not response_data.UseSnype and not response_data.Subtitled) or not response_data.EventGroup then return PlayVoiceResponseInternal(unit, eventType, force) end gv_VoiceResponsesQueue[response_data.EventGroup] = gv_VoiceResponsesQueue[response_data.EventGroup] or {} table.insert(gv_VoiceResponsesQueue[response_data.EventGroup], {event_type = eventType, unit = unit, force = force }) end MapVar("g_VoiceResponsesEnabled", true) function PlayVoiceResponseInternal(unit, eventType, force, delayedVR) if GetOpenLoadingScreen() or IsSetpiecePlaying() or GetDialog("ConversationDialog") then return end if not g_VoiceResponsesEnabled then return end if IsKindOf(unit, "UnitData") then unit = UnitDataDefs[unit.session_id] elseif type(unit) == "string" then unit = UnitDataDefs[unit] end if not unit or not IsKindOfClasses(unit, "Unit", "UnitDataCompositeDef") then assert(false, string.format("Couldn't find unit data def for this vr: %s", eventType)) return end local is_unit = IsKindOfClasses(unit, "Unit") if IsMerc(unit) then local udata = gv_UnitData[is_unit and unit.session_id or unit.id] if udata and not udata:IsLocalPlayerControlled() then DbgVoiceResponse(eventType, "Did not play - no local player merc") return end end if is_unit and unit:IsNPC() and not HasVisibilityTo(GetPoVTeam(), unit) and unit.team.side ~= "neutral" then return end -- Get parameters for this event type local response_data = VoiceResponseTypes[eventType] local subtitle, snype, cooldown, chanceToPlay, synchGroup, oncePerTurn, oncePerCombat, oncePerGame, usesOtherLines if response_data then subtitle = response_data.Subtitled snype = response_data.UseSnype cooldown = response_data.Cooldown chanceToPlay = response_data.ChanceToPlay or nil synchGroup = response_data.SynchGroup oncePerTurn = response_data.OncePerTurn oncePerCombat = response_data.OncePerCombat oncePerGame = response_data.OncePerGame usesOtherLines = response_data.UsesOtherLines end local suppressAllVR = response_data.SuppressAll if #g_ActiveBanters > 0 and not suppressAllVR then DbgVoiceResponse(eventType, "didn't play - banter is playing") return end local downedOrPainType = eventType == "Downed" or eventType == "HeavilyWoundedSelection" or eventType == "Pain" if IsMerc(unit) and not downedOrPainType then local ud = gv_UnitData[is_unit and unit.session_id or unit.id] local inControl = not ud or (not ud:HasStatusEffect("Berserk") and not ud:HasStatusEffect("Panicked")) if not inControl then DbgVoiceResponse(eventType, "Didn't play - unit is panicked or berserk", unit and unit.Name) return end end if is_unit and unit:IsDowned() and not downedOrPainType then DbgVoiceResponse(eventType, "Didn't play - downed unit", unit and unit.Name) return end if delayedVR and is_unit and unit:IsIncapacitated() and eventType ~= "Downed" then DbgVoiceResponse(eventType, "Didn't play event group - unit is incapacitated.", unit and unit.Name) return end if is_unit and unit:IsDead() and eventType ~= "AIDeath" and eventType ~= "DramaticDeath" then DbgVoiceResponse(eventType, "Didn't play - unit is dead and trying to speak.", unit and unit.Name) return end -- Resolve responses local canUseFallbackVR = (eventType == "AiDeath" or eventType == "Pain") and unit.species == "Human" local responses = VoiceResponses[unit.VoiceResponseId ~= "" and unit.VoiceResponseId or (is_unit and unit.unitdatadef_id) or unit.id] local useFallbackVR if not responses and not canUseFallbackVR then return end local lines = responses and responses:ResolveResponses(eventType) or empty_table -- resolve inheritance according to InheritFrom property lines = #lines > 0 and lines or usesOtherLines and usesOtherLines ~= "" and responses:ResolveResponses(usesOtherLines) or empty_table local lines_count = #lines if lines_count == 0 and canUseFallbackVR then --use fallback vr responses = VoiceResponses[unit.FallbackMissingVR] useFallbackVR = true assert(responses, "FallbackMissingVR did not find any responses as well.") lines = responses:ResolveResponses(eventType) lines = #lines > 0 and lines lines_count = #lines end if lines_count == 0 then DbgVoiceResponse(eventType, "Didn't play - no written responses", unit and unit.Name) return end local unitName = unit.unitdatadef_id or unit.id local linesWithCD = {} if response_data.PerLineCooldown > 0 then local linesOnCD = gv_VoiceLinesCD[unitName] and gv_VoiceLinesCD[unitName][eventType] if linesOnCD then for _, line in pairs(linesOnCD) do if not line.gameId or line.gameId ~= Game.id then gv_VoiceLinesCD = {} --reset the whole table if the game session is different as the var is pergame and per exe break end if line.playedAt + response_data.PerLineCooldown >= RealTime() then linesWithCD[line.lineIdx] = true end end end end -- Determine the next line variation g_VoiceResponses[unitName] = g_VoiceResponses[unitName] or {} local lastPlay = g_VoiceResponses[unitName][eventType] local unitGroup = (unit.group or unit.unitdatadef_id) or unit.id if IsMerc(unit) then unitGroup = "Mercs" else unitGroup = unit.Affiliation or unit.id end if synchGroup then g_VoiceResponses[unitGroup] = g_VoiceResponses[unitGroup] or {} lastPlay = g_VoiceResponses[unitGroup][eventType] end local lineIdx = -1 if lines_count == 1 and not linesWithCD[1] then lineIdx = 1 elseif lines_count == 2 then local lastPlayedIdx = lastPlay and lastPlay.lineIdx if lastPlayedIdx and lastPlayedIdx == 1 and not linesWithCD[2] then lineIdx = 2 elseif not linesWithCD[1] then lineIdx = 1 end else if lastPlay then lineIdx = 1 + AsyncRand(lines_count,"VoiceResponseLines") local try = 0 while (lineIdx == lastPlay.lineIdx or linesWithCD[lineIdx]) and try < Max(lines_count, 10) do lineIdx = 1 + AsyncRand(lines_count, "VoiceResponseLines") try = try + 1 end lineIdx = not linesWithCD[lineIdx] and lineIdx or -1 else local try = 0 lineIdx = 1 + AsyncRand(lines_count, "VoiceResponseLines") while linesWithCD[lineIdx] and try < Max(lines_count, 10) do lineIdx = 1 + AsyncRand(lines_count, "VoiceResponseLines") try = try + 1 end lineIdx = not linesWithCD[lineIdx] and lineIdx or -1 end end if lineIdx == -1 then DbgVoiceResponse(eventType, "Didn't play - all lines are on cd", unit and unit.Name) return end local now = RealTime() if (type(force) ~= "boolean" or force ~= true) and not suppressAllVR then if force ~= "skip_globalcd" then -- Check global cooldown local lastPlayedTime = g_voiceRespLastPlayed and g_voiceRespLastPlayed.playedAt + g_voiceRespLastPlayed.duration or 0 if (now - lastPlayedTime < const.GlobalVoiceResponseCooldown) and (not snype) then --if the last played VR was not subtitled and the current one is, we should ignor the cooldown as the non-subtitled VR will be stopped --this means that we should only look at the cooldown if the last message was subtitled or when both messages are not subtitled if g_voiceRespLastPlayed.subtitle or ((not g_voiceRespLastPlayed.subtitle) and (not subtitle)) then if subtitle or snype then DbgVoiceResponse(eventType, "didn't play - global cooldown") end return end elseif g_voiceRespLastPlayed then --check if last played is enemy vr and check global enemy vr cd local lastIsEnemy = g_voiceRespLastPlayed.playedByEnemy local isEnemy = not IsMerc(unit) if isEnemy and lastIsEnemy and (now - lastPlayedTime < const.Combat.EnemyVrGlobalCd) and (not snype) then if g_voiceRespLastPlayed.subtitle or ((not g_voiceRespLastPlayed.subtitle) and (not subtitle)) then if subtitle or snype then DbgVoiceResponse(eventType, "didn't play - global ENEMY cooldown") end return end end end end if g_suppressAllUntil and now < g_suppressAllUntil then if subtitle or snype then DbgVoiceResponse(eventType, "didn't play - global suppress by " .. (g_LastVRPlayed and g_LastVRPlayed.eventType or "Unknown")) end return end -- Check line cooldown local lastPlayTime = lastPlay and lastPlay.playedAt if lastPlayTime then local timePassed = now - (lastPlayTime + lastPlay.duration or 0) if timePassed < (cooldown or 1000) then if (subtitle or snype) and eventType ~= "BusySatView" then DbgVoiceResponse(eventType, "didn't play - cooldown", cooldown, unit.Nick or unit.Name or "???") end return end end -- Check chance if chanceToPlay and (not force or force == "skip_globalcd") then local rand = AsyncRand(100) if rand > chanceToPlay then DbgVoiceResponse(eventType, "didn't play - chance", rand, ">", chanceToPlay, unit.Nick or unit.Name or "???") return end end -- check once per turn and combat // reset on turn end local recordunit = g_VoiceResponses[unitName][eventType] local recordgroup if synchGroup then recordgroup = g_VoiceResponses[unitGroup][eventType] end if recordunit and next(recordunit) and (oncePerTurn and recordunit.OncePerTurn or oncePerCombat and recordunit.OncePerCombat) then DbgVoiceResponse("didn't play - OncePerTurn/OncePerCombat", eventType, unit.Nick or unit.Name or "???") return end if recordgroup and next(recordgroup) and (oncePerTurn and recordgroup.OncePerTurn or oncePerCombat and recordgroup.OncePerCombat) then DbgVoiceResponse(eventType, "didn't play - OncePerTurn/OncePerCombat", unit.Nick or unit.Name or "???") return end if recordunit and oncePerGame and recordunit.OncePerGame then DbgVoiceResponse(eventType, "didn't play - OncePerGame", unit.Nick or unit.Name or "???") return end end -- VR interrupts local queued = false if g_LastVRPlayed and (not force or force == "skip_globalcd") or (suppressAllVR and #g_ActiveBanters > 0) then local lastPlayRecord = g_LastVRPlayed local currentlyPlaying = lastPlayRecord and lastPlayRecord.playedAtGameTime + lastPlayRecord.duration > GameTime() or (suppressAllVR and #g_ActiveBanters > 0) if currentlyPlaying then -- TH queue will resolve playing TH while another one is playing, we just need to take care of TH when current isn't. if lastPlayRecord and not lastPlayRecord.snype and not lastPlayRecord.subtitle and lastPlayRecord.soundStopCallback and not suppressAllVR then lastPlayRecord.soundStopCallback() else if not subtitle and not suppressAllVR then DbgVoiceResponse(eventType,"didn't play - another VR is playing - ", lastPlayRecord.eventType) return else queued = true end end end end local line = lines[lineIdx] local voice = GetVoiceFilename(line, is_unit and xxhash(rawget(useFallbackVR and UnitDataDefs[unit.FallbackMissingVR] or unit, "session_id"))) local duration = GetSoundDurationGameTime(voice) or ReadDurationFromText(_InternalTranslate(line)) -- Incur 1000 cooldown on the suppressed voice response. if response_data.Suppresses and not force then for i, event_type in ipairs(response_data.Suppresses) do local suppressedPreset = VoiceResponseTypes[event_type] local cooldownFakeRecord = { playedAt = (now + suppressedPreset.Cooldown) - 1000, lineIdx = 0, duration = duration } g_VoiceResponses[unitName][event_type] = cooldownFakeRecord if synchGroup then g_VoiceResponses[unitGroup][event_type] = cooldownFakeRecord end end end local soundStopCallback = false if subtitle then local id = IsKindOf(unit, "Unit") and unit.id or rawget(unit, "unitdatadef_id") or unit.session_id or unit.id local attachedTemplate = SpawnPartyAttachedTalkingHeadNotification(id) if attachedTemplate or IsValid(unit) then local synthLine = PlaceObj("TalkingHeadLine") synthLine.Character = rawget(unit, "unitdatadef_id") or id synthLine.Text = line local synthNotification = PlaceObj("TalkingHeadNotificationBase") synthNotification.id = "VoiceResponse" .. eventType .. id synthNotification.Lines = { synthLine } synthNotification.NoSound = not queued synthNotification.ResetTimePlayed = queued and not g_LastVRPlayed if attachedTemplate then synthNotification.CustomLogic = attachedTemplate synthNotification.duration = duration synthNotification.SuppressAll = suppressAllVR else synthNotification.duration = duration synthNotification.CustomLogic = "FloatingText" synthLine.object = unit end PlayTalkingHead(synthNotification) soundStopCallback = function() synthNotification:Stop() end snype = false -- Both shouldn't be on, but let's turn it off just in case. end end if snype then local id = IsKindOf(unit, "Unit") and unit.session_id or unit.id local synthLine = PlaceObj("TalkingHeadLine") synthLine.Character = rawget(unit, "unitdatadef_id") or id synthLine.Text = line local synthNotification = PlaceObj("TalkingHeadNotificationBase") synthNotification.id = "VoiceResponse" .. eventType .. id synthNotification.Lines = { synthLine } PlayTalkingHead(synthNotification) soundStopCallback = function() synthNotification:Stop() end elseif not queued then if voice then local oldCallback = soundStopCallback local sound_type = response_data.SoundType or "Voiceover" if sound_type == "Voiceover" then local soundHandle = PlaySound(voice, sound_type) soundStopCallback = function() if oldCallback then oldCallback() end; SetSoundVolume(soundHandle, -1, 300); end else if IsValid(unit) then unit:SetSound(voice, sound_type) soundStopCallback = function() if oldCallback then oldCallback() end; if IsValid(unit) then unit:StopSound(300) end; end end end else CombatLog("debug", "Couldn't find voice file " .. eventType .. " from " .. _InternalTranslate(unit.Nick or unit.Name or "???") .. " of line " .. _InternalTranslate(line)) end end local lastPlayTime = lastPlay and lastPlay.playedAt if const.DbgVoiceResponses then DbgVoiceResponse(eventType, lineIdx, _InternalTranslate(unit.Nick or unit.Name or "???"), lastPlayTime and (now - lastPlayTime), cooldown) end local record = { playedAt = now, playedAtGameTime = GameTime(), duration = (duration or 0) + (voice and queued and g_LastVRPlayed and g_LastVRPlayed.duration or 0), soundStopCallback = soundStopCallback, eventType = eventType, lineIdx = lineIdx, OncePerTurn = oncePerTurn, OncePerCombat = oncePerCombat, OncePerGame = oncePerGame, subtitle = subtitle, snype = snype, playedByEnemy = not IsMerc(unit), SuppressAll = suppressAllVR, } g_voiceRespLastPlayed = record g_VoiceResponses[unitName][eventType] = record if response_data.PerLineCooldown > 0 then local lineCDRecord = {lineIdx = record.lineIdx, playedAt = record.playedAt, gameId = Game.id} if not gv_VoiceLinesCD[unitName] then gv_VoiceLinesCD[unitName] = {} end gv_VoiceLinesCD[unitName][eventType] = gv_VoiceLinesCD[unitName][eventType] or {} gv_VoiceLinesCD[unitName][eventType][record.lineIdx] = lineCDRecord end if synchGroup then g_VoiceResponses[unitGroup][eventType] = record --DbgVoiceResponse(eventType, lineIdx, unitGroup) end g_LastVRPlayed = record if suppressAllVR then g_suppressAllUntil = now + duration end GossipVR(response_data, unitName) return duration end function ResetVoiceResponses(restriction_type) for unit, events in pairs(g_VoiceResponses) do for etype, record in pairs(events) do if record[restriction_type] then record[restriction_type] = nil end end end g_voiceRespLastPlayed = false end function StopVoiceResponses() local timeNow = RealTime() for unit, events in pairs(g_VoiceResponses) do for etype, record in pairs(events) do local stillPlaying = record.playedAt + (record.duration or 0) > timeNow if not stillPlaying or record.SuppressAll then break end if record.soundStopCallback then record.soundStopCallback() end record.duration = 0 end end end function GetRandomBanterFromGroups(groups, units, context, specificBanters) local banters = {} local allBanterGroups = {} local unplayedBanterGroups = {} for i, group in ipairs(groups) do local availableBanters, banterActors, unplayed = FilterAvailableBanters(Presets.BanterDef[group], context, units) if availableBanters then local groupEntry = {group = group, banters = availableBanters, actors = banterActors} table.insert(allBanterGroups, groupEntry) if unplayed then table.insert(unplayedBanterGroups, groupEntry) end end end if next(specificBanters) then local availableBanters, banterActors, unplayed = FilterAvailableBanters(specificBanters, context, units) if availableBanters then local groupEntry = {group = "SpecificBantersOfGroup", banters = availableBanters, actors = banterActors} table.insert(allBanterGroups, groupEntry) if unplayed then table.insert(unplayedBanterGroups, groupEntry) end end end -- If any unplayed, random between them only if next(unplayedBanterGroups) then allBanterGroups = unplayedBanterGroups end local idx = InteractionRand(#allBanterGroups, "BanterRandom") + 1 local randomBanterGroup = allBanterGroups[idx] if randomBanterGroup then banters = randomBanterGroup.banters end if #banters == 0 then CombatLog("debug", "BanterMarker triggered, but there werent any valid actors.") return false end local idx = InteractionRand(#banters, "BanterRandom") + 1 return banters[idx], randomBanterGroup.actors[idx] end --------------- editor debug info-------------- if FirstLoad then g_BanterEditorDebugInfo = false end DefineClass.BanterDebugInfo = { __parents = {"PropertyObject"}, properties = { { id = "id", name = "Banter ID", editor = "text", default = "ID" }, }, preset = false, } function BanterDebugInfo:GetProperties() local props = table.copy(PropertyObject.GetProperties(self)) -- from conversations ForEachPresetInCampaign("Conversation", function(preset) preset:ForEachSubObject("BanterFunctionObjectBase", function(obj, parents) if table.find(obj.Banters, self.id) then local element = { id = (preset.id)..#props, name = ComposePhraseId(parents), default = EditorViewAbridged(obj, obj.Banters, "banter"), sel_path = GedParentsListToSelection(parents), category = "Conversation references", editor = "text", read_only = true, buttons = { { name = "View", func = "ConversationEditorSelect", param = { preset_id = preset.id, sel_path = GedParentsListToSelection(parents), }, }, } } table.insert(props, element) end end) end) -- from quests ForEachPresetInCampaign("QuestsDef",function(preset, group, filter) preset:ForEachSubObject("BanterFunctionObjectBase", function(obj, parents) if table.find(obj.Banters,self.id) then local element = { id = (preset.id)..#props, name = ComposeSubobjectName(parents), default = EditorViewAbridged(obj, obj.Banters, "banter"), category = "Quests references", editor = "text", read_only = true, buttons = { { name = "View", func = "QuestsEditorSelect", param = { preset_id = preset.id, }, }, }, } table.insert(props, element) end end) end) -- from markers local map_name = GetMapName() if not g_DebugMarkersInfo or not g_DebugMarkersInfo[map_name] then GatherMarkerScriptingData() end -- filter for current banter ForEachDebugMarkerData("banter", self.preset, function(marker_info, res_item_info) local element = { id = "h_" .. marker_info.handle .. "_" .. #props, name = marker_info.name, default = res_item_info.editor_view_abridged, category = marker_info.map and marker_info.map.." GridMarker references" or "GridMarker references", editor = "text", read_only = true, } local name = marker_info.map==map_name and "View" or "View on other map" element.buttons = { { name = name, func = "GridMarkerEditorSelectDiffMap", param = { map = marker_info.map }, } } table.insert(props, element) end) return props end function OnMsg.GedOnEditorSelect(obj, selected, editor) if editor and editor.app_template == "BanterEditor" then if selected and rawget(obj, "id") then local infoobj = BanterDebugInfo:new{ preset = obj, id = obj.id,} g_BanterEditorDebugInfo = infoobj editor:BindObj("state", infoobj) else g_BanterEditorDebugInfo = false end end end ------------------------------------------------------------- DefineClass.OverheardMarker = { __parents = { "GridMarker", "CommandObject", "GameDynamicDataObject" }, properties = { { category = "BanterMarker", id = "BanterGroups", name = "Banter Groups", editor = "string_list", arbitrary_value = true, items = PresetGroupsCombo("BanterDef"), default = false }, { category = "BanterMarker", id = "SpecificBanters", name = "SpecificBanters", help = "Specific Banters to play when interacted with.", editor = "preset_id_list", default = {}, preset_class = "BanterDef", item_default = "", }, { category = "BanterMarker", id = "ConflictDisable", name = "Disabled In Conflict", editor = "bool", default = true }, }, AreaWidth = 28, AreaHeight = 28, Trigger = "activation", target_banter = false, target_units = false, old_target_positions = false, target_positions = false, waiting_activation = false, playing_banter = false } function OverheardMarker:GetEditorTypeText() return Untranslated("[Overheard]") end local lDirections = { point(-1, 0), point(-1, -1), point(0, -1), point(1, -1), point(1, 0), point(1, 1), point(0, 1), point(-1, 1) } local lRadiusDeviation = guim * 2 if FirstLoad then g_debugOverheardMarker = false end function OverheardMarker:SetDynamicData(data) if not data then return end if data.unit_handles then local units = {} local old_positions = {} for i, h in ipairs(data.unit_handles) do local obj = HandleToObject[h] if not IsKindOf(obj, "Unit") then break end units[i] = obj if units[i] and data.old_target_positions[h] then old_positions[units[i]] = data.old_target_positions[h] end end -- Not all units exist anymore (ambient life or whatever) if #units ~= #data.unit_handles or #units == 0 then return end self.target_units = units self.old_target_positions = old_positions end self.target_banter = data.target_banter self.waiting_activation = data.waiting_activation if data.target_banter then self:SetCommand("Resume") end end function OverheardMarker:GetDynamicData(data) if not self.target_units then return end local handles = {} local old_positions = {} for i, u in ipairs(self.target_units) do handles[i] = u:GetHandle() if self.old_target_positions and self.old_target_positions[u] then old_positions[handles[i]] = self.old_target_positions[u] end end -- Not all units could be saved, could've been despawned if #handles ~= #self.target_units then return end data.unit_handles = handles data.target_banter = self.target_banter data.waiting_activation = self.waiting_activation data.old_target_positions = old_positions end function OverheardMarker:GameInit() if not self.TriggerConditions then self.TriggerConditions = { UnitIsNearbyArea:new({ TargetUnit = "any merc" }) } end end function OverheardMarker:ReleaseUnits() -- Restore units to where they came from (or idle) if self.target_units then for i, u in ipairs(self.target_units) do if not IsValid(u) or u:IsDead() or u.command == "EnterCombat" or u.being_interacted_with then goto continue end if not g_Combat and self.old_target_positions and self.old_target_positions[u] then u:SetCommandParams("GotoSlab", {move_anim = "Walk"}) u:SetCommand("GotoSlab", self.old_target_positions[u]) else u:SetCommand("Idle") end ::continue:: end end end function OverheardMarker:StopRunning() self:ReleaseUnits() if self.playing_banter and self.playing_banter ~= true and IsValid(self.playing_banter) then DoneBanter(self.playing_banter) end self.playing_banter = false self.waiting_activation = false self.target_banter = false self.target_units = false self.target_positions = false self.old_target_positions = false Halt() end function OverheardMarker:StartRunning() if #g_Units == 0 then WaitMsg("TeamsUpdated") end self:ReleaseUnits() -- Check enable conditions, if there are any we must recheck this marker periodically. if not self:IsMarkerEnabled() then Sleep(5000) self:SetCommand("StartRunning") return end if (self.ConflictDisable and IsConflictMode(gv_CurrentSectorId)) or g_Combat then if g_debugOverheardMarker then print("overheard marker started in combat/conflict") end self:StopRunning() return end -- Decide which banter to play. local banter, actor = GetRandomBanterFromGroups(self.BanterGroups, g_Units, { require_idle = true }, self.SpecificBanters) NetUpdateHash("OverheardMarker:StartRunning()", banter) if not banter then if g_debugOverheardMarker then print("overheard marker didn't find banter to play") end self:StopRunning() return end self.target_banter = banter self.target_units = actor if g_debugOverheardMarker then print("will play", banter) end -- We want to run the resume command immediately as to prevent a yield -- that would cause other markers to potentially steal these units before a position -- is picked for them and they're made busy self.command = "Resume" self:Resume() end function OverheardMarker:VisualizePositions() DbgClear() local markerPos = self:GetPos() for i, direction in ipairs(lDirections) do local randomizePos = BraidRandom(RealTime(), guim, lRadiusDeviation) local possiblePos = SnapToPassSlab(markerPos + direction * randomizePos) local vbox = GetVoxelBBox(possiblePos) DbgAddBox(vbox, const.clrRed) end end function OverheardMarker:Resume() -- Pick positions for the units. local positions = {} local oldPositions = {} local markerPos = self:GetPos() if g_debugOverheardMarker then DbgClear() DbgAddBox(GetVoxelBBox(markerPos), const.clrGreen) end local count = #self.target_units table.validate(self.target_units) self.target_units = table.ifilter(self.target_units, function(_, u) return not u:IsDead() end) if count ~= #self.target_units then -- Table validate will remove invalid self:StopRunning() return end local unitsInvalidNow = false for i, u in ipairs(self.target_units) do if (IsPlayerEnemy(u) and u:IsAware()) or not lUnitIdleForBanter(u) then unitsInvalidNow = true self.target_units[i] = false end end if unitsInvalidNow then self:StopRunning() return end local markPosCollision = markerPos if not markPosCollision:IsValidZ() then markPosCollision = markPosCollision:SetTerrainZ() end markPosCollision = markPosCollision:SetZ(markPosCollision:z() + const.SlabSizeZ / 2) local cancel = false for i, u in ipairs(self.target_units) do local unitPos = false local incr = 1 while not unitPos do local direction = lDirections[((i + incr) % #lDirections) + 1] local randomizePos = BraidRandom(u:GetHandle() + incr, guim, lRadiusDeviation) local slabToCheck = markerPos + direction * randomizePos if markerPos:IsValidZ() then slabToCheck = slabToCheck:SetZ(markerPos:z()) end local possiblePos = SnapToPassSlab(slabToCheck) local occupied = not possiblePos or not CanOccupy(u, possiblePos) or table.find(positions, possiblePos) if not occupied then -- Check if there is a wall in the way or something. -- The positions could be slightly moved due to the snap. local possPosCollision = possiblePos if not possPosCollision:IsValidZ() then possPosCollision = possPosCollision:SetTerrainZ() end possPosCollision = possPosCollision:SetZ(possPosCollision:z() + const.SlabSizeZ / 2) local objectsInTheWay = IntersectObjectsOnSegment(markPosCollision, possPosCollision, const.efVisible) if not objectsInTheWay then unitPos = possiblePos break end end if g_debugOverheardMarker and possiblePos then local vbox = GetVoxelBBox(possiblePos) DbgAddBox(vbox, const.clrRed) end incr = incr + 1 if incr == #lDirections then if g_debugOverheardMarker then print("Overheard banter couldn't find suitable slab for one of the actors.") end self:StopRunning() return end end positions[i] = unitPos oldPositions[u] = self.old_target_positions and self.old_target_positions[u] or u:GetPos() end self.target_positions = positions self.old_target_positions = oldPositions for i, u in ipairs(self.target_units) do local unitPos = positions[i] local facePos = markerPos if #self.target_units == 2 then facePos = i == 1 and positions[2] or positions[1] end u:SetCommandParams("OverheardConversationHeadTo", {move_anim = (u.body_type == "Small animal" and "Run" or "Walk")}) u:SetCommand("OverheardConversationHeadTo", unitPos, facePos, self) end self:SetCommand("WaitPositioning") end function OverheardMarker:AreAllInPosition() if g_debugOverheardMarker then DbgClear() end for i, u in ipairs(self.target_units or empty_table) do local targetPos = self.target_positions[i] if g_debugOverheardMarker then local vbox = GetVoxelBBox(targetPos) DbgAddBox(vbox, const.clrBlue) DbgAddVector(u:GetPos(), targetPos - u:GetPos()) end -- Ambient life got despawned if not IsValid(u) then self:StopRunning() return false end if not targetPos:IsValidZ() then targetPos = targetPos:SetTerrainZ() end local unitVoxelSnapped = SnapToVoxel(u) local targetPosSnapped = SnapToVoxel(targetPos) if unitVoxelSnapped ~= targetPosSnapped then return false end end return true end function OverheardMarker:WaitPositioning() while true do WaitMsg("OverheardConversationPointReached", 3000) if self:AreAllInPosition() then self.waiting_activation = true self:SetCommand("WaitConditions") end end end function OverheardMarker:TriggerThreadProc() -- nop, inherited from GridMarker end function OverheardMarker:WaitConditions() local emptyObj = {} while IsValid(self) do if self:AwarenessUnitCheck() then break end if not self.waiting_activation then self:StopRunning() break end if self.waiting_activation and self:EvaluateTriggerConditions(emptyObj) then self.waiting_activation = false self:ExecuteTriggerEffects(emptyObj) self:StopRunning() return end Sleep(1000) end end function OverheardMarker:AwarenessUnitCheck() if not self.target_units then return end for i, u in ipairs(self.target_units) do if g_Combat or not IsValid(u) or IsPlayerEnemy(u) and u:IsAware() then self:StopRunning() return true end end end function OverheardMarker:ExecuteTriggerEffects() WaitMsg("CombatStart", 300) if self:AwarenessUnitCheck() then return end local banterDef = Banters[self.target_banter] if not banterDef or not IsBanterAvailable(banterDef) then return end self.playing_banter = true -- If there are two units in this conversation, make them face each other. if #self.target_units == 2 then self.target_units[1]:InterruptCommand("OverheardConversation", self.target_units[2]:GetPos(), self) self.target_units[2]:InterruptCommand("OverheardConversation", self.target_units[1]:GetPos(), self) else local myPos = self:GetPos() for i, u in ipairs(self.target_units) do u:InterruptCommand("OverheardConversation", myPos, self) end end local banterObj = PlayBanter(self.target_banter, self.target_units) self.playing_banter = banterObj local notTimedOut, preset_id while preset_id ~= banterObj.preset.id do notTimedOut, preset_id = WaitMsg("BanterDone", 500) if not self.playing_banter then return end end self.playing_banter = false self:StopRunning() end function NetSyncEvents.RestartOverheardMarkers() CreateGameTimeThread(function() Sleep(1000) MapForEach("map", "OverheardMarker", function(o) if not o.target_banter then o:SetCommand("StartRunning") end end) end) end function OnMsg.ExplorationStart() if GameReplayScheduled then return end if netInGame and not netGameInfo.started then return end NetSyncEvent("RestartOverheardMarkers") end function OnMsg.CombatEnd() FireNetSyncEventOnHost("RestartOverheardMarkers") end function OnMsg.ConflictEnd(sector) if gv_CurrentSectorId == sector.Id then return end FireNetSyncEventOnHost("RestartOverheardMarkers") end ------------------------------------------------------------- MapVar("g_approachBanterCooldown", {}) MapVar("g_approachBanterPlayed", {}) MapVar("g_lastApproachBanterPlayed", {}) MapVar("g_approachBanterCooldownTime", 30) function UpdateApproachBanters() NetUpdateHash("ApproachBanterCheck_UPDATE", GameTime()) -- Dont play approach banters while other banters are playing if g_ActiveBanters and #g_ActiveBanters > 0 then return end for _, unit in ipairs(g_Units) do -- Check if unit has banters. if not unit.approach_banters or not next(unit.approach_banters) or not unit.approach_banters_distance then goto continue end NetUpdateHash("ApproachBanterCheck_HAS", unit.session_id) -- Check cooldown local cooldownId = unit.approach_banters_cooldown_id or unit.session_id if g_approachBanterCooldown[cooldownId] and g_approachBanterCooldown[cooldownId] > GameTime() then goto continue end NetUpdateHash("ApproachBanterCheck_CD", unit.session_id) -- Already playing a banter. if IsUnitPartOfAnyActiveBanter(unit) then goto continue end NetUpdateHash("ApproachBanterCheck_BUSY", unit.session_id) -- Same unit if g_lastApproachBanterPlayed and g_lastApproachBanterPlayed.unit == unit then goto continue end NetUpdateHash("ApproachBanterCheck_PLAYED", unit.session_id) -- Check if any units approaching and not hidden local approachingUnits = { unit } -- Unit that has the approach banter should be first in the list for "any" priority. local approachDistance = unit.approach_banters_distance * guim for _, otherUnit in ipairs(g_Units) do if otherUnit ~= unit and IsMerc(otherUnit) and not otherUnit:HasStatusEffect("Hidden") and otherUnit:GetDist(unit) < approachDistance and GetZDifference(unit:GetPos(), otherUnit:GetPos()) < const.SlabSizeZ / 2 then approachingUnits[#approachingUnits + 1] = otherUnit end end if #approachingUnits == 1 then goto continue end NetUpdateHash("ApproachBanterCheck_APRCH", unit.session_id) -- Check if any banters are playable (custom conditions) local ctx = { approachingUnits = approachingUnits, filter_if_other = unit.last_played_banter_id } -- Context to be used by UnitApproachedBy local unitsToUseForBanter = { } table.iappend(unitsToUseForBanter, approachingUnits) -- Need to be first to be prefered. table.iappend(unitsToUseForBanter, g_Units) -- Append all units at the end to fill in missing actors. Others will be picked as priority. local bantersFiltered, actorsPicked = FilterAvailableBanters(unit.approach_banters, ctx, unitsToUseForBanter) if not bantersFiltered then goto continue end NetUpdateHash("ApproachBanterCheck_FLTR", unit.session_id) -- Remove banters in which the unit who is being approached isn't picked as an actor. local notActorFiltered = {} for i, b in ipairs(bantersFiltered) do if table.find(actorsPicked[i], unit) then notActorFiltered[#notActorFiltered + 1] = b end end bantersFiltered = notActorFiltered -- Remove played banters (for now do this only if part of a group cooldown) if unit.approach_banters_cooldown_id then local filteredTwice = {} for i, b in ipairs(bantersFiltered) do local sameAsLast = g_lastApproachBanterPlayed and g_lastApproachBanterPlayed.banter == b if not sameAsLast then -- if not g_lastApproachBanterPlayed[b] then filteredTwice[#filteredTwice + 1] = b end end bantersFiltered = filteredTwice end if #bantersFiltered == 0 then goto continue end -- Play random banter from the pool local idx = InteractionRand(#bantersFiltered, "ApproachBanter") + 1 local banter = bantersFiltered[idx] unit.last_played_banter_id = banter PlayAndWaitBanter(banter, unitsToUseForBanter) -- Mark as played g_approachBanterCooldown[cooldownId] = GameTime() + 1000 * g_approachBanterCooldownTime if unit.approach_banters_cooldown_id then g_approachBanterPlayed[banter] = true g_lastApproachBanterPlayed = { banter = banter, unit = unit } end ::continue:: end end function OnMsg.AmbientLifeSpawn() g_approachBanterPlayed = {} end function GetBantersWithGroupCharacters() local group_actors = {} local count = 0 for k,v in sorted_pairs(Banters) do for _, line in ipairs(v.Lines) do if line.AnyOfThese then for _, line in ipairs(line.AnyOfThese) do if line.Voiced and not UnitDataDefs[line.Character] then group_actors[line.Character] = group_actors[line.Character] or {} table.insert(group_actors[line.Character], v) StoreErrorSource(v, "unknown character " .. line.Character) end end else if line.Voiced and not UnitDataDefs[line.Character] then group_actors[line.Character] = group_actors[line.Character] or {} table.insert(group_actors[line.Character], v) StoreErrorSource(v, "unknown character " .. line.Character) end end end end CopyToClipboard( table.concat( table.keys2(group_actors), "\n" ) ) return group_actors end -- Self is the triangle x image function RadioBanterTriangleAnimation(self, vertical) local x = vertical and 0 or 1 local y = vertical and 1 or 0 self:CreateThread("update-movement", function() local lastPos = 0 while self.window_state ~= "destroying" do local pos = AsyncRand(2) if pos == 0 or true then pos = -1 end local dur = 2500 pos = pos * AsyncRand(40, 60) pos = lastPos + pos if pos < -200 or pos > 400 then pos = pos - lastPos pos = lastPos - pos end self:AddInterpolation({ id = "lastPos", type = const.intRect, originalRect = sizebox(0, 0, 1000, 1000), targetRect = sizebox(lastPos * x, lastPos * y, 1000, 1000), duration = 0, }) self:AddInterpolation({ id = "move", type = const.intRect, originalRect = sizebox(lastPos * x, lastPos * y, 1000, 1000), targetRect = sizebox(pos * x, pos * y, 1000, 1000), duration = dur, }) lastPos = pos Sleep(dur) Sleep(500) end end) end -- for debug function GetAllPlayedPlayOnceBanters() local list = {} for banterId, cooldown in pairs(g_BanterCooldowns) do local preset = Banters[banterId] if preset.Once then list[#list + 1] = banterId end end local dedupe = {} local units = MapGet("map", "Unit") for i, u in ipairs(units) do if u.banters_played_lines then for i, p in ipairs(u.banters_played_lines) do if not dedupe[p] then list[#list + 1] = p dedupe[p] = true end end end end return list end if FirstLoad then g_BanterBeingDebugged = false end function DebugSpecificBanter(banter) g_BanterBeingDebugged = banter.id print("Debugging banter", banter.id) end function BanterDebugLog(presetId, text) if g_BanterBeingDebugged ~= presetId then return end if text == "played" then print("[Banter]", presetId, "played successfully!") return end print("[Banter] ", presetId, " didn't play because: ", text) end function EditorTestBanterLine(banter, line) local char = line.Character if not UnitDataDefs[char] then print("Invalid test actor " .. char) return end local selected = Selection and Selection[1] if not selected then print("Select a unit to test banter") return end local pos local positions = GetCombatPath(selected):GetReachableMeleeRangePositions(selected) if positions and positions[1] then pos = point(point_unpack(positions[1])) else pos = GetCursorPos() end CreateMapRealTimeThread(function() local unit = SpawnUnit(char, char .. "_banterTest", pos) unit.banters = {banter.id} unit:SetSide("neutral") local testFinished = false unit.PlayInteractionBanter = function(self) EndAllBanter() local newBanter = BanterPlayer:new({ preset = banter, associated_units = { unit }, fallback_actor = { selected }, }) CreateGameTimeThread(function() newBanter.current_line = table.find(banter.Lines, line) newBanter:PlayBanterLine() testFinished = true end) end while IsValid(unit) do if testFinished then break end Sleep(100) end unit:delete() end) end function BanterLineContext() return function(obj, prop_meta, parent) local extra_annotation = obj["Annotation"] if IsT(extra_annotation) then extra_annotation = _InternalTranslate(extra_annotation) end local is_radio = parent.isRadio local voice = "" if obj.Voiced then voice = "voice:" .. obj["Character"]..(is_radio and " radio" or "") end local comment = parent.Comment if #comment > 0 then comment = comment .. " " end if extra_annotation then return string.format("section:%s/%s %s %s%s", string.gsub(parent.group, " ", "_"), parent.id, extra_annotation, comment, voice) else return string.format("section:%s/%s %s%s", string.gsub(parent.group, " ", "_"), parent.id, comment, voice) end end end function BanterLineThinContext() return function(obj, prop_meta, parent) local extra_annotation = parent["Annotation"] if IsT(extra_annotation) then extra_annotation = _InternalTranslate(extra_annotation) end local voice = "" if GetParentTableOfKind(obj, "BanterLine").Voiced then voice = " voice:" .. obj["Character"] end if extra_annotation then return string.format("section:%s/%s %s%s", string.gsub(parent.group, " ", "_"), parent.id, extra_annotation, voice) else return string.format("section:%s/%s%s", string.gsub(parent.group, " ", "_"), parent.id, voice) end end end DefineConstInt("Default", "BoredBanterCD", 3, 1, "The cooldown between bored banters measured in days.") DefineConstInt("Default", "BoredBanterMinHiredSince", 2, 1, "The minimum amount of days since which the merc has been hired.") GameVar("gv_LastBoredBanter", false) GameVar("gv_MercsLastMoveTime", {}) --Timestamp when unit finished last movement function NetSyncEvents.Mp_PlayBoredBanter(banter) if #g_ActiveBanters == 0 then gv_LastBoredBanter = Game.CampaignTime / const.Scale.day PlayBanter(banter, g_Units) end end local function BoredBanterCheck() if ConflictOrSectorEnterTime and ConflictOrSectorEnterTime + 5 * 1000 > GameTime() then --delay before sector enter and conflict resolved return end if not Dbg_BoredBanters and gv_LastBoredBanter and Game.CampaignTime / const.Scale.day < (gv_LastBoredBanter + const.BoredBanterCD) then --stil on cd return end if g_Combat or GetSatelliteDialog() or GetDialog("PDADialog") or IsInventoryOpened() or #g_Units == 0 then --not in exploration return end if gv_CurrentSectorId and gv_Sectors[gv_CurrentSectorId].conflict then --sector is in conflict return end if #g_ActiveBanters ~= 0 or GetDialog("ConversationDialog") then --banter or conversation currently playing return end if #g_TalkingHeadQueue ~= 0 then --talking head playing return end local now = GameTime() local banterConvCD = not Dbg_BoredBanters and 30 * 1000 or 5 * 1000 local lastMoveCD = not Dbg_BoredBanters and 5 * 1000 or 5 * 1000 if g_ActiveBanters.lastPlayedTime and (g_ActiveBanters.lastPlayedTime + banterConvCD) > now or g_LastConv and (g_LastConv + banterConvCD) > now then --no banter or conv has played in the last X secs return end local mainMercs, allMercs for _, squad in ipairs(g_PlayerSquads) do if squad.CurrentSector == gv_CurrentSectorId then for _, merc in ipairs(squad.units) do local unitData = g_Units[merc] if not unitData then goto continue end --Hired since check local hiredAtDay = (GetMercStateFlag(unitData.session_id, "HiredAt") or Game.CampaignTime) / const.Scale.day local hiredSinceDays = (Game.CampaignTime / const.Scale.day) - hiredAtDay local isVeteran = hiredSinceDays >= const.BoredBanterMinHiredSince or Dbg_BoredBanters --Not moved check local notMoved local lastMovedTime = gv_MercsLastMoveTime[unitData.session_id] if lastMovedTime then notMoved = (lastMovedTime + lastMoveCD) < now else gv_MercsLastMoveTime[unitData.session_id] = GameTime() notMoved = false end --Add only if all cond from above are met if notMoved then local unit = g_Units[merc] if isVeteran then mainMercs = mainMercs or {} mainMercs[#mainMercs + 1] = unit end allMercs = allMercs or {} allMercs[#allMercs + 1] = unit end ::continue:: end end end local mercDlgs = PresetsGroupInCampaignArray("BanterDef", "MercDialogues") local availableBanters = FilterAvailableBanters(mercDlgs, nil, mainMercs) if availableBanters then local banter = table.rand(availableBanters) NetSyncEvent("Mp_PlayBoredBanter", banter) end end MapVar("ConflictOrSectorEnterTime", false) function OnMsg.ConflictEnd(sector) if gv_CurrentSectorId and gv_Sectors[gv_CurrentSectorId] == sector then ConflictOrSectorEnterTime = GameTime() end end function OnMsg.OnEnterMapVisual() ConflictOrSectorEnterTime = GameTime() end MapGameTimeRepeat("BoredBanter", 1000, BoredBanterCheck) function ResumeFuncs.PlayBanterEffect(stack, context, resume) local units = {} for i, uHandle in ipairs(resume.units) do local u = HandleToObject[uHandle] if IsValid(u) then units[#units + 1] = u end end local actorOverride = false if resume.any_actor_override then actorOverride = HandleToObject[resume.any_actor_override] actorOverride = IsValid(actorOverride) and actorOverride end local fallbackUnit = false if resume.fallbackUnit then fallbackUnit = HandleToObject[resume.fallbackUnit] fallbackUnit = IsValid(fallbackUnit) and fallbackUnit end local init_members = { preset = Banters[resume.preset], associated_units = units, fallback_actor = fallbackUnit, any_actor_override = actorOverride, id = g_NextBanterId, } g_NextBanterId = g_NextBanterId + 1 local banterObj = BanterPlayer:new(init_members) if IsPoint(resume.player_pos) then banterObj:SetPos(resume.player_pos) end banterObj.current_line = resume.current_line g_ActiveBanters[#g_ActiveBanters + 1] = banterObj g_ActiveBanters[banterObj.preset.id] = true g_IdToBanter[banterObj.id] = banterObj NetUpdateHash("ResumeBanter", banterObj.id, banterObj.current_line) local notTimedOut, preset_id while preset_id ~= banterObj.preset.id do notTimedOut, preset_id = WaitMsg("BanterDone", 500) end end function TestBantersUsage() local bantersPreset = {} local undefinedBanters = {} local unusedBanters = {} ForEachPreset("BanterDef", function(banterPreset) table.insert(unusedBanters, banterPreset) table.insert(bantersPreset, banterPreset) end) local function DoBantersExist(banterGroup, searchField, obj, isFromPreset) for idx, banter in ipairs(banterGroup) do if not table.find(bantersPreset, searchField, banter) then if isFromPreset then table.insert(undefinedBanters, { banter, obj[idx] }) else table.insert(undefinedBanters, { banter, obj }) end end end end local function AreBantersUsed(banterGroup, searchField) unusedBanters = table.ifilter(unusedBanters, function(idx, banter) return not table.find(banterGroup, banter[searchField]) end) end --for each marker in markers.debug.lua on every map check used banters for _, markersOnMap in pairs(g_DebugMarkersInfo) do for _, marker in ipairs(markersOnMap) do if next(marker.BanterGroups) then DoBantersExist(marker.BanterGroups, "group", marker) AreBantersUsed(marker.BanterGroups, "group") end if next(marker.SpecificBanters) then DoBantersExist(marker.SpecificBanters, "id", marker) AreBantersUsed(marker.SpecificBanters, "id") end if next(marker.BanterTriggerEffects) then local bantersList = {} for _, banterEffect in ipairs(marker.BanterTriggerEffects) do for _, banter in ipairs(banterEffect.Banters) do table.insert(bantersList, banter) end end DoBantersExist(bantersList, "id", marker) AreBantersUsed(bantersList, "id") end if next(marker.ApproachedBanters) then DoBantersExist(marker.ApproachedBanters, "id", marker) AreBantersUsed(marker.ApproachedBanters, "id") end if marker.ApproachBanterGroup then DoBantersExist({marker.ApproachBanterGroup}, "group", marker) AreBantersUsed({marker.ApproachBanterGroup}, "group") end end end local questsPresetBantersInfo = {} local questsPresetBanters = {} ForEachPreset("QuestsDef", function (questPreset) questPreset:ForEachSubObject("BanterFunctionObjectBase", function(effect, parents) for _, banter in ipairs(effect.Banters) do table.insert_unique(questsPresetBanters, banter) table.insert_unique(questsPresetBantersInfo, questPreset) end end) end) DoBantersExist(questsPresetBanters, "id", questsPresetBantersInfo, "presets") AreBantersUsed(questsPresetBanters, "id") local setpiecesPresetBanters = {} local setpiecesPresetBantersInfo = {} ForEachPreset("SetpiecePrg", function(setpiecePreset) setpiecePreset:ForEachSubObject("BanterFunctionObjectBase", function(effect, parents) for _, banter in ipairs(effect.Banters) do table.insert_unique(setpiecesPresetBanters, banter) table.insert_unique(setpiecesPresetBantersInfo, setpiecePreset) end end) end) DoBantersExist(setpiecesPresetBanters, "id", setpiecesPresetBantersInfo, "presets") AreBantersUsed(setpiecesPresetBanters, "id") local guardpostPresetBanters = {} local guardpostPresetBantersInfo = {} ForEachPreset("GuardpostObjective", function(guardPostPreset) guardPostPreset:ForEachSubObject("BanterFunctionObjectBase", function(effect, parents) for _, banter in ipairs(effect.Banters) do table.insert_unique(guardpostPresetBanters, banter) table.insert_unique(guardpostPresetBantersInfo, guardPostPreset) end end) end) DoBantersExist(guardpostPresetBanters, "id", guardpostPresetBantersInfo, "presets") AreBantersUsed(guardpostPresetBanters, "id") --ignore these banter groups local banterGroupsToIgnore = {"MercDialogues", "Testing", "Testing - Banter Test 1", "Testing - Banter Test 2", "A_System"} unusedBanters = table.ifilter(unusedBanters, function(idx, banter) return not table.find(banterGroupsToIgnore, banter.group) end) return undefinedBanters, unusedBanters, banterGroupsToIgnore end function BanterFXCombo() local fxes = {} for _, group in ipairs(Presets.BanterDef) do for _, banter in ipairs(group) do fxes[banter.FX] = true end end return table.keys(fxes, "sorted") end --[[function OnMsg.UnitAwarenessChanged(unit) if not unit then return end for i = #g_ActiveBanters, 1, -1 do local b = g_ActiveBanters[i] if b.associated_units and b.preset.KillOnAnyActorAware then if table.find(b.associated_units, unit) then DoneBanter(b) else local any_actor = b.associated_units[1] if b.associated_units[any_actor] == "any" then DoneBanter(b) end end end end end]] -- We handle this on combat start due in order to handle -- neutral units that cannot be awared -- Check (209190) vs (214319) function OnMsg.CombatStarting() for i = #g_ActiveBanters, 1, -1 do local b = g_ActiveBanters[i] if not b.preset.KillOnAnyActorAware then goto continue end local units = b.associated_units for i, unit in ipairs(units) do if table.find(b.associated_units, unit) then DoneBanter(b) else local any_actor = b.associated_units[1] if b.associated_units[any_actor] == "any" then DoneBanter(b) end end end ::continue:: end end local s_DemoBanters = { "Banters_Civilians", "Banters_Local_Ernie", "Banters_Local_Ernie_Triggered", "Banters_Militia", "MercDialogues", "Radio", "SharedOverheard_Civilians", "SharedOverheard_Enemies", "SharedOverheard_Custom", } function OnMsg.GatherVoiceBanters(blacklist_banter_voices) local all_banter_voices, used_banter_voices = {}, {} for _, group_banters in ipairs(Presets.BanterDef or empty_table) do for _, banter_def in ipairs(group_banters or empty_table) do local used_group = not not table.find(s_DemoBanters, banter_def.group) for _, line in ipairs(banter_def.Lines or empty_table) do if line.AnyOfThese then for _, thin_line in ipairs(line.AnyOfThese) do local voice_id = TGetID(thin_line.Text) all_banter_voices[voice_id] = true if used_group then used_banter_voices[voice_id] = true end end else local voice_id = TGetID(line.Text) all_banter_voices[voice_id] = true if used_group then used_banter_voices[voice_id] = true end end end end end for voice_id in pairs(all_banter_voices) do if not used_banter_voices[voice_id] then blacklist_banter_voices[voice_id] = true end end end