923 lines
51 KiB
C#
923 lines
51 KiB
C#
using System.Runtime.CompilerServices;
|
||
using System.Text.Json;
|
||
using Ananta.SDK.Network;
|
||
using Ananta.Server.ClientData.Client4229938;
|
||
using Ananta.Server.Configuration;
|
||
using Ananta.Server.Protocol.Client4229938;
|
||
|
||
namespace Ananta.Server.Handlers.Game;
|
||
|
||
/// <summary>
|
||
/// 赛车检查点门(R247 · 2026-10-01)—— 服务端显式摆放。
|
||
///
|
||
/// <b>症状</b>:整条赛道上一道绿框门都没有(用户:「开始赛车了,没有看到绿框门」)。
|
||
///
|
||
/// <b>为什么没有</b>:门是 gadget 对
|
||
/// <c>Racing_challenge01_CheckPoint</c> / <c>Racing_challenge01_CheckPointEnd</c>,
|
||
/// 官方**按检查点位置在运行时 spawn**;而检查点来自
|
||
/// <c>RacingDriverTrackInformationConfig.RaidSpoonName</c> 指向的 Spoon 图 ——
|
||
/// 这张图**从未合并进客户端**(<c>SpoonMd5=""</c> ⇒ <c>SpoonRaidMgr</c> 走
|
||
/// <c>#NoCreateIssue</c>),所以官方那条链一次都没执行过,门**从来没人 spawn**。
|
||
/// 不是坏了,是没装。
|
||
///
|
||
/// <b>为什么必须服务端摆</b>:R244 已经试过「客户端 Lua 自己
|
||
/// <c>gResourceManager:LoadAssetWithCallBack</c> + <c>GameObject.Instantiate</c>」,
|
||
/// 实机证据是 16/16 全部实例化成功、<c>go0.active=true</c>、<c>scale=1/1/1</c>、
|
||
/// <c>children=2</c>、全程**零** <c>Load asset FAILED</c> —— 但**肉眼一道都看不见**。
|
||
/// 这与「家具全无 / 健身器材 / 摊位补给」是同一个病根:**客户端不会自己实例化这类物件**,
|
||
/// 必须由服务端经 gadget 通道下发。
|
||
///
|
||
/// <b>通道</b>:<c>SpawnGadgetAsync</c>(健身器材 / PropRestock / 摊位补给都走它,实测可见)。
|
||
/// ⛔ <c>iScale</c> 必须传 <b>0</b>:100(GM 面板默认)出来是 <c>transform scale (0,0,1)</c>
|
||
/// 的**零缩放空壳、没有 prefab** —— 正是保龄球球瓶「有物理有音效但看不见」的根因。
|
||
///
|
||
/// <b>坐标</b>:客户端 R225 已用 <c>LoadRaceTrackData</c> 采出 16 个检查点(沿弧长等分),
|
||
/// R247 把它们与**服务端那套 pathId** 一起落到 <c>config/racing-gates.json</c>。
|
||
/// 服务端读表摆放 —— 与健身器材 <c>_gym_capture.py</c> 的「离线采样 → 落配置 → 服务端摆」完全同构。
|
||
///
|
||
/// <b>拿不到表时</b>:在玩家四周摆 4 道「验证信标」。它的价值是把
|
||
/// <b>「通道对不对」和「坐标对不对」两个变量解耦</b>:看得见信标 ⇒ 通道没问题,只差坐标。
|
||
///
|
||
/// 回滚:<c>world.content.racingGates.enabled = false</c>(不用重编译)。
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
/// <summary>
|
||
/// <summary>
|
||
/// <c>Racing_challenge01_CheckPoint</c>(官方赛道检查点门)+ <c>..._CheckPointEnd</c>(终点门)。
|
||
/// 服务端 GadgetCatalog 那套 id 空间。
|
||
///
|
||
/// <para>★★★ R256 更正:R252 写的「**这两个 prefab 的资源根本没随这个客户端包**
|
||
/// ⇒ 摆上去也是隐形的」**是错的**。硬证据(<c>gym_tools/_r256_gate_instances.py</c>,
|
||
/// 全库 1682 块 + 88B 记录头校准):关卡块 <c>vfc_1479</c> / <c>vfc_859</c> 里官方
|
||
/// **静态摆了 16 个** <c>Racing_challenge01_CheckPoint</c>(如
|
||
/// <c>(1914.60, 0, 2234.30) yaw=255.71</c>)+ <b>2 个 <c>...End</c></b>
|
||
/// + <c>SelfRacing_PortalGuide</c> + <c>SelfRacing_EffectGuideLine01</c>
|
||
/// ⇒ 官方自己就在关卡里摆它们 ⇒ 客户端必然能渲染 ⇒ **资源一定随包**。
|
||
/// 当轮「看不见」的真因见 <see cref="OfficialTrack3Checkpoints"/> 说明
|
||
/// (yaw 被算成 <c>atan(dx)</c> 恒 ±90 + <c>pairMirror</c> 同坐标重叠)。</para>
|
||
///
|
||
/// <para>这两个常量同时用于:① 默认摆位(<c>pathIdOverride = 0</c> 时);
|
||
/// ② 清理旧批次(<c>SpawnRacingGatesAsync</c> ①)。</para>
|
||
/// </summary>
|
||
internal const int RacingGateCheckPointPathId = 884611825;
|
||
|
||
/// <summary>★ R256:<c>Racing_challenge01_CheckPointEnd</c>(终点门)。</summary>
|
||
private const int RacingGateEndPathId = 271608686;
|
||
|
||
/// <summary>
|
||
/// R252:<c>SelfRacing_PortalGuide</c>(单人赛车·传送门指引)——
|
||
/// 从客户端自己的注册表块 `vfc_907` 逐字读出(`name`/`GraphId`/`path`/`pathHash`/`pathId`
|
||
/// 五元组,全表 2932 条已导出为 <c>gym_tools/_r252_vfc907_registry.json</c>)。
|
||
/// 它是「单人赛车」族里**唯一**有资源块的物件。
|
||
/// </summary>
|
||
internal const int SelfRacingPortalGuidePathId = 2017204172;
|
||
|
||
/// <summary>
|
||
/// R253:<c>Jobrace_gongmen</c>(竞速·拱门)—— <b>本轮锁定的目标物件</b>。
|
||
///
|
||
/// <para><b>为什么是它</b>(三条独立证据):</para>
|
||
/// <list type="number">
|
||
/// <item>名字直译就是<b>拱门</b>:<c>Jobrace</c>(Job Race,竞速)+ <c>gongmen</c>(拱门)。
|
||
/// 全库唯一一个名字里带「拱门」的 gadget。</item>
|
||
/// <item>它的美术结构就是拱门:资源块 <c>vfc_1583</c> / <c>vfc_1582</c> 里 dump 出
|
||
/// <c>gp_arch_gongmen_blue_left</c> / <c>_right</c> / <c>_up</c> 三件套
|
||
/// (<c>arch</c> = 拱)—— **左柱 + 右柱 + 顶拱**,与参考视频里
|
||
/// 「路两侧各一根光柱、顶端向内弯折成拱」完全同构;
|
||
/// 另有 <c>gp_arch_gongmen_yellow</c>(黄门)与 <c>fx_gp_arch_gongmen_blue_lod1/2</c>。</item>
|
||
/// <item>它**明确绑定本玩法**:<c>vfc_904</c> 里 <c>Jobrace_</c> 族与
|
||
/// <c>23300888</c> 同现 —— <c>Jobrace_Spurt01_23300888</c> / <c>23300888.Jobrace_F01</c> /
|
||
/// <c>23300888.Racing_challenge02</c> / <c>ClientActionRacing_challenge01_23300888</c>。
|
||
/// 而 <c>23300888</c> 正是本赛道玩法 id(<c>RacingDriverTrackInformationConfig.RaidSpoonName
|
||
/// = "23300888_RacingDriver"</c>)。</item>
|
||
/// </list>
|
||
///
|
||
/// <para><b>与 <c>gp_wangpaicheshen_men_blue/yelllow</c> 的区别</b>:后者名字里有 <c>men</c>,
|
||
/// 但只在 <c>vfc_1582/vfc_1583</c> 里作为**特效资源**出现,且没有 left/right/up 的三段结构;
|
||
/// <c>arch_gongmen</c> 才是拱形门本体。</para>
|
||
/// </summary>
|
||
internal const int JobraceGongmenPathId = 64493331;
|
||
|
||
/// <summary>
|
||
/// ★ R273:<c>S6_Racing_CheckPoint</c>(`440353630`)—— R270 那轮误把它当成门摆了上去。
|
||
/// 只要它没进 <c>purgeIds</c>,一旦把 <c>pathIdOverride</c> 换回官方默认(0),
|
||
/// 上一批 S6 门就不会被清 ⇒ 赛道上叠一层残留。故此处登记,纳入每局清理。
|
||
/// </summary>
|
||
internal const int S6RacingCheckPointPathId = 440353630;
|
||
|
||
/// <summary>
|
||
/// <summary>
|
||
/// ★★★★★ R255(2026-10-02)—— **官方权威检查点表**:track 3 = Nova(`23300888`),
|
||
/// `RacingDriverTrackInformationConfig.RacingPathName = "RT_888_001"`,
|
||
/// **含终点共 26 个**。
|
||
///
|
||
/// <para><b>数据来源(离线可复现,零注入)</b>:关卡块
|
||
/// <c>Ananta_Data/StreamingAssets/Blocks/vfc_819</c> 里躺着一份**明文 JSON**
|
||
/// (偏移 ≈ 58662107),<c>exportName =
|
||
/// "Assets/Res/Prefab/ZoneShapeView/ZoneShapes_WorldMap_Release_Saidao.prefab"</c>:</para>
|
||
/// <code>
|
||
/// {"exportName":"...Saidao.prefab","simplifyTolerance":0.1,
|
||
/// "tracks":[{"trackIndex":0,"pointCount":259,"isClosed":false,
|
||
/// "points":[{"x":3112.446,"y":-0.11,"z":899.959,
|
||
/// "checkpointIndex":-1,"distanceToNextCheckpoint":141.98}, ...]}]}
|
||
/// </code>
|
||
/// <para>⇒ <b>官方把检查点划分烘焙在每个赛道点的 `checkpointIndex` 里</b>
|
||
/// (同族类 <c>MassAI.ZoneGraph.RacingTrackPoint</c> 的字段 =
|
||
/// <c>x,y,z,checkpointIndex,distanceToNextCheckpoint</c>)。
|
||
/// <c>checkpointIndex</c> 值域 = <b>-1 与 0..25</b>;<c>-1</c> 是起点前的引导段
|
||
/// ⇒ **真正的检查点是 0..25 共 26 个**(与用户「跟着别人的视频一个一个数出 26 个」完全一致)。</para>
|
||
///
|
||
/// <para><b>检查点 k 的位置 = 第 k 组的第一个点</b>(已由 <c>distanceToNextCheckpoint</c> 自证:
|
||
/// cp0 组末点 <c>s=215.07, d_last=5.93</c> ⇒ 221.00,而 cp1 组起点 <c>s=220.99</c>,吻合;
|
||
/// <c>-1</c> 组末点 <c>s=120.77, d_last=21.21</c> ⇒ 141.98 = cp0 起点 <c>s=141.98</c>,吻合)。</para>
|
||
///
|
||
/// <para><b>为什么必须内置、不能用 R225 那套采样</b>:
|
||
/// ① R225 走 <c>LX6.Utils.LuaUtils.LoadRaceTrackData</c> →
|
||
/// <c>RaceTrackExportData.ExtractFilteredTrackPoints()</c>,那条链**只取 x/y/z、把
|
||
/// `checkpointIndex` 削掉了**;
|
||
/// ② 它还做了**不等距重采样** —— 官方 259 点 / 总长 <b>3382.44 m</b>,探针只拿到
|
||
/// 256 点 / <b>3225 m</b> ⇒ 在它上面做「弧长等分」**必然错位**;
|
||
/// ③ R225 的数量本身就是猜的(<c>R225_CP_COUNT = 16 -- 原版 Spoon 图数量未知,取保守中值</c>);
|
||
/// ④ 那张采集表还是**客户端探针每局重写**的(见 <c>PathIdOverride</c>),坐标同样不可托付。</para>
|
||
///
|
||
/// <para><b>交叉验证</b>:R225 采出的 16 个点在官方 259 点列上**逐点命中、距离 0.00 m**
|
||
/// ⇒ 同一个几何路径,只是点序列/弧长不同 —— 这既证明「赛道认对了」,
|
||
/// 也证明「用探针曲线等分」的方案从根上就不可能对齐官方检查点。</para>
|
||
///
|
||
/// <para><b>yaw</b>(R293 起)= **该检查点处的「道路方向」(局部切向)**,取官方 259 点折线里
|
||
/// **含该点那一段**的方向(<c>atan2(Δx, Δz)</c>)。语义 = <b>门面法向沿路 ⇒ 门横跨路面</b>。</para>
|
||
///
|
||
/// <para>★★★ <b>R255d 的现象真因(R256 定案)</b>:**不是**朝向差 90°,而是两个独立问题叠加 ——
|
||
/// ① <b>成对镜像</b>:当时 <c>pairMirror=true</c> 在**同一坐标**又摆了一扇,
|
||
/// 于是「两扇门叠在一块 / 都在中间」(服务端日志实证那局 <c>n=26</c>,但视觉上重叠);
|
||
/// ② <b>朝向从来没算对过</b>:客户端探针 <c>racing_guard.lua</c> 的 <c>yawOf()</c> 写的是
|
||
/// <c>math.atan(dx, dz)</c>,而 LuaJIT 的 <c>math.atan</c> **只有单参形式、第二参被静默丢弃**
|
||
/// ⇒ 实际算的是 <c>atan(dx)</c>,恒在 ±90 附近。逐条对上(<c>gym_tools/_r256_yawsemantics.py</c>):
|
||
/// cp00→cp01 Δx=-69.83 ⇒ <c>atan(-69.83)</c> = -89.18°,表里实测 **-89.20**;
|
||
/// cp04→cp05 Δx=+0.25 ⇒ <c>atan(0.25)</c> = 14.00°,表里实测 **14.00**。
|
||
/// 外面包着 <c>pcall</c> 不报错 ⇒ 注释里写的 <c>atan2</c> 兜底**从未执行**。R256 已改为显式 <c>atan2</c>。</para>
|
||
///
|
||
/// <para><b>⇒ <c>yawOffset</c> 保持 0,不要用 ±90 去「掰」</b>。R255d 那条「必须 +90」是
|
||
/// 被上面两个假象误导后加的**猜测**,已被官方数据推翻。</para>
|
||
///
|
||
/// <para>生成脚本:<c>gym_tools/_r255_extract_tracks.py</c> +
|
||
/// <c>_r255_reduce_cp.py</c> + <c>_r255_gen_official_gates.py</c>;
|
||
/// 报告 <c>gym_tools/2.1诊断_赛车门官方权威位置_R255.md</c>。</para>
|
||
/// </summary>
|
||
internal static readonly (float X, float Y, float Z, float Yaw)[] OfficialTrack3Checkpoints =
|
||
[
|
||
// ★★★★★ R293(2026-10-02)定案:yaw = **该检查点处的「道路方向」(局部切向)**
|
||
// —— ⛔ **R290 那版「指向下一个检查点」作废**。
|
||
// 用户原话(21:0x):「检查点可以不用朝向下一个点,就和道路平行就行了」。
|
||
//
|
||
// ✅ **为什么「指向下一个点」是错的**(复算脚本 `gym_tools/_r293_road_tangent.py`):
|
||
// 「道路方向」= 含该点的**那一段**官方折线方向;
|
||
// 「指向下一个检查点」= 取的是**下一段路的弦**。直道上二者等价,**弯道上差得离谱** ——
|
||
// 26 个点里 **9 个偏 > 20°**:
|
||
// cp07 偏 **114.8°**(入向 -91.6° vs 出向 +23.2°,急弯)
|
||
// cp22 偏 71.1° · cp10 偏 70.9° · cp14 偏 62.8° · cp05 偏 51.5°
|
||
// cp19 偏 50.5° · cp02 偏 39.1° · cp21 偏 34.9° · cp15 偏 34.5°
|
||
// `Jobrace_gongmen` 是 **X-Y 平面薄板**(`Box Volume ls=(21.56,14.46,2.42)` ⇒ 法向 = local Z)
|
||
// ⇒ 只有 **local Z 沿路** 门才「横跨路面」。用弦向就把门拧成「顺路躺」;
|
||
// 更糟的是 `ExpandGatePairs` 的 ±8 m 侧移是沿 `right(yaw)` 的 ⇒ 也被拧成
|
||
// **沿路前后串**(cp07 实测 A 在前、B 在后,相距 15.6 m)⇒ 两半根本合不成一扇门。
|
||
//
|
||
// ★ **数据来源(官方、可复算、零猜测)**:关卡块 `vfc_819` 明文 JSON
|
||
// `Assets/Res/Prefab/ZoneShapeView/ZoneShapes_WorldMap_Release_Saidao.prefab`
|
||
// track0 = **259 点 / 3382.44 m / isClosed=false**。
|
||
// 26 个检查点**逐点落在折线上,最大偏差 0.009 m** ⇒ 直接取「含该点那一段」的方向。
|
||
// ⚠ 与本仓 `_r255_official_cp.txt` 里那列 yaw 最大差 **0.61°**(同源互证)。
|
||
//
|
||
// ⛔ **别拿箭头公式来套门**:官方 `CarRaceManager.lua:UpdateArrowPosAndRot` 走
|
||
// `LookRotation(nextTargetPos - targetPos)` ⇒ 那确实是「指向下一个检查点」,
|
||
// 但那是**指示牌 / 箭头**(`CheckPointArrow`,客户端的 `csToLua` 小地图箭头)的语义。
|
||
// 门(`Jobrace_gongmen`)是另一件东西。**R290 翻车就是拿箭头公式去套门**。
|
||
//
|
||
// ⚠ 官方箭头还做 `math.floor(y/10 + 0.5) * 10` 的 **10° 吸附**;本表用原始切向,不吸附。
|
||
//
|
||
// ★ A/B 开关:`racingGates.yawMode` = `road`(默认,本表)/ `next`(= 下面
|
||
// `OfficialTrack3YawNext`,R290 那套弦向)。改它**不用重编译、不用重启服务端**
|
||
// (`private-server.json` 热重载 + 每局开赛重读),下一局即生效。
|
||
( 2988.36f, -0.11f, 968.96f, -61.54f), // cp00
|
||
( 2918.53f, -0.11f, 1005.92f, -63.52f), // cp01
|
||
( 2758.34f, -0.11f, 1065.55f, -73.69f), // cp02 ← 弦向 -35.23(偏 39.1°)
|
||
( 2711.20f, -0.11f, 1132.29f, 31.30f), // cp03
|
||
( 2759.20f, -0.11f, 1228.22f, 0.18f), // cp04
|
||
( 2759.45f, -0.11f, 1308.83f, 0.18f), // cp05 ← 弦向 -51.27(偏 51.5°)
|
||
( 2691.94f, -0.11f, 1362.97f, -91.57f), // cp06
|
||
( 2540.19f, -0.11f, 1358.80f, -91.57f), // cp07 ← 弦向 +23.22(偏 114.8° 最狠)
|
||
( 2601.51f, -0.11f, 1501.76f, 27.93f), // cp08
|
||
( 2607.47f, -0.11f, 1639.18f, -0.78f), // cp09
|
||
( 2608.00f, -0.11f, 1717.72f, 0.56f), // cp10 ← 弦向 -70.32(偏 70.9°)
|
||
( 2518.33f, -0.11f, 1749.80f, -88.35f), // cp11
|
||
( 2442.01f, -0.11f, 1751.01f, -89.15f), // cp12 ← 弦向 -119.32(偏 30.1°)
|
||
( 2252.23f, -0.11f, 1644.42f, -124.37f), // cp13
|
||
( 2174.30f, -0.10f, 1626.09f, -89.92f), // cp14 ← 弦向 -27.12(偏 62.8°)
|
||
( 2147.08f, -0.10f, 1679.23f, 4.82f), // cp15 ← 弦向 -29.68(偏 34.5°)
|
||
( 2106.91f, -0.11f, 1749.70f, -88.48f), // cp16
|
||
( 2039.88f, -0.11f, 1765.89f, -87.05f), // cp17
|
||
( 1907.99f, -0.11f, 1756.08f, -88.33f), // cp18
|
||
( 1716.35f, -0.11f, 1765.58f, -90.16f), // cp19 ← 弦向 -140.65(偏 50.5°)
|
||
( 1671.20f, -0.04f, 1710.52f, -175.27f), // cp20
|
||
( 1681.39f, -0.10f, 1655.91f, 161.19f), // cp21 ← 弦向 +126.34(偏 34.9°)
|
||
( 1875.95f, -0.10f, 1512.77f, 113.35f), // cp22 ← 弦向 -175.56(偏 71.1°)
|
||
( 1868.70f, -0.11f, 1419.32f, -169.76f), // cp23
|
||
( 1805.49f, -0.11f, 1301.48f, -92.36f), // cp24
|
||
( 1582.79f, -0.11f, 1266.68f, -99.18f), // cp25 = 终点(含终点第 26 个)
|
||
];
|
||
|
||
/// <summary>
|
||
/// R290 遗留:**「指向下一个检查点」的弦向 yaw**(<c>atan2(Δx, Δz)</c>,末点回退用前一点)。
|
||
/// 只在 <c>racingGates.yawMode = "next"</c> 时使用,留作 A/B 对照与回滚。
|
||
/// ⚠ **默认不要用**:弯道上 9 个点偏 > 20°(cp07 偏 114.8°),见
|
||
/// <see cref="OfficialTrack3Checkpoints"/> 的说明。
|
||
/// 生成脚本 <c>gym_tools/_r290_official_yaw_table.py</c>。
|
||
/// </summary>
|
||
internal static readonly float[] OfficialTrack3YawNext =
|
||
[
|
||
-62.11f, -69.58f, -35.23f, 26.58f, 0.18f, -51.27f, -91.57f, 23.22f,
|
||
2.48f, 0.39f, -70.32f, -89.09f, -119.32f, -103.24f, -27.12f, -29.68f,
|
||
-76.42f, -94.25f, -87.16f, -140.65f, 169.43f, 126.34f, -175.56f, -151.79f,
|
||
-98.88f, -98.88f,
|
||
];
|
||
|
||
/// <summary>R255:把内置官方表变成摆位计划(含 <c>YawOffset</c> 修正)。</summary>
|
||
/// <remarks>
|
||
/// ★★★ R256 —— **默认改用官方自己的检查点 prefab**(<see cref="RacingGateCheckPointPathId"/>),
|
||
/// 不再用 R253 选的 <c>Jobrace_gongmen</c>(拱门)。
|
||
///
|
||
/// <para><b>为什么换回去</b>:<c>config/racing-gates.json</c> 里(客户端探针写的)
|
||
/// 每个检查点本来就是 <c>pathId 884611825</c> / 终点 <c>271608686</c> ——
|
||
/// 也就是 <c>Res/Prefab/Gadget/Racing_challenge01_CheckPoint.prefab</c>。
|
||
/// 是 R253 起用 <c>PathIdOverride=64493331</c> 把它**覆盖**成了拱门。</para>
|
||
///
|
||
/// <para>R254 的注释写过「884611825 资源从未随包 ⇒ 隐形」,**该判断已被推翻**:
|
||
/// 关卡块 <c>vfc_1479</c> 里官方**实实在在地摆了 16 个该 prefab**
|
||
/// (+2 个 <c>...End</c>,见 <c>_r256_gate_instances.py</c> / <c>_r190_racing_checkpoints.json</c>)
|
||
/// ⇒ 客户端一定能渲染它,资源必然随包。
|
||
/// 当轮"看不见"的真因是 **yaw 算错**(见下)把门转到了错误方向/位置。</para>
|
||
///
|
||
/// <para><b>切换开关</b>:<c>racingGates.pathIdOverride</c>。
|
||
/// <c>0</c>(默认)= 官方检查点 prefab(终点自动用 <c>...End</c>);
|
||
/// <c>64493331</c> = 回退到 R253~R255 的拱门(此时终点同 prefab)。</para>
|
||
/// </remarks>
|
||
internal static List<RacingGatePlacement> BuildOfficialTrackGates(RacingGateSettings cfg)
|
||
{
|
||
var list = new List<RacingGatePlacement>(OfficialTrack3Checkpoints.Length);
|
||
var last = OfficialTrack3Checkpoints.Length - 1;
|
||
var overridden = cfg.PathIdOverride != 0;
|
||
|
||
// ★★★★★ R293 —— 朝向来源二选一(`racingGates.yawMode`):
|
||
// "road"(默认)= 表里的 c.Yaw = **该点道路局部切向** ⇒ 门横跨路面、两半跨路并排;
|
||
// "next" = R290 的弦向(OfficialTrack3YawNext)⇒ 只在弯道会偏,留作 A/B。
|
||
// 改这个键**不用重编译、不用重启服务端**(private-server.json 热重载,每局开赛重读)。
|
||
var useNext = string.Equals(cfg.YawMode, "next", StringComparison.OrdinalIgnoreCase);
|
||
|
||
for (var i = 0; i < OfficialTrack3Checkpoints.Length; i++)
|
||
{
|
||
var c = OfficialTrack3Checkpoints[i];
|
||
var isEnd = i == last;
|
||
var pid = overridden
|
||
? cfg.PathIdOverride
|
||
: (isEnd ? RacingGateEndPathId : RacingGateCheckPointPathId);
|
||
var baseYaw = useNext && i < OfficialTrack3YawNext.Length
|
||
? OfficialTrack3YawNext[i]
|
||
: c.Yaw;
|
||
list.Add(new RacingGatePlacement(
|
||
pid,
|
||
c.X, c.Y, c.Z,
|
||
baseYaw + cfg.YawOffset,
|
||
isEnd ? $"cp{i:D2}-END" : $"cp{i:D2}"));
|
||
}
|
||
|
||
return list;
|
||
}
|
||
|
||
private const string RacingGateLogKey = "client4229938-racing-gates";
|
||
|
||
/// <summary>一条待摆放的门。</summary>
|
||
internal sealed record RacingGatePlacement(
|
||
int PathId, float X, float Y, float Z, float Yaw, string Label);
|
||
|
||
/// <summary>
|
||
/// ⛔⛔ <b>R258 定案:这个开关 = 「我们自己造的门宽」,不是官方数据 ⇒ 默认必须关。</b>
|
||
///
|
||
/// <b>用户的两条纠正(R258,决定了本开关的命运)</b>:
|
||
/// 「<i>路宽不一定代表门宽啊,不要查错方向了</i>」+「<i>赛道的宽度也是根据检查点的宽度来计算的</i>」
|
||
/// ⇒ <b>门宽是「门」这件物件自带的</b>,赛道按它建;所以**没有**需要我们填的宽度,
|
||
/// 也就不该有 <c>pairMirror</c> / <c>pairOffset</c> 这两个旋钮。
|
||
/// R257 把它们打开,等于「我们自己在给门调宽」—— 已被明确叫停。
|
||
///
|
||
/// <b>R258 的官方摆位判据(这才是「一个实例够不够」的正确证法)</b>:
|
||
/// <c>gym_tools/_r190_racing_checkpoints.json</c> 里官方静态摆了 **14 个**
|
||
/// <c>Racing_challenge01_CheckPoint</c>,两两**最近距离 131.69 m、完全不成对**
|
||
/// ⇒ 官方就是「**一个检查点摆一个实例**」。
|
||
/// 再加上 gadget 注册表(<c>vfc_907</c>)里带「拱门」语义的物件**只有一个**
|
||
/// (<c>Jobrace_gongmen</c> <c>64493331</c>,其 prefab 自带 <c>_left</c>/<c>_right</c>/<c>_up</c>)
|
||
/// ⇒ **一扇门 = 一个实例**,宽度由 prefab 自身决定,我们没有可填的数。
|
||
///
|
||
/// <b>R254 的假设(已推翻)</b>:看到画面只有一根立柱 + 顶端向内弯折 ⇒ 认为
|
||
/// 一个 <c>Jobrace_gongmen</c> 实例只是**半扇拱门**,要同坐标摆两个(yaw 差 180°)才成整扇。
|
||
///
|
||
/// <b>R255d 的实测证据(推翻它)</b>:
|
||
/// ① 资产扫描(`gym_tools/_r255c_gate_prefab_scan.py`,全库 1682 块)——
|
||
/// <c>vfc_1583</c> 里 **同一个 prefab** 自带三件套:
|
||
/// <c>gp_arch_gongmen_blue_left</c>(+`_left_lod2`)、
|
||
/// <c>gp_arch_gongmen_blue_right</c>(+`_right_lod12`)、
|
||
/// <c>gp_arch_gongmen_blue_up</c>(+`_up_lod`)⇒ **一个实例就是完整一扇门**。
|
||
/// R254 当时看到的「只有右半」,最可能是**左半被棕榈树 / 那栋黑楼挡住**。
|
||
/// ② 打开本开关后用户实测原话:「**门叠在一块了**」——
|
||
/// 因为两个完整门被摆在**同一坐标**,只能互相 z-fighting 重叠。
|
||
///
|
||
/// <b>结论</b>:`pairMirror` 保持 **false**(配置 <c>world.content.racingGates.pairMirror</c>)。
|
||
///
|
||
/// <para>⛔ <b>R258 更正本条</b>:R257 把 A、B 拆到对侧(<c>-ox/-oz</c>)本身没错,
|
||
/// 但**整个「成对」模型是自造的**(见上)。官方每检查点只摆 1 个实例
|
||
/// ⇒ 一扇门 = 一个 prefab,不需要第二个实例,也就没有「柱到中线的距离」要调。
|
||
/// 所以 <c>pairOffset</c> 的真实语义只是「把整道门横向平移」,与「门宽」无关。</para>
|
||
/// </summary>
|
||
private static List<RacingGatePlacement> ExpandGatePairs(
|
||
List<RacingGatePlacement> plan, RacingGateSettings cfg)
|
||
{
|
||
if (!cfg.PairMirror || plan.Count == 0)
|
||
return plan;
|
||
|
||
var shift = cfg.PairOffset;
|
||
var outp = new List<RacingGatePlacement>(plan.Count * 2);
|
||
|
||
for (var i = 0; i < plan.Count; i++)
|
||
{
|
||
var p = plan[i];
|
||
|
||
// ★★★★★ R291 定案(这是 `yawOffset` 从「空操作」变成「真旋钮」的那一行):
|
||
// **侧移方向必须用「未加 `yawOffset` 的基准 yaw」,朝向才用含 offset 的 `p.Yaw`。**
|
||
//
|
||
// 为什么必须解耦(代数,可直接验算):
|
||
// `yawOffset` 加在 `BuildOfficialTrackGates:254`(本函数之前)⇒ `p.Yaw` 已含 offset。
|
||
// 若侧移也用它:`right(yaw+180) = −right(yaw)` ⇒ A 拿到旧 B 的位置 + 旧 B 的朝向,
|
||
// B 拿到旧 A 的位置 + 旧 A 的朝向 ⇒ **{A,B} 集合完全不变** ⇒ `yawOffset` 是**空操作**。
|
||
// (R289 据此白烧一局;R290 立铁律 529。)
|
||
// 解耦后:位置由 `baseYaw` 定(与 offset 无关 ⇒ 位置永不随 offset 动),
|
||
// 朝向 = `p.Yaw` = `baseYaw + offset` ⇒ **每一半绕自己的根部原地转 `offset` 度**
|
||
// ⇒ `yawOffset = 180` 真正把每一半**原地翻 180°**。
|
||
//
|
||
// ★ R291 为什么要翻这一下:**官方 prefab 自己给出了答案(实例级 dump,不是像素反推)**:
|
||
// 客户端运行时对象树 `Jobrace_gongmen_<id>` → `fx_gp_arch_gongmen_blue_lod2(Clone)`
|
||
// → `..Box Volume` 的局部位置 = **x = −8.94**、尺寸 **21.56 × 14.46 × 2.42**
|
||
// (薄边在 local Z ⇒ 门面是 local X‑Y 平面、法向是 local Z)。
|
||
// ⇒ **门体整体偏在 local −X 侧**(不是以根部为中心)。
|
||
// 我们给 `yaw = 该点指向下一检查点`(= 官方公式,R290 已与客户端 `arRot=(0,300,10)` 对上),
|
||
// Unity 约定 local +X = 赛道右侧 ⇒ **local −X = 赛道左侧** ⇒ 现门体朝**路外**长。
|
||
// ⇒ 与「两柱贴路两侧、顶端**朝路中央**收拢」正好相反 ⇒ **这就是用户说的「反了」**。
|
||
//
|
||
// Unity 约定:`forward = (sin yaw, 0, cos yaw)` ⇒ `right = (cos yaw, 0, −sin yaw)`。
|
||
// 直线段上本式与旧的中心差分**完全等价**(旧 n = (−cos yaw, +sin yaw) = −right)
|
||
// ⇒ R288 的 bug 修复性质不变。
|
||
var baseYaw = p.Yaw - cfg.YawOffset; // ★ R291:侧移基准(去掉朝向偏置)
|
||
var ox = 0f;
|
||
var oz = 0f;
|
||
if (shift != 0f)
|
||
{
|
||
var rad = baseYaw * (float)System.Math.PI / 180f;
|
||
ox = (float)System.Math.Cos(rad) * shift;
|
||
oz = -(float)System.Math.Sin(rad) * shift;
|
||
}
|
||
|
||
// A 摆 −right 侧、朝向 `p.Yaw`;B 摆 +right 侧、朝向 `p.Yaw+180°`
|
||
// ⇒ 两半的连线**严格垂直于路面**(同横截面、零前后错位);
|
||
// 两半各自绕根部转 `yawOffset` ⇒ `180` = 两臂朝路中央(R291 默认态)。
|
||
outp.Add(new RacingGatePlacement(
|
||
p.PathId, p.X - ox, p.Y, p.Z - oz, p.Yaw, p.Label + "-A"));
|
||
outp.Add(new RacingGatePlacement(
|
||
p.PathId, p.X + ox, p.Y, p.Z + oz, p.Yaw + 180f, p.Label + "-B"));
|
||
}
|
||
|
||
return outp;
|
||
}
|
||
|
||
private static readonly object RacingGateTableLock = new();
|
||
|
||
/// <summary>同一个 session 只提醒一次「表还没有」——缺采集文件是设置步骤,不是运行错误。</summary>
|
||
private static bool RacingGateNoticed(TcpSession session)
|
||
{
|
||
lock (session.Items)
|
||
{
|
||
if (session.Items.ContainsKey(RacingGateLogKey))
|
||
return false;
|
||
|
||
session.Items[RacingGateLogKey] = true;
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 在一局赛车开始时摆门。由 <c>RunRacingAutoStartAsync</c> 在 DISPLAY 推出去之后调用
|
||
/// (倒计时 3 秒期间门就该在了)。
|
||
/// </summary>
|
||
internal static async Task SpawnRacingGatesAsync(
|
||
TcpSession session, WorldEntryState state, uint trackId, uint taskId)
|
||
{
|
||
var cfg = PrivateServerConfigStore.Current.World.Content.RacingGates;
|
||
if (!cfg.Enabled)
|
||
{
|
||
session.Log.Info("[RACEGATE] ABLATION racingGates.enabled=false — no gates placed");
|
||
return;
|
||
}
|
||
|
||
var table = LoadRacingGateTable(session, cfg);
|
||
|
||
// ① 先清上一局的残留 —— 按 pathId 过滤,绝不动健身器材 / 摊位 / 补给
|
||
// (R234 教训:守卫拿 taskId 做「一局一次」键,两局同 taskId 会原地早退;
|
||
// 所以这里不复用「已摆过」标记,而是**每局先清再摆**,天然幂等。)
|
||
// ★ R252:清哪些 pathId **不能只写死那两个常量** —— 表里可以换成别的物件
|
||
// (本轮就把检查点门从 `Racing_challenge01_CheckPoint` 换成 `SelfRacing_PortalGuide`),
|
||
// 写死常量会漏清 ⇒ 每局在原地叠一批、越跑越多。
|
||
// 所以 = {常量} ∪ {表里出现过的所有 pathId}。
|
||
try
|
||
{
|
||
var purgeIds = new List<int>
|
||
{
|
||
RacingGateCheckPointPathId,
|
||
RacingGateEndPathId,
|
||
SelfRacingPortalGuidePathId, // ★ R253:R252 那轮摆的 16 个「指示牌」必须一起清
|
||
JobraceGongmenPathId, // ★ R253:本轮摆的拱门(自清,防换物件后残留)
|
||
S6RacingCheckPointPathId, // ★ R273:R270 误用的 S6 门(自清,否则换回官方件后叠一层)
|
||
};
|
||
foreach (var p in table)
|
||
{
|
||
if (!purgeIds.Contains(p.PathId))
|
||
purgeIds.Add(p.PathId);
|
||
}
|
||
|
||
var removed = 0;
|
||
foreach (var pid in purgeIds)
|
||
{
|
||
var (_, _, n) = await RemoveGadgetsAsync(session, state, null, pid);
|
||
removed += n;
|
||
}
|
||
|
||
if (removed > 0)
|
||
{
|
||
session.Log.Info($"[RACEGATE] cleared previous batch n={removed} "
|
||
+ $"ids=[{string.Join(",", purgeIds)}]");
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[RACEGATE] clear previous batch failed: {ex.Message}");
|
||
}
|
||
|
||
// ② 决定摆什么
|
||
// ★★★ R255:**默认走服务端内置的官方表**(26 个检查点,含终点)——
|
||
// 客户端那张采集表既丢 `checkpointIndex` 又做了不等距重采样,还被探针**每局重写**,
|
||
// 坐标不能托付给它。`UseOfficialTrack=false` 才回落到「表 / 信标」的 R254 行为。
|
||
List<RacingGatePlacement> plan;
|
||
string source;
|
||
if (cfg.UseOfficialTrack && OfficialTrack3Checkpoints.Length > 0)
|
||
{
|
||
plan = BuildOfficialTrackGates(cfg);
|
||
source = "official-R255";
|
||
}
|
||
else if (table.Count > 0)
|
||
{
|
||
plan = table;
|
||
source = "table";
|
||
}
|
||
else if (cfg.BeaconEnabled)
|
||
{
|
||
plan = BuildRacingGateBeacons(state, cfg);
|
||
source = "beacon";
|
||
}
|
||
else
|
||
{
|
||
if (RacingGateNoticed(session))
|
||
session.Log.Info($"[RACEGATE] no table at {cfg.TablePath} and beacons off — "
|
||
+ "nothing to place (run one lap so the client writes the checkpoint table)");
|
||
return;
|
||
}
|
||
|
||
// ⛔ R258:本块默认**不执行**(`pairMirror: false`)。理由见 `ExpandGatePairs` 的说明 ——
|
||
// 「一个实例只是半扇、要摆两个」是 R254 起的**自造模型**,已被官方摆位数据推翻
|
||
// (官方 14 个静态检查点最近相距 131.69 m,不成对 ⇒ 一个检查点一个实例)。
|
||
// 用户明确要求「门宽由门自带宽,不要我们去调」⇒ 保留代码仅为应急,别默认打开。
|
||
if (cfg.PairMirror)
|
||
{
|
||
var before = plan.Count;
|
||
plan = ExpandGatePairs(plan, cfg);
|
||
session.Log.Info($"[RACEGATE] pair-mirror on: {before} -> {plan.Count} "
|
||
+ $"halfSpan={cfg.PairOffset:F1} (A=center-h, B=center+h)");
|
||
}
|
||
|
||
session.Log.Info($"[RACEGATE] spawn track={trackId} task={taskId} source={source} "
|
||
+ $"n={plan.Count} channel={(GadgetSendChannel)cfg.Channel} iScale={cfg.IScale} "
|
||
+ $"logOnly={cfg.LogOnly}");
|
||
|
||
if (cfg.LogOnly)
|
||
{
|
||
foreach (var p in plan)
|
||
session.Log.Info($"[RACEGATE] (dry) {p.Label} pathId={p.PathId} "
|
||
+ $"at ({p.X:F1},{p.Y:F1},{p.Z:F1}) yaw={p.Yaw:F1}");
|
||
return;
|
||
}
|
||
|
||
// ③ 摆
|
||
var placed = 0;
|
||
foreach (var p in plan)
|
||
{
|
||
try
|
||
{
|
||
var result = await SpawnGadgetAsync(
|
||
session, state, p.PathId, p.X, p.Y, p.Z, p.Yaw,
|
||
(GadgetSendChannel)cfg.Channel, 0UL, cfg.IScale);
|
||
|
||
if (result.Ok)
|
||
{
|
||
placed++;
|
||
}
|
||
else
|
||
{
|
||
session.Log.Warn($"[RACEGATE] place FAILED {p.Label}({p.PathId}) "
|
||
+ $"at ({p.X:F1},{p.Y:F1},{p.Z:F1}): {result.Message}");
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[RACEGATE] place threw {p.Label}({p.PathId}): {ex.Message}");
|
||
}
|
||
}
|
||
|
||
// ★ R252:这里打印**实际用到的** pathId(去重),别再只打那两个常量 ——
|
||
// 否则换表之后日志会骗人(「done 16/16 cpPathId=884611825」而实际摆的是 2017204172)。
|
||
var usedIds = new SortedSet<int>();
|
||
foreach (var p in plan) usedIds.Add(p.PathId);
|
||
session.Log.Info($"[RACEGATE] done {placed}/{plan.Count} source={source} "
|
||
+ $"pathIds=[{string.Join(",", usedIds)}]");
|
||
|
||
// ★ R311:记下「这批门是什么时候摆出去的」—— 撤门宽限期(见 `RemoveCheckpointGatesAsync`)
|
||
// 要靠它算「客户端到底有没有时间把 prefab 加载出来」。
|
||
GateSpawnTick.Remove(session);
|
||
GateSpawnTick.Add(session, [Environment.TickCount64]);
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────────────
|
||
// ★★★★★ R294(2026-10-02)—— **过点即撤门 / 结束清场**
|
||
//
|
||
// 用户原话:「检查点开过去不会消失,而且比赛结束了所有检查点还在」。
|
||
//
|
||
// **真因**:`SpawnRacingGatesAsync` 一局只摆一次(26 点 × 2 半 = 52 个),摆完**从不回收**。
|
||
// 而官方的检查点标记是**跟着当前目标走的**:客户端 `CarRaceManager.lua` 只有一个
|
||
// `self.arrowGO`(`AddArrow` 加载、`RemoveArrow`/`Destroy` 销毁),过点后它被重建到下一个点,
|
||
// 跑完连它一起销毁 ⇒ 玩家看到的是「过点即消失」。我们 spawn 的是**服务端 gadget**,
|
||
// 客户端不知道它们的存在,所以永远不会自己消失。
|
||
//
|
||
// **撤哪几个**:客户端每过一个检查点就上报一条
|
||
// `AskSetTaskCounterValue(task=60000147, index=k, value=1)`(见 `GameRouter.RacingVehicle`),
|
||
// 而 **index 恰好等于我们的检查点下标** —— 客户端 `taskCounterList` 就是我们 Lua 喂的
|
||
// `R255_OFFICIAL_CP`(`racing_guard.lua:1077-1080, 1211`),
|
||
// 过 `list[k+1]`(= 我们的 cp k)时报 `index = (k+1) - 1 = k`。
|
||
//
|
||
// ⛔ **不能按 pathId 撤**:一局的 52 个门**同一个 pathId**(`Jobrace_gongmen`),
|
||
// 按 pathId 撤会把还没跑到的一起抹掉。所以按**坐标就近**挑出那一对:
|
||
// 官方 259 点折线上相邻检查点最近相距 **79 m**(cp00→cp01),而 A/B 只差 ±8 m,
|
||
// ⇒ 20 m 半径既能捞出完整的 2 个,又绝不会误伤隔壁。
|
||
// ────────────────────────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>过点撤门时的坐标匹配半径(米)。见上方 R294 段:相邻检查点最小间距 79 m。</summary>
|
||
private const float RacingGateRemoveRadiusM = 20f;
|
||
|
||
/// <summary>
|
||
/// R311(2026-10-03)· 本会话**最近一次摆门**的时刻(<see cref="Environment.TickCount64"/>)。
|
||
/// 只服务「撤门宽限期」一件事;`ConditionalWeakTable` ⇒ 会话结束自动回收,不留引用。
|
||
/// </summary>
|
||
private static readonly ConditionalWeakTable<TcpSession, long[]> GateSpawnTick = new();
|
||
|
||
/// <summary>
|
||
/// R312(2026-10-03)· 被「**前 N 个门延迟撤**」规则挂起的检查点下标(每会话一份)。
|
||
/// <para>用户原话:「前两个门设置为**到第三个门之后再消失**」。这里就是那个「挂起名单」——
|
||
/// `cp00`/`cp01` 上报通过时**不撤**,只登记;等 `cp02` 通过时连同 `cp02` 一起撤。
|
||
/// 元素类型用 `List<int>`(而非 `HashSet`)是为了日志里能按「通过的先后」原样打出来。</para>
|
||
/// <para>`ConditionalWeakTable` ⇒ 会话结束自动回收,不跨局/跨人泄漏。</para>
|
||
/// </summary>
|
||
private static readonly ConditionalWeakTable<TcpSession, List<int>> DeferredGatePass = new();
|
||
|
||
/// <summary>R294:撤这一局可能摆过的**所有**赛车门 pathId。</summary>
|
||
internal static int[] RacingGatePathIdSet(RacingGateSettings cfg)
|
||
{
|
||
var set = new SortedSet<int>
|
||
{
|
||
RacingGateCheckPointPathId,
|
||
RacingGateEndPathId,
|
||
SelfRacingPortalGuidePathId,
|
||
JobraceGongmenPathId,
|
||
S6RacingCheckPointPathId,
|
||
};
|
||
if (cfg.PathIdOverride != 0) set.Add(cfg.PathIdOverride);
|
||
return [.. set];
|
||
}
|
||
|
||
/// <summary>
|
||
/// R294:撤掉**第 <paramref name="cpIndex"/> 个检查点**那对门(A/B 两半)。
|
||
/// 返回撤掉的个数(0 = 这个点本来就没摆 / 已经撤过)。
|
||
/// </summary>
|
||
internal static async Task<int> RemoveCheckpointGatesAsync(
|
||
TcpSession session, WorldEntryState state, int cpIndex)
|
||
{
|
||
if (cpIndex < 0 || cpIndex >= OfficialTrack3Checkpoints.Length)
|
||
return 0;
|
||
|
||
// ★★★★★ R311(2026-10-03)· **撤门宽限期** —— 用户原话「**不是所有门都没有了啊,还是前两个**」。
|
||
//
|
||
// `console-20261003-015034.log` 时序(一局 52 个门在 `01:53:50` 同一秒全摆完):
|
||
// 摆门 +0 s → cp00 上报通过 +5 s → cp01 +9 s → cp02 +13 s → cp03 +16 s …
|
||
// 客户端按 `BabyDriver_CheckPointRange=22`(m) 上报「已过点」,服务端**上报即撤**
|
||
// ⇒ **前两个检查点的门在「客户端把 `Jobrace_gongmen` prefab 加载完并实例化」之前
|
||
// 就被 remove 抵消掉了** —— 这就是「前两个看不到、第三个起正常」的全部原因
|
||
// (第三个那对活过了首次加载的冷启动窗口)。
|
||
//
|
||
// ⛔ 不在这里 `await Task.Delay`:本方法的调用者 `GameRouter.RacingVehicle`
|
||
// 紧接着要 `await conn.ReturnEmptyOkAsync(msg)` 回执,拖十几秒客户端会判超时。
|
||
// ⇒ **丢给后台任务到点补撤**(`session.Active` 当守卫;`RemoveCheckpointGatesAsync`
|
||
// 本身按坐标重算 id 且幂等 ⇒ 重复调用安全)。
|
||
var gateCfg = PrivateServerConfigStore.Current.World.Content.RacingGates;
|
||
|
||
// ★★★★★ R312 · **用户指定规则**(2026-10-03 02:5x 原话):
|
||
// 「把前两个门的消失规则改一下,前两个门设置为**到第三个门之后再消失**,
|
||
// 第三个门后的所有门都维持现状」
|
||
//
|
||
// 【为什么不再靠「猜 prefab 加载耗时」】R311 把宽限期从「上报即撤」抬到 12 s,
|
||
// 用户实测仍是「没有任何变化」;而 `R311 kids` 诊断已证明门 GameObject
|
||
// **确实在客户端里**(`findGate(this cp)=11`,cp1..cp5 全命中)。
|
||
// ⇒ 与其继续调常量,不如把前两个门的生存期**锚到玩家的真实进度**上:
|
||
// 活到「第三个门被通过」为止 —— 这比任何秒数都稳。
|
||
//
|
||
// 【实现】前 `gateKeepFirstNCount` 个检查点:**登记挂起、不撤**;
|
||
// 走到 `cpIndex >= gateKeepFirstNCount`(= 第三个门)时,把**挂起的 + 当前的**
|
||
// 一次性撤掉。`gateKeepFirstNCount = 0` ⇒ 本条完全关闭,回到 R311 纯宽限期行为。
|
||
// ⛔ 撤挂起时走 `...CoreAsync` **绕过本分支** —— 否则那两个点会被再次挂起 ⇒ 永远撤不掉。
|
||
// ⚠ 挂起表按 session 存(`ConditionalWeakTable`)⇒ 换局/换人自动隔离,不跨局泄漏。
|
||
if (gateCfg.GateKeepFirstNCount > 0)
|
||
{
|
||
var held = DeferredGatePass.GetOrCreateValue(session);
|
||
if (cpIndex < gateCfg.GateKeepFirstNCount)
|
||
{
|
||
lock (held)
|
||
{
|
||
if (!held.Contains(cpIndex)) held.Add(cpIndex);
|
||
}
|
||
session.Log.Info($"[RACEGATE] cp{cpIndex:D2} pass HELD (R312: first "
|
||
+ $"{gateCfg.GateKeepFirstNCount} gates survive until cp"
|
||
+ $"{gateCfg.GateKeepFirstNCount:D2} is passed)");
|
||
return 0;
|
||
}
|
||
|
||
List<int> release;
|
||
lock (held)
|
||
{
|
||
release = [.. held];
|
||
held.Clear();
|
||
}
|
||
if (release.Count > 0)
|
||
{
|
||
session.Log.Info($"[RACEGATE] cp{cpIndex:D2} reached -> releasing held gates "
|
||
+ $"[{string.Join(",", release)}] (R312)");
|
||
foreach (var h in release)
|
||
{
|
||
try
|
||
{
|
||
await RemoveCheckpointGatesCoreAsync(session, state, h, gateCfg);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[RACEGATE] cp{h:D2} held-release failed: {ex.Message}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (gateCfg.GateMinLifetimeMs > 0 && GateSpawnTick.TryGetValue(session, out var spawnBox))
|
||
{
|
||
var gateAge = Environment.TickCount64 - spawnBox[0];
|
||
if (gateAge < gateCfg.GateMinLifetimeMs)
|
||
{
|
||
var waitMs = (int)(gateCfg.GateMinLifetimeMs - gateAge);
|
||
var dsess = session;
|
||
var dstate = state;
|
||
var dIndex = cpIndex;
|
||
session.Log.Info($"[RACEGATE] cp{cpIndex:D2} pass deferred {waitMs}ms "
|
||
+ $"(gate age {gateAge}ms < gateMinLifetimeMs={gateCfg.GateMinLifetimeMs}) "
|
||
+ "— 客户端可能还没把 Jobrace_gongmen 加载出来(R311 宽限期)");
|
||
_ = Task.Run(async () =>
|
||
{
|
||
try
|
||
{
|
||
await Task.Delay(waitMs);
|
||
// ⚠ `TcpSession` 没有 `Active` 成员(`Balloon.cs` 里的 `session.Active`
|
||
// 是另一套运行时对象的字段)⇒ 这里用最廉价的连通性判据 + try/catch 兜底。
|
||
if (dsess.Client is null || !dsess.Client.Connected)
|
||
{
|
||
dsess.Log.Info($"[RACEGATE] cp{dIndex:D2} deferred remove skipped (session gone)");
|
||
return;
|
||
}
|
||
// ⛔ R312:这里**必须**走 `...CoreAsync` —— 走公共方法会重新命中
|
||
// 「前 N 个挂起」分支,把自己又挂回去(永不撤除)。
|
||
var n = await RemoveCheckpointGatesCoreAsync(dsess, dstate, dIndex, gateCfg);
|
||
dsess.Log.Info($"[RACEGATE] cp{dIndex:D2} deferred remove done n={n}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
dsess.Log.Warn($"[RACEGATE] cp{dIndex:D2} deferred remove failed: {ex.Message}");
|
||
}
|
||
});
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
return await RemoveCheckpointGatesCoreAsync(session, state, cpIndex, gateCfg);
|
||
}
|
||
|
||
/// <summary>
|
||
/// R312:**真正的按坐标撤门**(纯执行,不含任何策略/宽限期/挂起逻辑)。
|
||
/// <para>抽出来是为了让「R311 撤门宽限期」和「R312 前 N 个门延迟到第 N+1 个通过再撤」
|
||
/// 两条策略都能调它,而**不会互相重入** —— 否则 `cpIndex < keepFirstN` 的那两个点
|
||
/// 会在递归里被**再次挂起** ⇒ 永远撤不掉(自己给自己下套)。</para>
|
||
/// </summary>
|
||
private static async Task<int> RemoveCheckpointGatesCoreAsync(
|
||
TcpSession session, WorldEntryState state, int cpIndex, RacingGateSettings gateCfg)
|
||
{
|
||
var c = OfficialTrack3Checkpoints[cpIndex];
|
||
var keep = RacingGatePathIdSet(gateCfg);
|
||
var r2 = RacingGateRemoveRadiusM * RacingGateRemoveRadiusM;
|
||
|
||
List<ulong> ids;
|
||
lock (state.SyncRoot)
|
||
{
|
||
ids = state.SpawnedGadgets.Values
|
||
.Where(r => Array.IndexOf(keep, r.PathId) >= 0)
|
||
.Where(r =>
|
||
{
|
||
var dx = r.X - c.X;
|
||
var dz = r.Z - c.Z;
|
||
return dx * dx + dz * dz <= r2;
|
||
})
|
||
.Select(r => r.InstanceId)
|
||
.ToList();
|
||
}
|
||
|
||
if (ids.Count == 0)
|
||
return 0;
|
||
|
||
var (_, _, n) = await RemoveGadgetsAsync(session, state, null, 0, ids);
|
||
session.Log.Info($"[RACEGATE] cp{cpIndex:D2} passed -> removed n={n} "
|
||
+ $"ids=[{string.Join(",", ids)}] (at {c.X:F1},{c.Z:F1})");
|
||
return n;
|
||
}
|
||
|
||
/// <summary>R294:撤掉这一局摆过的**全部**赛车门(比赛结束 / 完成上报时调)。</summary>
|
||
internal static async Task<int> RemoveAllRacingGatesAsync(TcpSession session, WorldEntryState state)
|
||
{
|
||
var keep = RacingGatePathIdSet(PrivateServerConfigStore.Current.World.Content.RacingGates);
|
||
var total = 0;
|
||
foreach (var pid in keep)
|
||
{
|
||
var (_, _, n) = await RemoveGadgetsAsync(session, state, null, pid);
|
||
total += n;
|
||
}
|
||
|
||
if (total > 0)
|
||
session.Log.Info($"[RACEGATE] cleared ALL racing gates n={total} "
|
||
+ $"pathIds=[{string.Join(",", keep)}]");
|
||
return total;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 读客户端 R247 落下的检查点表。文件不存在 / 解析失败都只是「还没有坐标」,
|
||
/// 退回信标模式,绝不抛。
|
||
/// </summary>
|
||
private static List<RacingGatePlacement> LoadRacingGateTable(TcpSession session, RacingGateSettings cfg)
|
||
{
|
||
var result = new List<RacingGatePlacement>();
|
||
try
|
||
{
|
||
string path;
|
||
if (Path.IsPathRooted(cfg.TablePath))
|
||
{
|
||
path = cfg.TablePath;
|
||
}
|
||
else
|
||
{
|
||
var root = PrivateServerConfigStore.ResolveProjectPath(".");
|
||
path = Path.Combine(root, cfg.TablePath);
|
||
}
|
||
|
||
if (!File.Exists(path))
|
||
{
|
||
if (RacingGateNoticed(session))
|
||
session.Log.Info($"[RACEGATE] checkpoint table not found at {path}");
|
||
return result;
|
||
}
|
||
|
||
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||
|
||
if (!doc.RootElement.TryGetProperty("items", out var items)
|
||
|| items.ValueKind != JsonValueKind.Array)
|
||
{
|
||
if (RacingGateNoticed(session))
|
||
session.Log.Warn($"[RACEGATE] {path} has no 'items' array");
|
||
return result;
|
||
}
|
||
|
||
foreach (var node in items.EnumerateArray())
|
||
{
|
||
if (!node.TryGetProperty("pathId", out var pidNode)
|
||
|| pidNode.ValueKind != JsonValueKind.Number
|
||
|| !pidNode.TryGetInt32(out var pathId)
|
||
|| pathId == 0)
|
||
continue;
|
||
|
||
// ★★ R254:钉死 pathId —— 这张表是**客户端 R247 探针每局重写**的,
|
||
// 写进去的是客户端那套 id(884611825 / 271608686,资源从未随包 ⇒ 隐形)。
|
||
// R253 手工改成 64493331 后,用户跑一局就被覆盖回去了 ⇒ 必须服务端强制。
|
||
if (cfg.PathIdOverride != 0)
|
||
pathId = cfg.PathIdOverride;
|
||
|
||
if (!node.TryGetProperty("pos", out var posNode)
|
||
|| posNode.ValueKind != JsonValueKind.Array
|
||
|| posNode.GetArrayLength() < 3)
|
||
continue;
|
||
|
||
var xyz = new float[3];
|
||
var i = 0;
|
||
foreach (var c in posNode.EnumerateArray())
|
||
{
|
||
if (i >= 3) break;
|
||
xyz[i++] = c.ValueKind == JsonValueKind.Number ? c.GetSingle() : 0f;
|
||
}
|
||
|
||
var yaw = 0f;
|
||
if (node.TryGetProperty("yaw", out var yawNode) && yawNode.ValueKind == JsonValueKind.Number)
|
||
yaw = yawNode.GetSingle();
|
||
|
||
var label = node.TryGetProperty("name", out var nameNode)
|
||
&& nameNode.ValueKind == JsonValueKind.String
|
||
? nameNode.GetString() ?? ""
|
||
: $"gate{result.Count + 1}";
|
||
|
||
result.Add(new RacingGatePlacement(pathId, xyz[0], xyz[1], xyz[2], yaw, label));
|
||
}
|
||
|
||
if (result.Count > 0)
|
||
session.Log.Info($"[RACEGATE] table loaded n={result.Count} from {path}");
|
||
else if (RacingGateNoticed(session))
|
||
session.Log.Info($"[RACEGATE] table at {path} parsed to 0 usable rows");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
if (RacingGateNoticed(session))
|
||
session.Log.Warn($"[RACEGATE] table read failed: {ex.Message}");
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 「验证信标」:在玩家四周各摆一道门。用途只有一个 ——
|
||
/// 把「通道对不对」与「坐标对不对」解耦。看得见 ⇒ 通道没问题,只差那张坐标表。
|
||
/// </summary>
|
||
private static List<RacingGatePlacement> BuildRacingGateBeacons(
|
||
WorldEntryState state, RacingGateSettings cfg)
|
||
{
|
||
var list = new List<RacingGatePlacement>();
|
||
|
||
float px, py, pz;
|
||
lock (state.SyncRoot)
|
||
{
|
||
var p = state.LastReportedPlayerPosition;
|
||
px = p.X;
|
||
py = p.Y;
|
||
pz = p.Z;
|
||
}
|
||
|
||
var ahead = cfg.BeaconAhead <= 0f ? 40f : cfg.BeaconAhead;
|
||
(float Dx, float Dz, string Tag)[] dirs =
|
||
[
|
||
(1f, 0f, "E"),
|
||
(-1f, 0f, "W"),
|
||
(0f, 1f, "N"),
|
||
(0f, -1f, "S"),
|
||
];
|
||
|
||
foreach (var d in dirs)
|
||
{
|
||
list.Add(new RacingGatePlacement(
|
||
SelfRacingPortalGuidePathId,
|
||
px + d.Dx * ahead,
|
||
py,
|
||
pz + d.Dz * ahead,
|
||
0f,
|
||
"beacon-" + d.Tag));
|
||
}
|
||
|
||
return list;
|
||
}
|
||
}
|