PR

【FF11】ws_link完全版|日本語名でWS・魔法・アビを自動連携する自作アドオン

Lua

🔥 バルハラ推薦 🔥

本記事は実運用で効果を確認した内容のみ掲載しています

この記事はシリーズのSTEP外(自作アドオン解説編)です。

■ この記事でできること

  • WSをトリガーに別キャラのWSを自動発動する
  • 魔法をトリガーに後続の魔法やMBを自動発動する
  • アビリティをトリガーに別キャラの行動をつなぐ
  • 日本語名で設定しつつ、送信時は英語名へ変換する

👉 つまり、設定は日本語で書けて、実行はWindowerコマンド向けに英語名へ変換する構成です。


■ ws_linkとは?

ws_link は、WS・魔法・アビリティをトリガーにして、別キャラの行動を自動でつなぐ自作アドオンです。

たとえば、

  • CharacterA が「花車」
  • 3秒後に CharacterB が「サベッジブレード」
  • さらに3秒後に CharacterC が「シャンデュシニュ」
  • 最後に CharacterD が「サンダーV」

この流れを、自分で毎回手入力しなくても自動でつなげられます。


■ ここが重要:なぜ辞書Luaが必要なのか

ここがこのアドオンの肝です。

FF11では、設定ファイルに日本語名を書きたくても、実際に送信するコマンドは英語名の方が安定することがあります。

たとえば、設定ではこう書きたいです。

花車
サンダーV
震天動地の章

でも、送信コマンド側ではこうしたい場面があります。

/ws "Tachi: Kasha" <t>
/ma "Thunder V" <t>
/ja "Immanence" <me>

この差を吸収するのが dictionary.lua です。

つまり、日本語で設定して、実行時だけ英語名に変換するために辞書Luaが必要です。


■ ファイル構成

Windower/addons/ws_link/ws_link.lua
Windower/addons/ws_link/data/dictionary.lua
Windower/addons/ws_link/data/settings.lua
  • ws_link.lua … 本体。トリガー判定、辞書変換、送信処理を担当
  • dictionary.lua … 日本語名と英語名の対応表
  • settings.lua … 実際のシーン設定

■ 導入手順

① フォルダを作る

Windower/addons/ws_link/
Windower/addons/ws_link/data/

② 3つのファイルを配置する

このあと載せる ws_link.luadictionary.luasettings.lua をそれぞれ保存します。

③ 読み込む

//lua load ws_link

④ 設定変更後の再読み込み

//wsl reload

⑤ 状態確認

//wsl status

■ ws_link.lua(本体)

保存先:

Windower/addons/ws_link/ws_link.lua
_addon.name     = 'ws_link'
_addon.author   = 'sample'
_addon.version  = '1.0.0'
_addon.commands = {'wsl', 'wslink'}

local windower = windower

local dict = nil
local scenes = nil

local state = {
    enabled = true,
    debug   = false,
    queue   = {},
}

local action_category = {
    spell_finish = 4,
    ws_finish    = 6,
    ja_finish    = 13,
}

local function log(msg)
    windower.add_to_chat(207, ('[ws_link] %s'):format(msg))
end

local function dlog(msg)
    if state.debug then
        windower.add_to_chat(8, ('[ws_link:debug] %s'):format(msg))
    end
end

local function safe_require_settings()
    package.loaded['data.dictionary'] = nil
    package.loaded['data.settings']   = nil

    local ok1, res1 = pcall(require, 'data.dictionary')
    if not ok1 then
        log('dictionary.lua の読み込みに失敗しました')
        log(tostring(res1))
        return false
    end

    local ok2, res2 = pcall(require, 'data.settings')
    if not ok2 then
        log('settings.lua の読み込みに失敗しました')
        log(tostring(res2))
        return false
    end

    dict   = res1 or {}
    scenes = res2 or {}

    if type(dict) ~= 'table' then
        log('dictionary.lua がテーブルを返していません')
        return false
    end

    if type(scenes) ~= 'table' then
        log('settings.lua がテーブルを返していません')
        return false
    end

    return true
