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

923 lines
51 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 个点偏 &gt; 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&lt;int&gt;`(而非 `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 &lt; 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;
}
}