ターゲットの仕組みを作ってみよう

Modを作ろう

Skyrimは3Dのアクションゲームなので、弓や魔法はクロスヘアで狙いをつけて放ちます。でも、当てるのが難しい場面もあります。そこで、MMO RPGによくあるターゲットの仕組みを考えました。

ターゲットの仕組みとは

World of Warcraft、Final Fantasy 14、Ever QuestといったMMO RPGではターゲットという概念があり、プレイヤーのアクションはターゲットに対して行われます。ターゲットはマウスやキーボードで選択します。

この仕組みの良いところは、当てるのが簡単だということです。ターゲットさえ指定できれば、攻撃は必ず当たります。命中率が設定されていて確率で外れるのは別の話です。対象を指定することすらできなかった、ということは起こりません。

Skyrimにはターゲットという仕組みがないので、クロスヘアで狙いをつける必要があります。動きの速い敵、奥の敵に当てたいのに手前の敵、死体、障害物などに邪魔されて当てられないこともあります。これはこれでリアルなのですが、コストのかかる魔法や時間のかかる魔法が外れたらイライラします。

インターフェイスを考える

いくつか思いつきました。それぞれメリットとデメリットがあります。

クエストマーカー方式

Skyrimに標準で用意されているクエストマーカーを使う方法です。

ホットキーを押したらActorを選択するモードに移行します。あらかじめプレイヤーの行動を封じる処理をしておきます。

周囲のActorを検索して一覧を作ります。Actorはプレイヤーから見た角度順にソートすると良いでしょう。

左右の移動キーもしくは左右の攻撃、防御キーで、選択するActorを切り替えていきます。選択中のActorはクエストのReferenceAliasに入れて、クエストマーカーが出るようにしておきます。

アクティベートキーを押したら選択を完了します。

メリットとしては、カメラが動かないので状況が判断しやすいです。そして、クエストマーカーはHUDに常に表示され続けるのでコンパスで追いやすいです。

デメリットは、Actorがカメラの外にいる場合はActor自体が見えないこと、そしてクエストマーカーは動作が安定しないことです。

どうやら、クエストマーカーが到達できない場所に設定されると、マーカーがあさっての方角を示したり、しばらく表示されなくなってしまうようです。マーカーを強制的に表示させる方法もあるのですが、これをやるとクエストが進行したときの効果音とメッセージが出るので鬱陶しいです。

ReferenceAliasにActorを入れるのではなく、ReferenceAliasにアイテムを入れておき、Actorに持たせる方法も試してみましたが、結果は同じでした。

クエストマーカーのかわりにスペルのエフェクトを使う方法もあります。生命検知などのエフェクトを使えば確実に表示されます。ただしコンパスには出ません。

カメラ切り替え方式

選択中のActorにカメラを合わせる方法です。FalloutのVATSに似ています。

基本的な処理の仕組みはクエストマーカー方式と同じです。Actorを選択するモードになったら、選択中のActorにカメラを切り替えます。

アクティベートキーを押して決定したらカメラはプレイヤーに戻して、選択したActorにクエストマーカーを付けるなりエフェクトを表示させるなりします。

メリットはActorをしっかり目視確認できることです。

デメリットはカメラのアングルが激しく切り替わるので、状況の判断が難しい場合があることです。

カメラの切り替えはPapyrusのSetCameraTarget関数で行います。この関数はカメラの対象は指定できるのですが、カメラの位置は全自動で決まってしまい動かせないようです。現実世界で例えるなら、被写体は選べるがカメラマンの位置はランダムだということです。基本的には対象のActorの背面に移動します。Actorがアニメーションの最中であれば、カメラ位置はFurnitureなどで設定されているアングルになるようです。そして、アングルは動かせないみたいです。つまり、プレイヤーとActorの位置関係の把握が難しい場合があります。

ロックオン方式

Skyrimにロックオンの仕組みを実装したModがあります。例えばhimika’s lock on (stradivuckos updated version) SSEがそうです。

ロックオン中の対象はLockON_MainというクエストのTargetというReferenceAliasに入っているようです。ですので、クエストのレコードを参照してそこから辿っていけばActorを取得できます。Dark Soulsシリーズのような操作性になります。

