Line to target
-- Monster Target Indicator(仇恨连线)—— 每只大怪用红线连接当前锁定的猎人 / 随从猎人 / 猫狗。
-- 数据:snow.enemy.EnemyCharacterBase.getCurrentTargetCharacter()。招式之间目标为空时保留 1.5 秒,换目标立即切线。
-- 目标与世界坐标在 PlayerManager.update 前置钩子内读取;re.on_frame 只投影、绘制坐标快照。
-- 使用 REFramework 背景绘图列表(旧版回退到 draw),不依赖 d2d,也不修改游戏内的名字。
local CONFIG_FILE = "TargetLines.json"
local config = { enabled = true }   -- 菜单只有一个开关(2026-10-31 用户:"只需要 On/off 就行,英文版")
local hasCfg = false
pcall(function() hasCfg = fs.glob("TargetLines%.json$")[1] ~= nil end)
if hasCfg then
    pcall(function()
        local c = json.load_file(CONFIG_FILE)
        if type(c) == "table" then for k, v in pairs(c) do if config[k] ~= nil then config[k] = v end end end
    end)
end
local function saveConfig() pcall(json.dump_file, CONFIG_FILE, config) end
if not hasCfg then saveConfig() end   -- 第一次启动写一份默认配置(免得日志里每次报"读不到")

local HOLD = 1.5
local MAX_TARGET_DISTANCE = 50.0
local MONSTER_Y = 1.0
local PLAYER_Y = 0.9
local OTOMO_Y = 0.5
-- 从外到内叠加静态荧光;线宽以 1080p 为基准,颜色使用 ABGR。
local LINE_LAYERS = {
    { width = 7, color = 0x183030FF },
    { width = 5, color = 0x383030FF },
    { width = 3, color = 0xFF3030FF },
    { width = 1, color = 0xFFD0D0FF },
}
local HEAD_NAMES = { "Head_00", "Head", "Head_01", "head" }

local lines = {}        -- 仅保存复制后的世界坐标,不包含游戏对象
local bossLast = {}     -- 怪物地址 → { target = 托管对象引用, kind, t }
local headCache = {}    -- 怪物地址 → { mesh, joint };joint = false 表示没有找到骨骼
local kindCache = {}
local lastStatus
local diag = { hook = false, err = nil }

local function clearState()
    lines, bossLast, headCache, kindCache = {}, {}, {}, {}
    lastStatus = nil
end

local function kindOf(ch)
    local addr = ch:get_address()
    local kind = kindCache[addr]
    if kind == nil then
        local name = ch:get_type_definition():get_full_name()
        if name:find("^snow%.player%.") then kind = "player"
        elseif name:find("^snow%.otomo%.") then kind = "otomo"
        else kind = false end
        kindCache[addr] = kind
    end
    return kind
end

local function offsetPosition(pos, height)
    if not pos then return nil end
    return Vector3f.new(pos.x, pos.y + height, pos.z)
end

local function monsterPosition(enemy, addr, newHeads, rootPos)
    -- 头部骨骼的查找或读取失败不影响连线,改用怪物根位置。
    local ok, pos = pcall(function()
        local mesh = enemy:call("get_RefMesh")
        if not mesh then return nil end
        local rec = headCache[addr]
        if not rec or rec.mesh:get_address() ~= mesh:get_address() then
            local go = mesh:call("get_GameObject")
            local transform = go and go:call("get_Transform")
            local joint
            if transform then
                for _, name in ipairs(HEAD_NAMES) do
                    joint = transform:call("getJointByName", name)
                    if joint then break end
                end
            end
            rec = { mesh = mesh, joint = joint or false }
        end
        newHeads[addr] = rec
        if rec.joint and sdk.is_managed_object(rec.joint) then
            return rec.joint:call("get_Position")
        end
    end)
    if ok and pos then return offsetPosition(pos, 0) end
    return offsetPosition(rootPos, MONSTER_Y)
end

local function collectBoss(enemy, addr, now, newHeads)
    if enemy:call("checkDie") == true or enemy:call("get_IsCombatMode") ~= true then return end

    local last = bossLast[addr]
    local target = enemy:call("getCurrentTargetCharacter")
    local hasCurrentTarget = target ~= nil
    local kind
    if target then
        if not sdk.is_managed_object(target) then return end
        kind = kindOf(target)
        if not kind then return end   -- 明确锁定其他对象时,不保留上一目标
    elseif not last or now - last.t >= HOLD then
        return
    else
        target, kind = last.target, last.kind
    end

    -- 当前目标和缓冲目标每帧都用原始根位置检查距离,超距清理优先于 1.5 秒缓冲。
    -- 不返回 last 即同时丢弃该怪物的连线与缓冲;读取抛错由外层逐怪 pcall 隔离。
    if not sdk.is_managed_object(target) then return end
    local rootPos = enemy:call("get_Pos")
    local targetPos = target:call("get_Pos")
    if not rootPos or not targetPos then return end
    local dx, dy, dz = rootPos.x - targetPos.x, rootPos.y - targetPos.y, rootPos.z - targetPos.z
    if dx * dx + dy * dy + dz * dz > MAX_TARGET_DISTANCE * MAX_TARGET_DISTANCE then return end

    -- 仅游戏返回的有效目标能建立或刷新缓冲;沿用旧目标时不延长缓冲时间。
    if hasCurrentTarget then
        last = { target = target, kind = kind, t = now }
    end
    local to = offsetPosition(targetPos, kind == "otomo" and OTOMO_Y or PLAYER_Y)
    local from = monsterPosition(enemy, addr, newHeads, rootPos)
    if not from then return end
    return last, { from = from, to = to }
