極フォークタワー:魔の塔向け
当コミュニティで作成・公開されたSplatoon Script/Layoutを紹介します
この記事では、当コミュニティ「The Scions of Tooler」のユーザーによって作成・公開された、極フォークタワー:魔の塔向けのSplatoon ScriptとLayoutを4種類紹介します。
極魔の塔向けSplatoon情報の整理にあたり、光のツーラー「レイアウト変更履歴」およびGitHubで公開されているクレセントアイル:北征編のLayout一覧を参考にしています。
利用前の注意
SplatoonのScriptはPCやゲームへ直接アクセスできる仕組みです。内容を確認できないコードは導入せず、利用は自己責任で行ってください。また、ゲームやSplatoonのアップデートによって動作しなくなる可能性があります。
この記事で紹介する4種類
- 2ボス・ダンシングソード5連ノックバックガイド
- 3ボス・8連ビームガイド
- 2属性リング安置ガイド
- 2属性リング受ける属性表示Layout
ScriptとLayoutの導入方法
Scriptを導入する
- 掲載コードの「コードを表示」を開きます。
- 右上の「コピー」を押します。
- ゲーム内で「/splatoon」を実行します。
- 「Scripts」タブを開きます。
- 「Install from Clipboard」を押します。
- 追加されたScriptが有効になっていることを確認します。
Layoutを導入する
- 掲載コードの「Layoutを表示」を開きます。
- 右上の「コピー」を押します。
- ゲーム内で「/splatoon」を実行します。
- 「Layouts」を開きます。
- 「Import from clipboard」を押します。
- 追加されたLayoutが有効になっていることを確認します。
2ボス・ダンシングソード5連ノックバックガイド
ダンシングソードで出現する5本の剣を順番に記録し、それぞれへ「1本目」から「5本目」まで表示するScriptです。
ノックバックの詠唱が始まると対象の剣を強調し、剣から自分へ伸びる線と、現在位置から20m先の予測着地点を表示します。
2ボス・ダンシングソード5連ノックバックガイドのコードを表示
using System;
using System.Collections.Generic;
using System.Numerics;
using ECommons.DalamudServices;
using ECommons.GameFunctions;
using ECommons.Hooks.ActionEffectTypes;
using Splatoon;
using Splatoon.SplatoonScripting;
namespace MySplatoonScripts;
public class DancingSwordKnockbackGuide : SplatoonScript
{
// 蜃気楼の島 クレセントアイル:北征編[1346]
public override HashSet<uint>? ValidTerritories => [1346];
public override Metadata Metadata => new(1, "The Scions of Tooler");
// ==========================================
// VFX
// ==========================================
private const string SwordVfx =
"vfx/common/eff/m0272_aura_sword_t1.avfx";
private const string SwordAVfx =
"vfx/common/eff/m0272_aura_sword_a_t1.avfx";
// ==========================================
// Knockback
// ==========================================
// 剣気衝撃
private const uint KnockbackCastId = 49660;
// 吹き飛ばし距離
private const float KnockbackDistance = 20.0f;
// 吹き飛ばし発生からガイドを消すまでの時間
private const long GuideHideDelayMs = 500;
// ==========================================
// Sword sequence
// ==========================================
private const int MaxSwords = 5;
// VFXが15秒以上空いたら新しいセットとして扱う
private const long ResetTimeMs = 15000;
private int swordCount;
private long lastSwordTime;
private readonly uint[] swordObjectIds =
new uint[MaxSwords];
// 現在49660を処理している剣
private uint activeSwordId;
// 0以外なら、この時刻に現在の予測ガイドを消す
private long guideHideAt;
// ==========================================
// Setup
// ==========================================
public override void OnSetup()
{
for (int i = 1; i <= MaxSwords; i++)
{
// ----------------------------------
// 剣本体の円と出現順
// ----------------------------------
Controller.RegisterElement(
$"Sword{i}",
new Element(1)
{
refActorType = 0,
refActorComparisonType = 2,
radius = 3.0f,
thicc = 4.0f,
overlayText = $"{i}本目",
overlayFScale = 1.5f,
Enabled = false,
}
);
// ----------------------------------
// 剣から予測着地点までの線
// ----------------------------------
Controller.RegisterElement(
$"KBLine{i}",
new Element(3)
{
refActorType = 0,
refActorComparisonType = 2,
// 始点は基準Actorである剣自身
refX = 0.0f,
refY = 0.0f,
radius = 0.0f,
thicc = 8.0f,
color = 0xFF00FF00,
Enabled = false,
}
);
// ----------------------------------
// 予測着地点
// ----------------------------------
Controller.RegisterElement(
$"Landing{i}",
new Element(1)
{
refActorType = 0,
refActorComparisonType = 2,
radius = 2.0f,
thicc = 6.0f,
color = 0xFF00FF00,
overlayText = "予測着地点",
overlayFScale = 1.5f,
Enabled = false,
}
);
}
}
// ==========================================
// Sword detection
// ==========================================
public override void OnVFXSpawn(
uint target,
string vfxPath)
{
if (!IsSwordVfx(vfxPath))
return;
long now = Environment.TickCount64;
if (lastSwordTime != 0 &&
now - lastSwordTime > ResetTimeMs)
{
ResetSequence();
}
lastSwordTime = now;
// 同じ剣を重複登録しない
if (Array.IndexOf(swordObjectIds, target) >= 0)
return;
if (swordCount >= MaxSwords)
return;
int index = swordCount;
swordObjectIds[index] = target;
swordCount++;
int number = index + 1;
var sword =
Controller.GetElementByName(
$"Sword{number}"
);
if (sword == null)
return;
sword.refActorObjectID = target;
sword.radius = 3.0f;
sword.thicc = 4.0f;
sword.overlayText = $"{number}本目";
// VFXの種類によって円の色だけ変える
if (vfxPath == SwordVfx)
{
sword.color = 0xFF00AEEF;
}
else
{
sword.color = 0xFFFF4040;
}
sword.Enabled = true;
}
// ==========================================
// Knockback cast start
// ==========================================
public override void OnStartingCast(
uint source,
uint castId)
{
if (castId != KnockbackCastId)
return;
int index =
Array.IndexOf(
swordObjectIds,
source
);
// 記録した剣以外からの詠唱は無視
if (index < 0)
return;
// 前の剣のガイドを消す
DisableAllKnockbackGuides();
activeSwordId = source;
guideHideAt = 0;
int number = index + 1;
// ----------------------------------
// 詠唱中の剣を強調
// ----------------------------------
var sword =
Controller.GetElementByName(
$"Sword{number}"
);
if (sword != null)
{
sword.refActorObjectID = source;
sword.radius = 6.0f;
sword.thicc = 10.0f;
sword.overlayText = $"{number}本目";
sword.Enabled = true;
}
// ----------------------------------
// 線と着地点を同じ剣に紐付ける
// ----------------------------------
var line =
Controller.GetElementByName(
$"KBLine{number}"
);
if (line != null)
{
line.refActorObjectID = source;
}
var landing =
Controller.GetElementByName(
$"Landing{number}"
);
if (landing != null)
{
landing.refActorObjectID = source;
}
UpdateActiveGuide();
}
// ==========================================
// Update
// ==========================================
public override void OnUpdate()
{
UpdateActiveGuide();
}
private void UpdateActiveGuide()
{
if (activeSwordId == 0)
return;
int index =
Array.IndexOf(
swordObjectIds,
activeSwordId
);
if (index < 0)
return;
int number = index + 1;
// 吹き飛ばし発生から0.5秒後に、
// 線と予測着地点を消す
if (guideHideAt != 0 &&
Environment.TickCount64 >= guideHideAt)
{
DisableKnockbackGuide(number);
activeSwordId = 0;
guideHideAt = 0;
return;
}
var swordObject =
activeSwordId.GetObject();
if (swordObject == null)
return;
var player =
Svc.Objects.LocalPlayer;
if (player == null)
return;
Vector3 swordPosition =
swordObject.Position;
Vector3 playerPosition =
player.Position;
// ----------------------------------
// 剣から現在の自分への方向
// ----------------------------------
Vector3 direction =
playerPosition - swordPosition;
// 高低差は無視
direction.Y = 0.0f;
if (direction.LengthSquared() < 0.001f)
return;
direction =
Vector3.Normalize(direction);
// ----------------------------------
// 現在の自分の位置から20m先
//
// 固定せず、ガイドが消えるまで
// 毎フレーム自分の位置へ追従させる
// ----------------------------------
Vector3 landingPosition =
playerPosition +
direction * KnockbackDistance;
// Elementは剣Actor基準なので、
// ワールド座標から剣相対座標へ変換
Vector3 relativeLanding =
landingPosition -
swordPosition;
UpdateLine(
number,
relativeLanding
);
UpdateLanding(
number,
relativeLanding
);
}
private void UpdateLine(
int number,
Vector3 relativeLanding)
{
var line =
Controller.GetElementByName(
$"KBLine{number}"
);
if (line == null)
return;
// 始点は剣自身
line.refX = 0.0f;
line.refY = 0.0f;
// 終点は現在の予測着地点
line.offX = relativeLanding.X;
line.offY = relativeLanding.Z;
line.Enabled = true;
}
private void UpdateLanding(
int number,
Vector3 relativeLanding)
{
var landing =
Controller.GetElementByName(
$"Landing{number}"
);
if (landing == null)
return;
landing.offX = relativeLanding.X;
landing.offY = relativeLanding.Z;
landing.Enabled = true;
}
// ==========================================
// Knockback execution
// ==========================================
public override void OnActionEffectEvent(
ActionEffectSet set)
{
if (set.Action?.RowId != KnockbackCastId)
return;
if (activeSwordId == 0)
return;
int index =
Array.IndexOf(
swordObjectIds,
activeSwordId
);
if (index < 0)
return;
int number = index + 1;
// 吹き飛ばしが発生した剣の円とラベルを消す
var sword =
Controller.GetElementByName(
$"Sword{number}"
);
if (sword != null)
sword.Enabled = false;
// 線と着地点は0.5秒後に消す
guideHideAt =
Environment.TickCount64 +
GuideHideDelayMs;
}
// ==========================================
// Helpers
// ==========================================
private bool IsSwordVfx(string path)
{
return
path == SwordVfx ||
path == SwordAVfx;
}
private void DisableKnockbackGuide(int number)
{
var line =
Controller.GetElementByName(
$"KBLine{number}"
);
if (line != null)
line.Enabled = false;
var landing =
Controller.GetElementByName(
$"Landing{number}"
);
if (landing != null)
landing.Enabled = false;
}
private void DisableAllKnockbackGuides()
{
for (int i = 1; i <= MaxSwords; i++)
{
DisableKnockbackGuide(i);
}
}
// ==========================================
// Reset
// ==========================================
private void ResetSequence()
{
swordCount = 0;
activeSwordId = 0;
guideHideAt = 0;
Array.Clear(
swordObjectIds,
0,
swordObjectIds.Length
);
for (int i = 1; i <= MaxSwords; i++)
{
var sword =
Controller.GetElementByName(
$"Sword{i}"
);
if (sword != null)
{
sword.refActorObjectID = 0;
sword.radius = 3.0f;
sword.thicc = 4.0f;
sword.overlayText = $"{i}本目";
sword.Enabled = false;
}
var line =
Controller.GetElementByName(
$"KBLine{i}"
);
if (line != null)
{
line.refActorObjectID = 0;
line.refX = 0.0f;
line.refY = 0.0f;
line.offX = 0.0f;
line.offY = 0.0f;
line.Enabled = false;
}
var landing =
Controller.GetElementByName(
$"Landing{i}"
);
if (landing != null)
{
landing.refActorObjectID = 0;
landing.offX = 0.0f;
landing.offY = 0.0f;
landing.Enabled = false;
}
}
}
public override void OnReset()
{
lastSwordTime = 0;
ResetSequence();
}
}
3ボス・8連ビームガイド
ネクロフォビア戦でプレイヤーに付与されたデバフを確認し、次に受ける色を「赤受ける」または「青受ける」と表示するScriptです。
8回分のビームを出現順に記録し、現在の手順で避ける必要がある危険レーンをフィールド上へ表示します。
3ボス・8連ビームガイドのコードを表示
using ECommons.Configuration;
using ECommons.DalamudServices;
using Splatoon;
using Splatoon.SplatoonScripting;
using System.Collections.Generic;
using System.Globalization;
using System.Numerics;
namespace SplatoonScriptsOfficial.Duties.Dawntrail.Forked_Tower_Magic
{
internal class Necrophobia_Beam_Guide : SplatoonScript
{
public override HashSet<uint>? ValidTerritories { get; } =
[1346];
public override Metadata Metadata =>
new(7, "The Scions of Tooler");
private Config Conf =>
Controller.GetConfig<Config>();
private const uint BossBaseId = 19431;
private const uint BarrierHeadBaseId = 19432;
private const uint SewingDreadStatusId = 5136;
private const uint SewingPanicStatusId = 5137;
private const string PlayerIndicatorName =
"Sewing_NextColor";
private const float ClockwiseShift = -32.0f;
private const float CounterClockwiseShift = 32.0f;
private const float LaneLength = 80.0f;
private const float LaneRadius = 32.0f;
private const uint UnsafeColor = 0xE0202020;
private const float UnsafeFillIntensity = 0.70f;
private const uint RedTextColor = 0xFFFF4040;
private const uint BlueTextColor = 0xFF40A0FF;
private const uint TextBackgroundColor = 0xC0000000;
private enum RequiredColor
{
None,
Red,
Blue
}
private enum ArenaMarker
{
A,
B,
C,
D,
One,
Two,
Three,
Four
}
private readonly ArenaMarker[] AllMarkers =
{
ArenaMarker.A,
ArenaMarker.B,
ArenaMarker.C,
ArenaMarker.D,
ArenaMarker.One,
ArenaMarker.Two,
ArenaMarker.Three,
ArenaMarker.Four
};
private readonly Dictionary<ArenaMarker, Vector3>
MarkerPositions = new()
{
[ArenaMarker.A] = new(100f, -724f, 783f),
[ArenaMarker.B] = new(117f, -724f, 800f),
[ArenaMarker.C] = new(100f, -724f, 817f),
[ArenaMarker.D] = new(83f, -724f, 800f),
[ArenaMarker.One] = new(88f, -724f, 788f),
[ArenaMarker.Two] = new(112f, -724f, 788f),
[ArenaMarker.Three] = new(112f, -724f, 812f),
[ArenaMarker.Four] = new(88f, -724f, 812f)
};
private enum BeamVariant
{
Ab,
Ba
}
private class BeamInfo
{
public ArenaMarker HeadMarker;
public BeamVariant Variant;
}
private readonly Dictionary<int, BeamInfo> Beams =
new();
private readonly HashSet<string> SeenBeamVfx =
new();
private int BeamOrder;
private int CurrentStep = 1;
private uint CurrentSewingStatus;
private bool StatusInitialized;
public override void OnSetup()
{
ResetState();
foreach (var marker in AllMarkers)
{
RegisterUnsafeLane(
marker,
true
);
RegisterUnsafeLane(
marker,
false
);
}
RegisterPlayerIndicator();
HideAllLanes();
HidePlayerIndicator();
}
public override void OnReset()
{
ResetState();
HideAllLanes();
HidePlayerIndicator();
}
private void ResetState()
{
Beams.Clear();
SeenBeamVfx.Clear();
BeamOrder = 0;
CurrentStep = 1;
CurrentSewingStatus = 0;
StatusInitialized = false;
}
private void RegisterUnsafeLane(
ArenaMarker marker,
bool clockwise)
{
Vector3 position =
MarkerPositions[marker];
float shift =
clockwise
? ClockwiseShift
: CounterClockwiseShift;
string name =
GetLaneName(
marker,
clockwise
);
string shiftText =
shift.ToString(
CultureInfo.InvariantCulture
);
string lengthText =
LaneLength.ToString(
CultureInfo.InvariantCulture
);
string radiusText =
LaneRadius.ToString(
CultureInfo.InvariantCulture
);
string xText =
position.X.ToString(
CultureInfo.InvariantCulture
);
string yText =
position.Y.ToString(
CultureInfo.InvariantCulture
);
string zText =
position.Z.ToString(
CultureInfo.InvariantCulture
);
string colorText =
UnsafeColor.ToString(
CultureInfo.InvariantCulture
);
string fillText =
UnsafeFillIntensity.ToString(
CultureInfo.InvariantCulture
);
string json =
$@"{{
""Name"":""{name}"",
""type"":3,
""refX"":{shiftText},
""refY"":0.0,
""offX"":{shiftText},
""offY"":{lengthText},
""radius"":{radiusText},
""color"":{colorText},
""fillIntensity"":{fillText},
""thicc"":2.0,
""refActorDataID"":{BarrierHeadBaseId},
""refActorComparisonType"":3,
""LimitDistance"":true,
""DistanceSourceX"":{xText},
""DistanceSourceY"":{zText},
""DistanceSourceZ"":{yText},
""DistanceMax"":3.0,
""includeRotation"":true,
""AdditionalRotation"":0.0,
""onlyVisible"":true,
""FillStep"":2.0,
""Enabled"":false
}}";
Controller.RegisterElementFromCode(
name,
json
);
}
private void RegisterPlayerIndicator()
{
Controller.RegisterElement(
PlayerIndicatorName,
new Element(1)
{
refActorType = 1,
radius = 0.0f,
thicc = 0.0f,
overlayText = "",
overlayTextColor = RedTextColor,
overlayBGColor = TextBackgroundColor,
overlayVOffset = 2.5f,
overlayFScale = 2.0f,
Enabled = false,
}
);
}
public override void OnUpdate()
{
if (!Conf.Enabled ||
!NecrophobiaExists())
{
HideAllLanes();
HidePlayerIndicator();
return;
}
TrackPlayerStatus();
}
private void TrackPlayerStatus()
{
var player =
Svc.Objects.LocalPlayer;
if (player == null)
return;
uint newStatus = 0;
foreach (var status in
player.StatusList)
{
if (status.StatusId ==
SewingPanicStatusId)
{
newStatus =
SewingPanicStatusId;
break;
}
if (status.StatusId ==
SewingDreadStatusId)
{
newStatus =
SewingDreadStatusId;
break;
}
}
if (newStatus == 0)
return;
if (!StatusInitialized)
{
StatusInitialized = true;
CurrentSewingStatus =
newStatus;
CurrentStep = 1;
UpdatePlayerIndicator(
newStatus
);
TryShowCurrentStep();
return;
}
if (newStatus ==
CurrentSewingStatus)
{
return;
}
CurrentSewingStatus =
newStatus;
CurrentStep++;
HideAllLanes();
if (CurrentStep > 8)
{
HidePlayerIndicator();
return;
}
UpdatePlayerIndicator(
newStatus
);
TryShowCurrentStep();
}
private void UpdatePlayerIndicator(
uint statusId)
{
var indicator =
Controller.GetElementByName(
PlayerIndicatorName
);
if (indicator == null)
return;
if (statusId ==
SewingDreadStatusId)
{
indicator.overlayText =
"赤受ける";
indicator.overlayTextColor =
RedTextColor;
}
else if (statusId ==
SewingPanicStatusId)
{
indicator.overlayText =
"青受ける";
indicator.overlayTextColor =
BlueTextColor;
}
else
{
indicator.Enabled = false;
return;
}
indicator.Enabled = true;
}
private void HidePlayerIndicator()
{
var indicator =
Controller.GetElementByName(
PlayerIndicatorName
);
if (indicator != null)
{
indicator.overlayText = "";
indicator.Enabled = false;
}
}
public override void OnVFXSpawn(
uint target,
string vfxPath)
{
BeamVariant variant;
if (vfxPath.Contains(
"m0475_stlp_ab_c0x.avfx"))
{
variant =
BeamVariant.Ab;
}
else if (vfxPath.Contains(
"m0475_stlp_ba_c0x.avfx"))
{
variant =
BeamVariant.Ba;
}
else
{
return;
}
var obj =
Svc.Objects.SearchById(
target
);
if (obj == null)
return;
if (obj.BaseId !=
BarrierHeadBaseId)
{
return;
}
string key =
$"{target:X8}:{variant}";
if (!SeenBeamVfx.Add(key))
return;
if (BeamOrder >= 8)
return;
ArenaMarker headMarker =
GetNearestMarker(
obj.Position
);
BeamOrder++;
Beams[BeamOrder] =
new BeamInfo
{
HeadMarker = headMarker,
Variant = variant
};
TryShowCurrentStep();
}
private void TryShowCurrentStep()
{
if (!StatusInitialized)
return;
if (CurrentStep < 1 ||
CurrentStep > 8)
{
return;
}
if (!Beams.TryGetValue(
CurrentStep,
out var beam))
{
return;
}
RequiredColor requiredColor =
GetRequiredColor();
if (requiredColor ==
RequiredColor.None)
{
return;
}
bool clockwiseSafe;
if (beam.Variant ==
BeamVariant.Ab)
{
clockwiseSafe =
requiredColor ==
RequiredColor.Blue;
}
else
{
clockwiseSafe =
requiredColor ==
RequiredColor.Red;
}
bool clockwiseUnsafe =
!clockwiseSafe;
ShowUnsafeLane(
beam.HeadMarker,
clockwiseUnsafe
);
}
private RequiredColor GetRequiredColor()
{
if (CurrentSewingStatus ==
SewingPanicStatusId)
{
return RequiredColor.Blue;
}
if (CurrentSewingStatus ==
SewingDreadStatusId)
{
return RequiredColor.Red;
}
return RequiredColor.None;
}
private void ShowUnsafeLane(
ArenaMarker marker,
bool clockwiseUnsafe)
{
HideAllLanes();
string name =
GetLaneName(
marker,
clockwiseUnsafe
);
var element =
Controller.GetElementByName(
name
);
if (element != null)
{
element.Enabled = true;
}
}
private void HideAllLanes()
{
foreach (var marker in AllMarkers)
{
DisableLane(
marker,
true
);
DisableLane(
marker,
false
);
}
}
private void DisableLane(
ArenaMarker marker,
bool clockwise)
{
string name =
GetLaneName(
marker,
clockwise
);
var element =
Controller.GetElementByName(
name
);
if (element != null)
{
element.Enabled = false;
}
}
private string GetLaneName(
ArenaMarker marker,
bool clockwise)
{
return
$"Sewing_{GetMarkerName(marker)}_" +
(clockwise ? "CW" : "CCW");
}
private ArenaMarker GetNearestMarker(
Vector3 position)
{
ArenaMarker nearest =
ArenaMarker.A;
float nearestDistance =
float.MaxValue;
foreach (var pair in
MarkerPositions)
{
float dx =
position.X -
pair.Value.X;
float dz =
position.Z -
pair.Value.Z;
float distance =
dx * dx +
dz * dz;
if (distance <
nearestDistance)
{
nearestDistance =
distance;
nearest =
pair.Key;
}
}
return nearest;
}
private string GetMarkerName(
ArenaMarker marker)
{
if (marker == ArenaMarker.A)
return "A";
if (marker == ArenaMarker.B)
return "B";
if (marker == ArenaMarker.C)
return "C";
if (marker == ArenaMarker.D)
return "D";
if (marker == ArenaMarker.One)
return "1";
if (marker == ArenaMarker.Two)
return "2";
if (marker == ArenaMarker.Three)
return "3";
if (marker == ArenaMarker.Four)
return "4";
return "?";
}
private bool NecrophobiaExists()
{
foreach (var obj in
Svc.Objects)
{
if (obj == null)
continue;
if (obj.BaseId ==
BossBaseId)
{
return true;
}
}
return false;
}
public class Config : IEzConfig
{
public bool Enabled { get; set; } =
true;
}
}
}
2属性リング安置ガイド
自分へ付与されたリングの組み合わせから必要な属性を判定し、炎・氷・雷の床配置をもとに安置となる方向を扇形で表示するScriptです。
候補となる2方向のうち、現在の自分に近い側を選択して表示します。
2属性リング安置ガイドのコードを表示
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using Dalamud.Game.ClientState.Objects.Types;
using ECommons.DalamudServices;
using ECommons.Logging;
using ECommons.Schedulers;
using Splatoon;
using Splatoon.SplatoonScripting;
namespace MySplatoonScripts;
internal class Index_Ring_Guide : SplatoonScript
{
public override HashSet<uint>? ValidTerritories => [1346];
public override Metadata Metadata =>
new(13, "The Scions of Tooler");
private enum ElementType
{
None,
Fire,
Ice,
Lightning
}
private const uint IceFloorBaseId = 2015240;
private const uint FireFloorBaseId = 2015241;
private const uint LightningFloorBaseId = 2015242;
private const string IceLightningRingVfx =
"vfx/lockon/eff/m0947_ring_it_c0p.avfx";
private const string LightningFireRingVfx =
"vfx/lockon/eff/m0947_ring_tf_c0p.avfx";
private const string FireIceRingVfx =
"vfx/lockon/eff/m0947_ring_fi_c0p.avfx";
private static readonly Vector3 ArenaCenter =
new(0.0f, -684.0f, -628.0f);
private const float SectorDistance = 20.0f;
private const float SwitchDistanceDifference = 2.0f;
private const int RingDisplayMilliseconds = 6500;
private Element? safeSector;
private ElementType requiredElement =
ElementType.None;
private readonly Dictionary<ElementType, float>
floorRotations = [];
private TickScheduler? hideScheduler;
private int selectedSector = -1;
public override void OnSetup()
{
Controller.RegisterElement(
"SafeSector",
new Element(5)
{
Enabled = false,
type = 5,
refX = ArenaCenter.X,
refY = ArenaCenter.Z,
refZ = ArenaCenter.Y,
radius = 35.0f,
coneAngleMin = 150,
coneAngleMax = 210,
color = 3355508503,
fillIntensity = 0.35f,
thicc = 3.0f
});
safeSector =
Controller.GetElementByName(
"SafeSector");
if (safeSector != null)
safeSector.Enabled = false;
}
public override void OnVFXSpawn(
uint target,
string vfxPath)
{
var localPlayer =
Svc.Objects.LocalPlayer;
if (localPlayer == null)
return;
if (target != (uint)localPlayer.GameObjectId)
return;
var detected =
DetectRequiredElement(vfxPath);
if (detected == ElementType.None)
return;
hideScheduler?.Dispose();
hideScheduler = null;
requiredElement = detected;
selectedSector = -1;
if (safeSector != null)
safeSector.Enabled = false;
RefreshFloorRotations();
PluginLog.Warning(
$"[Index Ring Guide] " +
$"Required element=" +
$"{ElementName(requiredElement)} " +
$"path=\"{vfxPath}\"");
UpdateSafeSector();
hideScheduler =
new TickScheduler(
HideRingGuide,
RingDisplayMilliseconds);
}
public override void OnObjectCreation(
nint newObjectPtr)
{
if (newObjectPtr == 0)
return;
_ = new TickScheduler(() =>
{
var gameObject =
Svc.Objects.FirstOrDefault(
x => x.Address == newObjectPtr);
if (gameObject == null)
return;
if (GetFloorElement(gameObject.BaseId) ==
ElementType.None)
{
return;
}
RefreshFloorRotations();
UpdateSafeSector();
}, 100);
}
public override void OnMapEffect(
uint position,
ushort data1,
ushort data2)
{
if (position != 0)
return;
if (data1 == 1 &&
data2 == 2)
{
BeginNewFloorSet();
return;
}
if (data1 == 4 &&
data2 == 8)
{
ClearAll();
}
}
public override void OnUpdate()
{
if (requiredElement == ElementType.None)
return;
RefreshFloorRotations();
UpdateSafeSector();
}
public override void OnReset()
{
ClearAll();
}
public override void OnDisable()
{
ClearAll();
}
private void BeginNewFloorSet()
{
floorRotations.Clear();
selectedSector = -1;
if (safeSector != null)
safeSector.Enabled = false;
PluginLog.Warning(
"[Index Ring Guide] " +
"New floor set detected.");
}
private void RefreshFloorRotations()
{
RefreshFloorRotation(
ElementType.Fire,
FireFloorBaseId);
RefreshFloorRotation(
ElementType.Ice,
IceFloorBaseId);
RefreshFloorRotation(
ElementType.Lightning,
LightningFloorBaseId);
}
private void RefreshFloorRotation(
ElementType elementType,
uint baseId)
{
var floorObject =
Svc.Objects
.Where(x => x.BaseId == baseId)
.OrderByDescending(
x => x.GameObjectId)
.FirstOrDefault();
if (floorObject == null)
return;
var newRotation =
NormalizeRadians(
floorObject.Rotation);
if (floorRotations.TryGetValue(
elementType,
out var oldRotation))
{
var difference =
RotationDifference(
oldRotation,
newRotation);
if (difference < 0.01f)
return;
}
floorRotations[elementType] =
newRotation;
PluginLog.Warning(
$"[Index Ring Guide] " +
$"Floor rotation updated " +
$"element={ElementName(elementType)} " +
$"baseId={baseId} " +
$"objectId={floorObject.GameObjectId:X8} " +
$"rotation=" +
$"{RadiansToDegrees(newRotation):F1}");
}
private void UpdateSafeSector()
{
if (requiredElement == ElementType.None)
return;
if (floorRotations.Count < 3)
return;
if (!floorRotations.TryGetValue(
requiredElement,
out var floorRotation))
{
return;
}
var localPlayer =
Svc.Objects.LocalPlayer;
if (localPlayer == null ||
safeSector == null)
{
return;
}
var firstSector =
RotationToSectorIndex(
floorRotation);
var oppositeSector =
(firstSector + 3) % 6;
var firstPosition =
GetSectorPosition(
firstSector);
var oppositePosition =
GetSectorPosition(
oppositeSector);
var firstDistance =
HorizontalDistance(
localPlayer.Position,
firstPosition);
var oppositeDistance =
HorizontalDistance(
localPlayer.Position,
oppositePosition);
var newSelectedSector =
selectedSector;
if (selectedSector != firstSector &&
selectedSector != oppositeSector)
{
newSelectedSector =
firstDistance <= oppositeDistance
? firstSector
: oppositeSector;
}
else if (selectedSector == firstSector)
{
if (oppositeDistance +
SwitchDistanceDifference <
firstDistance)
{
newSelectedSector =
oppositeSector;
}
}
else if (selectedSector == oppositeSector)
{
if (firstDistance +
SwitchDistanceDifference <
oppositeDistance)
{
newSelectedSector =
firstSector;
}
}
if (newSelectedSector != selectedSector)
{
selectedSector =
newSelectedSector;
SetSectorAngles(
safeSector,
selectedSector);
PluginLog.Warning(
$"[Index Ring Guide] " +
$"Safe sector changed " +
$"element={ElementName(requiredElement)} " +
$"floorRotation=" +
$"{RadiansToDegrees(floorRotation):F1} " +
$"selectedSector={selectedSector} " +
$"firstDistance={firstDistance:F1} " +
$"oppositeDistance={oppositeDistance:F1}");
}
safeSector.Enabled =
selectedSector >= 0;
}
private void HideRingGuide()
{
if (safeSector != null)
safeSector.Enabled = false;
requiredElement =
ElementType.None;
selectedSector = -1;
hideScheduler = null;
PluginLog.Warning(
"[Index Ring Guide] " +
"Ring display time ended. " +
"Hiding safe sector.");
}
private void ClearAll()
{
hideScheduler?.Dispose();
hideScheduler = null;
if (safeSector != null)
safeSector.Enabled = false;
requiredElement =
ElementType.None;
selectedSector = -1;
floorRotations.Clear();
}
private static void SetSectorAngles(
Element element,
int sectorIndex)
{
var centerAngle =
180 + sectorIndex * 60;
element.coneAngleMin =
centerAngle - 30;
element.coneAngleMax =
centerAngle + 30;
}
private static int RotationToSectorIndex(
float rotation)
{
var degrees =
RadiansToDegrees(rotation);
var rotationIndex =
(int)MathF.Round(
degrees / 60.0f);
rotationIndex %= 3;
if (rotationIndex < 0)
rotationIndex += 3;
return (rotationIndex + 1) % 3;
}
private static Vector3 GetSectorPosition(
int sectorIndex)
{
var angle =
sectorIndex *
60.0f *
MathF.PI /
180.0f;
return new Vector3(
ArenaCenter.X +
MathF.Sin(angle) *
SectorDistance,
ArenaCenter.Y,
ArenaCenter.Z -
MathF.Cos(angle) *
SectorDistance);
}
private static ElementType DetectRequiredElement(
string vfxPath)
{
if (vfxPath.Equals(
IceLightningRingVfx,
StringComparison.OrdinalIgnoreCase))
{
return ElementType.Fire;
}
if (vfxPath.Equals(
LightningFireRingVfx,
StringComparison.OrdinalIgnoreCase))
{
return ElementType.Ice;
}
if (vfxPath.Equals(
FireIceRingVfx,
StringComparison.OrdinalIgnoreCase))
{
return ElementType.Lightning;
}
return ElementType.None;
}
private static ElementType GetFloorElement(
uint baseId)
{
return baseId switch
{
FireFloorBaseId =>
ElementType.Fire,
IceFloorBaseId =>
ElementType.Ice,
LightningFloorBaseId =>
ElementType.Lightning,
_ =>
ElementType.None
};
}
private static string ElementName(
ElementType element)
{
return element switch
{
ElementType.Fire =>
"炎",
ElementType.Ice =>
"氷",
ElementType.Lightning =>
"雷",
_ =>
"不明"
};
}
private static float RotationDifference(
float first,
float second)
{
var difference =
MathF.Abs(first - second);
var fullCircle =
MathF.PI * 2.0f;
return MathF.Min(
difference,
fullCircle - difference);
}
private static float RadiansToDegrees(
float radians)
{
var degrees =
NormalizeRadians(radians) *
180.0f /
MathF.PI;
if (degrees >= 359.95f)
degrees = 0.0f;
return degrees;
}
private static float NormalizeRadians(
float radians)
{
var fullCircle =
MathF.PI * 2.0f;
radians %= fullCircle;
if (radians < 0.0f)
radians += fullCircle;
return radians;
}
private static float HorizontalDistance(
Vector3 first,
Vector3 second)
{
var x =
first.X - second.X;
var z =
first.Z - second.Z;
return MathF.Sqrt(
x * x +
z * z);
}
}
2属性リング受ける属性表示Layout
自分に表示された2属性のリングを判定し、次に受ける属性をプレイヤー上へ表示するシンプルなLayoutです。
- 氷+雷のリング:炎を表示
- 雷+炎のリング:氷を表示
- 炎+氷のリング:雷を表示
2属性リング受ける属性表示Layoutを表示
~Lv2~{"Name":"MTE4_二重魔法","Group":"[7.5.5]極フォークタワー:魔の塔","ZoneLockH":[1346],"ElementsL":[{"Name":"トリガー氷雷","type":1,"refActorComparisonType":7,"refActorVFXPath":"vfx/lockon/eff/m0947_ring_it_c0p.avfx","refActorVFXMax":9000,"Conditional":true,"ConditionalReset":true,"Nodraw":true},{"Name":"氷雷","type":1,"fillIntensity":0.303,"overlayTextColor":3355443455,"overlayVOffset":3.0,"overlayFScale":3.0,"thicc":3.0,"overlayTextIntl":{"Jp":"炎"},"refActorType":1},{"Name":"トリガー炎雷","type":1,"refActorComparisonType":7,"refActorVFXPath":"vfx/lockon/eff/m0947_ring_tf_c0p.avfx","refActorVFXMax":9000,"Conditional":true,"ConditionalReset":true,"Nodraw":true},{"Name":"炎雷","type":1,"fillIntensity":0.303,"overlayTextColor":3372156928,"overlayVOffset":3.0,"overlayFScale":3.0,"thicc":3.0,"overlayTextIntl":{"Jp":"氷"},"refActorType":1},{"Name":"トリガー炎氷","type":1,"refActorComparisonType":7,"refActorVFXPath":"vfx/lockon/eff/m0947_ring_fi_c0p.avfx","refActorVFXMax":9000,"Conditional":true,"ConditionalReset":true,"Nodraw":true},{"Name":"炎氷","type":1,"fillIntensity":0.303,"overlayTextColor":3355508725,"overlayVOffset":3.0,"overlayFScale":3.0,"thicc":3.0,"overlayTextIntl":{"Jp":"雷"},"refActorType":1}]}
まとめ
必要なものだけを選んで導入し、極フォークタワー:魔の塔へ入る前にScriptまたはLayoutが有効になっていることを確認してください。
参考情報
最終確認:2026年8月22日

コメント