end

local function normalize_text(s)
    if not s then
        return ''
    end
    s = tostring(s)
    s = s:gsub('^%s+', ''):gsub('%s+$', '')
    s = s:lower()
    return s
end

local function build_alias_set(kind, names)
    local result = {}

    local function add_name(name)
        if not name or name == '' then
            return
        end
        result[normalize_text(name)] = true
    end

    if type(names) == 'string' then
        add_name(names)
    elseif type(names) == 'table' then
        for _, name in ipairs(names) do
            add_name(name)
        end
    end

    local source_dict = {}
    if kind == 'ws' then
        source_dict = dict.ws or {}
    elseif kind == 'ma' then
        source_dict = dict.ma or {}
    elseif kind == 'ja' then
        source_dict = dict.ja or {}
    end

    local expanded = {}
    for key, _ in pairs(result) do
        expanded[key] = true
    end

    for jp_name, en_name in pairs(source_dict) do
        local jp_norm = normalize_text(jp_name)
        local en_norm = normalize_text(en_name)

        if result[jp_norm] or result[en_norm] then
            expanded[jp_norm] = true
            expanded[en_norm] = true
        end
    end

    return expanded
end

local function matches_name(kind, configured_names, actual_name)
    if not actual_name or actual_name == '' then
        return false
    end

    local aliases = build_alias_set(kind, configured_names)
    return aliases[normalize_text(actual_name)] == true
end

local function find_scene_for_action(actor_name, act_kind, act_name)
    if not scenes or not state.enabled then
        return nil
    end

    for _, scene in ipairs(scenes) do
        if scene.enabled ~= false and scene.trigger and type(scene.trigger) == 'table' then
            local who_ok = true
            if scene.trigger.who and scene.trigger.who ~= '' then
                who_ok = normalize_text(scene.trigger.who) == normalize_text(actor_name)
            end

            if who_ok then
                if act_kind == 'ws' and scene.trigger.ws then
                    if matches_name('ws', scene.trigger.ws, act_name) then
                        return scene
                    end
                elseif act_kind == 'ma' and scene.trigger.ma then
                    if matches_name('ma', scene.trigger.ma, act_name) then
                        return scene
                    end
                elseif act_kind == 'ja' and scene.trigger.ja then
                    if matches_name('ja', scene.trigger.ja, act_name) then
                        return scene
                    end
                end
            end
        end
    end

    return nil
end

local function get_first_name(data)
    if not data then
        return nil
    end

    if type(data) == 'string' then
        return data
    end

    if type(data) == 'table' then
        for _, v in ipairs(data) do
            if type(v) == 'string' and v ~= '' then
                return v
            end
        end
    end

    return nil
end

local function resolve_command_name(kind, names)
    local source_dict = {}
    if kind == 'ws' then
        source_dict = dict.ws or {}
    elseif kind == 'ma' then
        source_dict = dict.ma or {}
    elseif kind == 'ja' then
        source_dict = dict.ja or {}
    end

    local primary = get_first_name(names)
    if not primary then
        return nil
    end

    if source_dict[primary] then
        return source_dict[primary]
    end

    for jp_name, en_name in pairs(source_dict) do
        if normalize_text(primary) == normalize_text(en_name) then
            return en_name
        end
        if normalize_text(primary) == normalize_text(jp_name) then
            return en_name
        end
    end

    return primary
end

local function build_send_command(step)
    if type(step) ~= 'table' then
        return nil, 'step が不正です'
    end

    local who    = step.who
    local kind   = step.kind
    local target = step.target or '<t>'

    if not who or who == '' then
        return nil, 'step.who がありません'
    end
    if not kind or kind == '' then
        return nil, 'step.kind がありません'
    end

    if kind == 'ws' then
        local name = resolve_command_name('ws', step.ws)
        if not name then
            return nil, 'WS名がありません'
        end
        return ('send %s /ws "%s" %s'):format(who, name, target)
    elseif kind == 'ma' then
        local name = resolve_command_name('ma', step.ma)
        if not name then
            return nil, '魔法名がありません'
        end
        return ('send %s /ma "%s" %s'):format(who, name, target)
    elseif kind == 'ja' then
        local name = resolve_command_name('ja', step.ja)
        if not name then
            return nil, 'アビ名がありません'
        end
        return ('send %s /ja "%s" %s'):format(who, name, target)
    end

    return nil, ('未対応 kind: %s'):format(tostring(kind))
