NPCとおしゃべりする[FF11]


今回はWindowerアドオンからNPC会話をしてみたいと思います。

NPCとの会話を開始するには、outgoingのIDが0x01Aのパケットを使うようです。
("???"を調べる等も同じようです)
outgoing 0x01Aのパケットはlibs/packets/fields.luaを見ると以下のようになっています。

-- libs/packets/fields.lua
enums['action'] = {
    [0x00] = 'NPC Interaction',
    [0x02] = 'Engage monster',
    [0x03] = 'Magic cast',
    [0x04] = 'Disengage',
    [0x05] = 'Call for Help',
    [0x07] = 'Weaponskill usage',
    [0x09] = 'Job ability usage',
    [0x0C] = 'Assist',
    [0x0D] = 'Reraise dialogue',
    [0x0E] = 'Cast Fishing Rod',
    [0x0F] = 'Switch target',
    [0x10] = 'Ranged attack',
    [0x12] = 'Dismount Chocobo',
    [0x14] = 'Zoning/Appear', -- I think, the resource for this is ambiguous.
    [0x19] = 'Monsterskill',
    [0x1A] = 'Mount',
}

-- Action
fields.outgoing[0x01A] = L{
    {ctype='unsigned int',      label='Target',             fn=id},             -- 04
    {ctype='unsigned short',    label='Target Index',       fn=index},          -- 08
    {ctype='unsigned short',    label='Category',           fn=e+{'action'}},   -- 0A
    {ctype='unsigned short',    label='Param'},                                 -- 0C
    {ctype='unsigned short',    label='_unknown1',          const=0},           -- 0E
    {ctype='float',             label='X Offset'},                              -- 10 -- non-zero values only observed for geo spells cast using a repositioned subtarget
    {ctype='float',             label='Z Offset'},                              -- 14
    {ctype='float',             label='Y Offset'},                              -- 18
}

Targetに対象NPCのID, Target Indexに対象NPCのIndexを入れ、CategoryにNPC Interaction(0x00)を指定してパケットを作ります。
Paramなどその他のパラメータは0となるようです。

packets = require('packets')

local npc = {name = "hoge"}
local npc_info = windower.ffxi.get_mob_by_name(npc.name)

if npc_info then
    local p = packets.new('outgoing', 0x01A, {
        ["Target"] = npc_info.id,
        ["Target Index"] = npc_info.index,
        ["Category"] = 0x00,
    })
    packets.inject(p) -- パケットを送信
end

会話を開始するとダイアログが表示されるNPCはincomingのIDが0x032(NPC Interaction Type 1)や0x034(NPC Interaction Type 2)のパケットがやってくるので、そのパケットを見て対応するoutgoingパケット(ID=0x05B:Dialogue optionsなど)を送ります。
(アドオンを作成する前に会話したいNPCがどのような挙動になっているのか確認すると良いと思います。)

なにかつくってみる

イオニスを付与してくれるNPCと会話して、イオニスを付けてもらいます。

処理ロジックは上位ミッションBFのトリガー交換アドオン"htmb"を参考にしました。

フロー
  1. outgoing 0x01Aのパケットで会話を開始する
  2. ダイアログが表示される会話であるincoming 0x034(NPC Interaction Type 2)のパケットがやってくる
  3. setkeyでEscキーを入力してダイアログをキャンセルする
  4. ダイアログをキャンセルした際に送信させるoutgoint 0x05Bのパケットの代わりにイオニス付与時のパケットを送信する
  5. イオニスが付与される
require('strings')
require('logger')
packets = require('packets')

ionis_npcs = {
    [256] = {name = 'Fleuricette'}, -- 西アドゥリン
    [257] = {name = 'Quiri-Aliri'}, -- 東アドゥリン
}

local is_ionis_npc_busy = false

function get_ionis_npc()
    local zone = windower.ffxi.get_info().zone
    local ionis_npc = ionis_npcs[zone]
    local npc = nil

    if ionis_npc then
        npc = windower.ffxi.get_mob_by_name(ionis_npc.name)
    else
        log('No ionis npc found!')
        return nil
    end

    if npc and math.sqrt(npc.distance) < 6 then
        return npc
    else
        log(ionis_npc.name..' found, but too far!')
        return nil
    end
end

function start_ionis()
    local npc = get_ionis_npc()

    if npc then
        local p = packets.new('outgoing', 0x01A, {
            ["Target"] = npc.id,
            ["Target Index"] = npc.index,
            ["Category"] = 0
        })
        packets.inject(p)
        is_ionis_npc_busy = true
    end
