{"id":"2S8esRpR5J","url":"https://pastebin.ca/2S8esRpR5J","raw_url":"https://raw.anybin.ca/2S8esRpR5J","visibility":"public","access":"public","created_at":1793547769200,"expires_at":1801323769200,"fetch_limit":null,"fetches_used":0,"reads_remaining":null,"size_bytes":9778,"syntax_hint":"lua","title":"Line to target","filename":"TargetLines.lua","change_note":null,"cipher":null,"cipher_meta":null,"parent_id":null,"root_id":"2S8esRpR5J","version":1,"owner_id":null,"recipient_id":null,"body":"-- Monster Target Indicator（仇恨连线）—— 每只大怪用红线连接当前锁定的猎人 / 随从猎人 / 猫狗。\n-- 数据：snow.enemy.EnemyCharacterBase.getCurrentTargetCharacter()。招式之间目标为空时保留 1.5 秒，换目标立即切线。\n-- 目标与世界坐标在 PlayerManager.update 前置钩子内读取；re.on_frame 只投影、绘制坐标快照。\n-- 使用 REFramework 背景绘图列表（旧版回退到 draw），不依赖 d2d，也不修改游戏内的名字。\nlocal CONFIG_FILE = \"TargetLines.json\"\nlocal config = { enabled = true }   -- 菜单只有一个开关（2026-10-31 用户：\"只需要 On/off 就行，英文版\"）\nlocal hasCfg = false\npcall(function() hasCfg = fs.glob(\"TargetLines%.json$\")[1] ~= nil end)\nif hasCfg then\n    pcall(function()\n        local c = json.load_file(CONFIG_FILE)\n        if type(c) == \"table\" then for k, v in pairs(c) do if config[k] ~= nil then config[k] = v end end end\n    end)\nend\nlocal function saveConfig() pcall(json.dump_file, CONFIG_FILE, config) end\nif not hasCfg then saveConfig() end   -- 第一次启动写一份默认配置（免得日志里每次报\"读不到\"）\n\nlocal HOLD = 1.5\nlocal MAX_TARGET_DISTANCE = 50.0\nlocal MONSTER_Y = 1.0\nlocal PLAYER_Y = 0.9\nlocal OTOMO_Y = 0.5\n-- 从外到内叠加静态荧光；线宽以 1080p 为基准，颜色使用 ABGR。\nlocal LINE_LAYERS = {\n    { width = 7, color = 0x183030FF },\n    { width = 5, color = 0x383030FF },\n    { width = 3, color = 0xFF3030FF },\n    { width = 1, color = 0xFFD0D0FF },\n}\nlocal HEAD_NAMES = { \"Head_00\", \"Head\", \"Head_01\", \"head\" }\n\nlocal lines = {}        -- 仅保存复制后的世界坐标，不包含游戏对象\nlocal bossLast = {}     -- 怪物地址 → { target = 托管对象引用, kind, t }\nlocal headCache = {}    -- 怪物地址 → { mesh, joint }；joint = false 表示没有找到骨骼\nlocal kindCache = {}\nlocal lastStatus\nlocal diag = { hook = false, err = nil }\n\nlocal function clearState()\n    lines, bossLast, headCache, kindCache = {}, {}, {}, {}\n    lastStatus = nil\nend\n\nlocal function kindOf(ch)\n    local addr = ch:get_address()\n    local kind = kindCache[addr]\n    if kind == nil then\n        local name = ch:get_type_definition():get_full_name()\n        if name:find(\"^snow%.player%.\") then kind = \"player\"\n        elseif name:find(\"^snow%.otomo%.\") then kind = \"otomo\"\n        else kind = false end\n        kindCache[addr] = kind\n    end\n    return kind\nend\n\nlocal function offsetPosition(pos, height)\n    if not pos then return nil end\n    return Vector3f.new(pos.x, pos.y + height, pos.z)\nend\n\nlocal function monsterPosition(enemy, addr, newHeads, rootPos)\n    -- 头部骨骼的查找或读取失败不影响连线，改用怪物根位置。\n    local ok, pos = pcall(function()\n        local mesh = enemy:call(\"get_RefMesh\")\n        if not mesh then return nil end\n        local rec = headCache[addr]\n        if not rec or rec.mesh:get_address() ~= mesh:get_address() then\n            local go = mesh:call(\"get_GameObject\")\n            local transform = go and go:call(\"get_Transform\")\n            local joint\n            if transform then\n                for _, name in ipairs(HEAD_NAMES) do\n                    joint = transform:call(\"getJointByName\", name)\n                    if joint then break end\n                end\n            end\n            rec = { mesh = mesh, joint = joint or false }\n        end\n        newHeads[addr] = rec\n        if rec.joint and sdk.is_managed_object(rec.joint) then\n            return rec.joint:call(\"get_Position\")\n        end\n    end)\n    if ok and pos then return offsetPosition(pos, 0) end\n    return offsetPosition(rootPos, MONSTER_Y)\nend\n\nlocal function collectBoss(enemy, addr, now, newHeads)\n    if enemy:call(\"checkDie\") == true or enemy:call(\"get_IsCombatMode\") ~= true then return end\n\n    local last = bossLast[addr]\n    local target = enemy:call(\"getCurrentTargetCharacter\")\n    local hasCurrentTarget = target ~= nil\n    local kind\n    if target then\n        if not sdk.is_managed_object(target) then return end\n        kind = kindOf(target)\n        if not kind then return end   -- 明确锁定其他对象时，不保留上一目标\n    elseif not last or now - last.t >= HOLD then\n        return\n    else\n        target, kind = last.target, last.kind\n    end\n\n    -- 当前目标和缓冲目标每帧都用原始根位置检查距离，超距清理优先于 1.5 秒缓冲。\n    -- 不返回 last 即同时丢弃该怪物的连线与缓冲；读取抛错由外层逐怪 pcall 隔离。\n    if not sdk.is_managed_object(target) then return end\n    local rootPos = enemy:call(\"get_Pos\")\n    local targetPos = target:call(\"get_Pos\")\n    if not rootPos or not targetPos then return end\n    local dx, dy, dz = rootPos.x - targetPos.x, rootPos.y - targetPos.y, rootPos.z - targetPos.z\n    if dx * dx + dy * dy + dz * dz > MAX_TARGET_DISTANCE * MAX_TARGET_DISTANCE then return end\n\n    -- 仅游戏返回的有效目标能建立或刷新缓冲；沿用旧目标时不延长缓冲时间。\n    if hasCurrentTarget then\n        last = { target = target, kind = kind, t = now }\n    end\n    local to = offsetPosition(targetPos, kind == \"otomo\" and OTOMO_Y or PLAYER_Y)\n    local from = monsterPosition(enemy, addr, newHeads, rootPos)\n    if not from then return end\n    return last, { from = from, to = to }\nend\n\n-- ===== 每帧采集（PlayerManager.update 前置钩子）=====\nlocal function tick()\n    if not config.enabled then clearState(); return end\n    local em = sdk.get_managed_singleton(\"snow.enemy.EnemyManager\")\n    local pm = sdk.get_managed_singleton(\"snow.player.PlayerManager\")\n    local qm = sdk.get_managed_singleton(\"snow.QuestManager\")\n    if not em or not pm or not qm then clearState(); return end\n\n    local status = qm:get_field(\"_QuestStatus\")\n    if status ~= lastStatus then\n        clearState()\n        lastStatus = status\n    end\n\n    local now = os.clock()\n    local newLines, newLast, newHeads = {}, {}, {}\n    for i = 0, (em:call(\"getBossEnemyCount\") or 0) - 1 do\n        local enemy = em:call(\"getBossEnemy\", i)\n        if enemy then\n            local addr = enemy:get_address()\n            local ok, last, line = pcall(collectBoss, enemy, addr, now, newHeads)\n            if ok then\n                newLast[addr] = last\n                if line then newLines[#newLines + 1] = line end\n            else\n                -- 单只怪读取失败时丢弃它的旧状态，其他怪照常更新。\n                newHeads[addr] = nil\n                diag.err = tostring(last)\n            end\n        end\n    end\n    bossLast, headCache, lines = newLast, newHeads, newLines\nend\n\ndo\n    local ok, err = pcall(function()\n        local td = sdk.find_type_definition(\"snow.player.PlayerManager\")\n        local method = td and td:get_method(\"update\")\n        if not method then error(\"PlayerManager.update is unavailable\") end\n        sdk.hook(method, function()\n            local updated, updateErr = pcall(tick)\n            if not updated then\n                clearState()\n                diag.err = tostring(updateErr)\n            end\n        end, function(retval) return retval end)\n    end)\n    diag.hook = ok\n    if not ok then diag.err = tostring(err) end\nend\n\nre.on_script_reset(clearState)\n\n-- ===== 内置绘图：只读取世界坐标快照 =====\nlocal function backgroundDrawList()\n    if type(imgui.get_background_draw_list) ~= \"function\" then return nil end\n    local ok, list = pcall(function()\n        local drawList = imgui.get_background_draw_list()\n        if drawList and type(drawList.add_line) == \"function\" then return drawList end\n    end)\n    if ok then return list end\nend\n\nlocal function drawGlowLine(from, to, drawList, scale)\n    local dx, dy = to.x - from.x, to.y - from.y\n    local lengthSquared = dx * dx + dy * dy\n    if lengthSquared == 0 then return end\n\n    if drawList then\n        for _, layer in ipairs(LINE_LAYERS) do\n            drawList:add_line(from, to, layer.color, layer.width * scale)\n        end\n        return\n    end\n\n    -- 旧版没有背景绘图接口时，以四边形和端点圆形模拟同样的分层粗线。\n    local length = math.sqrt(lengthSquared)\n    local nx, ny = -dy / length, dx / length\n    for _, layer in ipairs(LINE_LAYERS) do\n        local radius = layer.width * scale * 0.5\n        local ox, oy = nx * radius, ny * radius\n        draw.filled_quad(from.x + ox, from.y + oy, from.x - ox, from.y - oy,\n            to.x - ox, to.y - oy, to.x + ox, to.y + oy, layer.color)\n        draw.filled_circle(from.x, from.y, radius, layer.color, 16)\n        draw.filled_circle(to.x, to.y, radius, layer.color, 16)\n    end\nend\n\nre.on_frame(function()\n    if not config.enabled then return end\n    local snapshot = lines\n    local ok, err = pcall(function()\n        local display = imgui.get_display_size()\n        if not display or display.y <= 0 then return end\n        local scale = display.y / 1080.0\n        local drawList = backgroundDrawList()\n        for _, line in ipairs(snapshot) do\n            local from = draw.world_to_screen(line.from)\n            local to = draw.world_to_screen(line.to)\n            if from and to then drawGlowLine(from, to, drawList, scale) end\n        end\n    end)\n    if not ok then\n        lines = {}\n        diag.err = tostring(err)\n    end\nend)\n\n-- ===== 菜单 =====\nre.on_draw_ui(function()\n    if not imgui.tree_node(\"Monster Target Indicator\") then return end\n    local changed, enabled = imgui.checkbox(\"Enabled\", config.enabled)\n    if changed then\n        config.enabled = enabled\n        clearState()\n        saveConfig()\n    end\n    if not diag.hook then imgui.text(\"Hook failed (different game version?)\") end\n    if diag.err then imgui.text(\"Error: \" .. diag.err) end\n    imgui.tree_pop()\nend)\n"}