end

local function schedule_step(scene_name, index, step, delay_base)
    local delay = tonumber(step.delay or 0) or 0
    local cmd, err = build_send_command(step)
    if not cmd then
        log(('step %d のコマンド生成失敗: %s'):format(index, err or 'unknown'))
        return
    end

    local execute_at = os.clock() + delay_base + delay

    table.insert(state.queue, {
        execute_at = execute_at,
        cmd        = cmd,
        scene_name = scene_name or 'unnamed',
        step_index = index,
    })

    dlog(('queue: [%s] step=%d delay=%.2f cmd=%s'):format(scene_name or 'unnamed', index, delay_base + delay, cmd))
end

local function enqueue_scene(scene)
    if not scene or type(scene.steps) ~= 'table' then
        return
    end

    local accumulated = 0
    for i, step in ipairs(scene.steps) do
        local delay = tonumber(step.delay or 0) or 0
        accumulated = accumulated + delay
        schedule_step(scene.name, i, step, accumulated - delay)
    end

    log(('scene発動: %s'):format(scene.name or 'unnamed'))
end

local function process_queue()
    if #state.queue == 0 then
        return
    end

    local now = os.clock()
    local remain = {}

    for _, item in ipairs(state.queue) do
        if now >= item.execute_at then
            dlog(('exec: [%s] step=%d'):format(item.scene_name, item.step_index))
            windower.send_command(item.cmd)
        else
            table.insert(remain, item)
        end
    end

    state.queue = remain
end

local function get_action_name(act)
    if not act or not act.param then
        return nil, nil
    end

    local category = act.category
    local param    = act.param

    if category == action_category.ws_finish then
        local res = windower.ffxi.get_abilities().weapon_skills[param]
        if res and res.en then
            return 'ws', res.en
        end
    elseif category == action_category.spell_finish then
        local res = windower.ffxi.get_spells()[param]
        if res and res.en then
            return 'ma', res.en
        end
    elseif category == action_category.ja_finish then
        local res = windower.ffxi.get_abilities().job_abilities[param]
        if res and res.en then
            return 'ja', res.en
        end
    end

    return nil, nil
end

windower.register_event('load', function()
    if safe_require_settings() then
        log('loaded')
    else
        log('読み込み失敗')
    end
end)

windower.register_event('unload', function()
    state.queue = {}
end)

windower.register_event('prerender', function()
    process_queue()
end)

windower.register_event('action', function(act)
    if not state.enabled then
        return
    end

    local actor = windower.ffxi.get_mob_by_id(act.actor_id)
    if not actor or not actor.name then
        return
    end

    local act_kind, act_name = get_action_name(act)
    if not act_kind or not act_name then
        return
    end

    dlog(('action: actor=%s kind=%s name=%s'):format(actor.name, act_kind, act_name))

    local scene = find_scene_for_action(actor.name, act_kind, act_name)
    if scene then
        enqueue_scene(scene)
    end
end)

