This commit is contained in:
SepComet
2026-04-06 11:58:36 +08:00
parent b1b38b485e
commit aebc4011c7
84 changed files with 5796 additions and 238 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-05
@@ -0,0 +1,81 @@
## Context
本地回环测试中受控玩家出现小幅抖动。抖动根源之一是 `ReplayPendingInputs()` 中回放时对每个 `PredictedMoveStep` 的一次性大时长积分,与实时预测路径中 FixedUpdate 按 `Time.fixedDeltaTime` 逐步积分的形状不一致。
当前 `ReplayPendingInputs()` 实现:
```csharp
foreach (var replayInput in replayInputs)
{
ApplyTankMovementToPredictedState(
replayInput.Input.TurnInput,
replayInput.Input.ThrottleInput,
replayInput.SimulatedDurationSeconds); // 一次性传入总时长
}
```
Tank 运动学中旋转影响前进方向:`heading(t+dt) = heading(t) + turnInput * turnSpeed * dt`,`position(t+dt) = position(t) + forward(heading(t+dt)) * throttleSpeed * dt`。逐步积分和一次性积分在 dt 较大时产生分歧。
## Goals / Non-Goals
**Goals:**
- `ReplayPendingInputs()` 按固定步长逐步积分,与 FixedUpdate 预测路径完全一致
- 回放结果与逐步实时预测的轨迹一致,消除因积分形状不同导致的残余误差
- 不改变外部接口,只修改内部积分方式
**Non-Goals:**
- 不修改服务端的 50ms cadence
- 不解决 send interval 摆动问题(Step 3 范畴)
- 不修改 visual correction 逻辑(Step 4 范畴)
## Decisions
### Decision: 步长取服务端的 SimulationInterval(50ms),而非客户端的 Time.fixedDeltaTime(20ms)
**选择**:按服务端 `SimulationInterval`(50ms)作为回放步长。
**理由**:
- 服务端以 50ms 步长积分产生 authoritative state,客户端回放必须与其一致才能消除偏差
- 客户端 FixedUpdate 20ms 是渲染/物理步长,不代表服务端模拟粒度
- 每个 `PredictedMoveStep` 的 `SimulatedDurationSeconds` 可能是 50ms、100ms 等,按 50ms 步长逐次推进即可
**替代方案**:
- 用 20ms 步长回放:与客户端 FixedUpdate 一致,但与服务端不同步,仍会产生偏差
- 用 `SimulatedDurationSeconds` 作为单步:即当前行为,会导致非线性分歧
### Decision: 循环内部分步模拟,不引入新的状态累积
**选择**:在 `ReplayPendingInputs` 循环内按 50ms 步长迭代调用 `ApplyTankMovementToPredictedState`。
**实现方式**:
```csharp
private void ReplayPendingInputs(IReadOnlyList<PredictedMoveStep> replayInputs)
{
const float serverStepSeconds = 0.05f; // 50ms,服务端 SimulationInterval
foreach (var replayInput in replayInputs)
{
var remaining = replayInput.SimulatedDurationSeconds;
while (remaining > 0f)
{
var step = Mathf.Min(remaining, serverStepSeconds);
ApplyTankMovementToPredictedState(
replayInput.Input.TurnInput,
replayInput.Input.ThrottleInput,
step);
remaining -= step;
}
}
// ...
}
```
**理由**:
- 不改变 `PredictedMoveStep` 结构体接口,只修改消费方式
- 无需新增临时状态变量
- 逻辑清晰,与实时预测路径的积分形状完全一致
## Risks / Trade-offs
- **[风险]** 如果 `SimulatedDurationSeconds` 累计值有浮点误差,循环可能产生多一步或少一步的小偏差
- **缓解**:使用 `Mathf.Min(remaining, step)` 保护,最后一步自然截断;或对 `remaining -= step` 后加 epsilon 比较
- **[风险]** 50ms 步长对极短的输入(比如只有一帧的输入)会产生额外计算
- **可接受**:额外一次函数调用,代价可忽略
@@ -0,0 +1,23 @@
## Why
本地回环测试中出现受控玩家(controlled player)持续小幅抖动。经分析,问题根源之一是回滚重放(replay)时积分粒度与实时预测不一致:实时预测在 FixedUpdate 中按 `Time.fixedDeltaTime`(20ms)逐步积分,而回放时把某个输入的累计时长一次性喂给 `ApplyTankMovementToPredictedState()`。Tank 运动学是非线性的——边转向边前进时,每步的旋转角影响下一步的前进方向,导致逐步积分和一次性积分的轨迹不同。这种偏差在每次对账后出现,被 visual correction 反复拉回,表现为细碎抖动。
## What Changes
1. **修改 `ReplayPendingInputs()` 的积分方式**:将一次性大时长积分改为固定步长的逐步积分,与 `FixedUpdate` 预测路径的积分形状完全一致
2. **`PredictedMoveStep.SimulatedDurationSeconds` 的处理语义变更**:`SimulatedDurationSeconds` 仍记录该输入的总模拟时长,但 replay 时按服务端的 50ms 步长(`ServerAuthoritativeMovementConfiguration.SimulationInterval`)进行分步模拟
3. **添加测试**:比较相同输入序列下逐步预测和回放预测的轨迹一致性,验证修复效果
## Capabilities
### New Capabilities
- `client-prediction-replay-granularity`: 定义客户端回放预测与实时预测使用相同积分步长的行为约束和验证方式
### Modified Capabilities
- `client-gameplay-input`: 扩展 `ReplayPendingInputs` 的实现要求,明确回放必须使用固定步长逐步积分而非一次性累积积分
## Impact
- **涉及代码**:`Assets/Scripts/MovementComponent.cs` 中的 `ReplayPendingInputs()` 方法
- **涉及测试**:`Assets/Tests/EditMode/Network/GameplayFlowRoundTripTests.cs` 或新建回归测试
- **其他系统**:`PredictedMoveStep` 结构体(`ClientPredictionBuffer.cs`)的语义略有调整,但接口不变
@@ -0,0 +1,23 @@
# client-gameplay-input Specification
## MODIFIED Requirements
### Requirement: Controlled client movement input preserves immediate prediction and explicit stop signaling
The MVP client SHALL capture movement intent for the controlled player in Unity-side input code, apply local movement prediction immediately, and send `MoveInput` updates through the networking boundary. When movement input transitions from non-zero to idle, the client MUST send one final zero-vector `MoveInput` so authoritative movement can stop cleanly. When the client reconciles against authoritative state and replays pending `MoveInput` messages, the replay path MUST apply each pending input in fixed-duration substeps matching the server authoritative movement cadence, so that replay trajectory matches live prediction trajectory for the same input sequence.
#### Scenario: Controlled player moves locally without waiting for the network
- **WHEN** the controlled player provides non-zero movement input
- **THEN** the client applies local movement prediction immediately for presentation
- **THEN** the client submits a `MoveInput` carrying the current player id, tick, and planar movement vector through the networking send path
#### Scenario: Releasing movement emits an explicit stop update
- **WHEN** the controlled player releases movement input after previously providing non-zero movement
- **THEN** the client sends exactly one final `MoveInput` whose movement vector is zero
- **THEN** local predicted movement also stops immediately without waiting for authoritative correction
#### Scenario: Replay uses fixed-step substeps matching server cadence
- **WHEN** the client accepts an authoritative `PlayerState` and replays pending `MoveInput` messages
- **THEN** each `PredictedMoveStep` is consumed by applying its input in fixed-duration substeps equal to the server authoritative movement cadence
- **THEN** the replay accumulation shape is identical to the live FixedUpdate prediction path for the same input values
- **THEN** non-linear trajectories (e.g. simultaneous turn-and-move) produce the same result in both replay and live prediction
@@ -0,0 +1,36 @@
# client-prediction-replay-granularity Specification
## Purpose
Define the contract that client-side replay of pending movement inputs MUST use fixed-step accumulation matching the server authoritative movement cadence, not a single accumulated duration, so that replay trajectory matches live prediction trajectory for the same input sequence.
## Requirements
### Requirement: Replay uses fixed-step accumulation matching server cadence
The controlled-client prediction replay path SHALL consume each pending `PredictedMoveStep` by applying its input in fixed-duration substeps equal to the server authoritative movement cadence, regardless of the step's total `SimulatedDurationSeconds`. The replay accumulation shape MUST be identical to the live `FixedUpdate` prediction path for the same input values.
#### Scenario: Replay produces same trajectory as live prediction for steady input
- **WHEN** the client replays a `PredictedMoveStep` with turn=0, throttle=1, duration=0.15s using a 0.05s server cadence
- **THEN** the replay applies 0.05s + 0.05s + 0.05s substeps in sequence
- **THEN** the final predicted position matches the position that would result from three consecutive FixedUpdate predictions of 0.05s each with the same input
#### Scenario: Replay produces same trajectory as live prediction for turn-and-move input
- **WHEN** the client replays a `PredictedMoveStep` with turn=0.5, throttle=1, duration=0.10s using a 0.05s server cadence
- **THEN** the replay applies two 0.05s substeps where each substep's heading affects the next substep's forward direction
- **THEN** the final predicted heading and position match the live prediction path for the same input sequence
#### Scenario: Replay handles non-multiples of cadence interval
- **WHEN** the client replays a `PredictedMoveStep` with duration=0.12s using a 0.05s cadence
- **THEN** the replay applies 0.05s + 0.05s + 0.02s substeps sequentially
- **THEN** no remaining duration is lost or double-counted
### Requirement: Replay trajectory determinism is verifiable
The client prediction system SHOULD provide a deterministic way to verify that replay and live prediction produce identical trajectories for a given input sequence, enabling regression coverage.
#### Scenario: Replay and live prediction produce identical results
- **WHEN** a controlled client records a `MoveInput` sequence during live play
- **AND** the client triggers reconciliation and replays those same inputs
- **THEN** the final predicted pose after replay equals the predicted pose that would result from live FixedUpdate simulation for the same input sequence
- **THEN** the result is stable across multiple replays of the same input sequence
@@ -0,0 +1,23 @@
## 1. 理解和实现
- [x] 1.1 理解 `ApplyTankMovementToPredictedState` 的积分逻辑(旋转 → 前进方向的依赖关系)
- [x] 1.2 理解当前 `ReplayPendingInputs` 的一次性积分行为与问题
- [x] 1.3 在 `MovementComponent` 中引入服务端 `SimulationInterval` 的引用(50ms 步长常量)
## 2. 修改 `ReplayPendingInputs` 实现
- [x] 2.1 修改 `ReplayPendingInputs` 循环,将每个 `PredictedMoveStep` 的总时长按 50ms 步长分步模拟
- [x] 2.2 添加浮点截断保护,确保所有时长都被消耗而无遗失
- [x] 2.3 验证修改后的实现与 `FixedUpdate` 预测路径的积分形状一致
## 3. 添加回归测试
- [x] 3.1 在 `GameplayFlowRoundTripTests.cs` 或新建测试文件中添加轨迹一致性测试
- [x] 3.2 测试用例:相同 turn+throttle 输入序列,逐步预测 vs 回放预测的最终位置和旋转相等
- [x] 3.3 测试用例:非线性运动(同时转向和前进),验证逐步积分与一次性积分的结果不同
- [x] 3.4 测试用例:非 50ms 倍数的总时长(如 0.12s),验证分步后无遗失
## 4. 验证
- [ ] 4.1 运行所有 EditMode 测试确保无回归(在 Unity Editor 内执行)
- [ ] 4.2 本地回环验证抖动是否改善