322 lines
16 KiB
C#
322 lines
16 KiB
C#
using Ananta.SDK.Network;
|
||
using Ananta.Server.ClientData.Client4229938;
|
||
using Ananta.Server.Configuration;
|
||
using Ananta.Server.Protocol.Client4229938;
|
||
using Ananta.Server.RpcTypes.Client4229938; // ★ 刮刮乐:ScratchWire.SubTypeLevelOne
|
||
|
||
namespace Ananta.Server.Handlers.Game;
|
||
|
||
/// <summary>
|
||
/// 龙栖村(新祈村)庙会摊位「布景」—— 服务端主动把摊位机关摆到官方世界坐标上。
|
||
///
|
||
/// ★ 用户的原话:「现在能像别人服务那样把龙栖村所有的摊位布景都一起刷出来吗」
|
||
///
|
||
/// ── 与对照包的关系(第 89 轮已核实,别再多花时间)────────────────────────────
|
||
/// 对照包 `DRMK-PS_08.05_Lyco-v0.2.1` 里**没有任何摊位摆位代码**:
|
||
/// grep SyncGadgetAdd|GadgetEntityInfo|MiniGameStall 只命中它自己的
|
||
/// GameRouter.cs / GameRouter.SceneDeviceState.cs / MethodId.cs。
|
||
/// 它只做了**打气球一个**小游戏的完整链路,其余摊位(套圈/刮刮乐/五子棋…)
|
||
/// 连 wire 契约都没有(它的 Ananta.RpcTypes/Methods/Game/ 只有 BalloonZone + RacingZone)。
|
||
/// ⇒ 「像别人那样把摊位刷出来」这件事,**我方是走在前面的一方**,不是补课。
|
||
/// 对照包它自己也只有一句「入口空 ACK」,摊位本体照样看不见。
|
||
///
|
||
/// ── 为什么必须服务端摆(与健身房器材 / 派对家具 / 补货物件完全同因)────────────
|
||
/// 这批摊位机关(Gadget_DaQiQiu 912387408 / Gadget_RingToss 1937674505 /
|
||
/// ScratchGame 165071345 / Gadget_WuZiQi 2066229997 …)的真身分两半:
|
||
/// ① 客户端 Spoon 图里有一个 GADGET 节点(StartBalloonNode / StartRingTossNode …)
|
||
/// ② 关卡块(vfc_1479 / vfc_859)里有一份**官方世界摆位**
|
||
/// 而它**不在**客户端会自己实例化的清单里 —— grid-AOI 下发(68956797)**不驱动**
|
||
/// <c>LX6.Item.GadgetBlob.OnAoiAddBlobCfgByGridIndex</c> ⇒ 一件都不会自己出现。
|
||
/// 唯一可行路径 = 服务端拿 pathId + 官方坐标显式走 SyncGadgetAdd。
|
||
///
|
||
/// ── 数据来源(别再搞混两套 id 空间)────────────────────────────────────────
|
||
/// pathId = <c>ClientData/4229938/Configs/GadgetCatalog.json</c>(2935 条,服务端用)
|
||
/// ⛔ 不是客户端 <c>GadgetConfig.json</c> 的 1367 条(另一套;
|
||
/// 同一件 Gadget_DaQiQiu 在那边是 79304020,在服务端 catalog 是 912387408)
|
||
/// 世界坐标 = <c>Configs/MiniGameStalls.json</c>(解自关卡块,工具
|
||
/// gym_tools/_r87_minigame_gadget.py,记录结构 = 命中点-28 起头,
|
||
/// +0/+4/+8 = f32 x/y/z,+16 = yaw,+28 = pathId)
|
||
///
|
||
/// ── 判据(回答「是不是要按 F 才开始摆」)──────────────────────────────────
|
||
/// **不是。** 摆位是「玩家走进锚点半径」触发的常驻世界装点,一次会话每片区只摆一次。
|
||
/// 与玩家有没有交互、摊位开没开、Lua 有没有跑到那里**都无关** —— 这是纯服务端行为。
|
||
/// 反过来说:**看得见摊位 ≠ 摊位能玩**。能玩要各自的小游戏入口 RPC
|
||
/// (打气球 EnterBalloonZone 已实现;套圈 EnterRingTossZone 67108425 见下)。
|
||
///
|
||
/// ⛔ <c>PackedGadgetInfo.iScale</c> 必须是 **0**(100 ⇒ transform scale (0,0,1) 零尺寸空壳,
|
||
/// 看得见实体、看不见模型)。与气球的 <c>DestructibleInfo.IScale = 1000</c> 是
|
||
/// **同名不同尺度的两个字段**(铁律 39;本条在 R89 上真踩过一次,别再来)。
|
||
///
|
||
/// Rollback: <c>world.content.miniGameStalls.enabled = false</c>(config 热重载,不需重编译)。
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
private const string MiniGameStallKey = "client4229938-minigame-stall";
|
||
|
||
/// <summary>
|
||
/// ★ 刮刮乐桌的 pathId —— 走**服务端** catalog 空间
|
||
/// (<c>ClientData/4229938/Configs/GadgetCatalog.json</c>),不是客户端
|
||
/// <c>GadgetConfig.json</c> 的 1367 条那套(铁律:两个 id 空间别混)。
|
||
/// 数据侧:<c>Configs/MiniGameStalls.json</c> 的 <c>{ "pathId": 165071345, "name": "ScratchGame" }</c>。
|
||
/// </summary>
|
||
private const int ScratchTablePathId = 165071345;
|
||
|
||
internal sealed class MiniGameStallSessionState
|
||
{
|
||
internal object SyncRoot { get; } = new();
|
||
|
||
/// <summary>摊位片区本轮已摆过的名字。一次会话每片区一次,和健身房 / 营地一样。</summary>
|
||
internal HashSet<string> Placed { get; } = new(StringComparer.Ordinal);
|
||
|
||
internal int PlacedCount;
|
||
|
||
/// <summary>空表只抱怨一次,不每 tick 刷屏。</summary>
|
||
internal bool WarnedEmpty;
|
||
|
||
/// <summary>只记一次「跳过了几件」的明细,避免每片区重复。</summary>
|
||
internal HashSet<int> SkipLogged { get; } = new();
|
||
|
||
// ── ★ 刮刮乐「走近自动叫面板」的状态 ──────────────────────────────
|
||
|
||
/// <summary>服务端自己 mint 出来的 ScratchGame 桌 instanceId(`[GADGET] add id=…`)。</summary>
|
||
internal ulong ScratchGadgetUid;
|
||
|
||
internal float ScratchX = float.NaN;
|
||
internal float ScratchZ = float.NaN;
|
||
|
||
/// <summary>上一刻玩家是否在圈内(迟滞用)。</summary>
|
||
internal bool ScratchNear;
|
||
|
||
/// <summary>本轮是否已推过(进圈只推一次)。</summary>
|
||
internal bool ScratchOffered;
|
||
}
|
||
|
||
private static MiniGameStallSessionState MiniGameStallStateOf(TcpSession session)
|
||
{
|
||
if (session.Items.TryGetValue(MiniGameStallKey, out var existing)
|
||
&& existing is MiniGameStallSessionState typed)
|
||
return typed;
|
||
|
||
var created = new MiniGameStallSessionState();
|
||
session.Items[MiniGameStallKey] = created;
|
||
return created;
|
||
}
|
||
|
||
/// <summary>重连 / 切场景:忘掉已摆记录,下次进图重摆(客户端那边实例已随会话消失)。</summary>
|
||
internal static void ResetMiniGameStall(TcpSession session)
|
||
{
|
||
var owner = MiniGameStallStateOf(session);
|
||
lock (owner.SyncRoot)
|
||
{
|
||
owner.Placed.Clear();
|
||
owner.PlacedCount = 0;
|
||
owner.WarnedEmpty = false;
|
||
owner.SkipLogged.Clear();
|
||
owner.ScratchGadgetUid = 0;
|
||
owner.ScratchX = float.NaN;
|
||
owner.ScratchZ = float.NaN;
|
||
owner.ScratchNear = false;
|
||
owner.ScratchOffered = false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 由网格 AOI tick 调用(它已经知道玩家位置)。把玩家走进的每个摊位片区的机关摆一次。
|
||
/// </summary>
|
||
internal static async Task TickMiniGameStallAsync(
|
||
TcpSession session, WorldEntryState state, float px, float pz)
|
||
{
|
||
var cfg = PrivateServerConfigStore.Current.World.Content.MiniGameStalls;
|
||
if (!cfg.Enabled)
|
||
return;
|
||
|
||
var owner = MiniGameStallStateOf(session);
|
||
if (MiniGameStallCatalog4229938.Count == 0)
|
||
{
|
||
// 每会话一行即可 —— 缺数据文件是「还没抓点位」这个准备步骤,不是运行时故障。
|
||
lock (owner.SyncRoot)
|
||
{
|
||
if (owner.WarnedEmpty)
|
||
return;
|
||
owner.WarnedEmpty = true;
|
||
}
|
||
session.Log.Info($"[STALL] 没有可用摊位:{MiniGameStallCatalog4229938.LoadError}");
|
||
return;
|
||
}
|
||
|
||
var channel = cfg.Channel switch
|
||
{
|
||
1 => GadgetSendChannel.Aoi,
|
||
2 => GadgetSendChannel.Both,
|
||
3 => GadgetSendChannel.AddList,
|
||
_ => GadgetSendChannel.Add,
|
||
};
|
||
|
||
foreach (var spot in MiniGameStallCatalog4229938.WithinRadius(px, pz, 0))
|
||
{
|
||
var radius = spot.Radius + cfg.RadiusBonus;
|
||
if (radius > 0f)
|
||
{
|
||
var dx = spot.X - px;
|
||
var dz = spot.Z - pz;
|
||
if (dx * dx + dz * dz > radius * radius)
|
||
continue;
|
||
}
|
||
|
||
lock (owner.SyncRoot)
|
||
{
|
||
if (!owner.Placed.Add(spot.Name))
|
||
continue;
|
||
}
|
||
|
||
if (spot.Items.Count == 0)
|
||
{
|
||
session.Log.Info($"[STALL] 片区 {spot.Name} 还没有摊位坐标(先跑 _r87_minigame_gadget.py)");
|
||
continue;
|
||
}
|
||
|
||
session.Log.Info($"[STALL] 进入 {spot.Name}(raid={spot.Raid} 锚点 {spot.X:F1},{spot.Z:F1} " +
|
||
$"半径 {radius:F0}m),放置 {spot.Items.Count} 件摊位 channel={channel} " +
|
||
$"logOnly={cfg.LogOnly}");
|
||
|
||
if (cfg.LogOnly)
|
||
continue;
|
||
|
||
var placed = 0;
|
||
|
||
if (channel == GadgetSendChannel.AddList)
|
||
{
|
||
// 批量:一次推完整个片区。uniqueId=0 ⇒ 服务端自己 mint。
|
||
var specs = spot.Items
|
||
.Select(i => new GadgetSpawnSpec(i.PathId, i.X, i.Y, i.Z, i.Yaw, i.UniqueId, i.Name))
|
||
.ToList();
|
||
var batch = await SpawnGadgetListAsync(session, state, specs, cfg.IScale);
|
||
placed = batch.Ok ? batch.Count : 0;
|
||
if (!batch.Ok)
|
||
session.Log.Warn($"[STALL] {spot.Name} 批量放置失败:{batch.Message}");
|
||
}
|
||
else
|
||
{
|
||
foreach (var item in spot.Items)
|
||
{
|
||
// iScale 传 0 —— ⛔ 这是 PackedGadgetInfo.iScale 的语义,
|
||
// 与气球的 DestructibleInfo.IScale=1000 不是一个字段(铁律 39)。
|
||
var result = await SpawnGadgetAsync(
|
||
session, state, item.PathId, item.X, item.Y, item.Z, item.Yaw, channel,
|
||
item.UniqueId, 0);
|
||
if (result.Ok)
|
||
{
|
||
placed++;
|
||
// ★ 刮刮乐桌:记住服务端 mint 出来的 instanceId —— 推面板要用它
|
||
// (配表里 UniqueId = 0 ⇒ 只有 SpawnGadgetAsync 的返回值可信)。
|
||
if (item.PathId == ScratchTablePathId)
|
||
{
|
||
lock (owner.SyncRoot)
|
||
{
|
||
owner.ScratchGadgetUid = result.Id;
|
||
owner.ScratchX = item.X;
|
||
owner.ScratchZ = item.Z;
|
||
}
|
||
}
|
||
session.Log.Info($"[STALL] + {item.Name}({item.PathId}) " +
|
||
$"@({item.X:F2},{item.Y:F2},{item.Z:F2}) yaw={item.Yaw:F1} " +
|
||
$"uid={item.UniqueId}");
|
||
}
|
||
else
|
||
{
|
||
session.Log.Warn($"[STALL] {spot.Name} {item.Name}({item.PathId}) 放置失败:{result.Message}");
|
||
}
|
||
}
|
||
}
|
||
|
||
lock (owner.SyncRoot)
|
||
owner.PlacedCount += placed;
|
||
|
||
session.Log.Info($"[STALL] {spot.Name} 放置完成 {placed}/{spot.Items.Count}" +
|
||
(placed == spot.Items.Count ? "" : $"({spot.Items.Count - placed} 件没发出去)"));
|
||
|
||
// ★ 摆完之后看一眼玩家是不是正站在刮刮乐桌前(见 MaybeOfferScratchAsync)。
|
||
await MaybeOfferScratchAsync(session, owner, px, pz);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// ★★★ 玩家走近刮刮乐桌 ⇒ 推一条 <c>SyncStartScratchGameZone</c>(68649573),把面板叫出来。
|
||
///
|
||
/// ── 为什么必须服务端「主动」推 ────────────────────────────────────────
|
||
/// 客户端**不发** <c>AskStartScratchGame</c>:它在 2349 个 Lua 文件里 0 命中,
|
||
/// 9 份 <c>packets-*.log</c> 里也 0 命中(服务端 handler 已登记白名单、从未被调用过)。
|
||
/// 而刮刮乐面板的**唯一**入口是服务端推 <c>SyncStartScratchGameZone</c>:
|
||
/// <c>GameSceneToClientImpl.lua:1131</c> → <c>gScratchGameManager:Show(gadgetUid, subType, result)</c>
|
||
/// <c>ScratchGameManager.lua:7</c>:<c>result == nil</c> ⇒ return;否则
|
||
/// <c>GetEntitySearchByInstanceId(gadgetUid)</c> → <c>RegisterWaitLoad</c> →
|
||
/// <c>Find("ItemViewNode/UIPose")</c> → <c>CheckShow(SCRATCHCARD_PANEL, …)</c>。
|
||
/// ⇒ 服务端不推,「通用交互提示」永远只是提示、没有选项。
|
||
/// 详细 wire 见 <c>GameRouter.MiniGameEntry.PushScratchStartAsync</c>。
|
||
///
|
||
/// ── 迟滞(别在边界上连推)────────────────────────────────────────────
|
||
/// 进圈(dist ≤ <c>ScratchOfferRadius</c>)推**一次**;出圈超过 <c>ScratchRearmRadius</c>
|
||
/// 才复位,允许再进来再推。玩家一直站在圈里 ⇒ 只推一条。
|
||
///
|
||
/// ── 前提 ─────────────────────────────────────────────────────────
|
||
/// 桌子的 <c>gadgetUid</c> 必须是**服务端自己 mint 的那个**(<c>[GADGET] add id=…</c>)——
|
||
/// 配表里 <c>UniqueId = 0</c>,所以只有 <c>SpawnGadgetAsync</c> 的返回值可信。
|
||
/// 没摆到(uid==0)或走 <c>AddList</c> 批量通道(那条路目前不回收 id)⇒ 直接返回,什么都不做。
|
||
///
|
||
/// Rollback: <c>world.content.miniGameStalls.scratchAutoOpen = false</c>(配置热重载,不用重编译)。
|
||
/// </summary>
|
||
private static async Task MaybeOfferScratchAsync(
|
||
TcpSession session, MiniGameStallSessionState owner, float px, float pz)
|
||
{
|
||
var cfg = PrivateServerConfigStore.Current.World.Content.MiniGameStalls;
|
||
if (!cfg.ScratchAutoOpen)
|
||
return;
|
||
|
||
ulong gadgetUid;
|
||
float tx, tz;
|
||
lock (owner.SyncRoot)
|
||
{
|
||
gadgetUid = owner.ScratchGadgetUid;
|
||
tx = owner.ScratchX;
|
||
tz = owner.ScratchZ;
|
||
}
|
||
|
||
if (gadgetUid == 0 || float.IsNaN(tx) || float.IsNaN(tz))
|
||
return;
|
||
|
||
var dx = tx - px;
|
||
var dz = tz - pz;
|
||
var dist2 = dx * dx + dz * dz;
|
||
|
||
var offerR = MathF.Max(0.5f, cfg.ScratchOfferRadius);
|
||
var rearmR = MathF.Max(offerR + 0.5f, cfg.ScratchRearmRadius); // 迟滞必须 > 触发半径
|
||
|
||
var shouldPush = false;
|
||
lock (owner.SyncRoot)
|
||
{
|
||
if (dist2 <= offerR * offerR)
|
||
{
|
||
if (!owner.ScratchNear)
|
||
{
|
||
owner.ScratchNear = true;
|
||
if (!owner.ScratchOffered)
|
||
{
|
||
owner.ScratchOffered = true;
|
||
shouldPush = true;
|
||
}
|
||
}
|
||
}
|
||
else if (dist2 > rearmR * rearmR)
|
||
{
|
||
owner.ScratchNear = false;
|
||
owner.ScratchOffered = false;
|
||
}
|
||
}
|
||
|
||
if (!shouldPush)
|
||
return;
|
||
|
||
await PushScratchStartAsync(session, gadgetUid, ScratchWire.SubTypeLevelOne);
|
||
}
|
||
}
|