Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.MiniGameStall.cs
T

321 lines
16 KiB
C#
Raw Normal View History

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);
}
}