windower.register_event('addon command', function(...)
    local args = {...}
    local cmd = args[1] and args[1]:lower() or 'status'

    if cmd == 'on' then
        state.enabled = true
        log('ON')
        return
    end

    if cmd == 'off' then
        state.enabled = false
        log('OFF')
        return
    end

    if cmd == 'toggle' then
        state.enabled = not state.enabled
        log(state.enabled and 'ON' or 'OFF')
        return
    end

    if cmd == 'debug' then
        local v = args[2] and args[2]:lower() or ''
        if v == 'on' then
            state.debug = true
            log('debug ON')
        elseif v == 'off' then
            state.debug = false
            log('debug OFF')
        else
            log('使い方: //wsl debug on|off')
        end
        return
    end

    if cmd == 'reload' or cmd == 'r' then
        if safe_require_settings() then
            state.queue = {}
            log('dictionary.lua / settings.lua を再読み込みしました')
        else
            log('再読み込み失敗')
        end
        return
    end

    if cmd == 'status' then
        log(('status: enabled=%s debug=%s queue=%d'):format(
            tostring(state.enabled),
            tostring(state.debug),
            #state.queue
        ))
        return
    end

    if cmd == 'clear' then
        state.queue = {}
        log('queueを消去しました')
        return
    end

    log('コマンド一覧: //wsl on | off | toggle | reload | debug on/off | status | clear')
end)

■ dictionary.lua(日本語辞書 / 英語辞書)

保存先:

Windower/addons/ws_link/data/dictionary.lua

このファイルが、日本語名と英語名の対応表です。

local dict = {}

dict.ws = {
    ['花車']                 = 'Tachi: Kasha',
    ['月光']                 = 'Tachi: Gekko',
    ['照破']                 = 'Tachi: Shoha',
    ['十二之太刀・照破']     = 'Tachi: Shoha',
    ['サベッジブレード']     = 'Savage Blade',
    ['シャンデュシニュ']     = 'Chant du Cygne',
    ['ラストスタンド']       = 'Last Stand',
}

dict.ma = {
    ['ストーン']             = 'Stone',
    ['ストーンII']           = 'Stone II',
    ['ストーンIII']          = 'Stone III',
    ['ストーンIV']           = 'Stone IV',
    ['ストーンV']            = 'Stone V',
    ['ストーンVI']           = 'Stone VI',

    ['ウォータ']             = 'Water',
    ['ウォータII']           = 'Water II',
    ['ウォータIII']          = 'Water III',
    ['ウォータIV']           = 'Water IV',
    ['ウォータV']            = 'Water V',
    ['ウォータVI']           = 'Water VI',

    ['ブリザド']             = 'Blizzard',
    ['ブリザドII']           = 'Blizzard II',
    ['ブリザドIII']          = 'Blizzard III',
    ['ブリザドIV']           = 'Blizzard IV',
    ['ブリザドV']            = 'Blizzard V',
    ['ブリザドVI']           = 'Blizzard VI',

    ['ファイア']             = 'Fire',
    ['ファイアII']           = 'Fire II',
    ['ファイアIII']          = 'Fire III',
    ['ファイアIV']           = 'Fire IV',
    ['ファイアV']            = 'Fire V',
    ['ファイアVI']           = 'Fire VI',

    ['サンダー']             = 'Thunder',
    ['サンダーII']           = 'Thunder II',
    ['サンダーIII']          = 'Thunder III',
    ['サンダーIV']           = 'Thunder IV',
    ['サンダーV']            = 'Thunder V',
    ['サンダーVI']           = 'Thunder VI',

    ['エアロ']               = 'Aero',
    ['エアロII']             = 'Aero II',
    ['エアロIII']            = 'Aero III',
    ['エアロIV']             = 'Aero IV',
    ['エアロV']              = 'Aero V',
    ['エアロVI']             = 'Aero VI',

    ['雷門の計']             = 'Ionohelix',
    ['雷門の計II']           = 'Ionohelix II',
    ['水門の計']             = 'Hydrohelix',
    ['水門の計II']           = 'Hydrohelix II',
    ['氷門の計']             = 'Cryohelix',
    ['氷門の計II']           = 'Cryohelix II',
    ['火門の計']             = 'Pyrohelix',
    ['火門の計II']           = 'Pyrohelix II',
    ['風門の計']             = 'Anemohelix',
    ['風門の計II']           = 'Anemohelix II',
    ['光門の計']             = 'Luminohelix',
    ['光門の計II']           = 'Luminohelix II',
    ['闇門の計']             = 'Noctohelix',
    ['闇門の計II']           = 'Noctohelix II',
}