問題としては、ロックオンModは仕組み的にロックオン中だけターゲットが存在するので、ロックオンを解除するとターゲットもなくなってしまいます。ターゲットにスペルを詠唱するのであれば、ロックオンしていなければなりません。目の前の敵を殴りつつ、背面のフォロワーに回復魔法を詠唱する、なんてことはできないのです。

とはいえ、対象を選択する部分だけロックオンを使い、選択が終わったらクエストマーカーを付けておくとすれば、ある敵をロックオンしつつ、別の敵にスペルを唱えることも出来るでしょう。

実装の例

自作のModからの抜粋です。これだけで何かができるわけではないので、あくまでも参考にしてください。

まずは基本となるQuestです。eqQuestSelectorという名前のQuestを作ります。

ターゲットの仕組み Quest (papyrus)

Scriptname eqQuestSelectorScript Extends Quest

eqQuestSelectorAliasPlayerScript Property eqQuestSelectorAliasPlayer Auto


Actor Function SelectEnemy()
    if !eqQuestSelectorAliasPlayer.Setup()
        return none
    endif

    while true
        if eqQuestSelectorAliasPlayer.GetState() == "CANCELED"
            eqQuestSelectorAliasPlayer.Cleanup()
            return none
        elseif eqQuestSelectorAliasPlayer.GetState() == "SELECTED"
            eqQuestSelectorAliasPlayer.Cleanup()
            return eqQuestSelectorAliasPlayer.GetSelectedTarget()
        endif

        Utility.Wait(0.2)
    endwhile
EndFunction


Function AddTarget(Actor akTarget)
    eqQuestSelectorAliasPlayer.AddDetectedEnemy(akTarget)
EndFunction

次にReferenceAliasです。上のQuestにReferenceAliasをひとつ作ります。名前はなんでもいいです。Playerを入れておきます。

ターゲットの仕組み ReferenceAlias (papyrus)

Scriptname eqQuestSelectorAliasPlayerScript Extends ReferenceAlias

eqQuestMainScript Property eqQuestMain Auto
eqQuestTargetScript Property eqQuestTarget Auto

Spell Property eqSpellDetectEnemy Auto
Spell Property eqSpellSlowTime Auto

Sound Property eqSoundSwitchTargetSD Auto
Sound Property eqSoundUntargetSD Auto

Message Property eqMsgYouHaveNoTarget Auto

Actor Property PlayerRef Auto

Int StrafeLeftKey
Int StrafeRightKey
Int LeftAttackKey
Int RightAttackKey
Int ForwardKey
Int BackKey
Int ActivateKey

Actor[] ActorList
Int ActorMax
Int ActorIndex
Bool IsFirstPersonView


Function AddDetectedEnemy(Actor akTarget)
    ; place holder
EndFunction


Function Cleanup()
    GoToState("")

    UnregisterForAllKeys()
    UnregisterForAllModEvents()

    Game.SetCameraTarget(PlayerRef)
    Game.EnablePlayerControls()
    PlayerRef.SetDontMove(abDontMove = false)

    if IsFirstPersonView
        ToggleCameraState()
    endif

    PlayerRef.DispelSpell(eqSpellSlowTime)
EndFunction


Actor Function GetSelectedTarget()
    return ActorList[ActorIndex]
EndFunction


Bool Function RegisterAllEvents()
    ActivateKey = Input.GetMappedKey("Activate", deviceType = 0xFF)

    if !ActivateKey
        Debug.Notification("Could not detect input keys")
        return false
    endif

    StrafeLeftKey = Input.GetMappedKey("Strafe Left", deviceType = 0xFF)
    StrafeRightKey = Input.GetMappedKey("Strafe Right", deviceType = 0xFF)
    ForwardKey = Input.GetMappedKey("Forward", deviceType = 0xFF)
    BackKey = Input.GetMappedKey("Back", deviceType = 0xFF)
    LeftAttackKey = Input.GetMappedKey("Left Attack/Block", deviceType = 0xFF)
    RightAttackKey = Input.GetMappedKey("Right Attack/Block", deviceType = 0xFF)

    if (!ForwardKey && !StrafeRightKey && !LeftAttackKey) || (!BackKey && !StrafeLeftKey && !RightAttackKey)
        Debug.Notification("Could not detect input keys")
        return false
    endif

    RegisterForKey(ActivateKey)

    if LeftAttackKey
        RegisterForKey(LeftAttackKey)
    endif

    if RightAttackKey
        RegisterForKey(RightAttackKey)
    endif

    if StrafeLeftKey
        RegisterForKey(StrafeLeftKey)
    endif

    if StrafeRightKey
        RegisterForKey(StrafeRightKey)
    endif

    if ForwardKey
        RegisterForKey(ForwardKey)
    endif

    if BackKey
        RegisterForKey(BackKey)
    endif

    RegisterForModEvent("GamepadEvent_LStickMove", "OnControllerEvent") ; gamepadevents.dll (Go to bed)

    return true
