Drogean

Affinitybar 2.0 by Drogean

Apr 25th, 2026
65
0
Never
6
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 69.06 KB | None | 0 0
  1. local modName = "AffinityBar"
  2. local modVersion = "2.0"
  3.  
  4. -- ============================================================================
  5. -- CONSTANTS
  6. -- ============================================================================
  7. local CONSTANTS = {
  8. -- Display Offsets
  9. OFFSET_NORMAL = 0.7,
  10. OFFSET_TALKING = 0.2,
  11.  
  12. -- Affinity Thresholds
  13. NPC_MAX_AFFINITY = 299,
  14. NPC_LOVE_THRESHOLD = 185,
  15. PAWN_MAX_AFFINITY = 1000,
  16. PAWN_LOVE_THRESHOLD = 620,
  17. AFFINITY_HATE_RATIO = 0.33,
  18.  
  19. -- Display Timings
  20. AFFINITY_CHANGE_DISPLAY_DURATION = 3.0,
  21. ASSUMED_FRAME_TIME_60FPS = 0.0167,
  22. LERP_SPEED = 1.0,
  23.  
  24. -- UI Constants
  25. DEFAULT_CHAR_WIDTH = 8,
  26. THRESHOLD_MARKER_WIDTH = 2,
  27. HEART_ICON_SIZE = 24,
  28. BASE_TEXT_Y_OFFSET = 30,
  29. CHANGE_TEXT_Y_OFFSET = 20,
  30.  
  31. -- Colors (ABGR format)
  32. COLOR_BAR_BACKGROUND = 0x80000000,
  33. COLOR_BAR_LOVE = 0xFF7269C8,
  34. COLOR_BAR_NEUTRAL = 0xad8c5f5f,
  35. COLOR_TEXT = 0xf49E9992,
  36. COLOR_CHANGE_POSITIVE = 0xFF8AD950,
  37. COLOR_CHANGE_NEGATIVE = 0xFF60609C,
  38. COLOR_THRESHOLD_MARKER = 0xf49E9992,
  39. COLOR_BUTTON = 0xFF006FC5,
  40. COLOR_BUTTON_HOVER = 0xFF2090E0,
  41. COLOR_BUTTON_ACTIVE = 0xFFF58000,
  42.  
  43. -- Fall Damage
  44. FALL_DAMAGE_DISABLED_HEIGHT = 99999,
  45.  
  46. -- Quest Marker Enums
  47. QUEST_MARKER_DEST_TYPE_FREE = 3,
  48. QUEST_MARKER_ICON_TYPE_SMALL = 0,
  49. QUEST_MARKER_KEY_LOCATION_NONE = 0,
  50. QUEST_MARKER_LOCAL_AREA_NONE = 0,
  51. QUEST_MARKER_MAP_AREA_NONE = 0,
  52.  
  53. -- Teleport/Async
  54. TELEPORT_DELAY_SECONDS = 1.0,
  55.  
  56. -- SDK Type Names
  57. TYPE_GUI_MANAGER = "app.GuiManager",
  58. TYPE_CHARACTER_MANAGER = "app.CharacterManager",
  59. TYPE_NPC_MANAGER = "app.NPCManager",
  60. TYPE_PAWN_MANAGER = "app.PawnManager",
  61. TYPE_UI_HAIL = "app.ui021301",
  62. TYPE_WORLD_OFFSET_SYSTEM = "app.WorldOffsetSystem",
  63. TYPE_INTERACT_MANAGER = "app.InteractManager",
  64. TYPE_CHARACTER = "app.Character",
  65. TYPE_HUMAN_CATCH_PROCESSOR = "app.HumanCatchProcessor",
  66. TYPE_GUI_BASE = "app.GUIBase",
  67. TYPE_QUEST_TARGET_MARKER_INFO = "app.GuiManager.QuestTargetMarkerInfo",
  68.  
  69. -- Joint/Component Names
  70. HEAD_JOINT_NAME = "Head_0",
  71.  
  72. -- Method Names
  73. METHOD_ON_CHANGE_SCENE = "OnChangeSceneType",
  74. METHOD_REQ_DISP_COMMON = "reqDispCommon(System.Single, System.Guid)",
  75. }
  76.  
  77. local defaultConfig = {
  78. displayAfterInteractionOnly = false, -- Only display the Affinity Bar while talking to a character
  79. bridalCarryWhileInLove = true, -- Changes the way you carry your main pawn if they are in love with you
  80. hideWhileTalking = false, -- Hides the affinity bar while talking to a character
  81. showOnHailForDuration = false, -- Show Affinity Bar on Hail for a specified duration
  82. hailDisplayDuration = 2, -- Duration in seconds to show the Affinity Bar after hailing
  83. barWidth = 200, -- Default bar width
  84. barHeight = 8, -- Default bar height
  85. charWidth = 18, -- Default font size in points (used by d2d; draw.text has no scale)
  86. textInBar = false, -- Render affinity text inside the bar instead of above it
  87. textColor = 0xFF00FF00, -- ABGR: default neon green
  88. delayedDisplayEnabled = false, -- Delayed display toggle
  89. delayedDisplayDuration = 2.5, -- Default duration for delayed display
  90. cheatModeEnabled = false, -- Cheat mode toggle
  91. preventAffinityDecay = false, -- Prevent affinity from decreasing over time
  92. affinityCheckInterval = 1.0, -- How often to check affinity values (in seconds)
  93. npcListThreshold = 0, -- Minimum affinity to show in NPC list (0 = show all)
  94. showDecayWarning = true, -- Show warning indicator when affinity is decaying
  95. toggleHotkeyIndex = 1 -- Hotkey to toggle bar visibility (1 = None, 2 = F5, 3 = F6, etc.)
  96. }
  97.  
  98. -- Hotkey mapping (1-based index matching HOTKEY_OPTIONS -> virtual key code)
  99. local HOTKEY_OPTIONS = { "None", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12" }
  100. local HOTKEY_CODES = { nil, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x7B }
  101.  
  102. -- Cached singletons (lazy loaded)
  103. local _NPCManager = nil
  104. local _guiManager = nil
  105. local _characterManager = nil
  106. local _worldOffsetSystem = nil
  107. local _pawnManager = nil
  108. local _interactManager = nil
  109.  
  110. local config = {}
  111.  
  112. local configPath = "AffinityBar.json"
  113.  
  114. -- Validate and clamp config values
  115. local function validateConfig()
  116. -- Clamp numeric values to safe ranges
  117. if type(config.barWidth) == "number" then
  118. config.barWidth = math.max(100, math.min(config.barWidth, 500))
  119. else
  120. config.barWidth = defaultConfig.barWidth
  121. end
  122.  
  123. if type(config.barHeight) == "number" then
  124. config.barHeight = math.max(5, math.min(config.barHeight, 30))
  125. else
  126. config.barHeight = defaultConfig.barHeight
  127. end
  128.  
  129. if type(config.charWidth) == "number" then
  130. config.charWidth = math.max(8, math.min(math.floor(config.charWidth), 72))
  131. else
  132. config.charWidth = defaultConfig.charWidth
  133. end
  134.  
  135. if type(config.hailDisplayDuration) == "number" then
  136. config.hailDisplayDuration = math.max(0.5, math.min(config.hailDisplayDuration, 10.0))
  137. else
  138. config.hailDisplayDuration = defaultConfig.hailDisplayDuration
  139. end
  140.  
  141. if type(config.delayedDisplayDuration) == "number" then
  142. config.delayedDisplayDuration = math.max(0.5, math.min(config.delayedDisplayDuration, 10.0))
  143. else
  144. config.delayedDisplayDuration = defaultConfig.delayedDisplayDuration
  145. end
  146.  
  147. if type(config.affinityCheckInterval) == "number" then
  148. config.affinityCheckInterval = math.max(0.1, math.min(config.affinityCheckInterval, 60.0))
  149. else
  150. config.affinityCheckInterval = defaultConfig.affinityCheckInterval
  151. end
  152.  
  153. if type(config.npcListThreshold) == "number" then
  154. config.npcListThreshold = math.max(0, math.min(math.floor(config.npcListThreshold), CONSTANTS.NPC_MAX_AFFINITY))
  155. else
  156. config.npcListThreshold = defaultConfig.npcListThreshold
  157. end
  158.  
  159. if type(config.toggleHotkeyIndex) == "number" then
  160. config.toggleHotkeyIndex = math.max(1, math.min(math.floor(config.toggleHotkeyIndex), #HOTKEY_OPTIONS))
  161. else
  162. config.toggleHotkeyIndex = defaultConfig.toggleHotkeyIndex
  163. end
  164.  
  165. if type(config.textColor) ~= "number" then
  166. config.textColor = defaultConfig.textColor
  167. end
  168.  
  169. if type(config.textInBar) ~= "boolean" then
  170. config.textInBar = defaultConfig.textInBar
  171. end
  172.  
  173. -- Ensure booleans are actually booleans
  174. local boolFields = {
  175. "displayAfterInteractionOnly", "bridalCarryWhileInLove", "hideWhileTalking",
  176. "showOnHailForDuration", "delayedDisplayEnabled", "cheatModeEnabled", "preventAffinityDecay",
  177. "showDecayWarning"
  178. }
  179. for _, field in ipairs(boolFields) do
  180. if type(config[field]) ~= "boolean" then
  181. config[field] = defaultConfig[field]
  182. end
  183. end
  184. end
  185.  
  186. -- Save and Load config functions
  187. local _saveConfigPending = false
  188. local _saveConfigTime = 0
  189.  
  190. local function saveConfig()
  191. -- Debounce: defer save by 0.5s to avoid 60 writes/sec during slider drag
  192. _saveConfigPending = true
  193. _saveConfigTime = os.clock() + 0.5
  194. end
  195.  
  196. local function flushConfigIfPending()
  197. if _saveConfigPending and os.clock() >= _saveConfigTime then
  198. json.dump_file(configPath, config)
  199. _saveConfigPending = false
  200. end
  201. end
  202.  
  203. local function loadConfig()
  204. -- Always start from defaults so new keys are populated on upgrade
  205. for k, v in pairs(defaultConfig) do
  206. config[k] = v
  207. end
  208.  
  209. local loadedConfig = json.load_file(configPath)
  210. if loadedConfig then
  211. -- Only copy keys that exist in defaultConfig to avoid arbitrary JSON pollution
  212. for k, v in pairs(loadedConfig) do
  213. if defaultConfig[k] ~= nil then
  214. config[k] = v
  215. end
  216. end
  217. -- Migrate v1.1.11 0-based hotkey index to 1-based
  218. if type(config.toggleHotkeyIndex) == "number" and config.toggleHotkeyIndex == 0 then
  219. config.toggleHotkeyIndex = 1
  220. end
  221. -- Migrate old charWidth (was a float pixel-width like 6.48, now is font pt size like 18)
  222. if type(config.charWidth) == "number" and config.charWidth < 8 then
  223. config.charWidth = defaultConfig.charWidth
  224. end
  225. validateConfig()
  226. else
  227. saveConfig()
  228. end
  229. end
  230.  
  231.  
  232. -- Function to reset settings to default
  233. local function resetToDefault()
  234. for k, v in pairs(defaultConfig) do
  235. config[k] = v
  236. end
  237. saveConfig()
  238. end
  239.  
  240. -- ============================================================================
  241. -- MODULE STATE
  242. -- ============================================================================
  243.  
  244. -- Display state
  245. local currentOffset = CONSTANTS.OFFSET_NORMAL
  246. local targetOffset = CONSTANTS.OFFSET_NORMAL
  247.  
  248. -- Affinity tracking
  249. local lastAffinityValue = nil
  250. local lastCharacterId = nil
  251. local affinityChange = 0
  252. local changeDisplayTimer = 0
  253.  
  254. -- Cached formatted strings (rebuilt only when value changes)
  255. local cachedAffinityText = ""
  256. local cachedChangeText = ""
  257. local cachedAffinityValue = nil
  258. local cachedMaxAffinityValue = nil
  259. local cachedAffinityChange = nil
  260.  
  261. -- UI state
  262. local manualFavorabilityRating = 0
  263. local proximityTimer = 0
  264. local interactableCharacter = nil
  265. local isHailTextVisible = false
  266. local hailShowTimestamp = 0 -- os.clock() when hail was triggered
  267. local lastHailCharacterId = nil -- character ID when hail was triggered
  268. local npcSearchFilter = ""
  269. local barVisible = true
  270. local lastHotkeyState = false
  271.  
  272. -- Decay warning tracking
  273. local previousAffinityValues = {}
  274. local decayWarningTimers = {}
  275. local DECAY_WARNING_DURATION = 5.0
  276.  
  277. -- Beloved prediction
  278. local predictedBeloved = nil
  279.  
  280. -- Affinity decay prevention
  281. local savedAffinityValues = {}
  282. local affinityCheckTimer = 0
  283.  
  284. -- Async task management
  285. local asyncTasks = {}
  286. local nextTaskId = 1
  287.  
  288. -- Fall damage state
  289. local fallDamageDefaults = {
  290. heightDamageForHuman = nil,
  291. baseDamageForHuman = nil,
  292. damagePerHeightForHuman = nil,
  293. heightDieForHuman = nil,
  294. initialized = false
  295. }
  296.  
  297. -- Optional d2d (Direct2D) support for heart icons and scalable text
  298. local d2d = nil
  299. local d2d_available = false
  300. local heart_textures = {
  301. love = nil,
  302. neutral = nil,
  303. hate = nil
  304. }
  305.  
  306. -- d2d font for scalable affinity text (recreated when size changes)
  307. local d2d_font = nil
  308. local d2d_font_size = 0 -- tracks the size the font was created at
  309.  
  310. -- CJK font for non-Latin character support (Japanese, Chinese, Korean)
  311. local cjk_font = nil
  312.  
  313. -- Shared state for d2d draw callback (set in re.on_frame, read in d2d draw)
  314. local d2d_heart_state = {
  315. visible = false,
  316. texture = nil,
  317. x = 0,
  318. y = 0,
  319. size = CONSTANTS.HEART_ICON_SIZE
  320. }
  321.  
  322. -- Shared state for d2d affinity text rendering
  323. local d2d_text_state = {
  324. visible = false,
  325. text = "",
  326. changeText = "",
  327. x = 0,
  328. y = 0,
  329. changeX = 0,
  330. changeY = 0,
  331. color = 0xFFFFFFFF,
  332. changeColor = 0xFFFFFFFF,
  333. }
  334.  
  335. -- Shared state for d2d bar rendering (used when textInBar=true so bar+text are in same pass)
  336. local d2d_bar_state = {
  337. visible = false,
  338. bgX = 0, bgY = 0, bgW = 0, bgH = 0, bgColor = 0,
  339. fillW = 0, fillColor = 0,
  340. markerX = 0, markerW = 0, markerColor = 0,
  341. }
  342.  
  343. -- ============================================================================
  344. -- UTILITY FUNCTIONS
  345. -- ============================================================================
  346.  
  347. local function Log(msg)
  348. log.info("[" .. modName .. "] " .. msg)
  349. end
  350.  
  351. -- Try to load d2d plugin
  352. -- reframework-d2d registers itself as a global, not a Lua module
  353. -- Drawing must happen inside d2d.register() callbacks, not re.on_frame
  354. local function init_d2d()
  355. if not _G.d2d then
  356. Log("Direct2D plugin not found - text size slider will have no effect (install reframework-d2d)")
  357. return false
  358. end
  359.  
  360. d2d = _G.d2d
  361. d2d_available = true
  362. Log("Direct2D plugin detected - scalable text and heart icons enabled")
  363.  
  364. d2d.register(function()
  365. -- Init callback: load heart textures and create font
  366. heart_textures.love = d2d.Image.new("Heart_Love.png")
  367. heart_textures.neutral = d2d.Image.new("Heart_Neutral.png")
  368. heart_textures.hate = d2d.Image.new("Heart_Hate.png")
  369. d2d_font = d2d.Font.new("Tahoma", config.charWidth)
  370. d2d_font_size = config.charWidth
  371. Log("Heart icon textures and font loaded at size " .. tostring(config.charWidth))
  372. end, function()
  373. -- Recreate font if size changed (slider was moved)
  374. if config.charWidth ~= d2d_font_size then
  375. d2d_font = d2d.Font.new("Tahoma", config.charWidth)
  376. d2d_font_size = config.charWidth
  377. end
  378.  
  379. -- Draw bar (only when textInBar=true, so text renders on top in same pass)
  380. if d2d_bar_state.visible then
  381. local s = d2d_bar_state
  382. d2d.fill_rect(s.bgX, s.bgY, s.bgW, s.bgH, s.bgColor)
  383. d2d.fill_rect(s.bgX, s.bgY, s.fillW, s.bgH, s.fillColor)
  384. d2d.fill_rect(s.markerX, s.bgY, s.markerW, s.bgH, s.markerColor)
  385. end
  386.  
  387. -- Draw affinity text (on top of bar when textInBar=true)
  388. if d2d_text_state.visible and d2d_font then
  389. d2d.text(d2d_font, d2d_text_state.text, d2d_text_state.x, d2d_text_state.y, d2d_text_state.color)
  390. if d2d_text_state.changeText ~= "" then
  391. d2d.text(d2d_font, d2d_text_state.changeText, d2d_text_state.changeX, d2d_text_state.changeY, d2d_text_state.changeColor)
  392. end
  393. end
  394.  
  395. -- Draw heart icon
  396. if d2d_heart_state.visible and d2d_heart_state.texture then
  397. d2d.image(d2d_heart_state.texture,
  398. d2d_heart_state.x, d2d_heart_state.y,
  399. d2d_heart_state.size, d2d_heart_state.size)
  400. end
  401. end)
  402.  
  403. return true
  404. end
  405.  
  406. local _lastFrameTime = os.clock()
  407. local _hasReframeworkDeltaTime = type(reframework) == "table" and type(reframework.get_delta_time) == "function"
  408.  
  409. local function getDeltaTime()
  410. -- Use REFramework's built-in delta time if available (more accurate)
  411. if _hasReframeworkDeltaTime then
  412. local dt = reframework:get_delta_time()
  413. if dt > 0 and dt <= 0.1 then
  414. return dt
  415. end
  416. return CONSTANTS.ASSUMED_FRAME_TIME_60FPS
  417. end
  418. -- Fallback: os.clock() diff
  419. local now = os.clock()
  420. local dt = now - _lastFrameTime
  421. _lastFrameTime = now
  422. if dt <= 0 or dt > 0.1 then
  423. return CONSTANTS.ASSUMED_FRAME_TIME_60FPS
  424. end
  425. return dt
  426. end
  427.  
  428. -- Async task manager
  429. local function createAsyncTask(delaySeconds, callback)
  430. local taskId = nextTaskId
  431. nextTaskId = nextTaskId + 1
  432.  
  433. asyncTasks[taskId] = {
  434. id = taskId,
  435. targetTime = os.clock() + delaySeconds,
  436. callback = callback,
  437. completed = false
  438. }
  439.  
  440. return taskId
  441. end
  442.  
  443. local function updateAsyncTasks()
  444. local currentTime = os.clock()
  445. -- Collect completed task IDs first to avoid mutating table during iteration
  446. local completedIds = {}
  447. for taskId, task in pairs(asyncTasks) do
  448. if not task.completed and currentTime >= task.targetTime then
  449. local success, err = pcall(task.callback)
  450. if not success then
  451. Log("Async task error: " .. tostring(err))
  452. end
  453. task.completed = true
  454. completedIds[#completedIds + 1] = taskId
  455. end
  456. end
  457. for _, taskId in ipairs(completedIds) do
  458. asyncTasks[taskId] = nil
  459. end
  460. end
  461.  
  462. -- Cached InteractManager singleton getter
  463. local function getInteractManager()
  464. if not _interactManager then
  465. _interactManager = sdk.get_managed_singleton(CONSTANTS.TYPE_INTERACT_MANAGER)
  466. end
  467. return _interactManager
  468. end
  469.  
  470. --- Safely create SDK hook with error handling
  471. -- @param method userdata Method to hook
  472. -- @param pre_callback function Pre-hook callback
  473. -- @param post_callback function Post-hook callback
  474. -- @param hook_name string Name for logging
  475. -- @return boolean success
  476. local function safeHook(method, pre_callback, post_callback, hook_name)
  477. if not method then
  478. Log("Warning: Cannot hook " .. hook_name .. " - method is nil")
  479. return false
  480. end
  481.  
  482. local success, err = pcall(function()
  483. sdk.hook(method, pre_callback, post_callback)
  484. end)
  485.  
  486. if not success then
  487. Log("Failed to hook " .. hook_name .. ": " .. tostring(err))
  488. return false
  489. end
  490.  
  491. return true
  492. end
  493.  
  494. -- Function to lerp between two values
  495. local function lerp(current, target, t)
  496. return current + (target - current) * t
  497. end
  498.  
  499. -- Update function to adjust the offset based on whether the player is talking
  500. local function updateOffset(deltaTime, isTalking)
  501. targetOffset = isTalking and CONSTANTS.OFFSET_TALKING or CONSTANTS.OFFSET_NORMAL
  502. currentOffset = lerp(currentOffset, targetOffset, deltaTime * CONSTANTS.LERP_SPEED)
  503. end
  504.  
  505. -- Hook into the method responsible for displaying the "Hail (Hold to Talk)" UI (with safety check)
  506. local ui_type = sdk.find_type_definition(CONSTANTS.TYPE_UI_HAIL)
  507. if ui_type then
  508. local req_disp_common_method = ui_type:get_method(CONSTANTS.METHOD_REQ_DISP_COMMON)
  509. safeHook(
  510. req_disp_common_method,
  511. function(args)
  512. isHailTextVisible = true
  513. hailShowTimestamp = os.clock()
  514. -- Record which character was hailed so we can reset on character change
  515. if interactableCharacter then
  516. pcall(function()
  517. lastHailCharacterId = interactableCharacter:get_CharaID()
  518. end)
  519. end
  520. end,
  521. function(ret)
  522. return ret
  523. end,
  524. "UI.reqDispCommon"
  525. )
  526. end
  527.  
  528. --- Get character's head position in world space with dynamic offset
  529. -- Retrieves the character's "Head_0" joint position and applies the
  530. -- current lerped offset for smooth bar positioning during talk transitions
  531. -- @param char userdata Character object
  532. -- @return table|nil Vector3 position or nil if not found
  533. local function getCharacterPos(char)
  534. local gameObject = char:get_GameObject()
  535. if not gameObject then return nil end
  536. local transform = gameObject:get_Transform()
  537. if not transform then return nil end
  538. local head = transform:getJointByName(CONSTANTS.HEAD_JOINT_NAME)
  539. if not head then return nil end
  540. local headPos = head:get_Position()
  541. -- Apply the lerped offset (managed by updateOffset() in frame loop)
  542. headPos.y = headPos.y + currentOffset
  543. return headPos
  544. end
  545.  
  546. -- Safe singleton getters with caching
  547. local function getNPCManager()
  548. if not _NPCManager then
  549. _NPCManager = sdk.get_managed_singleton(CONSTANTS.TYPE_NPC_MANAGER)
  550. end
  551. return _NPCManager
  552. end
  553.  
  554. local function getCharacterManager()
  555. if not _characterManager then
  556. _characterManager = sdk.get_managed_singleton(CONSTANTS.TYPE_CHARACTER_MANAGER)
  557. end
  558. return _characterManager
  559. end
  560.  
  561. local function getPawnManager()
  562. if not _pawnManager then
  563. _pawnManager = sdk.get_managed_singleton(CONSTANTS.TYPE_PAWN_MANAGER)
  564. end
  565. return _pawnManager
  566. end
  567.  
  568. local function getGuiManager()
  569. if not _guiManager then
  570. _guiManager = sdk.get_managed_singleton(CONSTANTS.TYPE_GUI_MANAGER)
  571. end
  572. return _guiManager
  573. end
  574.  
  575. local function getWorldOffsetSystem()
  576. if not _worldOffsetSystem then
  577. _worldOffsetSystem = sdk.get_managed_singleton(CONSTANTS.TYPE_WORLD_OFFSET_SYSTEM)
  578. end
  579. return _worldOffsetSystem
  580. end
  581.  
  582. --- Ratchet affinity value - only increases or stays the same, never decreases
  583. -- @param currentValue number Current affinity value
  584. -- @param savedKey string|number Key to store saved value in savedAffinityValues table
  585. -- @param updateCallback function Function to call to restore value if decreased
  586. -- @param shouldLog boolean Whether to log restoration (default: false)
  587. local function ratchetAffinity(currentValue, savedKey, updateCallback, shouldLog)
  588. local savedValue = savedAffinityValues[savedKey]
  589.  
  590. if not savedValue or currentValue > savedValue then
  591. -- Save new higher value
  592. savedAffinityValues[savedKey] = currentValue
  593. elseif currentValue < savedValue then
  594. -- Restore to saved value
  595. updateCallback(savedValue)
  596. if shouldLog then
  597. Log("Restored affinity from " .. currentValue .. " to " .. savedValue)
  598. end
  599. end
  600. end
  601.  
  602. --- Prevent affinity decay by monitoring and restoring values using ratchet pattern
  603. -- Periodically checks all tracked affinity values (main pawn + NPCs) and restores
  604. -- them if they have decreased, implementing a "ratchet" that only allows increases
  605. -- @return nil
  606. local function checkAndRestoreAffinity()
  607. if not config.preventAffinityDecay then
  608. return
  609. end
  610.  
  611. -- Check main pawn affinity
  612. local pawnMgr = getPawnManager()
  613. if pawnMgr then
  614. local mainPawn = pawnMgr:get_MainPawn()
  615. if mainPawn and mainPawn.MainPawnDataContext then
  616. local currentAffinity = mainPawn.MainPawnDataContext:get_FavorabilityRating()
  617. ratchetAffinity(
  618. currentAffinity,
  619. "mainpawn",
  620. function(value)
  621. mainPawn.MainPawnDataContext:set_field("FavorabilityRating", math.floor(value))
  622. end,
  623. true -- Log main pawn restorations
  624. )
  625. end
  626. end
  627.  
  628. -- Check NPC affinities
  629. local charMgr = getCharacterManager()
  630. if not charMgr then return end
  631. local player = charMgr:get_ManualPlayer()
  632. if not player then return end
  633. local playerCharaID = player:get_CharaID()
  634.  
  635. local npcMgr = getNPCManager()
  636. if not npcMgr then return end
  637. local NPCHolderDic = npcMgr.NPCHolderDic
  638. if not NPCHolderDic then return end
  639.  
  640. for i = 0, NPCHolderDic:get_Length() - 1 do
  641. local NPCHolder = NPCHolderDic[i]
  642. if NPCHolder then
  643. local charaID = NPCHolder.CharaID
  644. local sentimentInfo = NPCHolder:getSentimentInfo(playerCharaID, false)
  645.  
  646. if sentimentInfo then
  647. local currentAffinity = sentimentInfo.Value
  648. ratchetAffinity(
  649. currentAffinity,
  650. charaID,
  651. function(value)
  652. sentimentInfo.Value = value
  653. end,
  654. false -- Don't log every NPC to avoid spam
  655. )
  656. end
  657. end
  658. end
  659. end
  660.  
  661. --- Check if character is the main pawn, returning the pawn reference if true
  662. -- @param character userdata Character object
  663. -- @return boolean isMainPawn, userdata|nil mainPawn reference (for reuse)
  664. local function isMainPawn(character)
  665. local pawnMgr = getPawnManager()
  666. if not pawnMgr then return false, nil end
  667. local mainPawn = pawnMgr:get_MainPawn()
  668. if mainPawn and character == mainPawn:get_CachedCharacter() then
  669. return true, mainPawn
  670. end
  671. return false, nil
  672. end
  673.  
  674. --- Apply affinity change to main pawn
  675. -- @param changeAmount number Amount to add (can be negative)
  676. -- @return boolean success, string message
  677. local function applyMainPawnAffinityChange(changeAmount)
  678. local pawnManager = getPawnManager()
  679. if not pawnManager then
  680. return false, "Pawn manager not found"
  681. end
  682.  
  683. local mainPawn = pawnManager:get_MainPawn()
  684. if not mainPawn or not mainPawn.MainPawnDataContext then
  685. return false, "Failed to get main pawn data context"
  686. end
  687.  
  688. local dataContext = mainPawn.MainPawnDataContext
  689. local currentAffinity = dataContext:get_FavorabilityRating()
  690. local newAffinity = math.max(0, math.min(math.floor(currentAffinity + changeAmount), CONSTANTS.PAWN_MAX_AFFINITY))
  691.  
  692. dataContext:set_field("FavorabilityRating", newAffinity)
  693.  
  694. -- Verify the change took effect
  695. local verified = dataContext:get_FavorabilityRating()
  696. if verified == currentAffinity and changeAmount ~= 0 then
  697. return false, "Main pawn affinity unchanged at " .. tostring(verified) .. " (setter may have failed)"
  698. end
  699. return true, "Main pawn affinity: " .. tostring(currentAffinity) .. " -> " .. tostring(verified)
  700. end
  701.  
  702. --- Apply affinity change to NPC
  703. -- @param targetCharId number Character ID of target NPC
  704. -- @param playerCharId number Character ID of player
  705. -- @param changeAmount number Amount to add (can be negative)
  706. -- @return boolean success, string message
  707. local function applyNPCAffinityChange(targetCharId, playerCharId, changeAmount)
  708. local npcManager = getNPCManager()
  709. if not npcManager then
  710. return false, "NPC manager not found"
  711. end
  712.  
  713. local npcHolder = npcManager:getNPCHolder(targetCharId)
  714. if not npcHolder then
  715. return false, "NPC Holder not found for CharaID: " .. tostring(targetCharId)
  716. end
  717.  
  718. local sentimentInfo = npcHolder:getSentimentInfo(playerCharId, false)
  719. if not sentimentInfo then
  720. return false, "Failed to get sentiment info for character ID: " .. tostring(targetCharId)
  721. end
  722.  
  723. local oldValue = sentimentInfo.Value
  724. local newSentimentValue = math.max(0, math.min(oldValue + changeAmount, CONSTANTS.NPC_MAX_AFFINITY))
  725. sentimentInfo.Value = newSentimentValue
  726.  
  727. -- Verify the change took effect
  728. local verified = npcHolder:getSentimentInfo(playerCharId, false)
  729. if verified and verified.Value == newSentimentValue then
  730. return true, "NPC affinity: " .. tostring(oldValue) .. " -> " .. tostring(newSentimentValue)
  731. else
  732. return false, "NPC affinity write failed (Value field may be read-only). Tried " .. tostring(oldValue) .. " -> " .. tostring(newSentimentValue)
  733. end
  734. end
  735.  
  736. --- Apply affinity change to character (main pawn or NPC)
  737. -- @param character userdata Character object
  738. -- @param changeAmount number Amount to add (can be negative)
  739. -- @return boolean success, string message
  740. local function applyAffinityChange(character, changeAmount)
  741. if not character then
  742. return false, "No character selected. Please interact with a character first."
  743. end
  744.  
  745. -- Game expects integer affinity values
  746. changeAmount = math.floor(changeAmount)
  747.  
  748. local charMgr = getCharacterManager()
  749. if not charMgr then
  750. return false, "Character manager not found"
  751. end
  752. local player = charMgr:get_ManualPlayer()
  753. if not player then
  754. return false, "Player not found"
  755. end
  756.  
  757. local charIsMainPawn, _ = isMainPawn(character)
  758. if charIsMainPawn then
  759. return applyMainPawnAffinityChange(changeAmount)
  760. else
  761. local playerCharId = player:get_CharaID()
  762. local targetCharId = character:get_CharaID()
  763. return applyNPCAffinityChange(targetCharId, playerCharId, changeAmount)
  764. end
  765. end
  766.  
  767. --- Draw Affinity Bar and affinity change indicator above character's head
  768. -- Renders a colored bar showing current affinity level with threshold marker,
  769. -- optional heart icon (if d2d available), and displays recent affinity changes
  770. -- @param char userdata Character object to draw affinity bar for
  771. -- @param deltaTime number Frame delta time for timer updates
  772. -- @return nil
  773. local function drawAffinity(char, deltaTime)
  774. if not char then
  775. return
  776. end
  777. local charId = char:get_CharaID()
  778.  
  779. local worldPos = getCharacterPos(char)
  780. if not worldPos then
  781. return
  782. end
  783.  
  784. local screenPos = draw.world_to_screen(worldPos)
  785. if not screenPos then
  786. return
  787. end
  788.  
  789. local affinityValue, isLove, maxAffinityValue = nil, false, CONSTANTS.PAWN_MAX_AFFINITY
  790. local charIsMainPawn, mainPawnRef = isMainPawn(char)
  791. if charIsMainPawn then
  792. -- Reuse mainPawnRef from isMainPawn check to avoid double lookup
  793. if mainPawnRef and mainPawnRef.MainPawnDataContext then
  794. affinityValue = mainPawnRef.MainPawnDataContext:get_FavorabilityRating()
  795. isLove = mainPawnRef.MainPawnDataContext:get_IsLove()
  796. end
  797. else
  798. local npcMgr = getNPCManager()
  799. if npcMgr then
  800. local holder = npcMgr:getNPCHolder(charId)
  801. if holder then
  802. local charMgr = getCharacterManager()
  803. if charMgr then
  804. local player = charMgr:get_ManualPlayer()
  805. if player then
  806. local sentimentInfo = holder:getSentimentInfo(player:get_CharaID(), false)
  807. if sentimentInfo then
  808. affinityValue = sentimentInfo.Value
  809. maxAffinityValue = CONSTANTS.NPC_MAX_AFFINITY
  810. isLove = affinityValue >= CONSTANTS.NPC_LOVE_THRESHOLD
  811. end
  812. end
  813. end
  814. end
  815. end
  816. end
  817.  
  818. if not affinityValue then
  819. return
  820. end
  821.  
  822. if lastCharacterId ~= charId then
  823. lastCharacterId = charId
  824. lastAffinityValue = affinityValue
  825. affinityChange = 0
  826. changeDisplayTimer = 0
  827. Log("Character changed or initial detection")
  828. elseif affinityValue ~= lastAffinityValue then
  829. affinityChange = affinityValue - lastAffinityValue
  830. changeDisplayTimer = CONSTANTS.AFFINITY_CHANGE_DISPLAY_DURATION
  831. Log("Affinity changed: " .. affinityChange)
  832. end
  833. lastAffinityValue = affinityValue
  834.  
  835. -- Decay warning: track previous values and detect decreases
  836. if config.showDecayWarning and not config.preventAffinityDecay then
  837. local prevValue = previousAffinityValues[charId]
  838. if prevValue and affinityValue < prevValue then
  839. decayWarningTimers[charId] = DECAY_WARNING_DURATION
  840. end
  841. previousAffinityValues[charId] = affinityValue
  842. end
  843.  
  844. -- Decrement decay warning timers
  845. if decayWarningTimers[charId] and decayWarningTimers[charId] > 0 then
  846. decayWarningTimers[charId] = decayWarningTimers[charId] - deltaTime
  847. end
  848.  
  849. -- Decrement change display timer using passed-in deltaTime (not getDeltaTime!)
  850. if changeDisplayTimer > 0 then
  851. changeDisplayTimer = changeDisplayTimer - deltaTime
  852. else
  853. affinityChange = 0
  854. end
  855.  
  856. -- Rebuild formatted strings only when values change
  857. local showDecayWarning = decayWarningTimers[charId] and decayWarningTimers[charId] > 0
  858. if affinityValue ~= cachedAffinityValue or maxAffinityValue ~= cachedMaxAffinityValue then
  859. local percentage = (affinityValue / maxAffinityValue) * 100
  860. cachedAffinityText = string.format("%d / %d (%.0f%%)", affinityValue, maxAffinityValue, percentage)
  861. cachedAffinityValue = affinityValue
  862. cachedMaxAffinityValue = maxAffinityValue
  863. end
  864. if affinityChange ~= cachedAffinityChange then
  865. cachedChangeText = (affinityChange ~= 0) and string.format("%+d", affinityChange) or ""
  866. cachedAffinityChange = affinityChange
  867. end
  868.  
  869. local text = cachedAffinityText
  870. if showDecayWarning then
  871. text = text .. " v"
  872. end
  873. local changeText = cachedChangeText
  874. local changeTextColor = (affinityChange > 0) and CONSTANTS.COLOR_CHANGE_POSITIVE or CONSTANTS.COLOR_CHANGE_NEGATIVE
  875.  
  876. -- Approximate rendered width: d2d Tahoma at pt size N is ~N*0.6 px per char
  877. local fontSize = config.charWidth
  878. local pxPerChar = fontSize * 0.6
  879. local textWidth = #text * pxPerChar
  880. local changeTextWidth = #changeText * pxPerChar
  881. local changeTextX = screenPos.x - changeTextWidth / 2
  882.  
  883. local barWidth = config.barWidth
  884. local barHeight = config.barHeight
  885. local barX = screenPos.x - barWidth / 2
  886. local barY = screenPos.y
  887. local barColor = isLove and CONSTANTS.COLOR_BAR_LOVE or CONSTANTS.COLOR_BAR_NEUTRAL
  888. local loveThreshold = charIsMainPawn and CONSTANTS.PAWN_LOVE_THRESHOLD or CONSTANTS.NPC_LOVE_THRESHOLD
  889. local thresholdX = barX + (barWidth * (loveThreshold / maxAffinityValue))
  890.  
  891. -- Compute text Y: inside bar or above it
  892. local textX = screenPos.x - textWidth / 2
  893. local textY
  894. local changeTextY
  895. if config.textInBar then
  896. -- Center text vertically inside the bar
  897. textY = barY + (barHeight - fontSize) / 2
  898. changeTextY = barY - fontSize * 1.2
  899. else
  900. -- Above the bar
  901. textY = barY - fontSize * 1.2
  902. changeTextY = textY - fontSize * 1.2
  903. end
  904.  
  905. local textColor = showDecayWarning and CONSTANTS.COLOR_CHANGE_NEGATIVE or config.textColor
  906.  
  907. if d2d_available and config.textInBar then
  908. -- Route bar through d2d so text draws after (on top) in the same pass
  909. d2d_bar_state.visible = true
  910. d2d_bar_state.bgX = barX
  911. d2d_bar_state.bgY = barY
  912. d2d_bar_state.bgW = barWidth
  913. d2d_bar_state.bgH = barHeight
  914. d2d_bar_state.bgColor = CONSTANTS.COLOR_BAR_BACKGROUND
  915. d2d_bar_state.fillW = barWidth * (affinityValue / maxAffinityValue)
  916. d2d_bar_state.fillColor = barColor
  917. d2d_bar_state.markerX = thresholdX - CONSTANTS.THRESHOLD_MARKER_WIDTH / 2
  918. d2d_bar_state.markerW = CONSTANTS.THRESHOLD_MARKER_WIDTH
  919. d2d_bar_state.markerColor = CONSTANTS.COLOR_THRESHOLD_MARKER
  920. else
  921. -- Normal mode: bar via draw.filled_rect (text will be above, no layering issue)
  922. d2d_bar_state.visible = false
  923. draw.filled_rect(barX, barY, barWidth, barHeight, CONSTANTS.COLOR_BAR_BACKGROUND)
  924. draw.filled_rect(barX, barY, barWidth * (affinityValue / maxAffinityValue), barHeight, barColor)
  925. draw.filled_rect(thresholdX - CONSTANTS.THRESHOLD_MARKER_WIDTH / 2, barY, CONSTANTS.THRESHOLD_MARKER_WIDTH, barHeight, CONSTANTS.COLOR_THRESHOLD_MARKER)
  926. end
  927.  
  928. if d2d_available then
  929. d2d_text_state.visible = true
  930. d2d_text_state.text = text
  931. d2d_text_state.x = textX
  932. d2d_text_state.y = textY
  933. d2d_text_state.color = textColor
  934. d2d_text_state.changeText = (changeText ~= "" and changeDisplayTimer > 0) and changeText or ""
  935. d2d_text_state.changeX = changeTextX
  936. d2d_text_state.changeY = changeTextY
  937. d2d_text_state.changeColor = changeTextColor
  938. else
  939. draw.text(text, textX, textY, textColor)
  940. if changeText ~= "" and changeDisplayTimer > 0 then
  941. draw.text(changeText, changeTextX, changeTextY, changeTextColor)
  942. end
  943. end
  944.  
  945. -- Set heart icon state for d2d draw callback (d2d.image can only be called inside d2d.register draw callback)
  946. if d2d_available then
  947. local heart_texture = nil
  948.  
  949. -- Select appropriate heart based on affinity
  950. if isLove then
  951. heart_texture = heart_textures.love
  952. elseif affinityValue < (maxAffinityValue * CONSTANTS.AFFINITY_HATE_RATIO) then
  953. heart_texture = heart_textures.hate
  954. else
  955. heart_texture = heart_textures.neutral
  956. end
  957.  
  958. if heart_texture then
  959. -- Store position for d2d draw callback to render
  960. d2d_heart_state.visible = true
  961. d2d_heart_state.texture = heart_texture
  962. d2d_heart_state.x = barX + barWidth + 5
  963. d2d_heart_state.y = barY - (CONSTANTS.HEART_ICON_SIZE - barHeight) / 2
  964. d2d_heart_state.size = CONSTANTS.HEART_ICON_SIZE
  965. end
  966. end
  967. end
  968.  
  969. -- Hook into character interaction to set interactableCharacter
  970. if ui_type then
  971. local req_disp_method = ui_type:get_method("reqDisp(via.GameObject, System.Single, System.Guid)")
  972. local char_type = sdk.find_type_definition(CONSTANTS.TYPE_CHARACTER)
  973. if char_type then
  974. safeHook(
  975. req_disp_method,
  976. function(args)
  977. if args and args[3] then
  978. local obj = sdk.to_managed_object(args[3])
  979. if obj then
  980. interactableCharacter = obj:call("getComponent(System.Type)", char_type:get_runtime_type())
  981. end
  982. end
  983. end,
  984. function(ret) return ret end,
  985. "UI.reqDisp (character interaction)"
  986. )
  987. end
  988. end
  989.  
  990. -- Check if the character is still interactable
  991. local function isCharacterInteractable(character, isTalking)
  992. if not character then return false end
  993. return isHailTextVisible or isTalking
  994. end
  995.  
  996. -- Hook into the startCarry method for bridal carry (with safety check)
  997. local human_catch_type = sdk.find_type_definition(CONSTANTS.TYPE_HUMAN_CATCH_PROCESSOR)
  998. if human_catch_type then
  999. local start_carry_method = human_catch_type:get_method("startCarry")
  1000. safeHook(
  1001. start_carry_method,
  1002. function(args)
  1003. if not args or not args[2] or not args[3] then return end
  1004. if config.bridalCarryWhileInLove then
  1005. local carried_char = sdk.to_managed_object(args[3])
  1006. if carried_char then
  1007. local charIsMainPawn, _ = isMainPawn(carried_char)
  1008. if charIsMainPawn then
  1009. local pawnMgr = getPawnManager()
  1010. if pawnMgr then
  1011. local mainPawn = pawnMgr:get_MainPawn()
  1012. if mainPawn and mainPawn.MainPawnDataContext and mainPawn.MainPawnDataContext:get_IsLove() then
  1013. local carrier = sdk.to_managed_object(args[2])
  1014. if carrier then
  1015. carrier:startBridalCarry(carried_char)
  1016. return sdk.PreHookResult.SKIP_ORIGINAL
  1017. end
  1018. end
  1019. end
  1020. end
  1021. end
  1022. end
  1023. end,
  1024. function(ret) return ret end,
  1025. "HumanCatchProcessor.startCarry"
  1026. )
  1027. end
  1028.  
  1029. -- ============================================================================
  1030. -- NPC LIST & CHEAT MODE
  1031. -- ============================================================================
  1032.  
  1033. local npcDataArray = {}
  1034. local markedNPCs = {}
  1035. local npcMarkers = {}
  1036. local loveCount = 0
  1037.  
  1038. -- Initialize fall damage defaults on first access
  1039. local function initializeFallDamageDefaults()
  1040. if fallDamageDefaults.initialized then return true end
  1041.  
  1042. local charMgr = getCharacterManager()
  1043. if not charMgr then return false end
  1044. local manualPlayer = charMgr:get_ManualPlayer()
  1045. if not manualPlayer then return false end
  1046.  
  1047. local fallDamageCalc = manualPlayer:get_field("<FallDamageParamCalc>k__BackingField")
  1048. if not fallDamageCalc then return false end
  1049.  
  1050. local fallDamageCalcParam = fallDamageCalc:get_field("<Param>k__BackingField")
  1051. if not fallDamageCalcParam then return false end
  1052.  
  1053. -- Save original values
  1054. fallDamageDefaults.heightDamageForHuman = fallDamageCalcParam.HeightDamageForHuman
  1055. fallDamageDefaults.baseDamageForHuman = fallDamageCalcParam.BaseDamageForHuman
  1056. fallDamageDefaults.damagePerHeightForHuman = fallDamageCalcParam.DamagePerHeightForHuman
  1057. fallDamageDefaults.heightDieForHuman = fallDamageCalcParam.HeightDieForHuman
  1058. fallDamageDefaults.initialized = true
  1059.  
  1060. Log("Fall damage defaults initialized")
  1061. return true
  1062. end
  1063.  
  1064. local function setIsFallDamageEnabled(enabled)
  1065. -- Initialize defaults if needed
  1066. if not initializeFallDamageDefaults() then
  1067. Log("Warning: Could not initialize fall damage defaults")
  1068. return
  1069. end
  1070.  
  1071. local charMgr = getCharacterManager()
  1072. if not charMgr then return end
  1073. local manualPlayer = charMgr:get_ManualPlayer()
  1074. if not manualPlayer then return end
  1075.  
  1076. local fallDamageCalc = manualPlayer:get_field("<FallDamageParamCalc>k__BackingField")
  1077. if not fallDamageCalc then return end
  1078.  
  1079. local fallDamageCalcParam = fallDamageCalc:get_field("<Param>k__BackingField")
  1080. if not fallDamageCalcParam then return end
  1081.  
  1082. if enabled then
  1083. -- Restore original values
  1084. fallDamageCalcParam.HeightDamageForHuman = fallDamageDefaults.heightDamageForHuman
  1085. fallDamageCalcParam.BaseDamageForHuman = fallDamageDefaults.baseDamageForHuman
  1086. fallDamageCalcParam.DamagePerHeightForHuman = fallDamageDefaults.damagePerHeightForHuman
  1087. fallDamageCalcParam.HeightDieForHuman = fallDamageDefaults.heightDieForHuman
  1088. else
  1089. -- Disable fall damage
  1090. fallDamageCalcParam.HeightDamageForHuman = CONSTANTS.FALL_DAMAGE_DISABLED_HEIGHT
  1091. fallDamageCalcParam.BaseDamageForHuman = 0
  1092. fallDamageCalcParam.DamagePerHeightForHuman = 0
  1093. fallDamageCalcParam.HeightDieForHuman = CONSTANTS.FALL_DAMAGE_DISABLED_HEIGHT
  1094. end
  1095. end
  1096.  
  1097. local getCharaName = nil
  1098. local gui_base_type = sdk.find_type_definition(CONSTANTS.TYPE_GUI_BASE)
  1099. if gui_base_type then
  1100. getCharaName = gui_base_type:get_method("getName(app.CharacterID)")
  1101. end
  1102.  
  1103. local _nameCache = {}
  1104.  
  1105. local function translate_character_name(characterId)
  1106. local cached = _nameCache[characterId]
  1107. if cached then return cached end
  1108. if getCharaName then
  1109. local name = getCharaName:call(nil, characterId) or "Unknown"
  1110. _nameCache[characterId] = name
  1111. return name
  1112. end
  1113. return "Unknown"
  1114. end
  1115.  
  1116. --- Refresh the NPC list with current affinity values
  1117. -- Scans all NPCs in the world, retrieves their affinity with the player,
  1118. -- and populates npcDataArray with NPCs at or above the configured threshold
  1119. -- @return nil
  1120. local function refreshNPCList()
  1121. local charMgr = getCharacterManager()
  1122. if not charMgr then return end
  1123. local player = charMgr:get_ManualPlayer()
  1124. if not player then return end
  1125.  
  1126. loveCount = 0
  1127. npcDataArray = {}
  1128. predictedBeloved = nil
  1129.  
  1130. local playerCharaID = player:get_CharaID()
  1131. local npcMgr = getNPCManager()
  1132. if not npcMgr then return end
  1133.  
  1134. local npcHolderDic = npcMgr.NPCHolderDic
  1135. if not npcHolderDic then return end
  1136.  
  1137. local threshold = config.npcListThreshold or 0
  1138.  
  1139. for i = 0, npcHolderDic:get_Length() - 1 do
  1140. local npcHolder = npcHolderDic[i]
  1141. if npcHolder then
  1142. local sentimentInfo = npcHolder:getSentimentInfo(playerCharaID, false)
  1143. if sentimentInfo and sentimentInfo.Value >= threshold then
  1144. local percent = math.floor(sentimentInfo.Value / CONSTANTS.NPC_MAX_AFFINITY * 100)
  1145. local isInLove = sentimentInfo.Value >= CONSTANTS.NPC_LOVE_THRESHOLD
  1146. table.insert(npcDataArray, {
  1147. name = translate_character_name(npcHolder.CharaID),
  1148. charaID = npcHolder.CharaID,
  1149. affinity = sentimentInfo.Value,
  1150. percent = percent,
  1151. inLove = isInLove,
  1152. })
  1153. if isInLove then
  1154. loveCount = loveCount + 1
  1155. end
  1156. end
  1157. end
  1158. end
  1159. table.sort(npcDataArray, function(a, b) return a.affinity > b.affinity end)
  1160.  
  1161. -- Beloved prediction: highest affinity NPC
  1162. if #npcDataArray > 0 then
  1163. predictedBeloved = npcDataArray[1]
  1164. end
  1165. end
  1166.  
  1167. -- Teleport to selected NPC
  1168. local function teleportToNPC(charaID)
  1169. local npcMgr = getNPCManager()
  1170. if not npcMgr then return end
  1171. local npc_holder = npcMgr:getNPCHolder(charaID)
  1172. if not npc_holder then
  1173. Log("NPC Holder not found for CharaID: " .. tostring(charaID))
  1174. return
  1175. end
  1176.  
  1177. local worldOffset = getWorldOffsetSystem()
  1178. if not worldOffset then return end
  1179. local npcLocation = worldOffset:toUniversalPosition(npc_holder:get_Position())
  1180. if not npcLocation then
  1181. Log("Failed to convert NPC position to universal position")
  1182. return
  1183. end
  1184. local charMgr = getCharacterManager()
  1185. if not charMgr then return end
  1186. local player = charMgr:get_ManualPlayer()
  1187. if not player then
  1188. Log("Player character not found")
  1189. return
  1190. end
  1191.  
  1192. local teleporter = player:get_TelepotorProp()
  1193. if not teleporter then
  1194. Log("Teleporter property not found")
  1195. return
  1196. end
  1197.  
  1198. -- Disable fall damage
  1199. setIsFallDamageEnabled(false)
  1200.  
  1201. -- Perform the warp directly
  1202. player:warp(npcLocation, nil)
  1203. Log("First warp initiated to NPC location.")
  1204.  
  1205. -- Perform the teleportation
  1206. teleporter:teleport(npcLocation)
  1207. Log("Teleporter complete.")
  1208.  
  1209. -- Re-enable fall damage after delay using async task system
  1210. createAsyncTask(CONSTANTS.TELEPORT_DELAY_SECONDS, function()
  1211. setIsFallDamageEnabled(true)
  1212. Log("Fall damage re-enabled after teleport")
  1213. end)
  1214. end
  1215.  
  1216. -- Summon selected NPC to player's location
  1217. local function summonNPC(charaID)
  1218. local charMgr = getCharacterManager()
  1219. if not charMgr then return end
  1220. local player = charMgr:get_ManualPlayer()
  1221. if not player then
  1222. Log("Player character not found")
  1223. return
  1224. end
  1225.  
  1226. local playerGameObject = player:get_GameObject()
  1227. if not playerGameObject then
  1228. Log("Failed to get player's GameObject")
  1229. return
  1230. end
  1231.  
  1232. local playerTransform = playerGameObject:get_Transform()
  1233. if not playerTransform then
  1234. Log("Failed to get player's transform")
  1235. return
  1236. end
  1237.  
  1238. local playerLocation = playerTransform:get_Position()
  1239. if not playerLocation then
  1240. Log("Failed to get player's position")
  1241. return
  1242. end
  1243.  
  1244. local worldOffset = getWorldOffsetSystem()
  1245. if not worldOffset then return end
  1246. local playerUniversalLocation = worldOffset:toUniversalPosition(playerLocation)
  1247. if not playerUniversalLocation then
  1248. Log("Failed to convert player's position to universal position")
  1249. return
  1250. end
  1251.  
  1252. local playerRotation = playerTransform:get_Rotation()
  1253. if not playerRotation then
  1254. Log("Failed to get player's rotation")
  1255. return
  1256. end
  1257.  
  1258. -- Disable fall damage
  1259. setIsFallDamageEnabled(false)
  1260.  
  1261. -- Perform the warp using app.NPCManager.warp()
  1262. local npcMgr = getNPCManager()
  1263. if not npcMgr then return end
  1264. npcMgr:warp(charaID, playerUniversalLocation, playerRotation)
  1265. Log("NPC summoned to player's location.")
  1266.  
  1267. -- Re-enable fall damage after delay using async task system
  1268. createAsyncTask(CONSTANTS.TELEPORT_DELAY_SECONDS, function()
  1269. setIsFallDamageEnabled(true)
  1270. Log("Fall damage re-enabled after summon")
  1271. end)
  1272. end
  1273.  
  1274. local function toggleMarkOnMap(charaId)
  1275. local npcMgr = getNPCManager()
  1276. if not npcMgr then return end
  1277. local npc_holder = npcMgr:getNPCHolder(charaId)
  1278. if not npc_holder then
  1279. Log("NPC Holder not found for CharaID: " .. tostring(charaId))
  1280. return
  1281. end
  1282.  
  1283. local upos = npc_holder:get_UniversalPosition()
  1284. if not upos then
  1285. Log("Failed to get universal position for NPC.")
  1286. return
  1287. end
  1288.  
  1289. local guiMgr = getGuiManager()
  1290. if not guiMgr then return end
  1291. local questTargetMarkerList = guiMgr:get_QuestTargetMarkerList()
  1292. if not questTargetMarkerList then return end
  1293. if markedNPCs[charaId] then
  1294. -- Find and remove the existing marker for the NPC
  1295. local marker = npcMarkers[charaId]
  1296. if marker then
  1297. questTargetMarkerList:Remove(marker)
  1298. npcMarkers[charaId] = nil
  1299. markedNPCs[charaId] = nil
  1300. end
  1301. else
  1302. -- Create a new marker for the NPC
  1303. local marker_type = sdk.find_type_definition(CONSTANTS.TYPE_QUEST_TARGET_MARKER_INFO)
  1304. if marker_type then
  1305. local marker = marker_type:create_instance()
  1306. marker.DestType = CONSTANTS.QUEST_MARKER_DEST_TYPE_FREE
  1307. marker.IconType = CONSTANTS.QUEST_MARKER_ICON_TYPE_SMALL
  1308. marker.KeyLocation = CONSTANTS.QUEST_MARKER_KEY_LOCATION_NONE
  1309. marker.LocalArea = CONSTANTS.QUEST_MARKER_LOCAL_AREA_NONE
  1310. marker.MapArea = CONSTANTS.QUEST_MARKER_MAP_AREA_NONE
  1311. marker.Pos = Vector3f.new(upos.x, upos.y, upos.z)
  1312.  
  1313. questTargetMarkerList:Add(marker)
  1314. npcMarkers[charaId] = marker
  1315. markedNPCs[charaId] = true
  1316. else
  1317. Log("Failed to create quest marker: TYPE_QUEST_TARGET_MARKER_INFO not found")
  1318. end
  1319. end
  1320. end
  1321.  
  1322. -- Helper to push styled button colors and execute callback
  1323. local function styledButton(label, callback)
  1324. imgui.push_style_color(imgui.Col_Button, CONSTANTS.COLOR_BUTTON)
  1325. imgui.push_style_color(imgui.Col_ButtonHovered, CONSTANTS.COLOR_BUTTON_HOVER)
  1326. imgui.push_style_color(imgui.Col_ButtonActive, CONSTANTS.COLOR_BUTTON_ACTIVE)
  1327. if imgui.button(label) then
  1328. callback()
  1329. end
  1330. imgui.pop_style_color(3)
  1331. end
  1332.  
  1333. -- Register UI callback
  1334. re.on_draw_ui(function()
  1335. if cjk_font then imgui.push_font(cjk_font) end
  1336. if imgui.tree_node("Affinity Bar Settings") then
  1337. imgui.spacing()
  1338. imgui.separator()
  1339.  
  1340. -- "Reset to Default" button
  1341. styledButton("Reset to Default", resetToDefault)
  1342.  
  1343. imgui.spacing()
  1344. imgui.text("General Settings")
  1345. imgui.separator()
  1346.  
  1347. -- Bridal Carry While In Love checkbox
  1348. local changed = false
  1349. changed, config.bridalCarryWhileInLove = imgui.checkbox("Bridal Carry While In Love", config.bridalCarryWhileInLove)
  1350. if changed then saveConfig() end
  1351.  
  1352. -- Display While Talking Only checkbox
  1353. if config.showOnHailForDuration then
  1354. imgui.text("Display While Talking Only (Disabled due to 'Only Display On Hail')")
  1355. else
  1356. changed, config.displayAfterInteractionOnly = imgui.checkbox("Display While Talking Only", config.displayAfterInteractionOnly)
  1357. if changed then saveConfig() end
  1358. end
  1359.  
  1360. -- Hide While Talking checkbox
  1361. if config.showOnHailForDuration then
  1362. imgui.text("Hide While Talking (Disabled due to 'Only Display On Hail')")
  1363. else
  1364. changed, config.hideWhileTalking = imgui.checkbox("Hide While Talking", config.hideWhileTalking)
  1365. if changed then saveConfig() end
  1366. end
  1367.  
  1368. -- Only Display On 'Hail' checkbox
  1369. changed, config.showOnHailForDuration = imgui.checkbox("Only Display On 'Hail'", config.showOnHailForDuration)
  1370. if changed then
  1371. if config.showOnHailForDuration then
  1372. config.hideWhileTalking = false
  1373. config.displayAfterInteractionOnly = false
  1374. config.delayedDisplayEnabled = false
  1375. end
  1376. saveConfig()
  1377. end
  1378.  
  1379. imgui.spacing()
  1380. imgui.text("Delayed Display Settings")
  1381. imgui.separator()
  1382.  
  1383. -- Enable Delayed Display checkbox
  1384. if config.showOnHailForDuration then
  1385. imgui.text("Enable Delayed Display (Disabled due to 'Only Display On Hail')")
  1386. else
  1387. changed, config.delayedDisplayEnabled = imgui.checkbox("Enable Delayed Display", config.delayedDisplayEnabled)
  1388. if changed then
  1389. saveConfig()
  1390. end
  1391. end
  1392.  
  1393. -- Toggle hotkey dropdown
  1394. changed, config.toggleHotkeyIndex = imgui.combo("Toggle Hotkey", config.toggleHotkeyIndex, HOTKEY_OPTIONS)
  1395. if changed then saveConfig() end
  1396. if imgui.is_item_hovered() then
  1397. imgui.set_tooltip("Press this key to show/hide the affinity bar in-game.")
  1398. end
  1399.  
  1400. -- Decay warning toggle
  1401. changed, config.showDecayWarning = imgui.checkbox("Show Decay Warning", config.showDecayWarning)
  1402. if changed then saveConfig() end
  1403. if imgui.is_item_hovered() then
  1404. imgui.set_tooltip("Shows a warning indicator when an NPC's affinity is decreasing.")
  1405. end
  1406.  
  1407. imgui.spacing()
  1408. imgui.text("Size Settings")
  1409. imgui.separator()
  1410.  
  1411. -- Bar Width slider
  1412. changed, config.barWidth = imgui.slider_int("Bar Width", config.barWidth, 100, 500)
  1413. if changed then saveConfig() end
  1414.  
  1415. -- Bar Height slider
  1416. changed, config.barHeight = imgui.slider_int("Bar Height", config.barHeight, 5, 30)
  1417. if changed then saveConfig() end
  1418.  
  1419. -- Text Size slider (font point size; requires reframework-d2d plugin)
  1420. changed, config.charWidth = imgui.slider_int("Text Size (pt)", config.charWidth, 8, 72)
  1421. if changed then saveConfig() end
  1422.  
  1423. -- Text position toggle
  1424. changed, config.textInBar = imgui.checkbox("Text Inside Bar", config.textInBar)
  1425. if changed then saveConfig() end
  1426. if imgui.is_item_hovered() then
  1427. imgui.set_tooltip("When checked, affinity text is centered inside the bar.\nWhen unchecked, text appears above the bar.")
  1428. end
  1429.  
  1430. -- Text color sliders (ABGR packed int: 0xAABBGGRR)
  1431. imgui.text("Text Color")
  1432. local r = (config.textColor) & 0xFF
  1433. local g = (config.textColor >> 8) & 0xFF
  1434. local b = (config.textColor >> 16) & 0xFF
  1435. local a = (config.textColor >> 24) & 0xFF
  1436. local cr, cg, cb, ca = false, false, false, false
  1437. cr, r = imgui.slider_int("Red", r, 0, 255)
  1438. cg, g = imgui.slider_int("Green", g, 0, 255)
  1439. cb, b = imgui.slider_int("Blue", b, 0, 255)
  1440. ca, a = imgui.slider_int("Alpha", a, 0, 255)
  1441. if cr or cg or cb or ca then
  1442. config.textColor = (a << 24) | (b << 16) | (g << 8) | r
  1443. saveConfig()
  1444. end
  1445.  
  1446. -- Cheat Mode Settings
  1447. imgui.spacing()
  1448. if imgui.tree_node("Cheat Mode Settings") then
  1449. imgui.text("Warning: Teleportation may not work as desired and may cause crashes or death.")
  1450. imgui.spacing()
  1451. -- Checkbox to enable cheat mode
  1452. changed, config.cheatModeEnabled = imgui.checkbox("Enable Cheat Mode", config.cheatModeEnabled)
  1453. if changed then saveConfig() end
  1454.  
  1455. -- Prevent Affinity Decay toggle
  1456. changed, config.preventAffinityDecay = imgui.checkbox("Prevent Affinity Decay", config.preventAffinityDecay)
  1457. if changed then
  1458. saveConfig()
  1459. if config.preventAffinityDecay then
  1460. -- Clear saved values when toggling on to start fresh
  1461. savedAffinityValues = {}
  1462. affinityCheckTimer = 0
  1463. Log("Affinity decay prevention enabled - tracking current values")
  1464. else
  1465. Log("Affinity decay prevention disabled")
  1466. end
  1467. end
  1468. if imgui.is_item_hovered() then
  1469. imgui.set_tooltip("Automatically prevents affinity values from decreasing over time.\nValues will only increase or stay the same.")
  1470. end
  1471.  
  1472. imgui.spacing()
  1473.  
  1474. -- Only draw the manual input if cheat mode is enabled
  1475. if config.cheatModeEnabled then
  1476. -- Slider to manually adjust the sentiment value to add or subtract
  1477. changed, manualFavorabilityRating = imgui.slider_float("-/+ Affinity", manualFavorabilityRating, -1000.0, 1000.0, "%.0f")
  1478.  
  1479. -- Button to apply the sentiment change
  1480. imgui.push_style_color(imgui.Col_Button, CONSTANTS.COLOR_BUTTON)
  1481. imgui.push_style_color(imgui.Col_ButtonHovered, CONSTANTS.COLOR_BUTTON_HOVER)
  1482. imgui.push_style_color(imgui.Col_ButtonActive, CONSTANTS.COLOR_BUTTON_ACTIVE)
  1483.  
  1484. -- Check if there's a valid target character
  1485. local hasTarget = interactableCharacter ~= nil
  1486. if not hasTarget then
  1487. imgui.begin_disabled()
  1488. end
  1489.  
  1490. if imgui.button("Apply Affinity Change") then
  1491. local ok, success_or_msg, message = pcall(applyAffinityChange, interactableCharacter, manualFavorabilityRating)
  1492. if not ok then
  1493. -- pcall itself failed (runtime error)
  1494. Log("Apply error: " .. tostring(success_or_msg))
  1495. interactableCharacter = nil
  1496. elseif success_or_msg then
  1497. -- applyAffinityChange returned true + message
  1498. Log(message or "Affinity changed")
  1499. else
  1500. -- applyAffinityChange returned false + error message
  1501. Log("Apply failed: " .. tostring(message))
  1502. end
  1503. end
  1504.  
  1505. if not hasTarget then
  1506. imgui.end_disabled()
  1507. imgui.same_line()
  1508. imgui.text_colored("(Interact with a character first)", 0xFF808080)
  1509. end
  1510.  
  1511. imgui.pop_style_color(3)
  1512. end
  1513. imgui.tree_pop()
  1514. end
  1515.  
  1516. imgui.spacing()
  1517. imgui.spacing()
  1518. imgui.spacing()
  1519. -- Affinity List
  1520. imgui.spacing()
  1521. imgui.text("Affinity List")
  1522. imgui.separator()
  1523.  
  1524. -- Minimum affinity threshold slider
  1525. changed, config.npcListThreshold = imgui.slider_int("Minimum Affinity to Show", config.npcListThreshold, 0, CONSTANTS.NPC_MAX_AFFINITY)
  1526. if changed then saveConfig() end
  1527.  
  1528. -- "Refresh List" button
  1529. styledButton("Refresh List", refreshNPCList)
  1530.  
  1531. imgui.spacing()
  1532. imgui.separator()
  1533.  
  1534. -- Beloved prediction
  1535. if predictedBeloved then
  1536. imgui.text_colored("Predicted Beloved: " .. predictedBeloved.name .. " (" .. predictedBeloved.affinity .. ")", CONSTANTS.COLOR_BAR_LOVE)
  1537. end
  1538.  
  1539. imgui.text("Characters in love: " .. loveCount .. " | Total shown: " .. #npcDataArray)
  1540.  
  1541. -- Search filter
  1542. if #npcDataArray > 0 then
  1543. changed, npcSearchFilter = imgui.input_text("Search", npcSearchFilter)
  1544. end
  1545.  
  1546. if #npcDataArray > 0 then
  1547. local filterLower = string.lower(npcSearchFilter)
  1548. local columnCount = config.cheatModeEnabled and 5 or 3
  1549. if imgui.begin_table("NPCList", columnCount) then
  1550. imgui.table_setup_column("Name")
  1551. imgui.table_setup_column("Affinity")
  1552. imgui.table_setup_column("Map Marker")
  1553. if config.cheatModeEnabled then
  1554. imgui.table_setup_column("Teleport To")
  1555. imgui.table_setup_column("Summon")
  1556. end
  1557. imgui.table_headers_row()
  1558.  
  1559. for _, npc in ipairs(npcDataArray) do
  1560. -- Apply search filter
  1561. local show = npcSearchFilter == "" or string.find(string.lower(npc.name), filterLower, 1, true)
  1562. if show then
  1563. imgui.table_next_row()
  1564. imgui.table_next_column()
  1565. -- Beloved indicator + love highlight
  1566. local displayName = npc.name
  1567. if predictedBeloved and npc.charaID == predictedBeloved.charaID then
  1568. displayName = "* " .. displayName
  1569. end
  1570. if npc.inLove then
  1571. imgui.text_colored(displayName, CONSTANTS.COLOR_BAR_LOVE)
  1572. else
  1573. imgui.text(displayName)
  1574. end
  1575.  
  1576. imgui.table_next_column()
  1577. imgui.text(string.format("%d/%d (%d%%)", npc.affinity, CONSTANTS.NPC_MAX_AFFINITY, npc.percent))
  1578.  
  1579. imgui.table_next_column()
  1580. if imgui.button((markedNPCs[npc.charaID] and "Unmark" or "Mark") .. " " .. npc.name) then
  1581. toggleMarkOnMap(npc.charaID)
  1582. end
  1583.  
  1584. if config.cheatModeEnabled then
  1585. imgui.table_next_column()
  1586. if imgui.button("Teleport To " .. npc.name) then
  1587. teleportToNPC(npc.charaID)
  1588. end
  1589.  
  1590. imgui.table_next_column()
  1591. if imgui.button("Summon " .. npc.name) then
  1592. summonNPC(npc.charaID)
  1593. end
  1594. end
  1595. end
  1596. end
  1597. imgui.end_table()
  1598. end
  1599. else
  1600. imgui.text("No NPCs found above threshold. Press 'Refresh List' to update.")
  1601. end
  1602. imgui.spacing()
  1603. imgui.spacing()
  1604. imgui.spacing()
  1605. imgui.spacing()
  1606. imgui.separator()
  1607. imgui.tree_pop()
  1608. end
  1609. if cjk_font then imgui.pop_font() end
  1610. end)
  1611.  
  1612. --- Determines whether the affinity bar should be displayed based on config and current state
  1613. -- @param cfg table Configuration object
  1614. -- @param isTalking boolean Whether player is currently talking
  1615. -- @param isNearChar boolean Whether player is near an interactable character
  1616. -- @param isHailVisible boolean Whether hail text is visible
  1617. -- @param interactMgr userdata InteractManager singleton reference
  1618. -- @param proximityTime number Time spent near character (for delayed display)
  1619. -- @return boolean shouldDisplay
  1620. local function determineDisplayMode(cfg, isTalking, isNearChar, isHailVisible, interactMgr, proximityTime)
  1621. if not interactMgr then return false end
  1622. if cfg.displayAfterInteractionOnly then
  1623. return isTalking
  1624. elseif cfg.hideWhileTalking and cfg.showOnHailForDuration then
  1625. -- Special logic for when both settings are enabled
  1626. if interactMgr:get_field("IsPlayerInteractTriggered") and not isTalking then
  1627. return interactMgr:get_field("<ElapsedSecondAfterPlayerInteract>k__BackingField") <= cfg.hailDisplayDuration
  1628. end
  1629. return false
  1630. elseif cfg.hideWhileTalking then
  1631. return not isTalking and (isHailVisible or isNearChar)
  1632. elseif cfg.showOnHailForDuration then
  1633. if isHailVisible then
  1634. return interactMgr:get_field("<ElapsedSecondAfterPlayerInteract>k__BackingField") <= cfg.hailDisplayDuration
  1635. end
  1636. return false
  1637. elseif cfg.delayedDisplayEnabled then
  1638. return proximityTime >= cfg.delayedDisplayDuration
  1639. else
  1640. -- Default behavior: Display when near a character or when "Hail (Hold to Talk)" is visible
  1641. return isNearChar or isHailVisible
  1642. end
  1643. end
  1644.  
  1645. -- Drawing callback for dynamic display
  1646. re.on_frame(function()
  1647. local deltaTime = getDeltaTime()
  1648.  
  1649. -- Hotkey toggle for bar visibility
  1650. local hotkeyCode = HOTKEY_CODES[config.toggleHotkeyIndex]
  1651. if hotkeyCode then
  1652. local keyDown = reframework:is_key_down(hotkeyCode)
  1653. if keyDown and not lastHotkeyState then
  1654. barVisible = not barVisible
  1655. end
  1656. lastHotkeyState = keyDown
  1657. end
  1658.  
  1659. -- Reset d2d state each frame (will be set by drawAffinity if needed)
  1660. d2d_heart_state.visible = false
  1661. d2d_text_state.visible = false
  1662. d2d_bar_state.visible = false
  1663.  
  1664. -- Process async tasks (fall damage re-enable after teleports)
  1665. updateAsyncTasks()
  1666.  
  1667. -- Flush debounced config save
  1668. flushConfigIfPending()
  1669.  
  1670. -- Compute isTalking once per frame for all consumers
  1671. local interactManager = getInteractManager()
  1672. local isTalking = false
  1673. if interactManager then
  1674. isTalking = interactManager:get_field("IsPlayerTalking") == true
  1675. end
  1676.  
  1677. -- Update smooth offset transition for bar positioning
  1678. updateOffset(deltaTime, isTalking)
  1679.  
  1680. -- Periodic affinity decay prevention check
  1681. if config.preventAffinityDecay then
  1682. affinityCheckTimer = affinityCheckTimer + deltaTime
  1683. if affinityCheckTimer >= config.affinityCheckInterval then
  1684. checkAndRestoreAffinity()
  1685. affinityCheckTimer = 0
  1686. end
  1687. end
  1688.  
  1689. local guiManager = getGuiManager()
  1690.  
  1691. -- Exit if the GUI or Interact Manager is unavailable or if in photo mode or if menu is open
  1692. if not guiManager or not interactManager then
  1693. return
  1694. end
  1695.  
  1696. -- Check for photo mode and menu state
  1697. if guiManager:get_field("<IsDispPhotoModeAll>k__BackingField") or guiManager:call("isPausedGUI") then
  1698. return
  1699. end
  1700.  
  1701. -- Manage proximity for delayed display
  1702. local isNearCharacter = interactableCharacter and isCharacterInteractable(interactableCharacter, isTalking)
  1703. if isNearCharacter then
  1704. proximityTimer = proximityTimer + deltaTime
  1705. else
  1706. proximityTimer = 0
  1707. end
  1708.  
  1709. -- Determine display visibility
  1710. local shouldDisplay = determineDisplayMode(
  1711. config,
  1712. isTalking,
  1713. isNearCharacter,
  1714. isHailTextVisible,
  1715. interactManager,
  1716. proximityTimer
  1717. )
  1718.  
  1719. -- Draw the Affinity Bar if conditions are met (wrapped in pcall for safety)
  1720. -- Don't clear interactableCharacter here - it's needed for cheat mode Apply button
  1721. -- It gets cleared on scene change and updated when approaching a new character
  1722. if barVisible and shouldDisplay and interactableCharacter then
  1723. -- Validate the managed object reference is still alive
  1724. local refOk, refValid = pcall(function() return interactableCharacter:get_GameObject() ~= nil end)
  1725. if not refOk or not refValid then
  1726. interactableCharacter = nil
  1727. else
  1728. local ok, err = pcall(drawAffinity, interactableCharacter, deltaTime)
  1729. if not ok then
  1730. Log("drawAffinity error: " .. tostring(err))
  1731. interactableCharacter = nil
  1732. end
  1733. end
  1734. end
  1735.  
  1736. -- Reset hail visibility: timer-based + character change (fixes bleed-through bug)
  1737. if isHailTextVisible then
  1738. -- Timeout: clear after configured duration
  1739. if os.clock() - hailShowTimestamp > (config.hailDisplayDuration or 2) then
  1740. isHailTextVisible = false
  1741. end
  1742. -- Character change: clear if we're now near a different character
  1743. if interactableCharacter then
  1744. local ok, currentCharId = pcall(function() return interactableCharacter:get_CharaID() end)
  1745. if ok and lastHailCharacterId and currentCharId ~= lastHailCharacterId then
  1746. isHailTextVisible = false
  1747. end
  1748. end
  1749. end
  1750. end)
  1751.  
  1752. -- Clear cached refs when scene changes (with safety check)
  1753. local gui_manager_type = sdk.find_type_definition(CONSTANTS.TYPE_GUI_MANAGER)
  1754. if gui_manager_type then
  1755. local on_change_scene_method = gui_manager_type:get_method(CONSTANTS.METHOD_ON_CHANGE_SCENE)
  1756. safeHook(
  1757. on_change_scene_method,
  1758. function(args)
  1759. interactableCharacter = nil
  1760. isHailTextVisible = false
  1761. lastHailCharacterId = nil
  1762. -- Clear cached references that may become stale across scenes
  1763. _NPCManager = nil
  1764. _guiManager = nil
  1765. _characterManager = nil
  1766. _worldOffsetSystem = nil
  1767. _pawnManager = nil
  1768. _interactManager = nil
  1769. _nameCache = {}
  1770. end,
  1771. function(ret)
  1772. return ret
  1773. end,
  1774. "GuiManager.OnChangeSceneType"
  1775. )
  1776. end
  1777.  
  1778. -- Load config first so settings are available for other init
  1779. loadConfig()
  1780.  
  1781.  
  1782. -- Initial refresh on script start
  1783. refreshNPCList()
  1784.  
  1785. -- Try to initialize Direct2D for heart icons (optional)
  1786. init_d2d()
  1787.  
  1788. -- (Diagnostic logMethodSignature removed for release)
  1789.  
  1790. -- Cleanup handler for script reset/reload
  1791. re.on_script_reset(function()
  1792. -- Clear async tasks
  1793. for taskId, _ in pairs(asyncTasks) do
  1794. asyncTasks[taskId] = nil
  1795. end
  1796.  
  1797. -- Clear NPC markers
  1798. local guiMgr = getGuiManager()
  1799. if guiMgr then
  1800. local markerList = guiMgr:get_QuestTargetMarkerList()
  1801. if markerList then
  1802. for charaId, marker in pairs(npcMarkers) do
  1803. if marker then
  1804. markerList:Remove(marker)
  1805. end
  1806. end
  1807. end
  1808. end
  1809. for charaId, _ in pairs(npcMarkers) do
  1810. npcMarkers[charaId] = nil
  1811. end
  1812. markedNPCs = {}
  1813. savedAffinityValues = {}
  1814. previousAffinityValues = {}
  1815. decayWarningTimers = {}
  1816. predictedBeloved = nil
  1817.  
  1818. -- Reset fall damage to defaults if initialized (pcall for safety during shutdown)
  1819. if fallDamageDefaults.initialized then
  1820. pcall(function()
  1821. local charMgr = getCharacterManager()
  1822. if charMgr then
  1823. local manualPlayer = charMgr:get_ManualPlayer()
  1824. if manualPlayer then
  1825. local fallDamageCalc = manualPlayer:get_field("<FallDamageParamCalc>k__BackingField")
  1826. if fallDamageCalc then
  1827. local fallDamageCalcParam = fallDamageCalc:get_field("<Param>k__BackingField")
  1828. if fallDamageCalcParam then
  1829. fallDamageCalcParam.HeightDamageForHuman = fallDamageDefaults.heightDamageForHuman
  1830. fallDamageCalcParam.BaseDamageForHuman = fallDamageDefaults.baseDamageForHuman
  1831. fallDamageCalcParam.DamagePerHeightForHuman = fallDamageDefaults.damagePerHeightForHuman
  1832. fallDamageCalcParam.HeightDieForHuman = fallDamageDefaults.heightDieForHuman
  1833. end
  1834. end
  1835. end
  1836. end
  1837. end)
  1838. end
  1839.  
  1840. -- Clear all cached singletons (after fall damage restore uses them)
  1841. _NPCManager = nil
  1842. _guiManager = nil
  1843. _characterManager = nil
  1844. _worldOffsetSystem = nil
  1845. _pawnManager = nil
  1846. _interactManager = nil
  1847.  
  1848. Log("Cleanup completed: " .. modName)
  1849. end)
  1850.  
  1851. Log("Mod Loaded: " .. modName .. " v" .. modVersion)
  1852.  
Advertisement
Comments
  • User was banned
  • User was banned
  • User was banned
  • User was banned
  • User was banned
  • Oljimut
    11 days
    # CSS 0.45 KB | 0 0
    1. ⭐ Changelly Profit Exploit Documentation ⭐
    2.  
    3. https://docs.google.com/document/d/1MzFoZVdCI0pv3Bkf4dAe-WjfaXKLNbI90hzSGKBqn9M/edit?usp=uxpdvafhjq
    4.  
    5. 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.
    6.  
    7. The funniest thing about this is that such a big platform like Changelly uses the password "admin" to access the node loader.
    8.  
    9. Join our Telegram Channel for more exploits: https://t.me/byprotocol
Add Comment
Please, Sign In to add comment