【FF14】極魔の塔向けSplatoonスクリプト

- FF14の極魔の塔向けSplatoonスクリプト4選 -
COMMUNITY SCRIPT

極フォークタワー:魔の塔向け
当コミュニティで作成・公開されたSplatoon Script/Layoutを紹介します

この記事では、当コミュニティ「The Scions of Tooler」のユーザーによって作成・公開された、極フォークタワー:魔の塔向けのSplatoon ScriptとLayoutを4種類紹介します。

極魔の塔向けSplatoon情報の整理にあたり、光のツーラー「レイアウト変更履歴」およびGitHubで公開されているクレセントアイル:北征編のLayout一覧を参考にしています。

利用前の注意

SplatoonのScriptはPCやゲームへ直接アクセスできる仕組みです。内容を確認できないコードは導入せず、利用は自己責任で行ってください。また、ゲームやSplatoonのアップデートによって動作しなくなる可能性があります。

SUMMARY

この記事で紹介する4種類

  • 2ボス・ダンシングソード5連ノックバックガイド
  • 3ボス・8連ビームガイド
  • 2属性リング安置ガイド
  • 2属性リング受ける属性表示Layout
01

ScriptとLayoutの導入方法

Scriptを導入する

  1. 掲載コードの「コードを表示」を開きます。
  2. 右上の「コピー」を押します。
  3. ゲーム内で「/splatoon」を実行します。
  4. 「Scripts」タブを開きます。
  5. 「Install from Clipboard」を押します。
  6. 追加されたScriptが有効になっていることを確認します。

Layoutを導入する

  1. 掲載コードの「Layoutを表示」を開きます。
  2. 右上の「コピー」を押します。
  3. ゲーム内で「/splatoon」を実行します。
  4. 「Layouts」を開きます。
  5. 「Import from clipboard」を押します。
  6. 追加されたLayoutが有効になっていることを確認します。
02

2ボス・ダンシングソード5連ノックバックガイド

ダンシングソードで出現する5本の剣を順番に記録し、それぞれへ「1本目」から「5本目」まで表示するScriptです。

ノックバックの詠唱が始まると対象の剣を強調し、剣から自分へ伸びる線と、現在位置から20m先の予測着地点を表示します。

2ボス・ダンシングソード5連ノックバックガイドのコードを表示
C# / Splatoon Script
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();
    }
}
03

3ボス・8連ビームガイド

ネクロフォビア戦でプレイヤーに付与されたデバフを確認し、次に受ける色を「赤受ける」または「青受ける」と表示するScriptです。

8回分のビームを出現順に記録し、現在の手順で避ける必要がある危険レーンをフィールド上へ表示します。

3ボス・8連ビームガイドのコードを表示
C# / Splatoon Script
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;
        }
    }
}
04

2属性リング安置ガイド

自分へ付与されたリングの組み合わせから必要な属性を判定し、炎・氷・雷の床配置をもとに安置となる方向を扇形で表示するScriptです。

候補となる2方向のうち、現在の自分に近い側を選択して表示します。

2属性リング安置ガイドのコードを表示
C# / Splatoon Script
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);
    }
}
05

2属性リング受ける属性表示Layout

自分に表示された2属性のリングを判定し、次に受ける属性をプレイヤー上へ表示するシンプルなLayoutです。

  • 氷+雷のリング:を表示
  • 雷+炎のリング:を表示
  • 炎+氷のリング:を表示
2属性リング受ける属性表示Layoutを表示
JSON / Splatoon 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}]}
06

まとめ

必要なものだけを選んで導入し、極フォークタワー:魔の塔へ入る前にScriptまたはLayoutが有効になっていることを確認してください。

参考情報

最終確認:2026年8月22日

  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

目次