EndFunction


Bool Function Setup()
    if !RegisterAllEvents()
        return false
    endif

    GoToState("INITIALIZE")

    return true
EndFunction


Function SwitchToNextActor()
    Int i
    Int j = ActorIndex

    while i < ActorMax
        i += 1
        j += 1

        if j >= ActorMax
            j = 0
        endif

        if TestActor(ActorList[j])
            ActorIndex = j
            return
        endif
    endwhile
EndFunction


Function SwitchToPreviousActor()
    Int i
    Int j = ActorIndex

    while i < ActorMax
        i += 1
        j -= 1

        if j < 0
            j = ActorMax - 1
        endif

        if TestActor(ActorList[j])
            ActorIndex = j
            return
        endif
    endwhile
EndFunction


Bool Function TestActor(Actor akTarget)
    if !akTarget
        eqQuestMain.Log("Selector TestActor: none")
        return false
    endif

    ;/
    if eqQuestMain.CheckFriendlyFire(akTarget)
        eqQuestMain.Log("Selector TestActor: " + akTarget + " is friendly fire")
        return false
    endif
    /;


    ;/
    if !PlayerRef.HasLOS(akTarget)
        eqQuestMain.Log("Selector TestActor: " + akTarget + " is out of sight")
        return false
    endif
    /;


    if akTarget.GetParentCell() != PlayerRef.GetParentCell()
        if akTarget.IsInInterior() != PlayerRef.IsInInterior()
            eqQuestMain.Log("Selector TestActor: " + akTarget + " is in other cell")
            return false
        endif
    endif

    eqQuestMain.Log("Selector TestActor: " + akTarget + " looks good")
    Game.SetCameraTarget(akTarget)
    eqSoundSwitchTargetSD.Play(akTarget)
    eqQuestTarget.FocusTarget = akTarget

    return true
EndFunction


Function ToggleCameraState()
    Int iKeyCode = Input.GetMappedKey("Toggle POV", 0x00)

    if iKeyCode != -1
        Input.TapKey(iKeyCode)
        Utility.Wait(0.2)
    else
        if Game.GetCameraState() == 0
            Game.ForceThirdPerson()
        else
            Game.ForceFirstPerson()
        endif
    endif
EndFunction


Event OnControllerEvent(String asEventName, String asArg, Float afArg, Form asSender)
EndEvent


State INITIALIZE
    Event OnBeginState()
        eqQuestMain.Log("Selector [INITIALIZE] OnBeginState")

        ; 変数初期化
        ActorList = new Actor[20]
        ActorMax = 0
        ActorIndex = 0

        eqSpellDetectEnemy.Cast(PlayerRef) ; 前方にスペルを発射

        if Game.GetCameraState() == 0
            ToggleCameraState()
            IsFirstPersonView = true
        else
            IsFirstPersonView = false
        endif

        eqSpellSlowTime.Cast(PlayerRef)

        PlayerRef.SetDontMove(abDontMove = true)

        if Game.IsCamSwitchControlsEnabled()
            Game.DisablePlayerControls(abMovement = false, abFighting = false, abCamSwitch = false, abLooking = true, abSneaking = false, abMenu = true, abActivate = true, abJournalTabs = true)
        endif

        GoToState("DETECTING")
    EndEvent


    ; DeliveryがTarget Actorのスペルが対象に着弾すると呼ばれる
    Function AddDetectedEnemy(Actor akTarget)
        eqQuestMain.Log("Selector [INITIALIZE] AddDetectedEnemy: " + akTarget)

        if TestActor(akTarget)
            PO3_SKSEFunctions.AddActorToArray(akTarget, ActorList)
            ActorIndex = ActorList.Find(akTarget)
            ActorMax += 1
        endif
    EndFunction
