TShockサーバーでGhostの移動を快適にする

## 前提
* [TShock for Terraria 1.4.1.2](https://github.com/Pryaxis/TShock/releases)
* C#それとなく知ってる
* [Visual Studio](https://visualstudio.microsoft.com/ja/)触ってる

## 注意

* 回り道します。悪しからず。

## プロジェクト準備

1. プロジェクト新規作成
1. `Class Library (.NET Framework)`で作成
1. DLしたTShockから、`TerrariaServer.exe`、`OTAPI.dll`、`ServerPlugins/TShockAPI.dll`、`Newtonsoft.Json.dll`(オプション)を参照に追加

## TShockプラグインとしての最小限の形

`TerrariaPlugin`継承して警告を適当に直していればこんな形になります

```cs
[ApiVersion(2, 1)]
public class GhostMain : TerrariaPlugin
{
	public override string Author => "YOUの名前";

	public override string Description => "プラグイン説明";

	public override string Name => "TeleportGhost";

	public override Version Version => Assembly.GetExecutingAssembly().GetName().Version;

	public GhostMain(Main game)
		: base(game)
	{
	}

	public override void Initialize()
	{
		// ここでHook登録
	}

	protected override void Dispose(bool disposing)
	{
		if (disposing)
		{
			// ここでHook削除
		}

		base.Dispose(disposing);
	}
}
```

ビルド後はプラグイン名.dllをServerPluginsディレクトリに入れる

## ゴースト移動を加速させよう

サーバー通信量増やせばできるけど、まあ**できません**

## じゃあどうするの？

短距離テレポートさせればいいじゃない
※移動加速も1秒に60回通常移動以上の距離テレポートさせれば可能です。~~通信量の無駄~~

## 短距離テレポート、やってみよう！

### まずはゴーストが移動したことを知る

基本的にTerrariaのプレイヤーの移動は`PlayerUpdate`パケットで送られます。ゴーストも例に漏れません。
なので、`PlayerUpdate`フックを探してそこで処理します。
Hookは`OTAPI.Hooks`か`ServerApi.Hooks`、`TShockAPI.Hooks`のどれかに大体あります。
...**ありませんでした！**

#### 仕方がないので自分で通信処理

通信を受け取るところにHookして処理を書きます

```cs
public override void Initialize()
{
	ServerApi.Hooks.NetGetData.Register(this, OnGetData);
}

protected override void Dispose(bool disposing)
{
	if (disposing)
	{
		ServerApi.Hooks.NetGetData.Deregister(this, OnGetData);
	}
}

private void OnGetData(GetDataEventArgs args)
{
	if (args.Handled)
	{
		return;
	}

	switch (args.MsgID)
	{
		case PacketTypes.PlayerUpdate:
			OnPlayerUpdate(args);
			break;
	}
}

private void OnPlayerUpdate(GetDataEventArgs args)
{
	// ゴーストの時だけ処理
	if (!Main.player[args.Msg.whoAmI].ghost)
	{
		return;
	}

	// 処理するので処理しましたフラグを建てる
	args.Handled = true;

	// 今回はゴーストが移動したときにテレポートさせたいだけなので、一度テラリアサーバーが行う処理と同じことをする。
	BinaryReader reader = new BinaryReader(new MemoryStream(args.Msg.readBuffer, args.Index, args.Length));
	int playerIndex = reader.ReadByte();
	if (playerIndex != Main.myPlayer || Main.ServerSideCharacter)
	{
		Player player = Main.player[playerIndex];

		BitsByte bitsByte = reader.ReadByte();
		BitsByte bitsByte1 = reader.ReadByte();
		BitsByte bitsByte2 = reader.ReadByte();
		BitsByte bitsByte3 = reader.ReadByte();
		player.controlUp = bitsByte[0];
		player.controlDown = bitsByte[1];
		player.controlLeft = bitsByte[2];
		player.controlRight = bitsByte[3];
		player.controlJump = bitsByte[4];
		player.controlUseItem = bitsByte[5];
		player.direction = (bitsByte[6] ? 1 : (-1));
		if (bitsByte1[0])
		{
			player.pulley = true;
			player.pulleyDir = (byte)((!bitsByte1[1]) ? 1 : 2);
		}
		else
		{
			player.pulley = false;
		}

		player.vortexStealthActive = bitsByte1[3];
		player.gravDir = (bitsByte1[4] ? 1 : (-1));
		player.TryTogglingShield(bitsByte1[5]);
		player.ghost = bitsByte1[6];
		player.selectedItem = reader.ReadByte();
		player.position = reader.ReadVector2();
		if (bitsByte1[2])
		{
			player.velocity = reader.ReadVector2();
		}
		else
		{
			player.velocity = Vector2.Zero;
		}

		if (bitsByte2[6])
		{
			player.PotionOfReturnOriginalUsePosition = reader.ReadVector2();
			player.PotionOfReturnHomePosition = reader.ReadVector2();
		}
		else
		{
			player.PotionOfReturnOriginalUsePosition = null;
			player.PotionOfReturnHomePosition = null;
		}

		player.tryKeepingHoveringUp = bitsByte2[0];
		player.IsVoidVaultEnabled = bitsByte2[1];
		player.sitting.isSitting = bitsByte2[2];
		player.downedDD2EventAnyDifficulty = bitsByte2[3];
		player.isPettingAnimal = bitsByte2[4];
		player.isTheAnimalBeingPetSmall = bitsByte2[5];
		player.tryKeepingHoveringDown = bitsByte2[7];
		player.sleeping.SetIsSleepingAndAdjustPlayerRotation(player, bitsByte3[0]);

		// ゴーストを短距離テレポート
		ShortTeleportGhost(player);

		// ゴーストにタイル情報送信
		RemoteClient.CheckSection(playerIndex, player.position);
	}
}

private void ShortTeleportGhost(Player player)
{
	// 今は何もしない
}
```

これでゴーストが移動しようとしたときに何かしらの処理ができるようになりました。

### テレポートの仕様を考える

* プレイヤーが意図したときにテレポートさせるようにしたい
* こちらが使える情報はゴーストの現在位置と入力の切り替わり(上下左右)

この2つの条件から、ダブルタップ(同じキーを2度押し)でテレポートさせるのが良さそうと考えました。

### ダブルタップ検知

実装する前に、「ダブルタップ」がどういう状態なのかを考えます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/685051/acfd3915-5852-4940-b9bb-e7591bfb78e4.png)

1個の入力に対し、「入力あり」と「入力なし」の2つの状態があります。
「入力あり」から「入力なし」への切り替わりでタイマーをスタートさせ、「入力なし」から「入力あり」への切り替わりでタイマーをストップ、そのタイマーの時間でダブルタップを判断することにしました。

実際の実装では、処理速度低減のため、常に1個だけタイマーを動かし、そのタイマーの時間を切り替わりで個別に記録することで判定させています。

```cs

// 配列に入力切り替わり時間と前回の入力を保存しておく(上左下右)
private int[,] lastControlChangedTicks = new int[Main.player.Length, 4];

private bool[,] lastPlayerControl = new bool[Main.player.Length, 4];

private int currentGameTick = 0;

private const int SHORT_TELEPORT_DOUBLE_TAP_TICK = 20;

private const int SHORT_TELEPORT_DISTANCE_X = 320;

private const int SHORT_TELEPORT_DISTANCE_Y = 320;

public override void Initialize()
{
	ServerApi.Hooks.NetGetData.Register(this, OnGetData);
	ServerApi.Hooks.GameUpdate.Register(this, UpdateGameTick);
}

protected override void Dispose(bool disposing)
{
	if (disposing)
	{
		ServerApi.Hooks.NetGetData.Deregister(this, OnGetData);
		ServerApi.Hooks.GameUpdate.Deregister(this, UpdateGameTick);
	}
}

private void UpdateGameTick(EventArgs args)
{
	currentGameTick++;
}

private void ShortTeleportGhost(Player player)
{
	int playerIndex = player.whoAmI;

	// テレポート相対位置
	Vector2 teleportOffset = Vector2.Zero;
	if (player.controlUp || player.controlJump)
	{
		if (!lastPlayerControl[playerIndex, 0])
		{
			// プレイヤーコントロール入力変化(上入力なし→あり)
			if (lastControlChangedTicks[playerIndex, 0] + SHORT_TELEPORT_DOUBLE_TAP_TICK > currentGameTick)
			{
				teleportOffset.Y -= SHORT_TELEPORT_DISTANCE_Y;
			}
		}
	}
	else
	{
		if (lastPlayerControl[playerIndex, 0])
		{
			// プレイヤーコントロール入力変化(上入力あり→なし)
			lastControlChangedTicks[playerIndex, 0] = currentGameTick;
		}
	}

	if (player.controlLeft)
	{
		if (!lastPlayerControl[playerIndex, 1])
		{
			// プレイヤーコントロール入力変化(左入力なし→あり)
			if (lastControlChangedTicks[playerIndex, 1] + SHORT_TELEPORT_DOUBLE_TAP_TICK > currentGameTick)
			{
				teleportOffset.X -= SHORT_TELEPORT_DISTANCE_X;
			}
		}
	}
	else
	{
		if (lastPlayerControl[playerIndex, 1])
		{
			// プレイヤーコントロール入力変化(上入力あり→なし)
			lastControlChangedTicks[playerIndex, 1] = currentGameTick;
		}
	}
	
	if (player.controlDown)
	{
		if (!lastPlayerControl[playerIndex, 2])
		{
			// プレイヤーコントロール入力変化(下入力なし→あり)
			if (lastControlChangedTicks[playerIndex, 2] + SHORT_TELEPORT_DOUBLE_TAP_TICK > currentGameTick)
			{
				teleportOffset.Y += SHORT_TELEPORT_DISTANCE_Y;
			}
		}
	}
	else
	{
		if (lastPlayerControl[playerIndex, 2])
		{
			// プレイヤーコントロール入力変化(左入力あり→なし)
			lastControlChangedTicks[playerIndex, 2] = currentGameTick;
		}
	}

	if (player.controlRight)
	{
		if (!lastPlayerControl[playerIndex, 3])
		{
			// プレイヤーコントロール入力変化(右入力なし→あり)
			if (lastControlChangedTicks[playerIndex, 3] + SHORT_TELEPORT_DOUBLE_TAP_TICK > currentGameTick)
			{
				teleportOffset.X += SHORT_TELEPORT_DISTANCE_X;
			}
		}
	}
	else
	{
		if (lastPlayerControl[playerIndex, 3])
		{
			// プレイヤーコントロール入力変化(右入力あり→なし)
			lastControlChangedTicks[playerIndex, 3] = currentGameTick;
		}
	}

	// プレイヤー入力を記録(パケットが来るまで入力は変化しないため、リセット等は必要ない)
	lastPlayerControl[playerIndex, 0] = player.controlUp || player.controlJump;
	lastPlayerControl[playerIndex, 1] = player.controlLeft;
	lastPlayerControl[playerIndex, 2] = player.controlDown;
	lastPlayerControl[playerIndex, 3] = player.controlRight;

	// TODO: 実際にテレポートさせる
}
```

### 実際にテレポートさせるぞ！

ゴーストの移動も検知でき、ダブルタップ検知もでき、あとはゴーストをテレポートさせるだけで完成です。楽勝ですね。
しかも、テレポート関数が最初から用意されています！最高です！

```cs
player.Teleport(player.position + teleportOffset);
```

...とはいかないんですね。~~テラリアなので。~~
この関数で、ちゃんとテレポートします(ディスコ杖のテレポートと同じです)。何が問題なのでしょうか？

それは、**他のプレイヤーからテレポートエフェクトが見える上、ゴーストになったプレイヤーが見えてしまいます**。

### それくらい妥協しない？

妥協しません。
`player.Teleport()`が~~ゴミ~~使えないということがわかったので、※今回のケース
別の方法を使います。

テラリアはサーバーから送られてきたプレイヤーデータを正とすることは少なく、例えばテレポートなどがクライアントで処理され、反映されます。他のもの(プレイヤー移動、武器使用など)はクライアントに無視されます。しかし、例外があります。

**キャラクターがサーバーで管理されている場合です**。

### 影響のないテレポート

長かったですが、これで終わりです。
キャラクターを一時的にサーバーサイドキャラクターとして扱い、その後戻すことによってテレポートを実現させています。

```cs
private void ShortTeleportGhost(Player player)
{
	// (省略)
	// 移動距離が0以外の場合
	if (teleportOffset != Vector2.Zero)
	{
		bool isSSC = Main.ServerSideCharacter;
		TSPlayer tsPlayer = TShock.Players[playerIndex];

		// サーバーサイドキャラクターではない場合、サーバーサイドキャラクターに一時的にする
		if (!isSSC)
		{
			Main.ServerSideCharacter = true;
			NetMessage.SendData((int)PacketTypes.WorldInfo, playerIndex, -1, null, 0, 0f, 0f, 0f, 0, 0, 0);
			tsPlayer.IgnoreSSCPackets = true;
		}

		player.position += teleportOffset;
		
		// プレイヤーがワールド端に行き過ぎないように補正
		if (player.position.X > Main.rightWorld - 992)
		{
			player.position.X = Main.rightWorld - 992;
		}
		if (player.position.X < 992)
		{
			player.position.X = 992;
		}
		if (player.position.Y > Main.bottomWorld - 992)
		{
			player.position.Y = Main.bottomWorld - 992;
		}
		if (player.position.Y < 992)
		{
			player.position.Y = 992;
		}

		// クライアントにプレイヤー情報送信(事実上のテレポート)
		NetMessage.SendData((int)PacketTypes.PlayerUpdate, playerIndex, -1, null, playerIndex);

		// サーバーサイドキャラクターではなかった場合、元に戻す
		if (!isSSC)
		{
			Main.ServerSideCharacter = false;
			NetMessage.SendData((int)PacketTypes.WorldInfo, playerIndex, -1, null, 0, 0f, 0f, 0f, 0, 0, 0);
			tsPlayer.IgnoreSSCPackets = false;
		}
	}
}
```

## コード全体

```cs
[ApiVersion(2, 1)]
public class GhostMain : TerrariaPlugin
{
	public override string Author => "YOUの名前";

	public override string Description => "プラグイン説明";

	public override string Name => "TeleportGhost";

	public override Version Version => Assembly.GetExecutingAssembly().GetName().Version;

	// 配列に入力切り替わり時間と前回の入力を保存しておく(上左下右)
	private int[,] lastControlChangedTicks = new int[Main.player.Length, 4];

	private bool[,] lastPlayerControl = new bool[Main.player.Length, 4];

	private int currentGameTick = 0;

	private const int SHORT_TELEPORT_DOUBLE_TAP_TICK = 20;

	private const int SHORT_TELEPORT_DISTANCE_X = 320;

	private const int SHORT_TELEPORT_DISTANCE_Y = 320;
	
	public GhostMain(Main game)
		: base(game)
	{
	}

	public override void Initialize()
	{
		ServerApi.Hooks.NetGetData.Register(this, OnGetData);
		ServerApi.Hooks.GameUpdate.Register(this, UpdateGameTick);
	}

	protected override void Dispose(bool disposing)
	{
		if (disposing)
		{
			ServerApi.Hooks.NetGetData.Deregister(this, OnGetData);
			ServerApi.Hooks.GameUpdate.Deregister(this, UpdateGameTick);
		}
	}

	private void UpdateGameTick(EventArgs args)
	{
		currentGameTick++;
	}

	private void OnGetData(GetDataEventArgs args)
	{
		if (args.Handled)
		{
			return;
		}

		switch (args.MsgID)
		{
			case PacketTypes.PlayerUpdate:
				OnPlayerUpdate(args);
				break;
		}
	}

	private void OnPlayerUpdate(GetDataEventArgs args)
	{
		// ゴーストの時だけ処理
		if (!Main.player[args.Msg.whoAmI].ghost)
		{
			return;
		}

		// 処理するので処理しましたフラグを建てる
		args.Handled = true;

		// 今回はゴーストが移動したときにテレポートさせたいだけなので、一度テラリアサーバーが行う処理と同じことをする。
		BinaryReader reader = new BinaryReader(new MemoryStream(args.Msg.readBuffer, args.Index, args.Length));
		int playerIndex = reader.ReadByte();
		if (playerIndex != Main.myPlayer || Main.ServerSideCharacter)
		{
			Player player = Main.player[playerIndex];

			BitsByte bitsByte = reader.ReadByte();
			BitsByte bitsByte1 = reader.ReadByte();
			BitsByte bitsByte2 = reader.ReadByte();
			BitsByte bitsByte3 = reader.ReadByte();
			player.controlUp = bitsByte[0];
			player.controlDown = bitsByte[1];
			player.controlLeft = bitsByte[2];
			player.controlRight = bitsByte[3];
			player.controlJump = bitsByte[4];
			player.controlUseItem = bitsByte[5];
			player.direction = (bitsByte[6] ? 1 : (-1));
			if (bitsByte1[0])
			{
				player.pulley = true;
				player.pulleyDir = (byte)((!bitsByte1[1]) ? 1 : 2);
			}
			else
			{
				player.pulley = false;
			}

			player.vortexStealthActive = bitsByte1[3];
			player.gravDir = (bitsByte1[4] ? 1 : (-1));
			player.TryTogglingShield(bitsByte1[5]);
			player.ghost = bitsByte1[6];
			player.selectedItem = reader.ReadByte();
			player.position = reader.ReadVector2();
			if (bitsByte1[2])
			{
				player.velocity = reader.ReadVector2();
			}
			else
			{
				player.velocity = Vector2.Zero;
			}

			if (bitsByte2[6])
			{
				player.PotionOfReturnOriginalUsePosition = reader.ReadVector2();
				player.PotionOfReturnHomePosition = reader.ReadVector2();
			}
			else
			{
				player.PotionOfReturnOriginalUsePosition = null;
				player.PotionOfReturnHomePosition = null;
			}

			player.tryKeepingHoveringUp = bitsByte2[0];
			player.IsVoidVaultEnabled = bitsByte2[1];
			player.sitting.isSitting = bitsByte2[2];
			player.downedDD2EventAnyDifficulty = bitsByte2[3];
			player.isPettingAnimal = bitsByte2[4];
			player.isTheAnimalBeingPetSmall = bitsByte2[5];
			player.tryKeepingHoveringDown = bitsByte2[7];
			player.sleeping.SetIsSleepingAndAdjustPlayerRotation(player, bitsByte3[0]);

			// ゴーストを短距離テレポート
			ShortTeleportGhost(player);

			// ゴーストにタイル情報送信
			RemoteClient.CheckSection(playerIndex, player.position);
		}
	}

	private void ShortTeleportGhost(Player player)
	{
		int playerIndex = player.whoAmI;

		// テレポート相対位置
		Vector2 teleportOffset = Vector2.Zero;
		if (player.controlUp || player.controlJump)
		{
			if (!lastPlayerControl[playerIndex, 0])
			{
				// プレイヤーコントロール入力変化(上入力なし→あり)
				if (lastControlChangedTicks[playerIndex, 0] + SHORT_TELEPORT_DOUBLE_TAP_TICK > currentGameTick)
				{
					teleportOffset.Y -= SHORT_TELEPORT_DISTANCE_Y;
				}
			}
		}
		else
		{
			if (lastPlayerControl[playerIndex, 0])
			{
				// プレイヤーコントロール入力変化(上入力あり→なし)
				lastControlChangedTicks[playerIndex, 0] = currentGameTick;
			}
		}

		if (player.controlLeft)
		{
			if (!lastPlayerControl[playerIndex, 1])
			{
				// プレイヤーコントロール入力変化(左入力なし→あり)
				if (lastControlChangedTicks[playerIndex, 1] + SHORT_TELEPORT_DOUBLE_TAP_TICK > currentGameTick)
				{
					teleportOffset.X -= SHORT_TELEPORT_DISTANCE_X;
				}
			}
		}
		else
		{
			if (lastPlayerControl[playerIndex, 1])
			{
				// プレイヤーコントロール入力変化(上入力あり→なし)
				lastControlChangedTicks[playerIndex, 1] = currentGameTick;
			}
		}
	
		if (player.controlDown)
		{
			if (!lastPlayerControl[playerIndex, 2])
			{
				// プレイヤーコントロール入力変化(下入力なし→あり)
				if (lastControlChangedTicks[playerIndex, 2] + SHORT_TELEPORT_DOUBLE_TAP_TICK > currentGameTick)
				{
					teleportOffset.Y += SHORT_TELEPORT_DISTANCE_Y;
				}
			}
		}
		else
		{
			if (lastPlayerControl[playerIndex, 2])
			{
				// プレイヤーコントロール入力変化(左入力あり→なし)
				lastControlChangedTicks[playerIndex, 2] = currentGameTick;
			}
		}

		if (player.controlRight)
		{
			if (!lastPlayerControl[playerIndex, 3])
			{
				// プレイヤーコントロール入力変化(右入力なし→あり)
				if (lastControlChangedTicks[playerIndex, 3] + SHORT_TELEPORT_DOUBLE_TAP_TICK > currentGameTick)
				{
					teleportOffset.X += SHORT_TELEPORT_DISTANCE_X;
				}
			}
		}
		else
		{
			if (lastPlayerControl[playerIndex, 3])
			{
				// プレイヤーコントロール入力変化(右入力あり→なし)
				lastControlChangedTicks[playerIndex, 3] = currentGameTick;
			}
		}

		// プレイヤー入力を記録(パケットが来るまで入力は変化しないため、リセット等は必要ない)
		lastPlayerControl[playerIndex, 0] = player.controlUp || player.controlJump;
		lastPlayerControl[playerIndex, 1] = player.controlLeft;
		lastPlayerControl[playerIndex, 2] = player.controlDown;
		lastPlayerControl[playerIndex, 3] = player.controlRight;

		// 移動距離が0以外の場合
		if (teleportOffset != Vector2.Zero)
		{
			bool isSSC = Main.ServerSideCharacter;
			TSPlayer tsPlayer = TShock.Players[playerIndex];

			// サーバーサイドキャラクターではない場合、サーバーサイドキャラクターに一時的にする
			if (!isSSC)
			{
				Main.ServerSideCharacter = true;
				NetMessage.SendData((int)PacketTypes.WorldInfo, playerIndex, -1, null, 0, 0f, 0f, 0f, 0, 0, 0);
				tsPlayer.IgnoreSSCPackets = true;
			}

			player.position += teleportOffset;
		
			// プレイヤーがワールド端に行き過ぎないように補正
			if (player.position.X > Main.rightWorld - 992)
			{
				player.position.X = Main.rightWorld - 992;
			}
			if (player.position.X < 992)
			{
				player.position.X = 992;
			}
			if (player.position.Y > Main.bottomWorld - 992)
			{
				player.position.Y = Main.bottomWorld - 992;
			}
			if (player.position.Y < 992)
			{
				player.position.Y = 992;
			}

			// クライアントにプレイヤー情報送信(事実上のテレポート)
			NetMessage.SendData((int)PacketTypes.PlayerUpdate, playerIndex, -1, null, playerIndex);

			// サーバーサイドキャラクターではなかった場合、元に戻す
			if (!isSSC)
			{
				Main.ServerSideCharacter = false;
				NetMessage.SendData((int)PacketTypes.WorldInfo, playerIndex, -1, null, 0, 0f, 0f, 0f, 0, 0, 0);
				tsPlayer.IgnoreSSCPackets = false;
			}
		}
	}
}
```