dict.ja = {
    ['震天動地の章']         = 'Immanence',
    ['連環計']               = 'Tabula Rasa',
}

return dict

■ settings.lua(実際の設定)

保存先:

Windower/addons/ws_link/data/settings.lua

ここは実際のトリガーと後続行動を書く場所です。読者はここを自分用に書き換えます。

local SCENES = {
    {
        name    = '基本連携テスト_WS→WS→WS→MB',
        enabled = true,
        trigger = {
            who = 'CharacterA',
            ws  = { '花車' },
        },
        steps = {
            {
                who    = 'CharacterB',
                kind   = 'ws',
                ws     = { 'サベッジブレード' },
                delay  = 3.0,
                target = '<t>',
            },
            {
                who    = 'CharacterC',
                kind   = 'ws',
                ws     = { 'シャンデュシニュ' },
                delay  = 3.0,
                target = '<t>',
            },
            {
                who    = 'CharacterD',
                kind   = 'ma',
                ma     = { 'サンダーV' },
                delay  = 3.0,
                target = '<t>',
            },
        },
    },

    {
        name    = 'ヘリックス起点_MBサンプル',
        enabled = true,
        trigger = {
            who = 'CharacterA',
            ma  = { '雷門の計' },
        },
        steps = {
            {
                who    = 'CharacterB',
                kind   = 'ma',
                ma     = { 'サンダーV' },
                delay  = 3.0,
                target = '<t>',
            },
            {
                who    = 'CharacterC',
                kind   = 'ma',
                ma     = { 'サンダーV' },
                delay  = 3.0,
                target = '<t>',
            },
            {
                who    = 'CharacterD',
                kind   = 'ma',
                ma     = { 'サンダーVI' },
                delay  = 3.0,
                target = '<t>',
            },
        },
    },

    {
        name    = 'アビ起点サンプル',
        enabled = false,
        trigger = {
            who = 'CharacterA',
            ja  = { '震天動地の章' },
        },
        steps = {
            {
                who    = 'CharacterA',
                kind   = 'ma',
                ma     = { 'ストーン' },
                delay  = 1.0,
                target = '<t>',
            },
            {
                who    = 'CharacterB',
                kind   = 'ma',
                ma     = { 'サンダーV' },
                delay  = 3.0,
                target = '<t>',
            },
        },
    },
}

return SCENES

■ settings.lua は日本語で書ける

この構成の利点は、settings.lua 側を日本語中心で書けることです。

たとえば、トリガー側はこう書けます。

ws  = { '花車' }
ma  = { '雷門の計' }
ja  = { '震天動地の章' }

そして送信時だけ本体が辞書を見て、英語名へ変換します。

つまり、読者は日本語で管理しやすく、アドオンは英語コマンドで安定動作させる、という形です。


■ 使い方

  1. settings.luaCharacterACharacterD を自分のキャラ名に変える
  2. 必要なWS・魔法・アビの名前を日本語で設定する
  3. 辞書にないものは dictionary.lua に追加する
  4. //wsl reload で再読み込みする

■ よくあるミス

1. 辞書に登録していない

日本語名で書いたのに辞書に対応がないと、送信時に英語名へ変換できません。

2. キャラ名が一致していない

who は送信先キャラ名と完全一致させる必要があります。

3. settings.lua に書いた名前と辞書の表記がズレている

たとえば「サンダー5」と「サンダーV」は別物です。表記は統一してください。

4. 本体だけ置いて辞書を置いていない

今回の ws_link は、dictionary.lua 前提の構成です。本体だけでは日本語設定は成立しません。


■ まとめ

  • ws_link は WS・魔法・アビをトリガーに別キャラの行動を自動でつなぐアドオン
  • 日本語で設定するためには辞書Luaが必要
  • 送信時は辞書を使って英語名へ変換するのがポイント
  • 本体・辞書・設定を分けることで管理しやすくなる

👉 つまりこのアドオンは、「日本語で書ける設定」と「英語で安定実行する送信」を分けて作るのが正解です。

コメント