Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- local modName = "AffinityBar"
- local modVersion = "2.0"
- -- ============================================================================
- -- CONSTANTS
- -- ============================================================================
- local CONSTANTS = {
- -- Display Offsets
- OFFSET_NORMAL = 0.7,
- OFFSET_TALKING = 0.2,
- -- Affinity Thresholds
- NPC_MAX_AFFINITY = 299,
- NPC_LOVE_THRESHOLD = 185,
- PAWN_MAX_AFFINITY = 1000,
- PAWN_LOVE_THRESHOLD = 620,
- AFFINITY_HATE_RATIO = 0.33,
- -- Display Timings
- AFFINITY_CHANGE_DISPLAY_DURATION = 3.0,
- ASSUMED_FRAME_TIME_60FPS = 0.0167,
- LERP_SPEED = 1.0,
- -- UI Constants
- DEFAULT_CHAR_WIDTH = 8,
- THRESHOLD_MARKER_WIDTH = 2,
- HEART_ICON_SIZE = 24,
- BASE_TEXT_Y_OFFSET = 30,
- CHANGE_TEXT_Y_OFFSET = 20,
- -- Colors (ABGR format)
- COLOR_BAR_BACKGROUND = 0x80000000,
- COLOR_BAR_LOVE = 0xFF7269C8,
- COLOR_BAR_NEUTRAL = 0xad8c5f5f,
- COLOR_TEXT = 0xf49E9992,
- COLOR_CHANGE_POSITIVE = 0xFF8AD950,
- COLOR_CHANGE_NEGATIVE = 0xFF60609C,
- COLOR_THRESHOLD_MARKER = 0xf49E9992,
- COLOR_BUTTON = 0xFF006FC5,
- COLOR_BUTTON_HOVER = 0xFF2090E0,
- COLOR_BUTTON_ACTIVE = 0xFFF58000,
- -- Fall Damage
- FALL_DAMAGE_DISABLED_HEIGHT = 99999,
- -- Quest Marker Enums
- QUEST_MARKER_DEST_TYPE_FREE = 3,
- QUEST_MARKER_ICON_TYPE_SMALL = 0,
- QUEST_MARKER_KEY_LOCATION_NONE = 0,
- QUEST_MARKER_LOCAL_AREA_NONE = 0,
- QUEST_MARKER_MAP_AREA_NONE = 0,
- -- Teleport/Async
- TELEPORT_DELAY_SECONDS = 1.0,
- -- SDK Type Names
- TYPE_GUI_MANAGER = "app.GuiManager",
- TYPE_CHARACTER_MANAGER = "app.CharacterManager",
- TYPE_NPC_MANAGER = "app.NPCManager",
- TYPE_PAWN_MANAGER = "app.PawnManager",
- TYPE_UI_HAIL = "app.ui021301",
- TYPE_WORLD_OFFSET_SYSTEM = "app.WorldOffsetSystem",
- TYPE_INTERACT_MANAGER = "app.InteractManager",
- TYPE_CHARACTER = "app.Character",
- TYPE_HUMAN_CATCH_PROCESSOR = "app.HumanCatchProcessor",
- TYPE_GUI_BASE = "app.GUIBase",
- TYPE_QUEST_TARGET_MARKER_INFO = "app.GuiManager.QuestTargetMarkerInfo",
- -- Joint/Component Names
- HEAD_JOINT_NAME = "Head_0",
- -- Method Names
- METHOD_ON_CHANGE_SCENE = "OnChangeSceneType",
- METHOD_REQ_DISP_COMMON = "reqDispCommon(System.Single, System.Guid)",
- }
- local defaultConfig = {
- displayAfterInteractionOnly = false, -- Only display the Affinity Bar while talking to a character
- bridalCarryWhileInLove = true, -- Changes the way you carry your main pawn if they are in love with you
- hideWhileTalking = false, -- Hides the affinity bar while talking to a character
- showOnHailForDuration = false, -- Show Affinity Bar on Hail for a specified duration
- hailDisplayDuration = 2, -- Duration in seconds to show the Affinity Bar after hailing
- barWidth = 200, -- Default bar width
- barHeight = 8, -- Default bar height
- charWidth = 18, -- Default font size in points (used by d2d; draw.text has no scale)
- textInBar = false, -- Render affinity text inside the bar instead of above it
- textColor = 0xFF00FF00, -- ABGR: default neon green
- delayedDisplayEnabled = false, -- Delayed display toggle
- delayedDisplayDuration = 2.5, -- Default duration for delayed display
- cheatModeEnabled = false, -- Cheat mode toggle
- preventAffinityDecay = false, -- Prevent affinity from decreasing over time
- affinityCheckInterval = 1.0, -- How often to check affinity values (in seconds)
- npcListThreshold = 0, -- Minimum affinity to show in NPC list (0 = show all)
- showDecayWarning = true, -- Show warning indicator when affinity is decaying
- toggleHotkeyIndex = 1 -- Hotkey to toggle bar visibility (1 = None, 2 = F5, 3 = F6, etc.)
- }
- -- Hotkey mapping (1-based index matching HOTKEY_OPTIONS -> virtual key code)
- local HOTKEY_OPTIONS = { "None", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12" }
- local HOTKEY_CODES = { nil, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x7B }
- -- Cached singletons (lazy loaded)
- local _NPCManager = nil
- local _guiManager = nil
- local _characterManager = nil
- local _worldOffsetSystem = nil
- local _pawnManager = nil
- local _interactManager = nil
- local config = {}
- local configPath = "AffinityBar.json"
- -- Validate and clamp config values
- local function validateConfig()
- -- Clamp numeric values to safe ranges
- if type(config.barWidth) == "number" then
- config.barWidth = math.max(100, math.min(config.barWidth, 500))
- else
- config.barWidth = defaultConfig.barWidth
- end
- if type(config.barHeight) == "number" then
- config.barHeight = math.max(5, math.min(config.barHeight, 30))
- else
- config.barHeight = defaultConfig.barHeight
- end
- if type(config.charWidth) == "number" then
- config.charWidth = math.max(8, math.min(math.floor(config.charWidth), 72))
- else
- config.charWidth = defaultConfig.charWidth
- end
- if type(config.hailDisplayDuration) == "number" then
- config.hailDisplayDuration = math.max(0.5, math.min(config.hailDisplayDuration, 10.0))
- else
- config.hailDisplayDuration = defaultConfig.hailDisplayDuration
- end
- if type(config.delayedDisplayDuration) == "number" then
- config.delayedDisplayDuration = math.max(0.5, math.min(config.delayedDisplayDuration, 10.0))
- else
- config.delayedDisplayDuration = defaultConfig.delayedDisplayDuration
- end
- if type(config.affinityCheckInterval) == "number" then
- config.affinityCheckInterval = math.max(0.1, math.min(config.affinityCheckInterval, 60.0))
- else
- config.affinityCheckInterval = defaultConfig.affinityCheckInterval
- end
- if type(config.npcListThreshold) == "number" then
- config.npcListThreshold = math.max(0, math.min(math.floor(config.npcListThreshold), CONSTANTS.NPC_MAX_AFFINITY))
- else
- config.npcListThreshold = defaultConfig.npcListThreshold
- end
- if type(config.toggleHotkeyIndex) == "number" then
- config.toggleHotkeyIndex = math.max(1, math.min(math.floor(config.toggleHotkeyIndex), #HOTKEY_OPTIONS))
- else
- config.toggleHotkeyIndex = defaultConfig.toggleHotkeyIndex
- end
- if type(config.textColor) ~= "number" then
- config.textColor = defaultConfig.textColor
- end
- if type(config.textInBar) ~= "boolean" then
- config.textInBar = defaultConfig.textInBar
- end
- -- Ensure booleans are actually booleans
- local boolFields = {
- "displayAfterInteractionOnly", "bridalCarryWhileInLove", "hideWhileTalking",
- "showOnHailForDuration", "delayedDisplayEnabled", "cheatModeEnabled", "preventAffinityDecay",
- "showDecayWarning"
- }
- for _, field in ipairs(boolFields) do
- if type(config[field]) ~= "boolean" then
- config[field] = defaultConfig[field]
- end
- end
- end
- -- Save and Load config functions
- local _saveConfigPending = false
- local _saveConfigTime = 0
- local function saveConfig()
- -- Debounce: defer save by 0.5s to avoid 60 writes/sec during slider drag
- _saveConfigPending = true
- _saveConfigTime = os.clock() + 0.5
- end
- local function flushConfigIfPending()
- if _saveConfigPending and os.clock() >= _saveConfigTime then
- json.dump_file(configPath, config)
- _saveConfigPending = false
- end
- end
- local function loadConfig()
- -- Always start from defaults so new keys are populated on upgrade
- for k, v in pairs(defaultConfig) do
- config[k] = v
- end
- local loadedConfig = json.load_file(configPath)
- if loadedConfig then
- -- Only copy keys that exist in defaultConfig to avoid arbitrary JSON pollution
- for k, v in pairs(loadedConfig) do
- if defaultConfig[k] ~= nil then
- config[k] = v
- end
- end
- -- Migrate v1.1.11 0-based hotkey index to 1-based
- if type(config.toggleHotkeyIndex) == "number" and config.toggleHotkeyIndex == 0 then
- config.toggleHotkeyIndex = 1
- end
- -- Migrate old charWidth (was a float pixel-width like 6.48, now is font pt size like 18)
- if type(config.charWidth) == "number" and config.charWidth < 8 then
- config.charWidth = defaultConfig.charWidth
- end
- validateConfig()
- else
- saveConfig()
- end
- end
- -- Function to reset settings to default
- local function resetToDefault()
- for k, v in pairs(defaultConfig) do
- config[k] = v
- end
- saveConfig()
- end
- -- ============================================================================
- -- MODULE STATE
- -- ============================================================================
- -- Display state
- local currentOffset = CONSTANTS.OFFSET_NORMAL
- local targetOffset = CONSTANTS.OFFSET_NORMAL
- -- Affinity tracking
- local lastAffinityValue = nil
- local lastCharacterId = nil
- local affinityChange = 0
- local changeDisplayTimer = 0
- -- Cached formatted strings (rebuilt only when value changes)
- local cachedAffinityText = ""
- local cachedChangeText = ""
- local cachedAffinityValue = nil
- local cachedMaxAffinityValue = nil
- local cachedAffinityChange = nil
- -- UI state
- local manualFavorabilityRating = 0
- local proximityTimer = 0
- local interactableCharacter = nil
- local isHailTextVisible = false
- local hailShowTimestamp = 0 -- os.clock() when hail was triggered
- local lastHailCharacterId = nil -- character ID when hail was triggered
- local npcSearchFilter = ""
- local barVisible = true
- local lastHotkeyState = false
- -- Decay warning tracking
- local previousAffinityValues = {}
- local decayWarningTimers = {}
- local DECAY_WARNING_DURATION = 5.0
- -- Beloved prediction
- local predictedBeloved = nil
- -- Affinity decay prevention
- local savedAffinityValues = {}
- local affinityCheckTimer = 0
- -- Async task management
- local asyncTasks = {}
- local nextTaskId = 1
- -- Fall damage state
- local fallDamageDefaults = {
- heightDamageForHuman = nil,
- baseDamageForHuman = nil,
- damagePerHeightForHuman = nil,
- heightDieForHuman = nil,
- initialized = false
- }
- -- Optional d2d (Direct2D) support for heart icons and scalable text
- local d2d = nil
- local d2d_available = false
- local heart_textures = {
- love = nil,
- neutral = nil,
- hate = nil
- }
- -- d2d font for scalable affinity text (recreated when size changes)
- local d2d_font = nil
- local d2d_font_size = 0 -- tracks the size the font was created at
- -- CJK font for non-Latin character support (Japanese, Chinese, Korean)
- local cjk_font = nil
- -- Shared state for d2d draw callback (set in re.on_frame, read in d2d draw)
- local d2d_heart_state = {
- visible = false,
- texture = nil,
- x = 0,
- y = 0,
- size = CONSTANTS.HEART_ICON_SIZE
- }
- -- Shared state for d2d affinity text rendering
- local d2d_text_state = {
- visible = false,
- text = "",
- changeText = "",
- x = 0,
- y = 0,
- changeX = 0,
- changeY = 0,
- color = 0xFFFFFFFF,
- changeColor = 0xFFFFFFFF,
- }
- -- Shared state for d2d bar rendering (used when textInBar=true so bar+text are in same pass)
- local d2d_bar_state = {
- visible = false,
- bgX = 0, bgY = 0, bgW = 0, bgH = 0, bgColor = 0,
- fillW = 0, fillColor = 0,
- markerX = 0, markerW = 0, markerColor = 0,
- }
- -- ============================================================================
- -- UTILITY FUNCTIONS
- -- ============================================================================
- local function Log(msg)
- log.info("[" .. modName .. "] " .. msg)
- end
- -- Try to load d2d plugin
- -- reframework-d2d registers itself as a global, not a Lua module
- -- Drawing must happen inside d2d.register() callbacks, not re.on_frame
- local function init_d2d()
- if not _G.d2d then
- Log("Direct2D plugin not found - text size slider will have no effect (install reframework-d2d)")
- return false
- end
- d2d = _G.d2d
- d2d_available = true
- Log("Direct2D plugin detected - scalable text and heart icons enabled")
- d2d.register(function()
- -- Init callback: load heart textures and create font
- heart_textures.love = d2d.Image.new("Heart_Love.png")
- heart_textures.neutral = d2d.Image.new("Heart_Neutral.png")
- heart_textures.hate = d2d.Image.new("Heart_Hate.png")
- d2d_font = d2d.Font.new("Tahoma", config.charWidth)
- d2d_font_size = config.charWidth
- Log("Heart icon textures and font loaded at size " .. tostring(config.charWidth))
- end, function()
- -- Recreate font if size changed (slider was moved)
- if config.charWidth ~= d2d_font_size then
- d2d_font = d2d.Font.new("Tahoma", config.charWidth)
- d2d_font_size = config.charWidth
- end
- -- Draw bar (only when textInBar=true, so text renders on top in same pass)
- if d2d_bar_state.visible then
- local s = d2d_bar_state
- d2d.fill_rect(s.bgX, s.bgY, s.bgW, s.bgH, s.bgColor)
- d2d.fill_rect(s.bgX, s.bgY, s.fillW, s.bgH, s.fillColor)
- d2d.fill_rect(s.markerX, s.bgY, s.markerW, s.bgH, s.markerColor)
- end
- -- Draw affinity text (on top of bar when textInBar=true)
- if d2d_text_state.visible and d2d_font then
- d2d.text(d2d_font, d2d_text_state.text, d2d_text_state.x, d2d_text_state.y, d2d_text_state.color)
- if d2d_text_state.changeText ~= "" then
- d2d.text(d2d_font, d2d_text_state.changeText, d2d_text_state.changeX, d2d_text_state.changeY, d2d_text_state.changeColor)
- end
- end
- -- Draw heart icon
- if d2d_heart_state.visible and d2d_heart_state.texture then
- d2d.image(d2d_heart_state.texture,
- d2d_heart_state.x, d2d_heart_state.y,
- d2d_heart_state.size, d2d_heart_state.size)
- end
- end)
- return true
- end
- local _lastFrameTime = os.clock()
- local _hasReframeworkDeltaTime = type(reframework) == "table" and type(reframework.get_delta_time) == "function"
- local function getDeltaTime()
- -- Use REFramework's built-in delta time if available (more accurate)
- if _hasReframeworkDeltaTime then
- local dt = reframework:get_delta_time()
- if dt > 0 and dt <= 0.1 then
- return dt
- end
- return CONSTANTS.ASSUMED_FRAME_TIME_60FPS
- end
- -- Fallback: os.clock() diff
- local now = os.clock()
- local dt = now - _lastFrameTime
- _lastFrameTime = now
- if dt <= 0 or dt > 0.1 then
- return CONSTANTS.ASSUMED_FRAME_TIME_60FPS
- end
- return dt
- end
- -- Async task manager
- local function createAsyncTask(delaySeconds, callback)
- local taskId = nextTaskId
- nextTaskId = nextTaskId + 1
- asyncTasks[taskId] = {
- id = taskId,
- targetTime = os.clock() + delaySeconds,
- callback = callback,
- completed = false
- }
- return taskId
- end
- local function updateAsyncTasks()
- local currentTime = os.clock()
- -- Collect completed task IDs first to avoid mutating table during iteration
- local completedIds = {}
- for taskId, task in pairs(asyncTasks) do
- if not task.completed and currentTime >= task.targetTime then
- local success, err = pcall(task.callback)
- if not success then
- Log("Async task error: " .. tostring(err))
- end
- task.completed = true
- completedIds[#completedIds + 1] = taskId
- end
- end
- for _, taskId in ipairs(completedIds) do
- asyncTasks[taskId] = nil
- end
- end
- -- Cached InteractManager singleton getter
- local function getInteractManager()
- if not _interactManager then
- _interactManager = sdk.get_managed_singleton(CONSTANTS.TYPE_INTERACT_MANAGER)
- end
- return _interactManager
- end
- --- Safely create SDK hook with error handling
- -- @param method userdata Method to hook
- -- @param pre_callback function Pre-hook callback
- -- @param post_callback function Post-hook callback
- -- @param hook_name string Name for logging
- -- @return boolean success
- local function safeHook(method, pre_callback, post_callback, hook_name)
- if not method then
- Log("Warning: Cannot hook " .. hook_name .. " - method is nil")
- return false
- end
- local success, err = pcall(function()
- sdk.hook(method, pre_callback, post_callback)
- end)
- if not success then
- Log("Failed to hook " .. hook_name .. ": " .. tostring(err))
- return false
- end
- return true
- end
- -- Function to lerp between two values
- local function lerp(current, target, t)
- return current + (target - current) * t
- end
- -- Update function to adjust the offset based on whether the player is talking
- local function updateOffset(deltaTime, isTalking)
- targetOffset = isTalking and CONSTANTS.OFFSET_TALKING or CONSTANTS.OFFSET_NORMAL
- currentOffset = lerp(currentOffset, targetOffset, deltaTime * CONSTANTS.LERP_SPEED)
- end
- -- Hook into the method responsible for displaying the "Hail (Hold to Talk)" UI (with safety check)
- local ui_type = sdk.find_type_definition(CONSTANTS.TYPE_UI_HAIL)
- if ui_type then
- local req_disp_common_method = ui_type:get_method(CONSTANTS.METHOD_REQ_DISP_COMMON)
- safeHook(
- req_disp_common_method,
- function(args)
- isHailTextVisible = true
- hailShowTimestamp = os.clock()
- -- Record which character was hailed so we can reset on character change
- if interactableCharacter then
- pcall(function()
- lastHailCharacterId = interactableCharacter:get_CharaID()
- end)
- end
- end,
- function(ret)
- return ret
- end,
- "UI.reqDispCommon"
- )
- end
- --- Get character's head position in world space with dynamic offset
- -- Retrieves the character's "Head_0" joint position and applies the
- -- current lerped offset for smooth bar positioning during talk transitions
- -- @param char userdata Character object
- -- @return table|nil Vector3 position or nil if not found
- local function getCharacterPos(char)
- local gameObject = char:get_GameObject()
- if not gameObject then return nil end
- local transform = gameObject:get_Transform()
- if not transform then return nil end
- local head = transform:getJointByName(CONSTANTS.HEAD_JOINT_NAME)
- if not head then return nil end
- local headPos = head:get_Position()
- -- Apply the lerped offset (managed by updateOffset() in frame loop)
- headPos.y = headPos.y + currentOffset
- return headPos
- end
- -- Safe singleton getters with caching
- local function getNPCManager()
- if not _NPCManager then
- _NPCManager = sdk.get_managed_singleton(CONSTANTS.TYPE_NPC_MANAGER)
- end
- return _NPCManager
- end
- local function getCharacterManager()
- if not _characterManager then
- _characterManager = sdk.get_managed_singleton(CONSTANTS.TYPE_CHARACTER_MANAGER)
- end
- return _characterManager
- end
- local function getPawnManager()
- if not _pawnManager then
- _pawnManager = sdk.get_managed_singleton(CONSTANTS.TYPE_PAWN_MANAGER)
- end
- return _pawnManager
- end
- local function getGuiManager()
- if not _guiManager then
- _guiManager = sdk.get_managed_singleton(CONSTANTS.TYPE_GUI_MANAGER)
- end
- return _guiManager
- end
- local function getWorldOffsetSystem()
- if not _worldOffsetSystem then
- _worldOffsetSystem = sdk.get_managed_singleton(CONSTANTS.TYPE_WORLD_OFFSET_SYSTEM)
- end
- return _worldOffsetSystem
- end
- --- Ratchet affinity value - only increases or stays the same, never decreases
- -- @param currentValue number Current affinity value
- -- @param savedKey string|number Key to store saved value in savedAffinityValues table
- -- @param updateCallback function Function to call to restore value if decreased
- -- @param shouldLog boolean Whether to log restoration (default: false)
- local function ratchetAffinity(currentValue, savedKey, updateCallback, shouldLog)
- local savedValue = savedAffinityValues[savedKey]
- if not savedValue or currentValue > savedValue then
- -- Save new higher value
- savedAffinityValues[savedKey] = currentValue
- elseif currentValue < savedValue then
- -- Restore to saved value
- updateCallback(savedValue)
- if shouldLog then
- Log("Restored affinity from " .. currentValue .. " to " .. savedValue)
- end
- end
- end
- --- Prevent affinity decay by monitoring and restoring values using ratchet pattern
- -- Periodically checks all tracked affinity values (main pawn + NPCs) and restores
- -- them if they have decreased, implementing a "ratchet" that only allows increases
- -- @return nil
- local function checkAndRestoreAffinity()
- if not config.preventAffinityDecay then
- return
- end
- -- Check main pawn affinity
- local pawnMgr = getPawnManager()
- if pawnMgr then
- local mainPawn = pawnMgr:get_MainPawn()
- if mainPawn and mainPawn.MainPawnDataContext then
- local currentAffinity = mainPawn.MainPawnDataContext:get_FavorabilityRating()
- ratchetAffinity(
- currentAffinity,
- "mainpawn",
- function(value)
- mainPawn.MainPawnDataContext:set_field("FavorabilityRating", math.floor(value))
- end,
- true -- Log main pawn restorations
- )
- end
- end
- -- Check NPC affinities
- local charMgr = getCharacterManager()
- if not charMgr then return end
- local player = charMgr:get_ManualPlayer()
- if not player then return end
- local playerCharaID = player:get_CharaID()
- local npcMgr = getNPCManager()
- if not npcMgr then return end
- local NPCHolderDic = npcMgr.NPCHolderDic
- if not NPCHolderDic then return end
- for i = 0, NPCHolderDic:get_Length() - 1 do
- local NPCHolder = NPCHolderDic[i]
- if NPCHolder then
- local charaID = NPCHolder.CharaID
- local sentimentInfo = NPCHolder:getSentimentInfo(playerCharaID, false)
- if sentimentInfo then
- local currentAffinity = sentimentInfo.Value
- ratchetAffinity(
- currentAffinity,
- charaID,
- function(value)
- sentimentInfo.Value = value
- end,
- false -- Don't log every NPC to avoid spam
- )
- end
- end
- end
- end
- --- Check if character is the main pawn, returning the pawn reference if true
- -- @param character userdata Character object
- -- @return boolean isMainPawn, userdata|nil mainPawn reference (for reuse)
- local function isMainPawn(character)
- local pawnMgr = getPawnManager()
- if not pawnMgr then return false, nil end
- local mainPawn = pawnMgr:get_MainPawn()
- if mainPawn and character == mainPawn:get_CachedCharacter() then
- return true, mainPawn
- end
- return false, nil
- end
- --- Apply affinity change to main pawn
- -- @param changeAmount number Amount to add (can be negative)
- -- @return boolean success, string message
- local function applyMainPawnAffinityChange(changeAmount)
- local pawnManager = getPawnManager()
- if not pawnManager then
- return false, "Pawn manager not found"
- end
- local mainPawn = pawnManager:get_MainPawn()
- if not mainPawn or not mainPawn.MainPawnDataContext then
- return false, "Failed to get main pawn data context"
- end
- local dataContext = mainPawn.MainPawnDataContext
- local currentAffinity = dataContext:get_FavorabilityRating()
- local newAffinity = math.max(0, math.min(math.floor(currentAffinity + changeAmount), CONSTANTS.PAWN_MAX_AFFINITY))
- dataContext:set_field("FavorabilityRating", newAffinity)
- -- Verify the change took effect
- local verified = dataContext:get_FavorabilityRating()
- if verified == currentAffinity and changeAmount ~= 0 then
- return false, "Main pawn affinity unchanged at " .. tostring(verified) .. " (setter may have failed)"
- end
- return true, "Main pawn affinity: " .. tostring(currentAffinity) .. " -> " .. tostring(verified)
- end
- --- Apply affinity change to NPC
- -- @param targetCharId number Character ID of target NPC
- -- @param playerCharId number Character ID of player
- -- @param changeAmount number Amount to add (can be negative)
- -- @return boolean success, string message
- local function applyNPCAffinityChange(targetCharId, playerCharId, changeAmount)
- local npcManager = getNPCManager()
- if not npcManager then
- return false, "NPC manager not found"
- end
- local npcHolder = npcManager:getNPCHolder(targetCharId)
- if not npcHolder then
- return false, "NPC Holder not found for CharaID: " .. tostring(targetCharId)
- end
- local sentimentInfo = npcHolder:getSentimentInfo(playerCharId, false)
- if not sentimentInfo then
- return false, "Failed to get sentiment info for character ID: " .. tostring(targetCharId)
- end
- local oldValue = sentimentInfo.Value
- local newSentimentValue = math.max(0, math.min(oldValue + changeAmount, CONSTANTS.NPC_MAX_AFFINITY))
- sentimentInfo.Value = newSentimentValue
- -- Verify the change took effect
- local verified = npcHolder:getSentimentInfo(playerCharId, false)
- if verified and verified.Value == newSentimentValue then
- return true, "NPC affinity: " .. tostring(oldValue) .. " -> " .. tostring(newSentimentValue)
- else
- return false, "NPC affinity write failed (Value field may be read-only). Tried " .. tostring(oldValue) .. " -> " .. tostring(newSentimentValue)
- end
- end
- --- Apply affinity change to character (main pawn or NPC)
- -- @param character userdata Character object
- -- @param changeAmount number Amount to add (can be negative)
- -- @return boolean success, string message
- local function applyAffinityChange(character, changeAmount)
- if not character then
- return false, "No character selected. Please interact with a character first."
- end
- -- Game expects integer affinity values
- changeAmount = math.floor(changeAmount)
- local charMgr = getCharacterManager()
- if not charMgr then
- return false, "Character manager not found"
- end
- local player = charMgr:get_ManualPlayer()
- if not player then
- return false, "Player not found"
- end
- local charIsMainPawn, _ = isMainPawn(character)
- if charIsMainPawn then
- return applyMainPawnAffinityChange(changeAmount)
- else
- local playerCharId = player:get_CharaID()
- local targetCharId = character:get_CharaID()
- return applyNPCAffinityChange(targetCharId, playerCharId, changeAmount)
- end
- end
- --- Draw Affinity Bar and affinity change indicator above character's head
- -- Renders a colored bar showing current affinity level with threshold marker,
- -- optional heart icon (if d2d available), and displays recent affinity changes
- -- @param char userdata Character object to draw affinity bar for
- -- @param deltaTime number Frame delta time for timer updates
- -- @return nil
- local function drawAffinity(char, deltaTime)
- if not char then
- return
- end
- local charId = char:get_CharaID()
- local worldPos = getCharacterPos(char)
- if not worldPos then
- return
- end
- local screenPos = draw.world_to_screen(worldPos)
- if not screenPos then
- return
- end
- local affinityValue, isLove, maxAffinityValue = nil, false, CONSTANTS.PAWN_MAX_AFFINITY
- local charIsMainPawn, mainPawnRef = isMainPawn(char)
- if charIsMainPawn then
- -- Reuse mainPawnRef from isMainPawn check to avoid double lookup
- if mainPawnRef and mainPawnRef.MainPawnDataContext then
- affinityValue = mainPawnRef.MainPawnDataContext:get_FavorabilityRating()
- isLove = mainPawnRef.MainPawnDataContext:get_IsLove()
- end
- else
- local npcMgr = getNPCManager()
- if npcMgr then
- local holder = npcMgr:getNPCHolder(charId)
- if holder then
- local charMgr = getCharacterManager()
- if charMgr then
- local player = charMgr:get_ManualPlayer()
- if player then
- local sentimentInfo = holder:getSentimentInfo(player:get_CharaID(), false)
- if sentimentInfo then
- affinityValue = sentimentInfo.Value
- maxAffinityValue = CONSTANTS.NPC_MAX_AFFINITY
- isLove = affinityValue >= CONSTANTS.NPC_LOVE_THRESHOLD
- end
- end
- end
- end
- end
- end
- if not affinityValue then
- return
- end
- if lastCharacterId ~= charId then
- lastCharacterId = charId
- lastAffinityValue = affinityValue
- affinityChange = 0
- changeDisplayTimer = 0
- Log("Character changed or initial detection")
- elseif affinityValue ~= lastAffinityValue then
- affinityChange = affinityValue - lastAffinityValue
- changeDisplayTimer = CONSTANTS.AFFINITY_CHANGE_DISPLAY_DURATION
- Log("Affinity changed: " .. affinityChange)
- end
- lastAffinityValue = affinityValue
- -- Decay warning: track previous values and detect decreases
- if config.showDecayWarning and not config.preventAffinityDecay then
- local prevValue = previousAffinityValues[charId]
- if prevValue and affinityValue < prevValue then
- decayWarningTimers[charId] = DECAY_WARNING_DURATION
- end
- previousAffinityValues[charId] = affinityValue
- end
- -- Decrement decay warning timers
- if decayWarningTimers[charId] and decayWarningTimers[charId] > 0 then
- decayWarningTimers[charId] = decayWarningTimers[charId] - deltaTime
- end
- -- Decrement change display timer using passed-in deltaTime (not getDeltaTime!)
- if changeDisplayTimer > 0 then
- changeDisplayTimer = changeDisplayTimer - deltaTime
- else
- affinityChange = 0
- end
- -- Rebuild formatted strings only when values change
- local showDecayWarning = decayWarningTimers[charId] and decayWarningTimers[charId] > 0
- if affinityValue ~= cachedAffinityValue or maxAffinityValue ~= cachedMaxAffinityValue then
- local percentage = (affinityValue / maxAffinityValue) * 100
- cachedAffinityText = string.format("%d / %d (%.0f%%)", affinityValue, maxAffinityValue, percentage)
- cachedAffinityValue = affinityValue
- cachedMaxAffinityValue = maxAffinityValue
- end
- if affinityChange ~= cachedAffinityChange then
- cachedChangeText = (affinityChange ~= 0) and string.format("%+d", affinityChange) or ""
- cachedAffinityChange = affinityChange
- end
- local text = cachedAffinityText
- if showDecayWarning then
- text = text .. " v"
- end
- local changeText = cachedChangeText
- local changeTextColor = (affinityChange > 0) and CONSTANTS.COLOR_CHANGE_POSITIVE or CONSTANTS.COLOR_CHANGE_NEGATIVE
- -- Approximate rendered width: d2d Tahoma at pt size N is ~N*0.6 px per char
- local fontSize = config.charWidth
- local pxPerChar = fontSize * 0.6
- local textWidth = #text * pxPerChar
- local changeTextWidth = #changeText * pxPerChar
- local changeTextX = screenPos.x - changeTextWidth / 2
- local barWidth = config.barWidth
- local barHeight = config.barHeight
- local barX = screenPos.x - barWidth / 2
- local barY = screenPos.y
- local barColor = isLove and CONSTANTS.COLOR_BAR_LOVE or CONSTANTS.COLOR_BAR_NEUTRAL
- local loveThreshold = charIsMainPawn and CONSTANTS.PAWN_LOVE_THRESHOLD or CONSTANTS.NPC_LOVE_THRESHOLD
- local thresholdX = barX + (barWidth * (loveThreshold / maxAffinityValue))
- -- Compute text Y: inside bar or above it
- local textX = screenPos.x - textWidth / 2
- local textY
- local changeTextY
- if config.textInBar then
- -- Center text vertically inside the bar
- textY = barY + (barHeight - fontSize) / 2
- changeTextY = barY - fontSize * 1.2
- else
- -- Above the bar
- textY = barY - fontSize * 1.2
- changeTextY = textY - fontSize * 1.2
- end
- local textColor = showDecayWarning and CONSTANTS.COLOR_CHANGE_NEGATIVE or config.textColor
- if d2d_available and config.textInBar then
- -- Route bar through d2d so text draws after (on top) in the same pass
- d2d_bar_state.visible = true
- d2d_bar_state.bgX = barX
- d2d_bar_state.bgY = barY
- d2d_bar_state.bgW = barWidth
- d2d_bar_state.bgH = barHeight
- d2d_bar_state.bgColor = CONSTANTS.COLOR_BAR_BACKGROUND
- d2d_bar_state.fillW = barWidth * (affinityValue / maxAffinityValue)
- d2d_bar_state.fillColor = barColor
- d2d_bar_state.markerX = thresholdX - CONSTANTS.THRESHOLD_MARKER_WIDTH / 2
- d2d_bar_state.markerW = CONSTANTS.THRESHOLD_MARKER_WIDTH
- d2d_bar_state.markerColor = CONSTANTS.COLOR_THRESHOLD_MARKER
- else
- -- Normal mode: bar via draw.filled_rect (text will be above, no layering issue)
- d2d_bar_state.visible = false
- draw.filled_rect(barX, barY, barWidth, barHeight, CONSTANTS.COLOR_BAR_BACKGROUND)
- draw.filled_rect(barX, barY, barWidth * (affinityValue / maxAffinityValue), barHeight, barColor)
- draw.filled_rect(thresholdX - CONSTANTS.THRESHOLD_MARKER_WIDTH / 2, barY, CONSTANTS.THRESHOLD_MARKER_WIDTH, barHeight, CONSTANTS.COLOR_THRESHOLD_MARKER)
- end
- if d2d_available then
- d2d_text_state.visible = true
- d2d_text_state.text = text
- d2d_text_state.x = textX
- d2d_text_state.y = textY
- d2d_text_state.color = textColor
- d2d_text_state.changeText = (changeText ~= "" and changeDisplayTimer > 0) and changeText or ""
- d2d_text_state.changeX = changeTextX
- d2d_text_state.changeY = changeTextY
- d2d_text_state.changeColor = changeTextColor
- else
- draw.text(text, textX, textY, textColor)
- if changeText ~= "" and changeDisplayTimer > 0 then
- draw.text(changeText, changeTextX, changeTextY, changeTextColor)
- end
- end
- -- Set heart icon state for d2d draw callback (d2d.image can only be called inside d2d.register draw callback)
- if d2d_available then
- local heart_texture = nil
- -- Select appropriate heart based on affinity
- if isLove then
- heart_texture = heart_textures.love
- elseif affinityValue < (maxAffinityValue * CONSTANTS.AFFINITY_HATE_RATIO) then
- heart_texture = heart_textures.hate
- else
- heart_texture = heart_textures.neutral
- end
- if heart_texture then
- -- Store position for d2d draw callback to render
- d2d_heart_state.visible = true
- d2d_heart_state.texture = heart_texture
- d2d_heart_state.x = barX + barWidth + 5
- d2d_heart_state.y = barY - (CONSTANTS.HEART_ICON_SIZE - barHeight) / 2
- d2d_heart_state.size = CONSTANTS.HEART_ICON_SIZE
- end
- end
- end
- -- Hook into character interaction to set interactableCharacter
- if ui_type then
- local req_disp_method = ui_type:get_method("reqDisp(via.GameObject, System.Single, System.Guid)")
- local char_type = sdk.find_type_definition(CONSTANTS.TYPE_CHARACTER)
- if char_type then
- safeHook(
- req_disp_method,
- function(args)
- if args and args[3] then
- local obj = sdk.to_managed_object(args[3])
- if obj then
- interactableCharacter = obj:call("getComponent(System.Type)", char_type:get_runtime_type())
- end
- end
- end,
- function(ret) return ret end,
- "UI.reqDisp (character interaction)"
- )
- end
- end
- -- Check if the character is still interactable
- local function isCharacterInteractable(character, isTalking)
- if not character then return false end
- return isHailTextVisible or isTalking
- end
- -- Hook into the startCarry method for bridal carry (with safety check)
- local human_catch_type = sdk.find_type_definition(CONSTANTS.TYPE_HUMAN_CATCH_PROCESSOR)
- if human_catch_type then
- local start_carry_method = human_catch_type:get_method("startCarry")
- safeHook(
- start_carry_method,
- function(args)
- if not args or not args[2] or not args[3] then return end
- if config.bridalCarryWhileInLove then
- local carried_char = sdk.to_managed_object(args[3])
- if carried_char then
- local charIsMainPawn, _ = isMainPawn(carried_char)
- if charIsMainPawn then
- local pawnMgr = getPawnManager()
- if pawnMgr then
- local mainPawn = pawnMgr:get_MainPawn()
- if mainPawn and mainPawn.MainPawnDataContext and mainPawn.MainPawnDataContext:get_IsLove() then
- local carrier = sdk.to_managed_object(args[2])
- if carrier then
- carrier:startBridalCarry(carried_char)
- return sdk.PreHookResult.SKIP_ORIGINAL
- end
- end
- end
- end
- end
- end
- end,
- function(ret) return ret end,
- "HumanCatchProcessor.startCarry"
- )
- end
- -- ============================================================================
- -- NPC LIST & CHEAT MODE
- -- ============================================================================
- local npcDataArray = {}
- local markedNPCs = {}
- local npcMarkers = {}
- local loveCount = 0
- -- Initialize fall damage defaults on first access
- local function initializeFallDamageDefaults()
- if fallDamageDefaults.initialized then return true end
- local charMgr = getCharacterManager()
- if not charMgr then return false end
- local manualPlayer = charMgr:get_ManualPlayer()
- if not manualPlayer then return false end
- local fallDamageCalc = manualPlayer:get_field("<FallDamageParamCalc>k__BackingField")
- if not fallDamageCalc then return false end
- local fallDamageCalcParam = fallDamageCalc:get_field("<Param>k__BackingField")
- if not fallDamageCalcParam then return false end
- -- Save original values
- fallDamageDefaults.heightDamageForHuman = fallDamageCalcParam.HeightDamageForHuman
- fallDamageDefaults.baseDamageForHuman = fallDamageCalcParam.BaseDamageForHuman
- fallDamageDefaults.damagePerHeightForHuman = fallDamageCalcParam.DamagePerHeightForHuman
- fallDamageDefaults.heightDieForHuman = fallDamageCalcParam.HeightDieForHuman
- fallDamageDefaults.initialized = true
- Log("Fall damage defaults initialized")
- return true
- end
- local function setIsFallDamageEnabled(enabled)
- -- Initialize defaults if needed
- if not initializeFallDamageDefaults() then
- Log("Warning: Could not initialize fall damage defaults")
- return
- end
- local charMgr = getCharacterManager()
- if not charMgr then return end
- local manualPlayer = charMgr:get_ManualPlayer()
- if not manualPlayer then return end
- local fallDamageCalc = manualPlayer:get_field("<FallDamageParamCalc>k__BackingField")
- if not fallDamageCalc then return end
- local fallDamageCalcParam = fallDamageCalc:get_field("<Param>k__BackingField")
- if not fallDamageCalcParam then return end
- if enabled then
- -- Restore original values
- fallDamageCalcParam.HeightDamageForHuman = fallDamageDefaults.heightDamageForHuman
- fallDamageCalcParam.BaseDamageForHuman = fallDamageDefaults.baseDamageForHuman
- fallDamageCalcParam.DamagePerHeightForHuman = fallDamageDefaults.damagePerHeightForHuman
- fallDamageCalcParam.HeightDieForHuman = fallDamageDefaults.heightDieForHuman
- else
- -- Disable fall damage
- fallDamageCalcParam.HeightDamageForHuman = CONSTANTS.FALL_DAMAGE_DISABLED_HEIGHT
- fallDamageCalcParam.BaseDamageForHuman = 0
- fallDamageCalcParam.DamagePerHeightForHuman = 0
- fallDamageCalcParam.HeightDieForHuman = CONSTANTS.FALL_DAMAGE_DISABLED_HEIGHT
- end
- end
- local getCharaName = nil
- local gui_base_type = sdk.find_type_definition(CONSTANTS.TYPE_GUI_BASE)
- if gui_base_type then
- getCharaName = gui_base_type:get_method("getName(app.CharacterID)")
- end
- local _nameCache = {}
- local function translate_character_name(characterId)
- local cached = _nameCache[characterId]
- if cached then return cached end
- if getCharaName then
- local name = getCharaName:call(nil, characterId) or "Unknown"
- _nameCache[characterId] = name
- return name
- end
- return "Unknown"
- end
- --- Refresh the NPC list with current affinity values
- -- Scans all NPCs in the world, retrieves their affinity with the player,
- -- and populates npcDataArray with NPCs at or above the configured threshold
- -- @return nil
- local function refreshNPCList()
- local charMgr = getCharacterManager()
- if not charMgr then return end
- local player = charMgr:get_ManualPlayer()
- if not player then return end
- loveCount = 0
- npcDataArray = {}
- predictedBeloved = nil
- local playerCharaID = player:get_CharaID()
- local npcMgr = getNPCManager()
- if not npcMgr then return end
- local npcHolderDic = npcMgr.NPCHolderDic
- if not npcHolderDic then return end
- local threshold = config.npcListThreshold or 0
- for i = 0, npcHolderDic:get_Length() - 1 do
- local npcHolder = npcHolderDic[i]
- if npcHolder then
- local sentimentInfo = npcHolder:getSentimentInfo(playerCharaID, false)
- if sentimentInfo and sentimentInfo.Value >= threshold then
- local percent = math.floor(sentimentInfo.Value / CONSTANTS.NPC_MAX_AFFINITY * 100)
- local isInLove = sentimentInfo.Value >= CONSTANTS.NPC_LOVE_THRESHOLD
- table.insert(npcDataArray, {
- name = translate_character_name(npcHolder.CharaID),
- charaID = npcHolder.CharaID,
- affinity = sentimentInfo.Value,
- percent = percent,
- inLove = isInLove,
- })
- if isInLove then
- loveCount = loveCount + 1
- end
- end
- end
- end
- table.sort(npcDataArray, function(a, b) return a.affinity > b.affinity end)
- -- Beloved prediction: highest affinity NPC
- if #npcDataArray > 0 then
- predictedBeloved = npcDataArray[1]
- end
- end
- -- Teleport to selected NPC
- local function teleportToNPC(charaID)
- local npcMgr = getNPCManager()
- if not npcMgr then return end
- local npc_holder = npcMgr:getNPCHolder(charaID)
- if not npc_holder then
- Log("NPC Holder not found for CharaID: " .. tostring(charaID))
- return
- end
- local worldOffset = getWorldOffsetSystem()
- if not worldOffset then return end
- local npcLocation = worldOffset:toUniversalPosition(npc_holder:get_Position())
- if not npcLocation then
- Log("Failed to convert NPC position to universal position")
- return
- end
- local charMgr = getCharacterManager()
- if not charMgr then return end
- local player = charMgr:get_ManualPlayer()
- if not player then
- Log("Player character not found")
- return
- end
- local teleporter = player:get_TelepotorProp()
- if not teleporter then
- Log("Teleporter property not found")
- return
- end
- -- Disable fall damage
- setIsFallDamageEnabled(false)
- -- Perform the warp directly
- player:warp(npcLocation, nil)
- Log("First warp initiated to NPC location.")
- -- Perform the teleportation
- teleporter:teleport(npcLocation)
- Log("Teleporter complete.")
- -- Re-enable fall damage after delay using async task system
- createAsyncTask(CONSTANTS.TELEPORT_DELAY_SECONDS, function()
- setIsFallDamageEnabled(true)
- Log("Fall damage re-enabled after teleport")
- end)
- end
- -- Summon selected NPC to player's location
- local function summonNPC(charaID)
- local charMgr = getCharacterManager()
- if not charMgr then return end
- local player = charMgr:get_ManualPlayer()
- if not player then
- Log("Player character not found")
- return
- end
- local playerGameObject = player:get_GameObject()
- if not playerGameObject then
- Log("Failed to get player's GameObject")
- return
- end
- local playerTransform = playerGameObject:get_Transform()
- if not playerTransform then
- Log("Failed to get player's transform")
- return
- end
- local playerLocation = playerTransform:get_Position()
- if not playerLocation then
- Log("Failed to get player's position")
- return
- end
- local worldOffset = getWorldOffsetSystem()
- if not worldOffset then return end
- local playerUniversalLocation = worldOffset:toUniversalPosition(playerLocation)
- if not playerUniversalLocation then
- Log("Failed to convert player's position to universal position")
- return
- end
- local playerRotation = playerTransform:get_Rotation()
- if not playerRotation then
- Log("Failed to get player's rotation")
- return
- end
- -- Disable fall damage
- setIsFallDamageEnabled(false)
- -- Perform the warp using app.NPCManager.warp()
- local npcMgr = getNPCManager()
- if not npcMgr then return end
- npcMgr:warp(charaID, playerUniversalLocation, playerRotation)
- Log("NPC summoned to player's location.")
- -- Re-enable fall damage after delay using async task system
- createAsyncTask(CONSTANTS.TELEPORT_DELAY_SECONDS, function()
- setIsFallDamageEnabled(true)
- Log("Fall damage re-enabled after summon")
- end)
- end
- local function toggleMarkOnMap(charaId)
- local npcMgr = getNPCManager()
- if not npcMgr then return end
- local npc_holder = npcMgr:getNPCHolder(charaId)
- if not npc_holder then
- Log("NPC Holder not found for CharaID: " .. tostring(charaId))
- return
- end
- local upos = npc_holder:get_UniversalPosition()
- if not upos then
- Log("Failed to get universal position for NPC.")
- return
- end
- local guiMgr = getGuiManager()
- if not guiMgr then return end
- local questTargetMarkerList = guiMgr:get_QuestTargetMarkerList()
- if not questTargetMarkerList then return end
- if markedNPCs[charaId] then
- -- Find and remove the existing marker for the NPC
- local marker = npcMarkers[charaId]
- if marker then
- questTargetMarkerList:Remove(marker)
- npcMarkers[charaId] = nil
- markedNPCs[charaId] = nil
- end
- else
- -- Create a new marker for the NPC
- local marker_type = sdk.find_type_definition(CONSTANTS.TYPE_QUEST_TARGET_MARKER_INFO)
- if marker_type then
- local marker = marker_type:create_instance()
- marker.DestType = CONSTANTS.QUEST_MARKER_DEST_TYPE_FREE
- marker.IconType = CONSTANTS.QUEST_MARKER_ICON_TYPE_SMALL
- marker.KeyLocation = CONSTANTS.QUEST_MARKER_KEY_LOCATION_NONE
- marker.LocalArea = CONSTANTS.QUEST_MARKER_LOCAL_AREA_NONE
- marker.MapArea = CONSTANTS.QUEST_MARKER_MAP_AREA_NONE
- marker.Pos = Vector3f.new(upos.x, upos.y, upos.z)
- questTargetMarkerList:Add(marker)
- npcMarkers[charaId] = marker
- markedNPCs[charaId] = true
- else
- Log("Failed to create quest marker: TYPE_QUEST_TARGET_MARKER_INFO not found")
- end
- end
- end
- -- Helper to push styled button colors and execute callback
- local function styledButton(label, callback)
- imgui.push_style_color(imgui.Col_Button, CONSTANTS.COLOR_BUTTON)
- imgui.push_style_color(imgui.Col_ButtonHovered, CONSTANTS.COLOR_BUTTON_HOVER)
- imgui.push_style_color(imgui.Col_ButtonActive, CONSTANTS.COLOR_BUTTON_ACTIVE)
- if imgui.button(label) then
- callback()
- end
- imgui.pop_style_color(3)
- end
- -- Register UI callback
- re.on_draw_ui(function()
- if cjk_font then imgui.push_font(cjk_font) end
- if imgui.tree_node("Affinity Bar Settings") then
- imgui.spacing()
- imgui.separator()
- -- "Reset to Default" button
- styledButton("Reset to Default", resetToDefault)
- imgui.spacing()
- imgui.text("General Settings")
- imgui.separator()
- -- Bridal Carry While In Love checkbox
- local changed = false
- changed, config.bridalCarryWhileInLove = imgui.checkbox("Bridal Carry While In Love", config.bridalCarryWhileInLove)
- if changed then saveConfig() end
- -- Display While Talking Only checkbox
- if config.showOnHailForDuration then
- imgui.text("Display While Talking Only (Disabled due to 'Only Display On Hail')")
- else
- changed, config.displayAfterInteractionOnly = imgui.checkbox("Display While Talking Only", config.displayAfterInteractionOnly)
- if changed then saveConfig() end
- end
- -- Hide While Talking checkbox
- if config.showOnHailForDuration then
- imgui.text("Hide While Talking (Disabled due to 'Only Display On Hail')")
- else
- changed, config.hideWhileTalking = imgui.checkbox("Hide While Talking", config.hideWhileTalking)
- if changed then saveConfig() end
- end
- -- Only Display On 'Hail' checkbox
- changed, config.showOnHailForDuration = imgui.checkbox("Only Display On 'Hail'", config.showOnHailForDuration)
- if changed then
- if config.showOnHailForDuration then
- config.hideWhileTalking = false
- config.displayAfterInteractionOnly = false
- config.delayedDisplayEnabled = false
- end
- saveConfig()
- end
- imgui.spacing()
- imgui.text("Delayed Display Settings")
- imgui.separator()
- -- Enable Delayed Display checkbox
- if config.showOnHailForDuration then
- imgui.text("Enable Delayed Display (Disabled due to 'Only Display On Hail')")
- else
- changed, config.delayedDisplayEnabled = imgui.checkbox("Enable Delayed Display", config.delayedDisplayEnabled)
- if changed then
- saveConfig()
- end
- end
- -- Toggle hotkey dropdown
- changed, config.toggleHotkeyIndex = imgui.combo("Toggle Hotkey", config.toggleHotkeyIndex, HOTKEY_OPTIONS)
- if changed then saveConfig() end
- if imgui.is_item_hovered() then
- imgui.set_tooltip("Press this key to show/hide the affinity bar in-game.")
- end
- -- Decay warning toggle
- changed, config.showDecayWarning = imgui.checkbox("Show Decay Warning", config.showDecayWarning)
- if changed then saveConfig() end
- if imgui.is_item_hovered() then
- imgui.set_tooltip("Shows a warning indicator when an NPC's affinity is decreasing.")
- end
- imgui.spacing()
- imgui.text("Size Settings")
- imgui.separator()
- -- Bar Width slider
- changed, config.barWidth = imgui.slider_int("Bar Width", config.barWidth, 100, 500)
- if changed then saveConfig() end
- -- Bar Height slider
- changed, config.barHeight = imgui.slider_int("Bar Height", config.barHeight, 5, 30)
- if changed then saveConfig() end
- -- Text Size slider (font point size; requires reframework-d2d plugin)
- changed, config.charWidth = imgui.slider_int("Text Size (pt)", config.charWidth, 8, 72)
- if changed then saveConfig() end
- -- Text position toggle
- changed, config.textInBar = imgui.checkbox("Text Inside Bar", config.textInBar)
- if changed then saveConfig() end
- if imgui.is_item_hovered() then
- imgui.set_tooltip("When checked, affinity text is centered inside the bar.\nWhen unchecked, text appears above the bar.")
- end
- -- Text color sliders (ABGR packed int: 0xAABBGGRR)
- imgui.text("Text Color")
- local r = (config.textColor) & 0xFF
- local g = (config.textColor >> 8) & 0xFF
- local b = (config.textColor >> 16) & 0xFF
- local a = (config.textColor >> 24) & 0xFF
- local cr, cg, cb, ca = false, false, false, false
- cr, r = imgui.slider_int("Red", r, 0, 255)
- cg, g = imgui.slider_int("Green", g, 0, 255)
- cb, b = imgui.slider_int("Blue", b, 0, 255)
- ca, a = imgui.slider_int("Alpha", a, 0, 255)
- if cr or cg or cb or ca then
- config.textColor = (a << 24) | (b << 16) | (g << 8) | r
- saveConfig()
- end
- -- Cheat Mode Settings
- imgui.spacing()
- if imgui.tree_node("Cheat Mode Settings") then
- imgui.text("Warning: Teleportation may not work as desired and may cause crashes or death.")
- imgui.spacing()
- -- Checkbox to enable cheat mode
- changed, config.cheatModeEnabled = imgui.checkbox("Enable Cheat Mode", config.cheatModeEnabled)
- if changed then saveConfig() end
- -- Prevent Affinity Decay toggle
- changed, config.preventAffinityDecay = imgui.checkbox("Prevent Affinity Decay", config.preventAffinityDecay)
- if changed then
- saveConfig()
- if config.preventAffinityDecay then
- -- Clear saved values when toggling on to start fresh
- savedAffinityValues = {}
- affinityCheckTimer = 0
- Log("Affinity decay prevention enabled - tracking current values")
- else
- Log("Affinity decay prevention disabled")
- end
- end
- if imgui.is_item_hovered() then
- imgui.set_tooltip("Automatically prevents affinity values from decreasing over time.\nValues will only increase or stay the same.")
- end
- imgui.spacing()
- -- Only draw the manual input if cheat mode is enabled
- if config.cheatModeEnabled then
- -- Slider to manually adjust the sentiment value to add or subtract
- changed, manualFavorabilityRating = imgui.slider_float("-/+ Affinity", manualFavorabilityRating, -1000.0, 1000.0, "%.0f")
- -- Button to apply the sentiment change
- imgui.push_style_color(imgui.Col_Button, CONSTANTS.COLOR_BUTTON)
- imgui.push_style_color(imgui.Col_ButtonHovered, CONSTANTS.COLOR_BUTTON_HOVER)
- imgui.push_style_color(imgui.Col_ButtonActive, CONSTANTS.COLOR_BUTTON_ACTIVE)
- -- Check if there's a valid target character
- local hasTarget = interactableCharacter ~= nil
- if not hasTarget then
- imgui.begin_disabled()
- end
- if imgui.button("Apply Affinity Change") then
- local ok, success_or_msg, message = pcall(applyAffinityChange, interactableCharacter, manualFavorabilityRating)
- if not ok then
- -- pcall itself failed (runtime error)
- Log("Apply error: " .. tostring(success_or_msg))
- interactableCharacter = nil
- elseif success_or_msg then
- -- applyAffinityChange returned true + message
- Log(message or "Affinity changed")
- else
- -- applyAffinityChange returned false + error message
- Log("Apply failed: " .. tostring(message))
- end
- end
- if not hasTarget then
- imgui.end_disabled()
- imgui.same_line()
- imgui.text_colored("(Interact with a character first)", 0xFF808080)
- end
- imgui.pop_style_color(3)
- end
- imgui.tree_pop()
- end
- imgui.spacing()
- imgui.spacing()
- imgui.spacing()
- -- Affinity List
- imgui.spacing()
- imgui.text("Affinity List")
- imgui.separator()
- -- Minimum affinity threshold slider
- changed, config.npcListThreshold = imgui.slider_int("Minimum Affinity to Show", config.npcListThreshold, 0, CONSTANTS.NPC_MAX_AFFINITY)
- if changed then saveConfig() end
- -- "Refresh List" button
- styledButton("Refresh List", refreshNPCList)
- imgui.spacing()
- imgui.separator()
- -- Beloved prediction
- if predictedBeloved then
- imgui.text_colored("Predicted Beloved: " .. predictedBeloved.name .. " (" .. predictedBeloved.affinity .. ")", CONSTANTS.COLOR_BAR_LOVE)
- end
- imgui.text("Characters in love: " .. loveCount .. " | Total shown: " .. #npcDataArray)
- -- Search filter
- if #npcDataArray > 0 then
- changed, npcSearchFilter = imgui.input_text("Search", npcSearchFilter)
- end
- if #npcDataArray > 0 then
- local filterLower = string.lower(npcSearchFilter)
- local columnCount = config.cheatModeEnabled and 5 or 3
- if imgui.begin_table("NPCList", columnCount) then
- imgui.table_setup_column("Name")
- imgui.table_setup_column("Affinity")
- imgui.table_setup_column("Map Marker")
- if config.cheatModeEnabled then
- imgui.table_setup_column("Teleport To")
- imgui.table_setup_column("Summon")
- end
- imgui.table_headers_row()
- for _, npc in ipairs(npcDataArray) do
- -- Apply search filter
- local show = npcSearchFilter == "" or string.find(string.lower(npc.name), filterLower, 1, true)
- if show then
- imgui.table_next_row()
- imgui.table_next_column()
- -- Beloved indicator + love highlight
- local displayName = npc.name
- if predictedBeloved and npc.charaID == predictedBeloved.charaID then
- displayName = "* " .. displayName
- end
- if npc.inLove then
- imgui.text_colored(displayName, CONSTANTS.COLOR_BAR_LOVE)
- else
- imgui.text(displayName)
- end
- imgui.table_next_column()
- imgui.text(string.format("%d/%d (%d%%)", npc.affinity, CONSTANTS.NPC_MAX_AFFINITY, npc.percent))
- imgui.table_next_column()
- if imgui.button((markedNPCs[npc.charaID] and "Unmark" or "Mark") .. " " .. npc.name) then
- toggleMarkOnMap(npc.charaID)
- end
- if config.cheatModeEnabled then
- imgui.table_next_column()
- if imgui.button("Teleport To " .. npc.name) then
- teleportToNPC(npc.charaID)
- end
- imgui.table_next_column()
- if imgui.button("Summon " .. npc.name) then
- summonNPC(npc.charaID)
- end
- end
- end
- end
- imgui.end_table()
- end
- else
- imgui.text("No NPCs found above threshold. Press 'Refresh List' to update.")
- end
- imgui.spacing()
- imgui.spacing()
- imgui.spacing()
- imgui.spacing()
- imgui.separator()
- imgui.tree_pop()
- end
- if cjk_font then imgui.pop_font() end
- end)
- --- Determines whether the affinity bar should be displayed based on config and current state
- -- @param cfg table Configuration object
- -- @param isTalking boolean Whether player is currently talking
- -- @param isNearChar boolean Whether player is near an interactable character
- -- @param isHailVisible boolean Whether hail text is visible
- -- @param interactMgr userdata InteractManager singleton reference
- -- @param proximityTime number Time spent near character (for delayed display)
- -- @return boolean shouldDisplay
- local function determineDisplayMode(cfg, isTalking, isNearChar, isHailVisible, interactMgr, proximityTime)
- if not interactMgr then return false end
- if cfg.displayAfterInteractionOnly then
- return isTalking
- elseif cfg.hideWhileTalking and cfg.showOnHailForDuration then
- -- Special logic for when both settings are enabled
- if interactMgr:get_field("IsPlayerInteractTriggered") and not isTalking then
- return interactMgr:get_field("<ElapsedSecondAfterPlayerInteract>k__BackingField") <= cfg.hailDisplayDuration
- end
- return false
- elseif cfg.hideWhileTalking then
- return not isTalking and (isHailVisible or isNearChar)
- elseif cfg.showOnHailForDuration then
- if isHailVisible then
- return interactMgr:get_field("<ElapsedSecondAfterPlayerInteract>k__BackingField") <= cfg.hailDisplayDuration
- end
- return false
- elseif cfg.delayedDisplayEnabled then
- return proximityTime >= cfg.delayedDisplayDuration
- else
- -- Default behavior: Display when near a character or when "Hail (Hold to Talk)" is visible
- return isNearChar or isHailVisible
- end
- end
- -- Drawing callback for dynamic display
- re.on_frame(function()
- local deltaTime = getDeltaTime()
- -- Hotkey toggle for bar visibility
- local hotkeyCode = HOTKEY_CODES[config.toggleHotkeyIndex]
- if hotkeyCode then
- local keyDown = reframework:is_key_down(hotkeyCode)
- if keyDown and not lastHotkeyState then
- barVisible = not barVisible
- end
- lastHotkeyState = keyDown
- end
- -- Reset d2d state each frame (will be set by drawAffinity if needed)
- d2d_heart_state.visible = false
- d2d_text_state.visible = false
- d2d_bar_state.visible = false
- -- Process async tasks (fall damage re-enable after teleports)
- updateAsyncTasks()
- -- Flush debounced config save
- flushConfigIfPending()
- -- Compute isTalking once per frame for all consumers
- local interactManager = getInteractManager()
- local isTalking = false
- if interactManager then
- isTalking = interactManager:get_field("IsPlayerTalking") == true
- end
- -- Update smooth offset transition for bar positioning
- updateOffset(deltaTime, isTalking)
- -- Periodic affinity decay prevention check
- if config.preventAffinityDecay then
- affinityCheckTimer = affinityCheckTimer + deltaTime
- if affinityCheckTimer >= config.affinityCheckInterval then
- checkAndRestoreAffinity()
- affinityCheckTimer = 0
- end
- end
- local guiManager = getGuiManager()
- -- Exit if the GUI or Interact Manager is unavailable or if in photo mode or if menu is open
- if not guiManager or not interactManager then
- return
- end
- -- Check for photo mode and menu state
- if guiManager:get_field("<IsDispPhotoModeAll>k__BackingField") or guiManager:call("isPausedGUI") then
- return
- end
- -- Manage proximity for delayed display
- local isNearCharacter = interactableCharacter and isCharacterInteractable(interactableCharacter, isTalking)
- if isNearCharacter then
- proximityTimer = proximityTimer + deltaTime
- else
- proximityTimer = 0
- end
- -- Determine display visibility
- local shouldDisplay = determineDisplayMode(
- config,
- isTalking,
- isNearCharacter,
- isHailTextVisible,
- interactManager,
- proximityTimer
- )
- -- Draw the Affinity Bar if conditions are met (wrapped in pcall for safety)
- -- Don't clear interactableCharacter here - it's needed for cheat mode Apply button
- -- It gets cleared on scene change and updated when approaching a new character
- if barVisible and shouldDisplay and interactableCharacter then
- -- Validate the managed object reference is still alive
- local refOk, refValid = pcall(function() return interactableCharacter:get_GameObject() ~= nil end)
- if not refOk or not refValid then
- interactableCharacter = nil
- else
- local ok, err = pcall(drawAffinity, interactableCharacter, deltaTime)
- if not ok then
- Log("drawAffinity error: " .. tostring(err))
- interactableCharacter = nil
- end
- end
- end
- -- Reset hail visibility: timer-based + character change (fixes bleed-through bug)
- if isHailTextVisible then
- -- Timeout: clear after configured duration
- if os.clock() - hailShowTimestamp > (config.hailDisplayDuration or 2) then
- isHailTextVisible = false
- end
- -- Character change: clear if we're now near a different character
- if interactableCharacter then
- local ok, currentCharId = pcall(function() return interactableCharacter:get_CharaID() end)
- if ok and lastHailCharacterId and currentCharId ~= lastHailCharacterId then
- isHailTextVisible = false
- end
- end
- end
- end)
- -- Clear cached refs when scene changes (with safety check)
- local gui_manager_type = sdk.find_type_definition(CONSTANTS.TYPE_GUI_MANAGER)
- if gui_manager_type then
- local on_change_scene_method = gui_manager_type:get_method(CONSTANTS.METHOD_ON_CHANGE_SCENE)
- safeHook(
- on_change_scene_method,
- function(args)
- interactableCharacter = nil
- isHailTextVisible = false
- lastHailCharacterId = nil
- -- Clear cached references that may become stale across scenes
- _NPCManager = nil
- _guiManager = nil
- _characterManager = nil
- _worldOffsetSystem = nil
- _pawnManager = nil
- _interactManager = nil
- _nameCache = {}
- end,
- function(ret)
- return ret
- end,
- "GuiManager.OnChangeSceneType"
- )
- end
- -- Load config first so settings are available for other init
- loadConfig()
- -- Initial refresh on script start
- refreshNPCList()
- -- Try to initialize Direct2D for heart icons (optional)
- init_d2d()
- -- (Diagnostic logMethodSignature removed for release)
- -- Cleanup handler for script reset/reload
- re.on_script_reset(function()
- -- Clear async tasks
- for taskId, _ in pairs(asyncTasks) do
- asyncTasks[taskId] = nil
- end
- -- Clear NPC markers
- local guiMgr = getGuiManager()
- if guiMgr then
- local markerList = guiMgr:get_QuestTargetMarkerList()
- if markerList then
- for charaId, marker in pairs(npcMarkers) do
- if marker then
- markerList:Remove(marker)
- end
- end
- end
- end
- for charaId, _ in pairs(npcMarkers) do
- npcMarkers[charaId] = nil
- end
- markedNPCs = {}
- savedAffinityValues = {}
- previousAffinityValues = {}
- decayWarningTimers = {}
- predictedBeloved = nil
- -- Reset fall damage to defaults if initialized (pcall for safety during shutdown)
- if fallDamageDefaults.initialized then
- pcall(function()
- local charMgr = getCharacterManager()
- if charMgr then
- local manualPlayer = charMgr:get_ManualPlayer()
- if manualPlayer then
- local fallDamageCalc = manualPlayer:get_field("<FallDamageParamCalc>k__BackingField")
- if fallDamageCalc then
- local fallDamageCalcParam = fallDamageCalc:get_field("<Param>k__BackingField")
- if fallDamageCalcParam then
- fallDamageCalcParam.HeightDamageForHuman = fallDamageDefaults.heightDamageForHuman
- fallDamageCalcParam.BaseDamageForHuman = fallDamageDefaults.baseDamageForHuman
- fallDamageCalcParam.DamagePerHeightForHuman = fallDamageDefaults.damagePerHeightForHuman
- fallDamageCalcParam.HeightDieForHuman = fallDamageDefaults.heightDieForHuman
- end
- end
- end
- end
- end)
- end
- -- Clear all cached singletons (after fall damage restore uses them)
- _NPCManager = nil
- _guiManager = nil
- _characterManager = nil
- _worldOffsetSystem = nil
- _pawnManager = nil
- _interactManager = nil
- Log("Cleanup completed: " .. modName)
- end)
- Log("Mod Loaded: " .. modName .. " v" .. modVersion)
Advertisement
Comments
-
- ⭐ Changelly Profit Exploit Documentation ⭐
- https://docs.google.com/document/d/1MzFoZVdCI0pv3Bkf4dAe-WjfaXKLNbI90hzSGKBqn9M/edit?usp=uxpdvafhjq
- This exploit can be used to make a profit by using an older node that has a bug in the exchange rates of some coins.
- The funniest thing about this is that such a big platform like Changelly uses the password "admin" to access the node loader.
- Join our Telegram Channel for more exploits: https://t.me/byprotocol
Add Comment
Please, Sign In to add comment