EndState


State DETECTING
    Event OnBeginState()
        eqQuestMain.Log("Selector [DETECTING] OnBeginState")

        Actor kCurrentActor = ActorList[ActorIndex]

        ; 敵対NPCの検出を開始
        Actor[] kActors = MiscUtil.ScanCellNPCs(PlayerRef, 2000.0)
        Int i = kActors.Length

        eqQuestMain.Log("Selector [DETECTING] OnBeginState: cell actor count = " + i + " (player is included)")

        while i > 0 && ActorMax < 20
            i -= 1

            if kActors[i] != PlayerRef && !kActors[i].IsDeleted() && !kActors[i].IsDead() && kActors[i].Is3DLoaded()
                PO3_SKSEFunctions.AddActorToArray(kActors[i], ActorList)
                ActorMax += 1
            endif
        endwhile

        if GetState() != "DETECTING"
            return
        endif

        eqQuestMain.Log("Selector [DETECTING] OnBeginState: CurrentIndex=" + ActorIndex + " CurrentActor=" + kCurrentActor)

        ; ActorをAngleで並び替える
        eqQuestMain.Log("Selector [DETECTING] OnBeginState: getting angle...")
        Float[] fActorAngle = new Float[20]
        Float fLowestScore = 99999.9
        Actor kLowestActor
        i = 0

        while i < ActorMax
            Float fDistance
            Float fScore

            if ActorList[i]
                fActorAngle[i] = PlayerRef.GetHeadingAngle(ActorList[i])
                fActorAngle[i] = Math.abs(fActorAngle[i])
                fDistance = PlayerRef.GetDistance(ActorList[i])
                fScore = fActorAngle[i] * 10.0 + fDistance

                if fScore < fLowestScore
                    fLowestScore = fScore
                    kLowestActor = ActorList[i]
                endif
            else
                fActorAngle[i] = 99999.9
            endif

            eqQuestMain.Log(i + " " + fActorAngle[i] + " " + fDistance + " " + fScore \
                + " " + ActorList[i] + " " + ActorList[i].GetDisplayName() )
            i += 1
        endwhile

        if GetState() != "DETECTING"
            return
        endif

        i = 0

        while i < ActorMax - 1
            Int j = 1

            while j < ActorMax - i && fActorAngle[j] < 99999.9
                if fActorAngle[j] < fActorAngle[j - 1]
                    Float fTmp = fActorAngle[j]
                    fActorAngle[j] = fActorAngle[j - 1]
                    fActorAngle[j - 1] = fTmp
                    Actor kTmp = ActorList[j]
                    ActorList[j] = ActorList[j - 1]
                    ActorList[j - 1] = kTmp
                endif

                j += 1
            endwhile

            i += 1
        endwhile

        eqQuestMain.Log("Selector [DETECTING] OnBeginState: sorting...")
        i = 0

        while i < ActorMax
            eqQuestMain.Log(i + " " + fActorAngle[i] + " " + ActorList[i])
            i += 1
        endwhile

        if GetState() != "DETECTING"
            return
        endif

        if kCurrentActor
            ; 選択中のActorの配列位置を調節
            eqQuestMain.Log("Selector [DETECTING] OnBeginState: current selected index = " + ActorIndex)
            ActorIndex = ActorList.Find(kCurrentActor)
        else
            ; Actorがまた未選択なら選択する
            if kLowestActor
                eqQuestMain.Log("Selector [DETECTING] OnBeginState: best score actor = " + kLowestActor)

                if TestActor(kLowestActor)
                    ActorIndex = ActorList.Find(kLowestActor)
                    GoToState("DETECTED")
                    return
                endif
            endif

            eqQuestMain.Log("Selector [DETECTING] OnBeginState: detecting best angle actor...")
            i = 0

            while i < ActorMax
                if TestActor(ActorList[i])
                    ActorIndex = i
                    GoToState("DETECTED")
                    return
                endif

                i += 1
            endwhile
        endif

        GoToState("DETECTED")
    EndEvent


    Event OnControllerEvent(String asEventName, String asArg, Float afArg, Form asSender)
        if asArg == "Up" || asArg == "Down"
            GoToState("CANCELED")
        endif
    EndEvent


    Event OnKeyDown(Int aiKeyCode)
        if aiKeyCode == ActivateKey
            if ActorMax
                GoToState("SELECTED")
            else
                GoToState("CANCELED")
            endif
        elseif aiKeyCode == ForwardKey || aiKeyCode == BackKey
            GoToState("CANCELED")
        endif
    EndEvent


    ; DeliveryがTarget Actorのスペルが対象に着弾すると呼ばれる
    Function AddDetectedEnemy(Actor akTarget)
        eqQuestMain.Log("Selector [DETECTING] AddDetectedEnemy: " + akTarget)

        if TestActor(akTarget)
            PO3_SKSEFunctions.AddActorToArray(akTarget, ActorList)
            ActorIndex = ActorList.Find(akTarget)
            ActorMax += 1
        endif
    EndFunction
