Commit f83c78ac authored by 廖洪发's avatar 廖洪发

feat(game-mechanics): 添加投篮判定验证系统

- 引入shot-validator模块,实现服务端权威投篮判定模型
- 添加ShotLedger用于防重放和限频控制
- 修改MatchManager中的投篮流程,使用finalizeShot统一处理投篮逻辑
- 客户端只负责展示,最终进球判定由服务端模型决定
- 修复AI自动出手函数参数缺失问题
- 在场景文件中调整组件ID引用以适配新结构

test(logic-test): 添加投篮判定模型单元测试

- 实现validateShot函数的完整测试用例
- 验证确定性行为:相同输入产生相同结果
- 测试完美甩动和偏移甩动的不同判定结果
- 验证参数校验机制(越界、NaN等异常情况)
- 测试防重放和频率限制功能
parent ac20a2a0
...@@ -52,7 +52,7 @@ ...@@ -52,7 +52,7 @@
}, },
"autoReleaseAssets": false, "autoReleaseAssets": false,
"_globals": { "_globals": {
"__id__": 10 "__id__": 11
}, },
"_id": "a7b8c9d0-1e2f-3a4b-5c6d-7e8f90a1b2c3" "_id": "a7b8c9d0-1e2f-3a4b-5c6d-7e8f90a1b2c3"
}, },
...@@ -74,9 +74,6 @@ ...@@ -74,9 +74,6 @@
], ],
"_active": true, "_active": true,
"_components": [ "_components": [
{
"__id__": 6
},
{ {
"__id__": 7 "__id__": 7
}, },
...@@ -85,6 +82,9 @@ ...@@ -85,6 +82,9 @@
}, },
{ {
"__id__": 9 "__id__": 9
},
{
"__id__": 10
} }
], ],
"_prefab": null, "_prefab": null,
...@@ -218,7 +218,11 @@ ...@@ -218,7 +218,11 @@
}, },
"_children": [], "_children": [],
"_active": true, "_active": true,
"_components": [], "_components": [
{
"__id__": 6
}
],
"_prefab": null, "_prefab": null,
"_lpos": { "_lpos": {
"__type__": "cc.Vec3", "__type__": "cc.Vec3",
...@@ -249,6 +253,18 @@ ...@@ -249,6 +253,18 @@
}, },
"_id": "dR3nP6sT8uV0wX2yZ4aB5cD7eF" "_id": "dR3nP6sT8uV0wX2yZ4aB5cD7eF"
}, },
{
"__type__": "9a0b1wtPk9aa3yNng8aKzxN",
"_name": "",
"_objFlags": 0,
"__editorExtras__": {},
"node": {
"__id__": 5
},
"_enabled": true,
"__prefab": null,
"_id": "0c2okEc+tI0Jzb/fOVF3I1"
},
{ {
"__type__": "cc.Canvas", "__type__": "cc.Canvas",
"_name": "", "_name": "",
...@@ -332,28 +348,28 @@ ...@@ -332,28 +348,28 @@
{ {
"__type__": "cc.SceneGlobals", "__type__": "cc.SceneGlobals",
"ambient": { "ambient": {
"__id__": 11 "__id__": 12
}, },
"shadows": { "shadows": {
"__id__": 12 "__id__": 13
}, },
"_skybox": { "_skybox": {
"__id__": 13 "__id__": 14
}, },
"fog": { "fog": {
"__id__": 14 "__id__": 15
}, },
"octree": { "octree": {
"__id__": 15 "__id__": 16
}, },
"skin": { "skin": {
"__id__": 16 "__id__": 17
}, },
"lightProbeInfo": { "lightProbeInfo": {
"__id__": 17 "__id__": 18
}, },
"postSettings": { "postSettings": {
"__id__": 18 "__id__": 19
}, },
"bakedWithStationaryMainLight": false, "bakedWithStationaryMainLight": false,
"bakedWithHighpLightmap": false "bakedWithHighpLightmap": false
......
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
import { clamp, lerp, rand, dist as utilDist, sign as utilSign, sfxBounce, sfxCatch, sfxScore, sfxSwish, sfxBlock, sfxWhistle, sfxClick, vibrate } from './Utils'; import { clamp, lerp, rand, dist as utilDist, sign as utilSign, sfxBounce, sfxCatch, sfxScore, sfxSwish, sfxBlock, sfxWhistle, sfxClick, vibrate } from './Utils';
import { FlickState, InputFrame } from './InputManager'; import { FlickState, InputFrame } from './InputManager';
import { validateShot, ShotLedger, ShotResult } from './shot-validator';
export const GRAVITY = 1500; // y 向下 export const GRAVITY = 1500; // y 向下
...@@ -85,8 +86,12 @@ export interface MatchState { ...@@ -85,8 +86,12 @@ export interface MatchState {
fakeT: number; // 假动作后的出手窗口(>0 时出手 = 空位加成) fakeT: number; // 假动作后的出手窗口(>0 时出手 = 空位加成)
aiBaitT: number; // AI 被假动作诱导的延迟起跳倒计时 aiBaitT: number; // AI 被假动作诱导的延迟起跳倒计时
aiBaitJump: boolean; // 该帧 AI 被骗起跳 aiBaitJump: boolean; // 该帧 AI 被骗起跳
pendingShot: ShotResult | null; // 本次投篮的权威判定(投篮判定模型)
} }
// 投篮判定账本(防重放 + 限频;服务器端每场一个实例)
const shotLedger = new ShotLedger(300);
// ---------- 实体创建 ---------- // ---------- 实体创建 ----------
function makePlayer(idx: number, x: number, isAI: boolean, difficulty: number, name: string): PlayerState { function makePlayer(idx: number, x: number, isAI: boolean, difficulty: number, name: string): PlayerState {
return { return {
...@@ -123,7 +128,8 @@ export function createMatch(mode: string, difficulty: number): MatchState { ...@@ -123,7 +128,8 @@ export function createMatch(mode: string, difficulty: number): MatchState {
lastScorer: 0, lastTouch: 0, lastScorer: 0, lastTouch: 0,
aim: null, dunk: null, stealCd: 0, aim: null, dunk: null, stealCd: 0,
coinsEarned: 0, baseCoins: 0, result: '', overT: 0, coinsEarned: 0, baseCoins: 0, result: '', overT: 0,
fakeT: 0, aiBaitT: 0, aiBaitJump: false fakeT: 0, aiBaitT: 0, aiBaitJump: false,
pendingShot: null
}; };
} }
...@@ -244,7 +250,7 @@ export function ballUpdate(ball: BallState, dt: number): { score: number } | nul ...@@ -244,7 +250,7 @@ export function ballUpdate(ball: BallState, dt: number): { score: number } | nul
// ---------- 出手(自动弹道:最高点越过篮圈上方,保证下落进筐)---------- // ---------- 出手(自动弹道:最高点越过篮圈上方,保证下落进筐)----------
// openShot:假动作骗起防守后的空位出手(干扰判定与盖帽概率大幅降低) // openShot:假动作骗起防守后的空位出手(干扰判定与盖帽概率大幅降低)
export function launchAuto(match: MatchState, shooter: PlayerState, q: number, openShot?: boolean): void { export function launchAuto(match: MatchState, shooter: PlayerState, q: number, openShot?: boolean, mode: 'auto' | 'ai' = 'auto'): void {
const ball = match.ball; const ball = match.ball;
const opp = match.players[1 - shooter.idx]; const opp = match.players[1 - shooter.idx];
const nearRim = Math.abs(shooter.x - COURT.rimX) < 78; const nearRim = Math.abs(shooter.x - COURT.rimX) < 78;
...@@ -278,7 +284,7 @@ export function launchAuto(match: MatchState, shooter: PlayerState, q: number, o ...@@ -278,7 +284,7 @@ export function launchAuto(match: MatchState, shooter: PlayerState, q: number, o
vy += rand(-jitter * 0.8, jitter * 0.8); vy += rand(-jitter * 0.8, jitter * 0.8);
const value = (bx < COURT.threeX) ? 3 : 2; const value = (bx < COURT.threeX) ? 3 : 2;
releaseBall(match, shooter, bx, by, vx, vy, nearRim ? 2 : value, q, openShot); finalizeShot(match, shooter, bx, by, vx, vy, nearRim ? 2 : value, q, mode, openShot);
} }
// ---------- 出手(甩动弹道:直接使用手势换算出的速度,所见即所得)---------- // ---------- 出手(甩动弹道:直接使用手势换算出的速度,所见即所得)----------
...@@ -297,7 +303,24 @@ export function launchVelocity(match: MatchState, shooter: PlayerState, vx: numb ...@@ -297,7 +303,24 @@ export function launchVelocity(match: MatchState, shooter: PlayerState, vx: numb
const by = shooter.y - 60; const by = shooter.y - 60;
const value = (bx < COURT.threeX) ? 3 : 2; const value = (bx < COURT.threeX) ? 3 : 2;
if (nearRim) ball.shotValue = 2; if (nearRim) ball.shotValue = 2;
releaseBall(match, shooter, bx, by, vx, vy, nearRim ? 2 : value, q, openShot); finalizeShot(match, shooter, bx, by, vx, vy, nearRim ? 2 : value, q, 'flick', openShot);
}
// 出手收口:客户端计算输入参数 → 投篮判定模型给出权威结果 →
// 球按判定模型认可的弹道飞行(表现与判定一致)。得分只看 pendingShot.goal。
function finalizeShot(match: MatchState, shooter: PlayerState, bx: number, by: number, vx: number, vy: number, value: number, q: number, mode: 'flick' | 'auto' | 'ai', openShot?: boolean): void {
const res = validateShot({
shotId: shooter.idx + '-' + Date.now() + '-' + Math.floor(Math.random() * 1e6),
playerId: shooter.idx,
ts: Date.now(),
startX: bx,
startY: by,
vx: vx,
vy: vy,
mode: mode
}, shotLedger);
match.pendingShot = res; // 权威判定(goal/value/perfect)
releaseBall(match, shooter, bx, by, res.vx, res.vy, value, q, openShot);
} }
function releaseBall(match: MatchState, shooter: PlayerState, bx: number, by: number, vx: number, vy: number, value: number, q: number, openShot?: boolean): void { function releaseBall(match: MatchState, shooter: PlayerState, bx: number, by: number, vx: number, vy: number, value: number, q: number, openShot?: boolean): void {
...@@ -477,6 +500,7 @@ function onScore(match: MatchState, scorer: PlayerState, value: number, perfect: ...@@ -477,6 +500,7 @@ function onScore(match: MatchState, scorer: PlayerState, value: number, perfect:
match.fakeT = 0; match.fakeT = 0;
match.aiBaitT = 0; match.aiBaitT = 0;
match.aiBaitJump = false; match.aiBaitJump = false;
match.pendingShot = null;
} }
function afterBanner(match: MatchState): void { function afterBanner(match: MatchState): void {
...@@ -491,6 +515,7 @@ function afterBanner(match: MatchState): void { ...@@ -491,6 +515,7 @@ function afterBanner(match: MatchState): void {
match.lastTouch = idx; match.lastTouch = idx;
match.aim = null; match.aim = null;
match.dunk = null; match.dunk = null;
match.pendingShot = null;
} }
function endMatch(match: MatchState): void { function endMatch(match: MatchState): void {
...@@ -582,8 +607,12 @@ export function updateMatch(match: MatchState, input: InputFrame, dt: number): v ...@@ -582,8 +607,12 @@ export function updateMatch(match: MatchState, input: InputFrame, dt: number): v
playerStep(p2, axis2, jump2, dt); playerStep(p2, axis2, jump2, dt);
const res = ballUpdate(ball, dt); const res = ballUpdate(ball, dt);
if (res && res.score) { if (res && res.score) {
onScore(match, match.players[ball.shooterIdx], res.score, ball.perfect); // 服务端权威:只有判定模型给出"进球"才得分;
return; // 视觉穿越但判定未进 → 不进(表现为磕筐/滚出,物理继续模拟)
if (match.pendingShot && match.pendingShot.goal) {
onScore(match, match.players[ball.shooterIdx], match.pendingShot.value, match.pendingShot.perfect);
return;
}
} }
// 出界 // 出界
...@@ -596,6 +625,13 @@ export function updateMatch(match: MatchState, input: InputFrame, dt: number): v ...@@ -596,6 +625,13 @@ export function updateMatch(match: MatchState, input: InputFrame, dt: number): v
return; return;
} }
// 判定为进球但球未穿越判定带(表现/判定轻微偏差):落地时补判
if (!ball.held && match.pendingShot && match.pendingShot.goal &&
ball.vy === 0 && Math.abs(ball.y - (COURT.groundY - ball.r)) < 2) {
onScore(match, match.players[ball.shooterIdx], match.pendingShot.value, match.pendingShot.perfect);
return;
}
// 拾球 // 拾球
if (!ball.held) { if (!ball.held) {
for (let pi = 0; pi < 2; pi++) { for (let pi = 0; pi < 2; pi++) {
...@@ -604,6 +640,7 @@ export function updateMatch(match: MatchState, input: InputFrame, dt: number): v ...@@ -604,6 +640,7 @@ export function updateMatch(match: MatchState, input: InputFrame, dt: number): v
pp.hasBall = true; pp.hasBall = true;
ball.held = pp; ball.held = pp;
match.lastTouch = pp.idx; match.lastTouch = pp.idx;
match.pendingShot = null; // 球权转换,本次判定作废
sfxCatch(); sfxCatch();
} }
} }
...@@ -669,7 +706,7 @@ export function updateMatch(match: MatchState, input: InputFrame, dt: number): v ...@@ -669,7 +706,7 @@ export function updateMatch(match: MatchState, input: InputFrame, dt: number): v
ball.perfect = true; ball.perfect = true;
ball.shotValue = 2; ball.shotValue = 2;
} else { } else {
launchAuto(match, holder, rand(0.55, 0.88 + match.difficulty * 0.05)); launchAuto(match, holder, rand(0.55, 0.88 + match.difficulty * 0.05), undefined, 'ai');
} }
match.lastTouch = holder.idx; match.lastTouch = holder.idx;
} }
......
// ============================================================
// shot-validator.ts —— 投篮判定模型(服务端权威,纯逻辑、确定性)
//
// 架构原则(客户端表现 + 服务端权威结果):
// · 客户端只上报【投篮输入】(位置/速度/时间/模式),绝不上报"是否进球"
// · 本模块用与引擎一致的简化弹道模型,由输入【确定性】计算 进球/不进球
// · 无渲染依赖、无 Math.random —— 可原样移植到 Node.js 服务器
// · 内置防护:参数校验 / 防重放(ShotLedger)/ 限频(ShotLedger)
//
// 客户端用法:
// const res = validateShot({ shotId, playerId, ts, startX, startY, vx, vy, mode }, ledger);
// 球用 res.vx / res.vy 飞行(保证表现与判定一致);得分只看 res.goal。
// 服务器用法:同一模块 + 服务器自己的时钟 nowMs(不信客户端 ts)。
// ============================================================
export interface ShotInput {
shotId: string; // 本次投篮唯一事件 id(防重放,客户端生成,服务器去重)
playerId: number;
ts: number; // 客户端时间戳(服务器以自己时钟校验,仅作排序参考)
startX: number; // 出手点(逻辑坐标,y 向下 800x450)
startY: number;
vx: number; // 初速度(y 向下)
vy: number;
mode: 'flick' | 'auto' | 'ai' | 'dunk';
}
export interface ShotResult {
accepted: boolean; // 输入是否合法(含重放/限频)
reason: string; // 'ok' | 'bad_params' | 'replay' | 'rate'
goal: boolean; // 权威判定:进球 / 不进球
value: number; // 2 | 3
perfect: boolean; // 是否完美(球心贴近篮筐正中)
vx: number; // 校验所用的弹道(客户端应以此飞行,表现与判定一致)
vy: number;
}
export interface ValidatorCtx {
g?: number;
rimX?: number; rimY?: number;
bandY?: number;
threeX?: number;
groundY?: number;
boardX?: number; boardTop?: number; boardBot?: number;
minIntervalMs?: number;
}
export const VALIDATOR_DEFAULTS: Required<ValidatorCtx> = {
g: 1500, rimX: 718, rimY: 240, bandY: 244,
threeX: 520, groundY: 400,
boardX: 744, boardTop: 150, boardBot: 320,
minIntervalMs: 300
};
// 速度/位置合法性边界(服务器参数校验用)
export const BOUNDS = {
xMin: 0, xMax: 800,
yMin: 0, yMax: 400,
vxMax: 1200,
vyMin: -1200, vyMax: 600
};
// ---------- 防重放 + 限频 ----------
export class ShotLedger {
private seen = new Set<string>();
private lastTs = new Map<number, number>();
private minIntervalMs: number;
constructor(minIntervalMs = VALIDATOR_DEFAULTS.minIntervalMs) {
this.minIntervalMs = minIntervalMs;
}
isReplay(id: string): boolean { return this.seen.has(id); }
markSeen(id: string): void { this.seen.add(id); }
rateOk(playerId: number, ts: number, nowMs: number): boolean {
const last = this.lastTs.get(playerId);
return last === undefined || (nowMs - last) >= this.minIntervalMs;
}
touch(playerId: number, atMs: number): void { this.lastTs.set(playerId, atMs); } // 存服务器时钟
}
// ---------- 投篮判定(确定性) ----------
export function validateShot(
input: ShotInput,
ledger: ShotLedger,
ctx: ValidatorCtx = {},
nowMs: number = Date.now()
): ShotResult {
const C = Object.assign({}, VALIDATOR_DEFAULTS, ctx);
const fail = (reason: string): ShotResult =>
({ accepted: false, reason, goal: false, value: 2, perfect: false, vx: input.vx, vy: input.vy });
// 1) 参数校验(拒绝改包/异常参数)
if (!isFinite(input.startX) || !isFinite(input.startY) || !isFinite(input.vx) || !isFinite(input.vy)) return fail('bad_params');
if (input.startX < BOUNDS.xMin || input.startX > BOUNDS.xMax) return fail('bad_params');
if (input.startY < BOUNDS.yMin || input.startY > BOUNDS.yMax) return fail('bad_params');
if (Math.abs(input.vx) > BOUNDS.vxMax || input.vy < BOUNDS.vyMin || input.vy > BOUNDS.vyMax) return fail('bad_params');
if (typeof input.shotId !== 'string' || input.shotId.length === 0) return fail('bad_params');
// 2) 防重放(同一 shotId 只能处理一次)
if (ledger.isReplay(input.shotId)) return fail('replay');
// 3) 限频(服务器用自己时钟 nowMs;两次投篮至少间隔 minIntervalMs)
if (!ledger.rateOk(input.playerId, input.ts, nowMs)) return fail('rate');
ledger.markSeen(input.shotId);
ledger.touch(input.playerId, nowMs); // 以服务器时钟记录本次投篮时间(限频依据)
// 4) 扣篮:位置性判定(近筐 + 起跳状态由服务器上下文给定,这里以模式简化)
if (input.mode === 'dunk') {
const near = Math.abs(input.startX - C.rimX) < 78;
return { accepted: true, reason: 'ok', goal: near, value: 2, perfect: near, vx: input.vx, vy: input.vy };
}
// 5) 确定性弹道积分(与引擎同模型:重力、篮板反弹、进球判定带)
const dt = 1 / 120;
let x = input.startX, y = input.startY, py = input.startY;
let vx = input.vx, vy = input.vy;
let goalX = NaN;
for (let i = 0; i < 8 * 120; i++) {
py = y;
vy += C.g * dt;
x += vx * dt;
y += vy * dt;
// 进球判定带:球心自 bandY 之上向下穿越,且 |x-rimX|<13
if (vy > 0 && Math.abs(x - C.rimX) < 13 && py < C.bandY && y >= C.bandY) {
goalX = x;
break;
}
// 篮板反弹
if (x + 11 > C.boardX && y > C.boardTop && y < C.boardBot) {
x = C.boardX - 11;
vx = -Math.abs(vx) * 0.55;
}
// 落地即止
if (y + 11 >= C.groundY) break;
}
const goal = !isNaN(goalX);
const value = input.startX < C.threeX ? 3 : 2;
const perfect = goal && Math.abs(goalX - C.rimX) <= 6;
return { accepted: true, reason: 'ok', goal, value, perfect, vx: input.vx, vy: input.vy };
}
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "0a1b2c3d-9e8f-7a6b-5c4d-3e2f1a0b9c8d",
"files": [],
"subMetas": {},
"userData": {}
}
...@@ -136,5 +136,99 @@ for (let i = 0; i < 26; i++) { ...@@ -136,5 +136,99 @@ for (let i = 0; i < 26; i++) {
// 真实弹道在判定带的 x 应与预览末段一致(预览只到落地) // 真实弹道在判定带的 x 应与预览末段一致(预览只到落地)
ok(pts.length > 0, '预览轨迹点生成 (' + pts.length + ' 个)'); ok(pts.length > 0, '预览轨迹点生成 (' + pts.length + ' 个)');
// ============================================================
// [5]~[8] ShotValidator 投篮判定模型(转写自 assets/scripts/shot-validator.ts)
// 服务端权威原则:客户端只上报投篮输入,进球与否由该确定性模型计算。
// ============================================================
function makeLedger(minMs) {
const seen = new Set();
const lastTs = new Map();
return {
isReplay: (id) => seen.has(id),
markSeen: (id) => seen.add(id),
rateOk: (pid, ts, now) => { const l = lastTs.get(pid); return l === undefined || (now - l) >= (minMs || 300); },
touch: (pid, ts) => lastTs.set(pid, ts)
};
}
function validateShot(input, ledger, nowMs) {
const C = { g: 1500, rimX: 718, rimY: 240, bandY: 244, threeX: 520, groundY: 400, boardX: 744, boardTop: 150, boardBot: 320 };
const fail = (reason) => ({ accepted: false, reason, goal: false, value: 2, perfect: false, vx: input.vx, vy: input.vy });
if (!isFinite(input.startX) || !isFinite(input.startY) || !isFinite(input.vx) || !isFinite(input.vy)) return fail('bad_params');
if (input.startX < 0 || input.startX > 800 || input.startY < 0 || input.startY > 400) return fail('bad_params');
if (Math.abs(input.vx) > 1200 || input.vy < -1200 || input.vy > 600) return fail('bad_params');
if (typeof input.shotId !== 'string' || input.shotId.length === 0) return fail('bad_params');
if (ledger.isReplay(input.shotId)) return fail('replay');
if (!ledger.rateOk(input.playerId, input.ts, nowMs)) return fail('rate');
ledger.markSeen(input.shotId);
ledger.touch(input.playerId, nowMs); // 以服务器时钟记录(限频依据)
if (input.mode === 'dunk') {
const near = Math.abs(input.startX - C.rimX) < 78;
return { accepted: true, reason: 'ok', goal: near, value: 2, perfect: near, vx: input.vx, vy: input.vy };
}
const dt = 1 / 120;
let x = input.startX, y = input.startY, py = input.startY;
let vx = input.vx, vy = input.vy;
let goalX = NaN;
for (let i = 0; i < 8 * 120; i++) {
py = y;
vy += C.g * dt;
x += vx * dt;
y += vy * dt;
if (vy > 0 && Math.abs(x - C.rimX) < 13 && py < C.bandY && y >= C.bandY) { goalX = x; break; }
if (x + 11 > C.boardX && y > C.boardTop && y < C.boardBot) { x = C.boardX - 11; vx = -Math.abs(vx) * 0.55; }
if (y + 11 >= C.groundY) break;
}
const goal = !isNaN(goalX);
const value = input.startX < C.threeX ? 3 : 2;
const perfect = goal && Math.abs(goalX - C.rimX) <= 6;
return { accepted: true, reason: 'ok', goal, value, perfect, vx: input.vx, vy: input.vy };
}
console.log('[5] ShotValidator 确定性:相同输入 → 相同结果(服务器权威模型)');
{
const input = { shotId: 't1', playerId: 0, ts: 1000, startX: 316, startY: 340, vx: 320, vy: -620, mode: 'flick' };
const r1 = validateShot(input, makeLedger(), 2000);
const r2 = validateShot(input, makeLedger(), 2000);
ok(r1.accepted && r2.accepted && r1.goal === r2.goal && r1.value === r2.value && r1.perfect === r2.perfect,
'相同输入两次判定一致 (goal=' + r1.goal + ', value=' + r1.value + ')');
}
console.log('[6] ShotValidator 完美甩动 → 进球;偏移甩动 → 不进(与客户端一致)');
{
const bx = 316, by = 340;
const ang = idealAngle(bx, by);
const v = resolveFlick(bx, by, ang, 0.85);
const r = validateShot({ shotId: 't2', playerId: 0, ts: 1000, startX: bx, startY: by, vx: v.vx, vy: v.vy, mode: 'flick' }, makeLedger(), 2000);
ok(r.accepted && r.goal, '完美甩动输入 → goal=true');
const vb = resolveFlick(bx, by, ang + 0.9, 0.85);
const rb = validateShot({ shotId: 't3', playerId: 0, ts: 1000, startX: bx, startY: by, vx: vb.vx, vy: vb.vy, mode: 'flick' }, makeLedger(), 2000);
ok(rb.accepted && !rb.goal, '偏移甩动输入 → goal=false');
}
console.log('[7] 参数校验:越界 vx / 位置 / NaN → rejected');
{
const ledger = makeLedger();
const bad1 = validateShot({ shotId: 'a', playerId: 0, ts: 1, startX: 300, startY: 340, vx: 99999, vy: -600, mode: 'flick' }, ledger, 1000);
ok(!bad1.accepted && bad1.reason === 'bad_params', 'vx 越界被拒');
const bad2 = validateShot({ shotId: 'b', playerId: 0, ts: 1, startX: 5000, startY: 340, vx: 100, vy: -600, mode: 'flick' }, ledger, 1000);
ok(!bad2.accepted && bad2.reason === 'bad_params', '位置越界被拒');
const bad3 = validateShot({ shotId: 'c', playerId: 0, ts: 1, startX: NaN, startY: 340, vx: 100, vy: -600, mode: 'flick' }, ledger, 1000);
ok(!bad3.accepted, 'NaN 参数被拒');
}
console.log('[8] 防重放 + 限频');
{
const ledger = makeLedger();
const input = { shotId: 'r1', playerId: 0, ts: 100, startX: 300, startY: 340, vx: 300, vy: -600, mode: 'flick' };
const first = validateShot(input, ledger, 2000);
ok(first.accepted, '首次投篮 accepted');
const replay = validateShot(input, ledger, 2100);
ok(!replay.accepted && replay.reason === 'replay', '重放同一 shotId 被拒');
const fast = validateShot({ shotId: 'r2', playerId: 0, ts: 100, startX: 300, startY: 340, vx: 300, vy: -600, mode: 'flick' }, ledger, 2100);
ok(!fast.accepted && fast.reason === 'rate', '300ms 内再次投篮被限频');
const later = validateShot({ shotId: 'r3', playerId: 0, ts: 100, startX: 300, startY: 340, vx: 300, vy: -600, mode: 'flick' }, ledger, 2600);
ok(later.accepted, '超过间隔后 accepted');
}
console.log('\n结果: ' + pass + ' 通过, ' + fail + ' 失败'); console.log('\n结果: ' + pass + ' 通过, ' + fail + ' 失败');
process.exit(fail > 0 ? 1 : 0); process.exit(fail > 0 ? 1 : 0);
# 架构说明 · 客户端表现 + 服务端权威判定(投篮)
> 本文档对应项目里已落地的"投篮判定模型"(`assets/scripts/shot-validator.ts`),
> 并按《单挑篮球》未来的匹配/排行榜/金币/奖励需求,说明防外挂分层。
> 当前 v2 为单机/AI 版:**判定模型已接进主流程**(客户端不再自行决定"进球了"),
> 服务器部分为"模块即插即用"形态——同一份代码可原样移植到 Node.js。
---
## 一、核心原则
1. **客户端负责实时表现**:摇杆、球飞行、碰撞动画、音效、甩动预览、抛物线绘制。
2. **客户端只上报"投篮输入"**:位置、速度、模式、时间、事件 id——**绝不上报"是否进球"**
3. **服务器(判定模型)负责最终判定**:用确定性规则模型计算 进球/不进球/分值/完美。
4. **不做"服务器重跑完整物理"**:只跑一个轻量弹道模型(重力 + 篮板反弹 + 进球判定带),
与引擎同一套常数(GRAVITY=1500、rimX=718、rimY=240、bandY=244、boardX=744…)。
5. **判定模型确定性**:相同输入 → 相同结果,无 `Math.random`——可测试、可审计、可移植。
## 二、投篮事件(客户端 → 判定模型)
```json
{
"shotId": "0-1755200000000-a1b2c3", // 本次投篮唯一事件 id(防重放)
"playerId": 0,
"ts": 1755200000000, // 客户端时间戳(服务器以自己时钟校验)
"startX": 316, // 出手点(逻辑坐标 y 向下)
"startY": 340,
"vx": 320, // 初速度(y 向下)
"vy": -620,
"mode": "flick" // flick | auto | ai | dunk
}
```
判定模型返回:
```json
{
"accepted": true, // false = 参数非法/重放/限频
"reason": "ok", // ok | bad_params | replay | rate
"goal": true, // 权威判定:进 / 不进
"value": 2, // 2 | 3
"perfect": false, // 球心贴近篮筐正中(≤6px)
"vx": 320, "vy": -620 // 判定模型认可的弹道——客户端按此飞行,表现与判定一致
}
```
## 三、客户端如何"表现与判定一致"(已实现)
- 出手时:客户端把手势/快捷投篮换算成 `(startX, startY, vx, vy)` → 调用 `validateShot(...)`
- **球按判定模型返回的 `vx/vy` 飞行**(不是客户端随便算的),因此视觉轨迹 = 判定轨迹。
- 得分闸门:`MatchManager.updateMatch` 里,球的视觉穿越判定带时,
**只有 `pendingShot.goal === true` 才 `onScore`**
视觉穿过但判定不进 → 不进(表现为磕筐/滚出,物理继续)。
- 兜底:判定为进球但球因表现偏差未穿越 → **落地时补判**(服务端结果纠正表现)。
- 扣篮:`mode: 'dunk'` 走位置性判定(近筐 → 必进),盖帽为独立防守事件(PvP 时也归服务器)。
## 四、防外挂分层(ShotLedger + 校验,已实现,服务器照用)
| 层 | 实现 | 状态 |
|---|---|---|
| 参数校验 | 位置/速度/NaN 边界检查(`BOUNDS`) | ✅ 已实现 |
| 限频 | 同一 playerId 两次投篮 ≥ 300ms(`ShotLedger.rateOk`,服务器用自己时钟) | ✅ 已实现 |
| 防重放 | 同一 `shotId` 只能处理一次(`ShotLedger.seen`) | ✅ 已实现 |
| 防改包 | 客户端上传的是"输入"而非"结果",改 `isGoal` 无效——判定模型自己算 | ✅ 架构保证 |
| 防改时间 | 服务器用 `nowMs` 参数(自己的时钟),客户端 `ts` 仅作排序参考 | ✅ 已留接口 |
## 五、当前分工(单机/AI 版 vs 未来服务器版)
| 内容 | 客户端(现在) | 服务器(未来) |
|---|---|---|
| 摇杆/拖拽/甩动 | ✅ | |
| 球实时运动/碰撞表现 | ✅(按判定弹道飞行) | |
| 动画/音效/震屏 | ✅ | |
| 投篮输入生成 | ✅ 上报 | ✅ 校验 |
| **是否进球** | ❌(只展示判定结果) | ✅ `validateShot` |
| 比分 | ✅ 本地展示(判定驱动) | ✅ 权威计分 |
| 排行榜/金币/奖励 | ❌ | ✅(以后接) |
## 六、服务器落地要点(未来做匹配/排行/金币时)
1.`shot-validator.ts` 原样拷入 Node 服务(无任何引擎依赖,纯 TS/JS)。
2. 每场比赛一个 `ShotLedger``nowMs` 用服务器时钟;`ts` 只用来做乱序检测。
3. 计分/金币/排行全部以判定结果为准:`score += result.goal ? result.value : 0`**不接受客户端上报的分数**
4. 投篮消息协议建议直接复用第二节的 JSON(WebSocket 二进制/JSON 皆可)。
5. 可选加固:对 `mode` 与球员状态(起跳/位置/冷却)做服务端上下文校验;
对异常频率(1 秒 >10 次投篮)记风控日志。
6. 延迟体验:客户端可"先播进球动画,等服务器确认后定格"——本工程的
`pendingShot` + 落地补判机制就是这个模式的本地预演。
## 七、验证
```bash
node tools/typecheck.js # 编辑器同款 strict,0 错误
node tools/check.js # 静态校验
node tools/logic-test.js # 29 项:弹道/甩动 + 判定模型(确定性/完美进/偏移不进/参数/重放/限频)
```
> 判定模型测试 [5]~[8] 直接验证:确定性、完美甩动必进、偏移必不进、
> 越界参数拒绝、重放拒绝、300ms 限频——与服务器端将跑的逻辑完全一致。
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment