feat(cheap-gen): 阶段一B #2+#13 physics 几何挂实例 + ProjectileState px/py→x/y

附A #2:physics 实例原为空 {}、几何运动函数仅模块级导出,AI 直觉 physics.integrateProjectile(...)
取不到。把 6 个运动原语(integrateProjectile/springStep/criticalDamping/moveWithConstraint/
clampSpeed/applyFriction)+ Projectile 类挂上实例(引模块级纯函数,不依赖 this),api.d.ts 接口同补。
附A #13:ProjectileState 位置 px/py → x/y(向母语直觉,与 Vec2/命中矩形的 x/y 统一)。impl/test/
PLUGIN.md 同步(px/py 仅 ProjectileState 字段,replace_all 安全)。

注:physics 当前无 prompt/skill 指引(便宜档 niche、projectile 罕用),本次是完整性 + 正确性
(实例可用 + 字段直觉),不改生成侧行为。验证:physics-lite 16/16 + 确定性(实例 integrateProjectile/
clampSpeed 可调、ProjectileState 返 x/y)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-29 10:05:38 -07:00
parent c6d31c0d81
commit d79e1b9f89
4 changed files with 36 additions and 28 deletions

View File

@ -41,11 +41,11 @@ import {
// —— 抛体弹道(有状态,逐帧 step ——
const proj = new Projectile(
{ px: 0, py: 0, vx: 5, vy: -12 },
{ x: 0, y: 0, vx: 5, vy: -12 },
{ gravity: { x: 0, y: 20 }, fixedStep: 1 / 120 }
);
proj.step(0.016); // 推进一帧,返回新状态
const st = proj.state; // { px, py, vx, vy }
const st = proj.state; // { x, y, vx, vy }
// —— 弹簧朝目标 10 收敛(临界阻尼=最快不振荡) ——
const k = 100, cc = criticalDamping(k); // 2√(k·1)=20

View File

@ -29,10 +29,10 @@ export interface Vec2 {
/** 抛体状态:位置 + 速度。 */
export interface ProjectileState {
/** 位置横坐标。 */
px: number;
/** 位置纵坐标。 */
py: number;
/** 位置横坐标#13:px→x,向母语直觉对齐)。 */
x: number;
/** 位置纵坐标#13:py→y。 */
y: number;
/** 速度横分量(单位/秒)。 */
vx: number;
/** 速度纵分量(单位/秒)。 */

View File

@ -90,8 +90,8 @@ function semiImplicitStep(s, gx, gy, h) {
// 半隐式v += g*h; p += v*h。symplectic能量行为比显式欧拉稳。
s.vx += gx * h;
s.vy += gy * h;
s.px += s.vx * h;
s.py += s.vy * h;
s.x += s.vx * h;
s.y += s.vy * h;
}
/**
@ -104,7 +104,7 @@ function semiImplicitStep(s, gx, gy, h) {
export function integrateProjectile(state, dtSeconds, opts) {
const { gx, gy, fixedStep } = normProjectileOpts(opts);
// 工作副本(不改入参)。
const s = { px: state.px, py: state.py, vx: state.vx, vy: state.vy };
const s = { x: state.x, y: state.y, vx: state.vx, vy: state.vy };
let remaining = dtSeconds > 0 ? dtSeconds : 0;
// 整步推进。
while (remaining >= fixedStep - EPS) {
@ -129,7 +129,7 @@ export class Projectile {
*/
constructor(init, opts) {
/** @type {ProjectileState} 内部状态。 */
this._s = { px: init.px, py: init.py, vx: init.vx, vy: init.vy };
this._s = { x: init.x, y: init.y, vx: init.vx, vy: init.vy };
const n = normProjectileOpts(opts);
this._gx = n.gx;
this._gy = n.gy;
@ -138,7 +138,7 @@ export class Projectile {
/** @returns {ProjectileState} 当前状态快照(只读拷贝)。 */
get state() {
return { px: this._s.px, py: this._s.py, vx: this._s.vx, vy: this._s.vy };
return { x: this._s.x, y: this._s.y, vx: this._s.vx, vy: this._s.vy };
}
/**
@ -161,7 +161,7 @@ export class Projectile {
* @param {ProjectileState} init
*/
reset(init) {
this._s = { px: init.px, py: init.py, vx: init.vx, vy: init.vy };
this._s = { x: init.x, y: init.y, vx: init.vx, vy: init.vy };
}
}
@ -344,6 +344,14 @@ export function createPhysicsLitePlugin(opts) {
init(ctx) {
void ctx;
},
// 运动/几何原语挂实例(#2 阶段一BAI 直觉 physics.integrateProjectile(...)/clampSpeed(...) 等直接可用,无需从模块 import引模块级纯函数/类)。
integrateProjectile,
springStep,
criticalDamping,
moveWithConstraint,
clampSpeed,
applyFriction,
Projectile,
/** 释放:无资源可释放;幂等占位。 */
dispose() {
if (disposed) return;

View File

@ -43,18 +43,18 @@ function rng(seed) {
* ========================================================================== */
test('integrateProjectile无重力时为匀速直线位置=p0+v0·t 精确)', () => {
const s0 = { px: 0, py: 0, vx: 3, vy: -2 };
const s0 = { x: 0, y: 0, vx: 3, vy: -2 };
const s = integrateProjectile(s0, 1.0, { gravity: { x: 0, y: 0 }, fixedStep: 1 / 240 });
// 无重力 → 匀速,位置应为 v0·t半隐式欧拉对匀速精确
assert.ok(Math.abs(s.px - 3) < 1e-9, 'px 应为 3');
assert.ok(Math.abs(s.py - (-2)) < 1e-9, 'py 应为 -2');
assert.ok(Math.abs(s.x - 3) < 1e-9, 'px 应为 3');
assert.ok(Math.abs(s.y - (-2)) < 1e-9, 'py 应为 -2');
// 速度不变。
assert.ok(Math.abs(s.vx - 3) < 1e-12 && Math.abs(s.vy - (-2)) < 1e-12, '无重力速度不变');
});
test('integrateProjectile重力下逼近解析抛物线小固定步长容差受控', () => {
const g = { x: 0, y: 10 };
const s0 = { px: 0, py: 0, vx: 5, vy: 0 };
const s0 = { x: 0, y: 0, vx: 5, vy: 0 };
const t = 2.0;
const fixedStep = 1 / 1000; // 足够小,半隐式离散误差受控
const s = integrateProjectile(s0, t, { gravity: g, fixedStep });
@ -65,21 +65,21 @@ test('integrateProjectile重力下逼近解析抛物线小固定步长
// 速度解析vy = g·t。
const vyExact = g.y * t;
assert.ok(Math.abs(s.px - pxExact) < 1e-6, `px 应≈${pxExact},实得 ${s.px}`);
assert.ok(Math.abs(s.x - pxExact) < 1e-6, `px 应≈${pxExact},实得 ${s.x}`);
// 半隐式欧拉对位置有 O(½·g·h·t) 量级误差;小步长下放宽容差到解析值的 1%。
assert.ok(Math.abs(s.py - pyExact) < pyExact * 0.01, `py 应≈${pyExact}1% 容差),实得 ${s.py}`);
assert.ok(Math.abs(s.y - pyExact) < pyExact * 0.01, `py 应≈${pyExact}1% 容差),实得 ${s.y}`);
assert.ok(Math.abs(s.vy - vyExact) < 1e-6, `vy 应≈${vyExact},实得 ${s.vy}`);
});
test('integrateProjectile时间步容差——固定步长越小越逼近解析收敛', () => {
const g = { x: 0, y: 10 };
const s0 = { px: 0, py: 0, vx: 0, vy: 0 };
const s0 = { x: 0, y: 0, vx: 0, vy: 0 };
const t = 1.0;
const pyExact = 0.5 * g.y * t * t; // = 5
// 三档固定步长,误差应单调减小(半隐式欧拉位置误差 ∝ 步长)。
const errCoarse = Math.abs(integrateProjectile(s0, t, { gravity: g, fixedStep: 1 / 50 }).py - pyExact);
const errMid = Math.abs(integrateProjectile(s0, t, { gravity: g, fixedStep: 1 / 200 }).py - pyExact);
const errFine = Math.abs(integrateProjectile(s0, t, { gravity: g, fixedStep: 1 / 800 }).py - pyExact);
const errCoarse = Math.abs(integrateProjectile(s0, t, { gravity: g, fixedStep: 1 / 50 }).y - pyExact);
const errMid = Math.abs(integrateProjectile(s0, t, { gravity: g, fixedStep: 1 / 200 }).y - pyExact);
const errFine = Math.abs(integrateProjectile(s0, t, { gravity: g, fixedStep: 1 / 800 }).y - pyExact);
assert.ok(errMid < errCoarse, `步长减小误差应减小mid(${errMid}) < coarse(${errCoarse})`);
assert.ok(errFine < errMid, `步长再减小误差应再减小fine(${errFine}) < mid(${errMid})`);
});
@ -88,14 +88,14 @@ test('integrateProjectiledt 切分一致性——同 fixedStep 下一步 vs
const g = { x: 1, y: 10 };
const opts = { gravity: g, fixedStep: 1 / 600 };
// 一次推进 0.6s。
const once = integrateProjectile({ px: 0, py: 0, vx: 2, vy: 0 }, 0.6, opts);
const once = integrateProjectile({ x: 0, y: 0, vx: 2, vy: 0 }, 0.6, opts);
// 分 6 次各推进 0.1s(用有状态 Projectile 累计)。
const proj = new Projectile({ px: 0, py: 0, vx: 2, vy: 0 }, opts);
const proj = new Projectile({ x: 0, y: 0, vx: 2, vy: 0 }, opts);
for (let i = 0; i < 6; i++) proj.step(0.1);
const many = proj.state;
// fixedStep 整除 0.1 与 0.6,整步序列一致 → 结果应高度一致(残余皆为 0
assert.ok(Math.abs(once.px - many.px) < 1e-6, `px 切分一致:${once.px} vs ${many.px}`);
assert.ok(Math.abs(once.py - many.py) < 1e-6, `py 切分一致:${once.py} vs ${many.py}`);
assert.ok(Math.abs(once.x - many.x) < 1e-6, `px 切分一致:${once.x} vs ${many.x}`);
assert.ok(Math.abs(once.y - many.y) < 1e-6, `py 切分一致:${once.y} vs ${many.y}`);
assert.ok(Math.abs(once.vy - many.vy) < 1e-9, 'vy 切分一致');
});
@ -197,7 +197,7 @@ test('fuzzintegrateProjectile 极端参数全程无 NaN/Infinity', () => {
const rand = rng(0x1CEB00DA);
for (let i = 0; i < 2000; i++) {
const state = {
px: rand() * 2000 - 1000, py: rand() * 2000 - 1000,
x: rand() * 2000 - 1000, y: rand() * 2000 - 1000,
vx: rand() * 2000 - 1000, vy: rand() * 2000 - 1000,
};
const opts = {
@ -207,7 +207,7 @@ test('fuzzintegrateProjectile 极端参数全程无 NaN/Infinity', () => {
const dt = rand() * 2;
const s = integrateProjectile(state, dt, opts);
assert.ok(
Number.isFinite(s.px) && Number.isFinite(s.py) && Number.isFinite(s.vx) && Number.isFinite(s.vy),
Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.vx) && Number.isFinite(s.vy),
`integrateProjectile 第 ${i} 出现非有限值`
);
}