EndState


State DETECTED
    Event OnBeginState()
        eqQuestMain.Log("Selector [DETECTED] OnBeginState")
        RegisterForSingleUpdate(30.0)
    EndEvent


    Event OnControllerEvent(String asEventName, String asArg, Float afArg, Form asSender)
        if asArg == "Right"
            SwitchToNextActor()
        elseif asArg == "Left"
            SwitchToPreviousActor()
        elseif asArg == "Up" || asArg == "Down"
            GoToState("CANCELED")
        endif
    EndEvent


    Event OnKeyDown(Int aiKeyCode)
        if aiKeyCode == ActivateKey
            if ActorMax
                GoToState("SELECTED")
            else
                GoToState("CANCELED")
            endif
        elseif aiKeyCode == RightAttackKey || aiKeyCode == StrafeRightKey
            SwitchToNextActor()
        elseif aiKeyCode == LeftAttackKey || aiKeyCode == StrafeLeftKey
            SwitchToPreviousActor()
        elseif aiKeyCode == ForwardKey || aiKeyCode == BackKey
            GoToState("CANCELED")
        endif
    EndEvent


    Event OnUpdate()
        eqQuestMain.Log("Selector [DETECTED] OnUpdate")
        GoToState("CANCELED")
    EndEvent
EndState


State CANCELED
    Event OnBeginState()
        eqQuestMain.Log("Selector [CANCELED] OnBeginState")
        eqSoundUntargetSD.Play(PlayerRef)
        eqQuestTarget.FocusTarget = none
    EndEvent
EndState


State SELECTED
    Event OnBeginState()
        eqQuestMain.Log("Selector [SELECTED] OnBeginState")
    EndEvent
EndState

そして、前方のActorを検知するスペルです。Magic EffectはFire and ForgotでTarget Actorにしておきます。

ターゲットの仕組み MagicEffect (papyrus)

Scriptname eqEffectDetectEnemyScript Extends ActiveMagicEffect

eqQuestSelectorScript Property eqQuestSelector Auto


; このパワーのDeliveryはTarget Actorで、対象に着弾しないとMagic Effectが発生しない
Event OnEffectStart(Actor akTarget, Actor akCaster)
    ; パワーが着弾したことを伝える
    eqQuestSelector.AddTarget(akTarget)
EndEvent

他にも、選択中は時間を遅くしたいのでSlow Timeのスペルを用意しておきます。

使い方は、ホットキーなどでActorを選択する場面になったら、以下のコードでActorを選択させて取得します。

ターゲットの仕組み 使い方 (papyrus)

Actor kTarget = eqQuestSelector.SelectEnemy()
debug.notification("selected actor is " + kTarget)

まとめ

今回作った仕組みはFinal Fantasy 14でいうところのフォーカスターゲットに相当します。ゲームの設計が違うので比べても仕方ないのですが、だいぶ遊びやすくなりました。FF14は効果音が公式から配布されていますので、効果音も付ければさらに雰囲気が出ます。

さらにはFollower Panelでステータスが出るようにするのも面白いです。公開できないのが残念です。

自前のターゲット選択のインターフェイス作ってはみたものの、戦闘中は使いづらく、使うのをやめてしまいました。インターフェイスにはhimika's lock onを使うようにして、ロックオン中の相手にスペルを唱える仕組みにかえてみました。

ロックオン中の相手(モラグ・トングの暗殺者)に激昂のスペルを唱えたところ。弾が飛んでいくのではなく、ロックオン中の相手に直接かかります。
タイトルとURLをコピーしました