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を使うようにして、ロックオン中の相手にスペルを唱える仕組みにかえてみました。