C#:
// =================================================================================================
// DragDropDemo (with /danime integration)
// -----------------------------------------------------------------------------------------------
// Carbon October 2025 Update / Rust.Community PR #55 (Draggables) + PR #69 (RectTransform Rotation)
// で追加された CUI Draggable / Slot 関連の機能を、ひとつの画面で確認できるデモプラグイン。
//
// /ddemo : デモUIを開く / 閉じる (トグル)
// /danime : 画像クルクル回転アニメ単体ウィンドウを開く / 閉じる (トグル)
//
// 画像取得は ImageLibrary 非依存。UnityWebRequest で取得 → FileStorage.server.Store で
// サーバ側に直接格納し、CRC値を CuiRawImageComponent.Png に渡す方式。
//
// Author : NINJA WORKS
// License: MIT
// =================================================================================================
using System;
using System.Collections;
using System.Collections.Generic;
using System.Reflection;
using HarmonyLib;
using Oxide.Core;
using Oxide.Game.Rust.Cui;
using UnityEngine;
using UnityEngine.Networking;
namespace Oxide.Plugins
{
[Info("DragDropDemo", "NINJA WORKS", "1.5.0")]
[Description("CUI Draggable / Slot / Rotation 機能の総合デモ + 画像回転アニメ /danime")]
public class DragDropDemo : RustPlugin
{
// =============================================================================================
// 定数
// =============================================================================================
private const string Root = "ddemo.root"; // /ddemo の親
private const string AnimeRoot = "danime.root"; // /danime の親
private const string AnimeImg = "danime.img"; // /danime の回転画像 (毎フレ作り直し)
private const string DemoAnimeImg = "ddemo.anime.img"; // /ddemo の Demo⑥ 内 回転画像 (毎フレ作り直し)
private const string DemoAnimeHandle = "ddemo.anime.handle"; // /ddemo の Demo⑥ 内 Draggableハンドル (1回だけ作成)
private const string DemoAnimeFrameImg = "ddemo.anime.frame"; // ⑥のキャンバス内コンテナ
private const string CmdReset = "ddemo.reset";
private const string CmdClose = "ddemo.close";
// フィルタ識別子
private const string FilterRed = "ddemo_red";
private const string FilterBlue = "ddemo_blue";
private const string FilterAny = "ddemo_any";
// 色プリセット
private const string ClrBgDark = "0.05 0.05 0.06 0.96";
private const string ClrPanel = "0.12 0.12 0.14 0.92";
private const string ClrSlot = "0.22 0.22 0.25 0.85";
private const string ClrAccent = "0.30 0.55 0.90 1";
private const string ClrRed = "0.85 0.30 0.30 1";
private const string ClrBlue = "0.30 0.55 0.90 1";
private const string ClrGreen = "0.30 0.80 0.40 1";
private const string ClrYellow = "0.95 0.85 0.30 1";
private const string ClrText = "0.95 0.95 0.95 1";
private const string ClrSubText = "0.65 0.65 0.70 1";
private const string ClrLine = "1 1 1 0.10";
// アニメ
private const string ImageUrl = "https://i.imgur.com/FM1iLA9.png";
private const float TickInterval = 0.03f; // 20 FPS
private const float DegPerSecond = 120f; // 1秒で半回転
// ⑥のアイコンサイズ (半径px)。ハンドルと画像両方に適用される。
// ここを変えるだけで Demo⑥ の回転画像サイズが一括で変わる。
private const float DemoAnimeRadius = 35f;
// 画像をハンドルより内側に少し縮めたい場合のマージン (片側px)。0なら全域。
private const float DemoAnimeImgInset = 0f;
// /danime 単体ウィンドウの画像サイズ (半径px)
private const float StandaloneAnimeRadius = 120f;
// ----- アイテムショートネーム (Rust標準アイテム) -----
// OnServerInitialized で ItemManager.FindItemDefinition から itemid を解決してキャッシュ
private const string ShortnameF1Grenade = "grenade.f1"; // Demo④ 赤系
private const string ShortnameMedSyringe = "syringe.medical"; // Demo④ 青系
private const string ShortnameWood = "wood"; // Demo⑤ A
private const string ShortnameStones = "stones"; // Demo⑤ B
private const string ShortnameMetalFrag = "metal.fragments"; // Demo⑤ C
// ピース識別 (Filter / Drop通知に使う)
// Draggable.Filter は「ddemo:<pieceKey>」、Slot.Filter は「ddemo:<slotGroup>」のように
// ddemo: プレフィックスで揃えて、ConsoleCommand 側で識別
private const string CmdDrop = "ddemo.drop"; // ピースがドロップされたときの通知コマンド (PositionRPC利用)
/// <summary>
/// ピース要素名 → ショートネーム のマッピング (ログ用に「何が」を解決するため)
/// </summary>
private static readonly Dictionary<string, string> PieceToShortname = new()
{
{ "ddemo4.piece.red", ShortnameF1Grenade },
{ "ddemo4.piece.blue1", ShortnameMedSyringe },
{ "ddemo5.piece0", ShortnameWood },
{ "ddemo5.piece1", ShortnameStones },
{ "ddemo5.piece2", ShortnameMetalFrag },
};
// =============================================================================================
// 状態
// =============================================================================================
private uint _imagePngId = 0; // FileStorage CRC. 0 = 未取得
private bool _downloading = false;
private enum AnimeMode { Standalone, InsideDemo }
private class AnimeState
{
public Timer Tick;
public float Angle;
public AnimeMode Mode;
public float RotStartTime;
}
private readonly Dictionary<ulong, AnimeState> _animeStates = new();
// /ddemo を開いているプレイヤー (Unload時の確実な破棄に使用)
private readonly HashSet<ulong> _demoOpenPlayers = new();
// ショートネーム → ItemId キャッシュ (OnServerInitialized で構築)
private readonly Dictionary<string, int> _itemIdCache = new();
// PositionRPC の enum 値をリフレクションで取得した結果
// null なら通知機能は無効 (失敗時のフォールバック)
private object _positionRPCValue = null;
// プレイヤーごとに生成したCUI要素名 (Unload/Close時の確実な破棄に使用)
// Draggable で reparent されたピースも UiDict 上は名前で残るので名前で消せばOK
private readonly Dictionary<ulong, HashSet<string>> _createdElements = new();
// =============================================================================================
// 永続化レイアウト
// =============================================================================================
// データファイル名 (oxide/data/DragDropDemo.json)
private const string DataFileName = "DragDropDemo";
/// <summary>
/// プレイヤー単位の「ピース要素名 → 着地スロット要素名」マッピング。
/// JSONとして oxide/data/DragDropDemo.json に保存され、UI再オープン/サーバ再起動を跨いで復元される。
/// </summary>
private class LayoutData
{
// userID(string) → (pieceName → slotName)
public Dictionary<string, Dictionary<string, string>> Players { get; set; } = new();
}
private LayoutData _layout = new();
/// <summary>初期配置 (保存データが無いプレイヤー用のフォールバック)。</summary>
private static readonly Dictionary<string, string> DefaultPieceSlots = new()
{
{ "ddemo4.piece.red", "ddemo4.slot.red" },
{ "ddemo4.piece.blue1", "ddemo4.slot.blue1" },
{ "ddemo5.piece0", "ddemo5.slot0" },
{ "ddemo5.piece1", "ddemo5.slot1" },
{ "ddemo5.piece2", "ddemo5.slot2" },
};
private void LoadLayout()
{
try
{
_layout = Interface.Oxide.DataFileSystem.ReadObject<LayoutData>(DataFileName) ?? new LayoutData();
if (_layout.Players == null) _layout.Players = new();
Puts($"[DragDropDemo] レイアウト読込: {_layout.Players.Count} 名分");
}
catch (Exception ex)
{
PrintError($"[DragDropDemo] レイアウト読込失敗: {ex.Message}");
_layout = new LayoutData();
}
}
private void SaveLayout()
{
try
{
Interface.Oxide.DataFileSystem.WriteObject(DataFileName, _layout);
}
catch (Exception ex)
{
PrintError($"[DragDropDemo] レイアウト保存失敗: {ex.Message}");
}
}
/// <summary>プレイヤーの「ピース → スロット」辞書を取得 (無ければ作る)。</summary>
private Dictionary<string, string> GetPlayerLayout(ulong userID)
{
string key = userID.ToString();
if (!_layout.Players.TryGetValue(key, out var dict))
{
dict = new Dictionary<string, string>(DefaultPieceSlots);
_layout.Players[key] = dict;
}
return dict;
}
/// <summary>そのプレイヤーが特定ピースを「今どのスロットに置いているか」を返す。未登録なら初期値。</summary>
private string GetSavedSlot(BasePlayer player, string pieceName)
{
if (player == null) return DefaultPieceSlots.TryGetValue(pieceName, out var d) ? d : null;
var dict = GetPlayerLayout(player.userID);
if (dict.TryGetValue(pieceName, out var slot) && !string.IsNullOrEmpty(slot)) return slot;
return DefaultPieceSlots.TryGetValue(pieceName, out var def) ? def : null;
}
/// <summary>ピースの配置を更新して保存。</summary>
private void SetSavedSlot(BasePlayer player, string pieceName, string slot)
{
if (player == null || string.IsNullOrEmpty(pieceName)) return;
var dict = GetPlayerLayout(player.userID);
dict[pieceName] = slot ?? "";
SaveLayout();
}
// プレイヤーごとの「現在どのスロットに何が入っているか」の記憶
// PositionRPCコマンドが届いた時に「変化したか」を判定するため
// key = $"{userID}:{slotName}"
// value = piece の shortname (空 = 何も入っていない)
private readonly Dictionary<string, string> _slotContents = new();
// ★調査モード★ true の間、OnServerCommand に来た全コマンドをログ出力
// /ddemo を開いているプレイヤーがいる間 ON / 全員閉じたら OFF
private bool _commandSpyMode = false;
// 観察を絞るため、無視するコマンド名のプレフィックス (頻繁すぎるもの)
private static readonly string[] SpyIgnorePrefixes =
{
"client.", // Rust標準クライアント同期 (頻発)
"input.", // 入力イベント (頻発)
"rpcq", // RPC queue (頻発)
"tick", // tick系
"global.tick",
"physics.",
"ai.",
"weather.",
"demo.", // demo記録系 (デモ録画と紛らわしい)
"auth.",
};
// =============================================================================================
// フック
// =============================================================================================
private Harmony _harmony;
private static DragDropDemo _instance;
private void Init()
{
cmd.AddChatCommand("ddemo", this, nameof(CmdChatDemo));
cmd.AddChatCommand("danime", this, nameof(CmdChatAnime));
_instance = this;
// 永続化レイアウトを読込
LoadLayout();
// Hook_DropRPC は private 空メソッドで Oxide/Carbon の自動フックには載らないため
// Harmony で直接 Postfix パッチを当て、ドロップ通知を取得する
try
{
_harmony = new Harmony("ninjaworks.dragdropdemo");
var target = typeof(CommunityEntity).GetMethod(
"Hook_DropRPC",
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
if (target == null)
{
PrintWarning("[DragDropDemo] Hook_DropRPC が見つからない (古いビルド?)");
}
else
{
var postfix = typeof(DragDropDemo).GetMethod(
nameof(HarmonyPostfix_DropRPC),
BindingFlags.Static | BindingFlags.NonPublic);
_harmony.Patch(target, postfix: new HarmonyMethod(postfix));
Puts("[DragDropDemo] Hook_DropRPC に Harmony パッチを適用しました");
}
var dragTarget = typeof(CommunityEntity).GetMethod(
"Hook_DragRPC",
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
if (dragTarget != null)
{
var dragPostfix = typeof(DragDropDemo).GetMethod(
nameof(HarmonyPostfix_DragRPC),
BindingFlags.Static | BindingFlags.NonPublic);
_harmony.Patch(dragTarget, postfix: new HarmonyMethod(dragPostfix));
}
}
catch (Exception ex)
{
PrintError($"[DragDropDemo] Harmonyパッチ適用失敗: {ex}");
}
}
private static void HarmonyPostfix_DropRPC(BasePlayer player, string draggedName,
string draggedSlot, string swappedName, string swappedSlot)
{
try { _instance?.OnDropRPC(player, draggedName, draggedSlot, swappedName, swappedSlot); }
catch (Exception ex) { _instance?.PrintError($"[DragDropDemo] DropRPC処理例外: {ex}"); }
}
private static void HarmonyPostfix_DragRPC(BasePlayer player, string name,
Vector3 position, CommunityEntity.DraggablePositionSendType type)
{
// 必要なら有効化。デフォは無効 (頻発するため)。
// _instance?.Puts($"[DragDropDemo] DragRPC <{player?.displayName}> name='{name}' pos={position} type={type}");
}
private void OnServerInitialized()
{
// ① ショートネーム → itemid 解決 (キャッシュ)
ResolveItemIds();
// ② PositionRPC enum 値をリフレクションで取得試行
// バージョンによって "Update" / "OnRelease" / "OnDrop" 等の名前が変わるので、
// 候補を順に試して、見つかったら採用する
ResolvePositionRPCValue();
// ③ 起動時に画像を先取りしておく (失敗しても /danime / /ddemo 実行時に再試行)
DownloadImage();
}
/// <summary>
/// 必要なアイテムショートネームを itemid に解決してキャッシュする。
/// </summary>
private void ResolveItemIds()
{
string[] shortnames =
{
ShortnameF1Grenade, ShortnameMedSyringe,
ShortnameWood, ShortnameStones, ShortnameMetalFrag
};
foreach (var sn in shortnames)
{
var def = ItemManager.FindItemDefinition(sn);
if (def == null)
{
PrintWarning($"[DragDropDemo] ItemDefinition '{sn}' が見つかりません");
continue;
}
_itemIdCache[sn] = def.itemid;
}
Puts($"[DragDropDemo] アイテムID解決: {_itemIdCache.Count}/{shortnames.Length}");
}
/// <summary>
/// CuiDraggableComponent.PositionRPC の enum 型をリフレクションで解決する。
/// このenumは「ドロップ時に位置をどの座標系で送るか」を指定するもの。
/// 実環境で利用可能な値: NormalizedScreen / NormalizedParent / Relative / RelativeAnchor
/// なんでも良いので NormalizedScreen を採用。
/// </summary>
private void ResolvePositionRPCValue()
{
try
{
var draggableType = typeof(CuiDraggableComponent);
var prop = draggableType.GetProperty("PositionRPC");
if (prop == null)
{
PrintWarning("[DragDropDemo] CuiDraggableComponent に PositionRPC が無い (古いビルド)");
return;
}
var enumType = prop.PropertyType;
if (!enumType.IsEnum)
{
PrintWarning("[DragDropDemo] PositionRPC が enum でない");
return;
}
// 実際に存在する候補名 (= 座標系) を順に試行
string[] candidates = { "NormalizedScreen", "NormalizedParent", "Relative", "RelativeAnchor" };
foreach (var name in candidates)
{
if (Enum.IsDefined(enumType, name))
{
_positionRPCValue = Enum.Parse(enumType, name);
Puts($"[DragDropDemo] PositionRPC enum 値解決: {enumType.Name}.{name}");
return;
}
}
var values = string.Join(", ", Enum.GetNames(enumType));
PrintWarning($"[DragDropDemo] PositionRPC 候補値が見つかりません。利用可能: {values}");
}
catch (Exception ex)
{
PrintWarning($"[DragDropDemo] PositionRPC 解決失敗: {ex.Message}");
}
}
/// <summary>
/// PositionRPC を CuiDraggableComponent にリフレクションで設定 (失敗しても無視)。
/// </summary>
private void TrySetPositionRPC(CuiDraggableComponent drag)
{
if (_positionRPCValue == null) return;
try
{
var prop = typeof(CuiDraggableComponent).GetProperty("PositionRPC");
prop?.SetValue(drag, _positionRPCValue);
}
catch
{
// Failed silently
}
}
/// <summary>
/// プラグインアンロード時に、UI とタイマーをすべて確実に破棄する。
///
/// 注意: Draggable で動かしたピースは、サーバから見たCUIツリー上の親が
/// クライアント実装次第で変わることがある。そのため "Root を破壊するだけ"
/// では取り残しが起きるケースがあるので、既知の要素名を明示的に DestroyUi する。
/// </summary>
private void Unload()
{
// ① 全アニメタイマーを止める
foreach (var kv in _animeStates)
{
kv.Value?.Tick?.Destroy();
}
_animeStates.Clear();
// ② UIを表示している可能性のある全プレイヤーに対してUIを破壊
// activePlayerList と、開閉状態が把握できている userID 群の和集合で破棄を試行
var targetIds = new HashSet<ulong>(_demoOpenPlayers);
foreach (var k in _createdElements.Keys) targetIds.Add(k);
foreach (var p in BasePlayer.activePlayerList)
{
if (p == null || !p.IsConnected) continue;
targetIds.Add(p.userID);
}
// Unload では NextTick が走らない可能性があるので、各プレイヤーに即時2回送信
foreach (var uid in targetIds)
{
var p = BasePlayer.FindByID(uid) ?? BasePlayer.FindSleeping(uid);
if (p == null || !p.IsConnected) continue;
var names = new HashSet<string>(AllFixedDemoNames);
if (_createdElements.TryGetValue(uid, out var s))
foreach (var n in s) names.Add(n);
for (int pass = 0; pass < 2; pass++)
foreach (var n in names)
try { CuiHelper.DestroyUi(p, n); } catch { }
}
_createdElements.Clear();
// レイアウトを最終保存
SaveLayout();
_demoOpenPlayers.Clear();
_slotContents.Clear();
_commandSpyMode = false;
// Harmonyパッチ解除
try { _harmony?.UnpatchAll("ninjaworks.dragdropdemo"); }
catch (Exception ex) { PrintError($"[DragDropDemo] Harmony解除失敗: {ex.Message}"); }
_harmony = null;
if (_instance == this) _instance = null;
}
private void OnPlayerDisconnected(BasePlayer player, string reason)
{
if (player == null) return;
StopAnime(player);
ForgetPlayerSlots(player);
_demoOpenPlayers.Remove(player.userID);
// 全員いなくなったら調査モードもOFF
if (_demoOpenPlayers.Count == 0 && _commandSpyMode)
{
_commandSpyMode = false;
Puts("[DragDropDemo][調査] 全員がデモを閉じました。OnServerCommand 観察を停止します。");
}
}
// =============================================================================================
// /ddemo
// =============================================================================================
private void CmdChatDemo(BasePlayer player, string command, string[] args)
{
if (player == null) return;
// close 引数 or 既に開いている場合は閉じる (トグル)
bool wantClose = (args.Length > 0 && args[0].ToLower() == "close")
|| _demoOpenPlayers.Contains(player.userID);
if (wantClose)
{
CloseDemo(player);
return;
}
ShowDemo(player);
}
[ConsoleCommand(CmdClose)]
private void CcClose(ConsoleSystem.Arg arg)
{
var p = arg.Player();
if (p == null) return;
CloseDemo(p);
}
[ConsoleCommand(CmdReset)]
private void CcReset(ConsoleSystem.Arg arg)
{
var p = arg.Player();
if (p == null) return;
// 永続化レイアウトを初期値に戻して保存 (Demo④⑤)
string key = p.userID.ToString();
_layout.Players[key] = new Dictionary<string, string>(DefaultPieceSlots);
SaveLayout();
// _slotContents の旧記憶もクリアして初期配置に揃える
ForgetPlayerSlots(p);
// CloseDemoは呼ばない (NextTickの遅延破棄が再描画した新UIを消してしまうため)。
// ShowDemo 自身が冒頭で DestroyUi(Root) → AddUi を行うので
// ピース位置はすべて初期オフセットで作り直される。
ShowDemo(p);
p.ChatMessage("[DragDropDemo] レイアウトをリセットしました (①〜⑤を初期配置に復元)");
}
// =============================================================================================
// ★調査機能①★ OnServerCommand フックで全コマンド観察
// /ddemo を開いている間、サーバが受信した全コンソールコマンドをログ出力する。
// ドラッグ中・ドロップ時にどんなコマンド名で何が送られるかを調査するため。
// =============================================================================================
private object OnServerCommand(ConsoleSystem.Arg arg)
{
if (!_commandSpyMode) return null;
if (arg?.cmd == null) return null;
string fullName = arg.cmd.FullName ?? "";
if (string.IsNullOrEmpty(fullName)) return null;
// ノイズ削減: 頻発するシステム系コマンドは無視
string lower = fullName.ToLowerInvariant();
for (int i = 0; i < SpyIgnorePrefixes.Length; i++)
{
if (lower.StartsWith(SpyIgnorePrefixes[i])) return null;
}
string playerName = "?";
try { playerName = arg.Player()?.displayName ?? "console"; } catch { }
string args = arg.FullString.ToString() ?? "";
Puts($"[DragDropDemo][調査] cmd='{fullName}' player='{playerName}' args='{args}'");
return null; // 元の処理を妨げない
}
// =============================================================================================
// ★ドロップ通知★ CommunityEntity.DropRPC が発火する Oxide/Carbon フック
//
// CommunityEntity.UI.Draggable.cs の Hook_DropRPC が空メソッドとして用意されており、
// Carbon/Oxide はこれを `OnDropRPC` という名前のフックとしてプラグインに配信する。
// (ConsoleCommandでは届かないので、いくら ddemo.drop / community.drop を待っても無意味)
//
// 引数:
// player : ドロップを実行したプレイヤー
// draggedName : ドラッグされたピースの要素名 (例 "ddemo4.piece.red")
// draggedSlot : 着地先スロットの要素名 (スロット外なら null)
// swappedName : スワップで押し出されたピースの要素名 (なければ null)
// swappedSlot : スワップで押し出されたピースの新スロット名 (なければ null)
// =============================================================================================
private void OnDropRPC(BasePlayer player, string draggedName, string draggedSlot,
string swappedName, string swappedSlot)
{
if (player == null) return;
Puts($"[DragDropDemo] OnDropRPC <{player.displayName}> dragged='{draggedName}' " +
$"slot='{draggedSlot}' swappedName='{swappedName}' swappedSlot='{swappedSlot}'");
if (string.IsNullOrEmpty(draggedName)) return;
if (!PieceToShortname.TryGetValue(draggedName, out var draggedShort)) return;
// スロット外にドロップされた場合 (DropAnywhere=true のときだけ起こる)
if (string.IsNullOrEmpty(draggedSlot))
{
Puts($"[DragDropDemo] <{player.displayName}> '{draggedShort}' をスロット外に配置");
return;
}
// スワップ発生時は両方の通知を出す
if (!string.IsNullOrEmpty(swappedName)
&& PieceToShortname.TryGetValue(swappedName, out var swappedShort))
{
Puts($"[DragDropDemo] <{player.displayName}> {draggedSlot} に '{draggedShort}' を入れ、" +
$"押し出された '{swappedShort}' は {swappedSlot} へ");
UpdateSlotMemory(player, draggedName, draggedShort, draggedSlot);
UpdateSlotMemory(player, swappedName, swappedShort, swappedSlot);
// 永続化: 両ピースの新しい着地スロットを保存
SetSavedSlot(player, draggedName, draggedSlot);
SetSavedSlot(player, swappedName, swappedSlot);
return;
}
// 通常のスロット移動 (空スロットへの移動)
string oldSlot = FindOldSlot(player, draggedShort);
if (oldSlot == draggedSlot) return;
Puts($"[DragDropDemo] <{player.displayName}> '{draggedShort}' を " +
$"{oldSlot ?? "?"} → {draggedSlot} へ移動");
UpdateSlotMemory(player, draggedName, draggedShort, draggedSlot);
// 永続化: ドラッグしたピースの新しい着地スロットを保存
SetSavedSlot(player, draggedName, draggedSlot);
}
/// <summary>旧スロットを記憶辞書から検索。</summary>
private string FindOldSlot(BasePlayer player, string shortname)
{
string prefix = $"{player.userID}:";
foreach (var kv in _slotContents)
{
if (kv.Key.StartsWith(prefix) && kv.Value == shortname)
return kv.Key.Substring(prefix.Length);
}
return null;
}
/// <summary>スロット記憶を更新 (旧スロットは空に、新スロットに shortname を入れる)。</summary>
private void UpdateSlotMemory(BasePlayer player, string pieceName, string shortname, string newSlot)
{
string oldSlot = FindOldSlot(player, shortname);
if (oldSlot != null)
_slotContents[$"{player.userID}:{oldSlot}"] = "";
if (!string.IsNullOrEmpty(newSlot))
_slotContents[$"{player.userID}:{newSlot}"] = shortname;
}
/// <summary>
/// ピースがどのスロットに移動したか変化検出して、Putsで通知。
/// </summary>
private void DetectSlotChange(BasePlayer player, string pieceName, string shortname, string newSlot)
{
// 旧スロットを探す
string oldSlot = null;
foreach (var kv in _slotContents)
{
if (kv.Key.StartsWith($"{player.userID}:") && kv.Value == shortname)
{
oldSlot = kv.Key.Substring(kv.Key.IndexOf(':') + 1);
break;
}
}
if (oldSlot == newSlot) return; // 変化なし
// スワップ相手 (新スロットに入っていたアイテム) を取得
string swappedOut = "";
string newKey = $"{player.userID}:{newSlot}";
if (_slotContents.TryGetValue(newKey, out var existing) && !string.IsNullOrEmpty(existing))
{
swappedOut = existing;
}
// 状態更新
if (oldSlot != null) _slotContents[$"{player.userID}:{oldSlot}"] = swappedOut;
_slotContents[newKey] = shortname;
// 通知ログ
if (!string.IsNullOrEmpty(swappedOut))
{
Puts($"[DragDropDemo] <{player.displayName}> {oldSlot ?? "?"} の '{shortname}' と {newSlot} の '{swappedOut}' を入れ替え");
}
else
{
Puts($"[DragDropDemo] <{player.displayName}> '{shortname}' を {oldSlot ?? "?"} → {newSlot} へ移動");
}
}
/// <summary>
/// プレイヤー固有のスロット内容を記録する。
/// </summary>
private void RememberSlotContent(BasePlayer player, string slot, string shortname)
{
if (player == null) return;
_slotContents[$"{player.userID}:{slot}"] = shortname ?? "";
}
/// <summary>
/// プレイヤーが /ddemo を閉じたときに、そのプレイヤーのスロット記憶をクリア。
/// </summary>
private void ForgetPlayerSlots(BasePlayer player)
{
if (player == null) return;
string prefix = $"{player.userID}:";
var keys = new List<string>();
foreach (var k in _slotContents.Keys)
{
if (k.StartsWith(prefix)) keys.Add(k);
}
foreach (var k in keys)
{
_slotContents.Remove(k);
}
}
// =============================================================================================
// /danime (単体ウィンドウでクルクル回す)
// =============================================================================================
private void CmdChatAnime(BasePlayer player, string command, string[] args)
{
if (player == null) return;
// すでに動いてたら停止 (トグル)
if (_animeStates.TryGetValue(player.userID, out var existing))
{
if (existing.Mode == AnimeMode.Standalone)
{
StopAnime(player);
player.ChatMessage("[danime] 停止しました");
return;
}
// /ddemo 側で動いてるなら止めて単体表示に切り替え
StopAnime(player);
}
// 画像未取得なら今ダウンロード
if (_imagePngId == 0)
{
if (_downloading)
{
player.ChatMessage("[danime] 画像取得中... もう一度コマンドを叩いてください");
return;
}
player.ChatMessage("[danime] 画像をダウンロード中...");
DownloadImage(success =>
{
if (success) StartAnimeStandalone(player);
else player.ChatMessage("[danime] 画像のDLに失敗しました");
});
return;
}
StartAnimeStandalone(player);
}
// =============================================================================================
// 画像ダウンロード (FileStorage 直接格納)
// =============================================================================================
private void DownloadImage(Action<bool> onComplete = null)
{
if (_imagePngId != 0) { onComplete?.Invoke(true); return; }
if (_downloading) { return; }
_downloading = true;
ServerMgr.Instance.StartCoroutine(DownloadCoroutine(onComplete));
}
private IEnumerator DownloadCoroutine(Action<bool> onComplete)
{
using (var req = UnityWebRequest.Get(ImageUrl))
{
req.timeout = 30;
yield return req.SendWebRequest();
bool ok = false;
#if UNITY_2020_2_OR_NEWER
bool failed = req.result != UnityWebRequest.Result.Success;
#else
bool failed = req.isNetworkError || req.isHttpError;
#endif
if (failed)
{
PrintError($"[DragDropDemo] 画像DL失敗: {req.error} ({ImageUrl})");
}
else
{
byte[] bytes = req.downloadHandler?.data;
if (bytes == null || bytes.Length == 0)
{
PrintError("[DragDropDemo] DLしたが空のバイト列");
}
else
{
try
{
uint id = FileStorage.server.Store(
bytes,
FileStorage.Type.png,
CommunityEntity.ServerInstance.net.ID);
_imagePngId = id;
ok = (id != 0);
Puts($"[DragDropDemo] 画像を FileStorage に格納 (id={id}, {bytes.Length} bytes)");
}
catch (Exception ex)
{
PrintError($"[DragDropDemo] FileStorage.Store 例外: {ex.Message}");
}
}
}
_downloading = false;
onComplete?.Invoke(ok);
}
}
// =============================================================================================
// メイン /ddemo UI 構築
// =============================================================================================
private void ShowDemo(BasePlayer player)
{
// 既存タイマーを片付ける (⑥のアニメが動いてる可能性)
StopAnime(player);
CuiHelper.DestroyUi(player, Root);
var c = new CuiElementContainer();
// ------- ルート -------
c.Add(new CuiElement
{
Name = Root,
Parent = "Overlay",
DestroyUi = Root,
Components =
{
new CuiImageComponent { Color = ClrBgDark },
new CuiRectTransformComponent { AnchorMin = "0 0", AnchorMax = "1 1" },
new CuiNeedsCursorComponent()
}
});
BuildHeader(c);
// 上段
BuildDemo1_BasicFreeDrag(c, "0.02 0.55", "0.34 0.92");
BuildDemo2_MaxDistance (c, "0.34 0.55", "0.66 0.92");
BuildDemo3_LimitToParent(c, "0.66 0.55", "0.98 0.92");
// 下段
BuildDemo4_SlotFilter (c, "0.02 0.10", "0.34 0.52", player);
BuildDemo5_Swap (c, "0.34 0.10", "0.66 0.52", player);
BuildDemo6_Rotation (c, "0.66 0.10", "0.98 0.52");
BuildFooter(c);
// 全要素名を抽出して記録 (Unload時の確実破棄に使う)
TrackCreatedElements(player, c);
CuiHelper.AddUi(player, c);
_demoOpenPlayers.Add(player.userID);
// ★調査モード ON★ /ddemo を開いている間、全コンソールコマンドをログ出力
_commandSpyMode = true;
Puts($"[DragDropDemo][調査] {player.displayName} がデモを開きました。OnServerCommand 観察を開始します。");
// 初期配置を記録 (これと差分でPutsログ通知する)
RememberSlotContent(player, "ddemo4.slot.red", ShortnameF1Grenade);
RememberSlotContent(player, "ddemo4.slot.blue1", ShortnameMedSyringe);
RememberSlotContent(player, "ddemo4.slot.blue2", "");
RememberSlotContent(player, "ddemo5.slot0", ShortnameWood);
RememberSlotContent(player, "ddemo5.slot1", ShortnameStones);
RememberSlotContent(player, "ddemo5.slot2", ShortnameMetalFrag);
RememberSlotContent(player, "ddemo5.slot3", "");
// 画像が用意できていれば、Demo⑥のアニメを起動
if (_imagePngId != 0)
{
StartAnimeInsideDemo(player);
}
else if (!_downloading)
{
// 起動時のDLが失敗していた場合の再試行
DownloadImage(success =>
{
if (success && _demoOpenPlayers.Contains(player.userID))
StartAnimeInsideDemo(player);
});
}
}
private void CloseDemo(BasePlayer player)
{
if (player == null) return;
StopAnime(player);
DestroyAllDemoElements(player);
// _slotContents は永続化レイアウトと連動するためクリアしない
_demoOpenPlayers.Remove(player.userID);
SaveLayout();
// ★調査モード自動OFF★ 誰も開いていない状態になったらログ出力を止める
if (_demoOpenPlayers.Count == 0 && _commandSpyMode)
{
_commandSpyMode = false;
Puts("[DragDropDemo][調査] 全員がデモを閉じました。OnServerCommand 観察を停止します。");
}
}
/// <summary>
/// CUIコンテナ内の全要素の "Name" を抽出して、プレイヤー単位で記録する。
/// BuildDemoFrame で生成されるGUID名フレームや、各ヘルパで追加した要素も漏らさず拾える。
/// </summary>
private void TrackCreatedElements(BasePlayer player, CuiElementContainer container)
{
if (player == null || container == null) return;
if (!_createdElements.TryGetValue(player.userID, out var set))
{
set = new HashSet<string>();
_createdElements[player.userID] = set;
}
foreach (var el in container)
{
if (!string.IsNullOrEmpty(el.Name))
set.Add(el.Name);
}
}
/// <summary>
/// /ddemo の全UI要素を確実に消す。
/// Draggable で親が付け替わったピースも UiDict 上は名前で生きているので、
/// 記録した名前を片っ端から DestroyUi すれば取りこぼしなく消える。
/// </summary>
// 既知の固定名 (GUIDフレーム以外の全Demo要素)
private static readonly string[] AllFixedDemoNames =
{
Root,
"ddemo1.canvas", "ddemo1.box",
"ddemo2.canvas", "ddemo2.box",
"ddemo3.canvas", "ddemo3.box",
"ddemo4.canvas",
"ddemo4.slot.red", "ddemo4.slot.blue1", "ddemo4.slot.blue2",
"ddemo4.piece.red", "ddemo4.piece.blue1",
"ddemo5.canvas",
"ddemo5.slot0", "ddemo5.slot1", "ddemo5.slot2", "ddemo5.slot3",
"ddemo5.piece0", "ddemo5.piece1", "ddemo5.piece2",
DemoAnimeFrameImg, DemoAnimeHandle, DemoAnimeImg,
AnimeRoot, AnimeImg
};
private void DestroyAllDemoElements(BasePlayer player)
{
if (player == null) return;
// 記録済みの動的要素 (フレームGUI含む) と固定名を統合して送信
var toDestroy = new HashSet<string>(AllFixedDemoNames);
if (_createdElements.TryGetValue(player.userID, out var set))
{
foreach (var n in set)
if (!string.IsNullOrEmpty(n)) toDestroy.Add(n);
_createdElements.Remove(player.userID);
}
// 1回目: 即時送信
foreach (var n in toDestroy)
{
try { CuiHelper.DestroyUi(player, n); } catch { }
}
// 2回目: 1フレ後に再送 (Draggable reparent 直後の RPC 順序ズレ対策)
// この時点で UiDict が確実に更新されており、name lookup が成功する
var snapshot = new List<string>(toDestroy);
ulong uid = player.userID;
NextTick(() =>
{
var p = BasePlayer.FindByID(uid);
if (p == null || !p.IsConnected) return;
foreach (var n in snapshot)
{
try { CuiHelper.DestroyUi(p, n); } catch { }
}
});
}
// =============================================================================================
// ヘッダー / フッター
// =============================================================================================
private void BuildHeader(CuiElementContainer c)
{
c.Add(new CuiElement
{
Parent = Root,
Components =
{
new CuiImageComponent { Color = ClrPanel },
new CuiRectTransformComponent { AnchorMin = "0 0.93", AnchorMax = "1 1" }
}
});
c.Add(new CuiLabel
{
Text =
{
Text = "CUI Draggable & Slot 機能総合デモ",
FontSize = 22,
Align = TextAnchor.MiddleLeft,
Color = ClrText
},
RectTransform = { AnchorMin = "0 0.93", AnchorMax = "0.6 1", OffsetMin = "20 0", OffsetMax = "0 0" }
}, Root);
c.Add(new CuiLabel
{
Text =
{
Text = "Carbon Oct'25 / Rust.Community PR #55 (Draggable) + #69 (Rotation)",
FontSize = 12,
Align = TextAnchor.MiddleLeft,
Color = ClrSubText
},
RectTransform = { AnchorMin = "0 0.93", AnchorMax = "0.6 1", OffsetMin = "20 -25", OffsetMax = "0 -5" }
}, Root);
c.Add(new CuiButton
{
Button = { Command = CmdClose, Color = "0.55 0.18 0.18 0.95" },
RectTransform = { AnchorMin = "1 0.93", AnchorMax = "1 1", OffsetMin = "-110 8", OffsetMax = "-15 -8" },
Text = { Text = "閉じる [X]", FontSize = 14, Align = TextAnchor.MiddleCenter, Color = ClrText }
}, Root);
c.Add(new CuiButton
{
Button = { Command = CmdReset, Color = "0.20 0.40 0.65 0.95" },
RectTransform = { AnchorMin = "1 0.93", AnchorMax = "1 1", OffsetMin = "-220 8", OffsetMax = "-115 -8" },
Text = { Text = "リセット", FontSize = 14, Align = TextAnchor.MiddleCenter, Color = ClrText }
}, Root);
}
private void BuildFooter(CuiElementContainer c)
{
c.Add(new CuiLabel
{
Text =
{
Text = "ヒント: 各エリアのアイテムをドラッグして挙動を確認してください。⑥は実画像が回転中。",
FontSize = 11,
Align = TextAnchor.MiddleCenter,
Color = ClrSubText
},
RectTransform = { AnchorMin = "0 0.02", AnchorMax = "1 0.08" }
}, Root);
}
// =============================================================================================
// 共通: デモエリア外枠
// =============================================================================================
private string BuildDemoFrame(CuiElementContainer c, string anchorMin, string anchorMax,
string title, string desc, string accent)
{
string id = "ddemo.frame." + CuiHelper.GetGuid();
c.Add(new CuiElement
{
Name = id,
Parent = Root,
Components =
{
new CuiImageComponent { Color = ClrPanel },
new CuiRectTransformComponent
{
AnchorMin = anchorMin, AnchorMax = anchorMax,
OffsetMin = "5 5", OffsetMax = "-5 -5"
},
new CuiOutlineComponent { Distance = "1 1", Color = ClrLine }
}
});
c.Add(new CuiElement
{
Parent = id,
Components =
{
new CuiImageComponent { Color = accent },
new CuiRectTransformComponent { AnchorMin = "0 1", AnchorMax = "1 1", OffsetMin = "0 -28", OffsetMax = "0 0" }
}
});
c.Add(new CuiLabel
{
Text = { Text = title, FontSize = 14, Align = TextAnchor.MiddleLeft, Color = ClrText },
RectTransform = { AnchorMin = "0 1", AnchorMax = "1 1", OffsetMin = "10 -26", OffsetMax = "-10 -2" }
}, id);
c.Add(new CuiLabel
{
Text =
{
Text = desc,
FontSize = 10,
Align = TextAnchor.UpperLeft,
Color = ClrSubText,
VerticalOverflow = VerticalWrapMode.Overflow
},
RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1", OffsetMin = "10 8", OffsetMax = "-10 -34" }
}, id);
return id;
}
// =============================================================================================
// Demo 1 — 基本: 何の制約もないフリーなドラッグ
// =============================================================================================
private void BuildDemo1_BasicFreeDrag(CuiElementContainer c, string aMin, string aMax)
{
string frame = BuildDemoFrame(c, aMin, aMax,
"① 基本ドラッグ (Free Drag)",
"Draggable のみを付けた最小構成。\n" +
"DropAnywhere=true なので任意の場所に置ける。",
ClrAccent);
string canvas = "ddemo1.canvas";
c.Add(new CuiElement
{
Name = canvas,
Parent = frame,
Components =
{
new CuiImageComponent { Color = "0 0 0 0.4" },
new CuiRectTransformComponent { AnchorMin = "0 0", AnchorMax = "1 1", OffsetMin = "10 30", OffsetMax = "-10 -100" }
}
});
c.Add(new CuiElement
{
Name = "ddemo1.box",
Parent = canvas,
Components =
{
new CuiImageComponent { Color = ClrBlue },
new CuiRectTransformComponent
{
AnchorMin = "0 0", AnchorMax = "0 0",
OffsetMin = "10 10", OffsetMax = "70 70"
},
new CuiDraggableComponent
{
DropAnywhere = true,
DragAlpha = 0.7f,
// KeepOnTop=true にすると EndDrag 時に Canvas 直下へ取り残されて
// Root 破棄時のカスケードで消えなくなるため false にしておく
KeepOnTop = false
},
new CuiOutlineComponent { Distance = "1 1", Color = "1 1 1 0.4" }
}
});
c.Add(new CuiLabel
{
Text = { Text = "Drag", FontSize = 12, Align = TextAnchor.MiddleCenter, Color = ClrText },
RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" }
}, "ddemo1.box");
}
// =============================================================================================
// Demo 2 — MaxDistance
// =============================================================================================
private void BuildDemo2_MaxDistance(CuiElementContainer c, string aMin, string aMax)
{
string frame = BuildDemoFrame(c, aMin, aMax,
"② MaxDistance",
"MaxDistance=120 を指定すると、開始位置から半径120px以内\n" +
"までしか移動できない。点線円が許容範囲。",
ClrGreen);
string canvas = "ddemo2.canvas";
c.Add(new CuiElement
{
Name = canvas,
Parent = frame,
Components =
{
new CuiImageComponent { Color = "0 0 0 0.4" },
new CuiRectTransformComponent { AnchorMin = "0 0", AnchorMax = "1 1", OffsetMin = "10 30", OffsetMax = "-10 -100" }
}
});
c.Add(new CuiElement
{
Parent = canvas,
Components =
{
new CuiImageComponent
{
Color = "0.3 0.8 0.4 0.18",
Sprite = "assets/content/ui/gameui/mlrs/mlrs_dotted_circle.png"
},
new CuiRectTransformComponent
{
AnchorMin = "0.5 0.5", AnchorMax = "0.5 0.5",
OffsetMin = "-120 -120", OffsetMax = "120 120"
}
}
});
c.Add(new CuiElement
{
Name = "ddemo2.box",
Parent = canvas,
Components =
{
new CuiImageComponent { Color = ClrGreen, Sprite = "assets/content/ui/gradient-circle.png" },
new CuiRectTransformComponent
{
AnchorMin = "0.5 0.5", AnchorMax = "0.5 0.5",
OffsetMin = "-30 -30", OffsetMax = "30 30"
},
new CuiDraggableComponent
{
MaxDistance = 120f,
DropAny
// ... [Code preview truncated due to length. Full source in attached file] ...