end

-- ===== 每帧采集(PlayerManager.update 前置钩子)=====
local function tick()
    if not config.enabled then clearState(); return end
    local em = sdk.get_managed_singleton("snow.enemy.EnemyManager")
    local pm = sdk.get_managed_singleton("snow.player.PlayerManager")
    local qm = sdk.get_managed_singleton("snow.QuestManager")
    if not em or not pm or not qm then clearState(); return end

    local status = qm:get_field("_QuestStatus")
    if status ~= lastStatus then
        clearState()
        lastStatus = status
    end

    local now = os.clock()
    local newLines, newLast, newHeads = {}, {}, {}
    for i = 0, (em:call("getBossEnemyCount") or 0) - 1 do
        local enemy = em:call("getBossEnemy", i)
        if enemy then
            local addr = enemy:get_address()
            local ok, last, line = pcall(collectBoss, enemy, addr, now, newHeads)
            if ok then
                newLast[addr] = last
                if line then newLines[#newLines + 1] = line end
            else
                -- 单只怪读取失败时丢弃它的旧状态,其他怪照常更新。
                newHeads[addr] = nil
                diag.err = tostring(last)
            end
        end
    end
    bossLast, headCache, lines = newLast, newHeads, newLines
end

do
    local ok, err = pcall(function()
        local td = sdk.find_type_definition("snow.player.PlayerManager")
        local method = td and td:get_method("update")
        if not method then error("PlayerManager.update is unavailable") end
        sdk.hook(method, function()
            local updated, updateErr = pcall(tick)
            if not updated then
                clearState()
                diag.err = tostring(updateErr)
            end
        end, function(retval) return retval end)
    end)
    diag.hook = ok
    if not ok then diag.err = tostring(err) end
end

re.on_script_reset(clearState)

-- ===== 内置绘图:只读取世界坐标快照 =====
local function backgroundDrawList()
    if type(imgui.get_background_draw_list) ~= "function" then return nil end
    local ok, list = pcall(function()
        local drawList = imgui.get_background_draw_list()
        if drawList and type(drawList.add_line) == "function" then return drawList end
    end)
    if ok then return list end
end

local function drawGlowLine(from, to, drawList, scale)
    local dx, dy = to.x - from.x, to.y - from.y
    local lengthSquared = dx * dx + dy * dy
    if lengthSquared == 0 then return end

    if drawList then
        for _, layer in ipairs(LINE_LAYERS) do
            drawList:add_line(from, to, layer.color, layer.width * scale)
        end
        return
    end

    -- 旧版没有背景绘图接口时,以四边形和端点圆形模拟同样的分层粗线。
    local length = math.sqrt(lengthSquared)
    local nx, ny = -dy / length, dx / length
    for _, layer in ipairs(LINE_LAYERS) do
        local radius = layer.width * scale * 0.5
        local ox, oy = nx * radius, ny * radius
        draw.filled_quad(from.x + ox, from.y + oy, from.x - ox, from.y - oy,
            to.x - ox, to.y - oy, to.x + ox, to.y + oy, layer.color)
        draw.filled_circle(from.x, from.y, radius, layer.color, 16)
        draw.filled_circle(to.x, to.y, radius, layer.color, 16)
    end
end

re.on_frame(function()
    if not config.enabled then return end
    local snapshot = lines
    local ok, err = pcall(function()
        local display = imgui.get_display_size()
        if not display or display.y <= 0 then return end
        local scale = display.y / 1080.0
        local drawList = backgroundDrawList()
        for _, line in ipairs(snapshot) do
            local from = draw.world_to_screen(line.from)
            local to = draw.world_to_screen(line.to)
            if from and to then drawGlowLine(from, to, drawList, scale) end
        end
    end)
    if not ok then
        lines = {}
        diag.err = tostring(err)
    end
end)

-- ===== 菜单 =====
re.on_draw_ui(function()
    if not imgui.tree_node("Monster Target Indicator") then return end
    local changed, enabled = imgui.checkbox("Enabled", config.enabled)
    if changed then
        config.enabled = enabled
        clearState()
        saveConfig()
    end
    if not diag.hook then imgui.text("Hook failed (different game version?)") end
    if diag.err then imgui.text("Error: " .. diag.err) end
    imgui.tree_pop()
end)