end

function incoming_ionis(id, data, modified, injected, blocked)
    if id == 0x034 then
        if is_ionis_npc_busy then
            local in_p = packets.parse('incoming', data)
            local npc = get_ionis_npc()

            if npc and npc.id == in_p["NPC"] then
                windower.send_command('wait 3;setkey escape;wait 0.5;setkey escape up;')
            end
        end
    end
end

function outgoing_ionis(id, data, modified, injected, blocked)
    if id == 0x05B then
        if is_ionis_npc_busy then
            local out_p = packets.parse('outgoing', data)
            local npc = get_ionis_npc()

            if npc and npc.id == out_p["Target"] then
                out_p["Option Index"] = 1
                out_p["_unknown1"] = 0
                is_ionis_npc_busy = false
                return packets.build(out_p)
            end
        end
    end
end

windower.register_event('addon command', function(...)
    local args = {...}
    if args[1] == 'ionis' then
        start_ionis()
    end
end)

windower.register_event('incoming chunk', incoming_ionis)
windower.register_event('outgoing chunk', outgoing_ionis)

この他にも、NPCとの対話はギアスフェットのテンポラリ交換アドオン"temps"も参考になります。

今回の記事は10月頃に書いていてアップしようと思っていたのですが、丁度その時に"勲章"や"パルス管"のデュプリケイトの話題があり、本記事はデュプリケイトとは関係ないですがパケット関連の記事はタイミングが悪いかなと思い、公開せずそのままでした。
今年の記事は今年のうちにということで、公開してみます。良いお年を。

追記

ダイアログを表示させない場合

フロー
  1. outgoing 0x01Aのパケットで会話を開始する
  2. ダイアログが表示される会話であるincoming 0x034(NPC Interaction Type 2)のパケットがやってくる
  3. incoming 0x034の"incoming chunk"イベント内でイオニス付与のパケット(outgoing 0x05B)を送信する
  4. incoming 0x034のパケットは"incoming chunk"イベント時に"retrun true"をしてブロックする(ブロックすることでクライントに"incoming 0x034"のパケットが届かずダイアログは表示されない)
  5. イオニスが付与される

処理ロジックはアドオンcraftを参考にしました。
(アドオンcraftにイオニスを付与する機能があり、ダイアログを表示させずにイオニスが付与されるのでどのような処理になっているのか気になったので調べてみました。)
今回紹介した2つの方法はイオニスを付与するoutgoing 0x05Bのパケットをどのタイミングで送信するかの違いがあります。
ダイアログを表示させない場合のほうがダイアログが表示されない分、イオニス付与までが速いです。

require('strings')
require('logger')
packets = require('packets')

ionis_npcs = {
    [256] = {name = 'Fleuricette', menu = 1201},
    [257] = {name = 'Quiri-Aliri', menu = 1201},
}

local is_ionis_npc_busy = false

function get_ionis_npc()
    local zone = windower.ffxi.get_info().zone
    local ionis_npc = ionis_npcs[zone]
    local npc = nil

    if ionis_npc then
        npc = windower.ffxi.get_mob_by_name(ionis_npc.name)
    else
        log('No ionis npc found!')
        return nil
    end

    if npc and math.sqrt(npc.distance) < 6 then
        return npc
    else
        log(ionis_npc.name..' found, but too far!')
        return nil
    end
end

function start_ionis()
    local npc = get_ionis_npc()

    if npc then
        local p = packets.new('outgoing', 0x01A, {
            ["Target"] = npc.id,
            ["Target Index"] = npc.index,
            ["Category"] = 0
        })
        packets.inject(p)
        is_ionis_npc_busy = true
    end
end

function incoming_ionis(id, data, modified, injected, blocked)
    if id == 0x034 then
        if is_ionis_npc_busy then
            local in_p = packets.parse('incoming', data)
            local npc = get_ionis_npc()

            if npc and npc.id == in_p["NPC"] then
                local p = packets.new('outgoing', 0x5B, {
                    ["Target"] = npc.id,
                    ["Option Index"] = 1,
                    ["Target Index"] = npc.index,
                    ["Automated Message"] = false,
                    ["Zone"] = in_p["Zone"],
                    ["Menu ID"] = ionis_npcs[in_p["Zone"]].menu,
                })
                packets.inject(p)
                is_ionis_npc_busy = false
                return true
            end
        end
    end
end

windower.register_event('addon command', function(...)
    local args = {...}
    if args[1] == 'ionis' then
        start_ionis()
    end
end)

windower.register_event('incoming chunk', incoming_ionis)