Controls(玩家可见的标志——切勿反转发布)
在编写任何使用 WASD、方向键、追逐摄像机或飞行器的游戏移动/转向/飞行代码之前,完整阅读本文档。 不要跳过此项而只打开 racing-kart 或 fps 类型文件——那些类型文件假定你已经知道这些标志。A/D 反转是车辆和飞行演示中最常见的阻塞发布问题。
与 building-games(循环、摄像机、3D 方向)和 building-games/references/input.md(按键状态、游戏手柄、触控)配合使用。本技能负责左/右/上对玩家意味着什么以及你如何证明它。
0. 硬性规则(违反则构建失败)
- 玩家可见的左/右是法律。 从追逐/后方摄像机视角,且载具向前移动时:
- A / ← → 机头(或倾斜)在屏幕上向左转
- D / → → 机头(或倾斜)在屏幕上向右转
- 切勿将 FPS 横向平移用作车辆转向。 FPS 的“D → +right 向量”是地面平面上的位置。车辆 A/D 是偏航(或滚转)速率,而不是横向平移偏移。混淆它们是 A/D 反转的头号原因。
- 在声明完成之前,必须运行控制自检(第5节)。 仅截图 QA 是不够的。如果 A 向右转,翻转转向/滚转符号,重新测试,然后发布——不要发明新的坐标故事。
1. 共享 3D 基础(到处使用此基础)
three.js:右手系,+Y 向上,网格面向 +Z,摄像机看向 −Z。
XZ 平面上的纯偏航航向(地面车辆、步行者、大多数街机关卡载具):
// 偏航 = 0 面向世界 −Z;+偏航 绕 +Y 逆时针(机头移向 −X)
forward = (-sin(yaw), 0, -cos(yaw))
right = ( cos(yaw), 0, -sin(yaw)) // = normalize(cross(forward, worldUp))
使用后方的追逐摄像机(摄像机位于 position - forward * dist 附近):
| 玩家所见 | 世界(此基础) | 输入 |
|---|---|---|
| 机头向左 | +偏航 | A / ← 必须产生 +偏航(或对飞机等效的向左倾斜) |
| 机头向右 | −偏航 | D / → 必须产生 −偏航 |
如果你的基础不同,请保持一个一致的配对——但上方的玩家可见行是强制性的。
2. 类型映射
2a. FPS / 步行(平移,非转向)
W = +向前, S = −向前, D = +向右, A = −向右 // 位置,非偏航
鼠标:偏航 -= movementX * sens; 俯仰 -= movementY * sens; 限制俯仰
身体偏航用于视角;移动仅使用偏航(不要将俯仰应用于行走)。
2b. 地面 / 水上车辆(卡丁车、摩托车、摩托艇、船、坦克、火星车、雪地摩托)
具有 heading/yaw 和向前 speed 的街机主体:
// 输入(按住键 → 每帧执行一次动作)
let steer = 0; // -1..+1,玩家可见
if (keys.has('KeyA') || keys.has('ArrowLeft')) steer += 1; // 向左
if (keys.has('KeyD') || keys.has('ArrowRight')) steer -= 1; // 向右
// 可选:steer = clamp(steer + gamepadX, -1, 1) 使用相同的符号约定
// 积分(speedFactor ~ 0..1 来自 |speed|)
const reverse = speed >= 0 ? 1 : -1; // 倒车时左轮仍然向左感觉
偏航 += steer * turnRate * speedFactor * reverse * dt;
// 沿航向移动
const fx = -Math.sin(yaw), fz = -Math.cos(yaw);
position.x += fx * speed * dt;
position.z += fz * speed * dt;
经典错误(请勿复制):
// 错误——这就是导致 A/D 反转发布的实际代码
if (KeyA) steer -= 1;
if (KeyD) steer += 1;
偏航 += steer * turnRate * dt; // A → −偏航 → 追逐摄像机下机头向右
如果你已经写了 KeyA → steer--,要么交换键位映射,要么在积分时取反一次(偏航 += -steer * …)——然后运行第5节。不要翻转两次(键位 + 积分 + 网格倾斜)。
2c. 固定翼飞行(飞机、滑翔机、遥控飞机)
| 输入 | 动作 | 玩家预期 |
|---|---|---|
| A / ← | 向左滚转(副翼) | 左翼向下/向左倾斜 |
| D / → | 向右滚转 | 右翼向下/向右倾斜 |
| W / ↑ | 俯仰(选择一种方案并标注 HUD) | 通常是机头向下或拉起——保持一致 |
| S / ↓ | 相反俯仰 | |
| Q / E | 偏航/方向舵(可选) | Q 向左,E 向右 |
- A/D 不是横向平移,也不是“带有 FPS 标志的地面转向”。
- 在载具的局部向前轴上应用滚转,并使用与A 键向左倾斜匹配的符号。如果网格倾斜方向错误,则翻转一个滚转应用上的符号(或 A/D → 滚转映射上的符号),而不是整个基础。
- 协调转弯:正倾斜应产生一个与倾斜方向匹配的转弯,在追逐/外部摄像机视角下。
2d. 直升机 / 无人机 / 6自由度
在开始覆盖层上记录方案。最低要求:
- 离散键上的油门/高度不能在按一次后锁定为“始终向上”(使用按住状态或明确的上下)。
- 横向平移/偏航:在载具的水平框架中(玩家可见),A 向左,D 向右。
2e. 2D 横向卷轴 / 平台游戏
D / → 在屏幕上向右移动;A / ← 在屏幕上向左移动。只有当游戏类型明确为倒置时才反转重力。
3. 摄像机必须一致
- 追逐摄像机:
desired = craftPos + up*height + forward*(-followDist);线性插值;lookAt(craft)。 - 为移动和摄像机一次性计算
forward——不要在摄像机路径中使用相反偏航符号重建。 - 调试顺序:(1) 键位注册 → (2) 符号正确(第5节)→ (3) 摄像机一致。
4. 输入管道(简要)
- 使用
Set在keydown/keyup上跟踪键位,使用event.code;在blur/visibilitychange时清除。在游戏循环中使用 dt 移动,而不是在按键处理程序中。 - 将键盘 + 触控 + 游戏手柄统一为动作(
throttle、steer、pitch、roll……)。参见building-games/references/input.md。 - 触控:左摇杆 = 移动/转向,右摇杆 = 动作;目标区域 ≥ 44px。
5. 强制性控制自检(在“完成”之前)
对于任何使用 A/D 的载具,仅截图不足以验证。
5a. 玩家可见检查清单(每个车辆/飞行构建)
在向前移动(速度 > 小 epsilon)且追逐摄像机在后方时:
| 按住 | 必须在约 0.5 秒内观察到 |
|---|---|
| A | 机头或倾斜在屏幕上向左移动 |
| D | 机头或倾斜在屏幕上向右移动 |
| W(地面) | 速度增加 / 沿面向方向移动 |
| S(地面) | 刹车或倒车(按设计) |
如果 A 失败:翻转转向/滚转符号一次,同时重新测试 A 和 D。
5b. 最小测试接口(实现此接口)
公开一个小钩子,以便你(和自动化 QA)无需猜测私有闭包即可证明符号:
// 例如 src/game/controlsTest.ts —— 仅用于开发/QA 即可
export type ControlsProbe = {
getYaw: () => number; // 弧度;或 getHeading()
getSpeed: () => number;
/** 注入按住的动作以代替真实键位;两者都会保持已应用状态,直到你
* 更改它们,因此第5c节可以跨帧保持一个键位并在最后清除。 */
setSteer?: (v: number) => void; // -1..1,与生产代码符号相同
setKeys?: (codes: string[]) => void; // 保持按住直到下次调用;`[]` 清除
};
declare global {
interface Window {
__controlsTest?: ControlsProbe;
}
}
当 import.meta.env.DEV 或设置了 ?qa=1 标志时,从游戏循环连接 window.__controlsTest。
5c. 自动化烟雾测试(运行它)
使用预装的 agent-browser CLI 驱动第5b节的探针——这是第一步,而不是手写脚本。抛出的 eval 以非零退出;仅仅为假值的不会,因此通过抛出来断言。将其作为一个 batch 运行——一次 CLI 调用,而不是每个动词一次——从 stdin 接收 JSON;--bail 在第一个失败步骤处停止并以非零退出。
agent-browser batch --bail <<'JSON' # find 的标签区分大小写:从 `snapshot -i` 复制
[["open","http://127.0.0.1:8080/"],
["find","text","Start","click"],
["eval","if (!window.__controlsTest?.setKeys) throw Error('缺少第5b节探针:添加 setKeys')"],
["eval","__controlsTest.setKeys(['KeyW'])"],
["wait","600"],
["eval","if (__controlsTest.getSpeed() <= 0.1) throw Error('W:没有移动')"],
["eval","(async () => { const t = __controlsTest, y0 = t.getYaw(); t.setKeys(['KeyW','KeyA']); await new Promise(r => setTimeout(r, 300)); t.setKeys(['KeyW']); const d = t.getYaw() - y0, w = Math.atan2(Math.sin(d), Math.cos(d)); if (w < -0.05) throw Error('A 向右转——反转了,得到 ' + w.toFixed(2)); if (w <= 0.05) throw Error('A 几乎没有转动(' + w.toFixed(2) + ')——保持更长时间或检查模拟是否正在运行'); return 'A ok' })()"],
["eval","__controlsTest.setKeys([])"],
["screenshot","/workspace/screenshots/controls.png"],
["close"]]
JSON
通过探针保持键位,而不是用 keydown —— agent-browser keydown 无法到达第4节的 Set,因此正确的游戏会被读作损坏。你的探针没有 setKeys?添加它(第5b节),或者自己分发按键事件——浏览器 QA 参考的 Keys 说明有机制和表单。
保持位于一个 eval 内部:分布在多个命令中时,它会持续命令执行所需的时间,并且 Math.atan2(Math.sin(d), Math.cos(d)) 中的 ±π 环绕会将转动超过 π 的载具读作向另一方向转动。IIFE 使得步骤可重运行——裸的 const d = … 第二次会因“已声明”而失败——而 async 使得保持能在页面时间内运行。对 D 重复测试,使用 'KeyD' 并镜像两个比较(w > 0.05 是反转,w >= -0.05 是几乎没有转动),使用单引号以便 JSON 无需转义;对于飞机,断言 roll:A ⇒ 向左倾斜。
A 向右转 是符号错误:翻转一个符号并重新运行。
A 几乎没有转动 则不是——载具移动方向正确,只是在 300 毫秒内小于 0.05 弧度,这对于船或重型火星车来说会发生;增加保持时间,或检查模拟是否正在运行,且不要翻转任何内容。任何其他非零退出——没有探针、守护进程挂起——意味着检查从未运行:首先阅读消息。在第一个流程之前阅读浏览器 QA 参考的 AGENTS.md 链接——动词、参数形状和回退都在那里。
5d. 不该做什么
- 不要只测试“D 增加了某个内部变量”。
- 不要将 FPS 的“D → 当面向 −Z 时 +X”用作车辆通过条件。
- 不要在修复时同时翻转网格倾斜、摄像机和转向——更改一个符号,重新测试。
6. 完成标准(控制)
- 在编写移动/转向/飞行代码之前打开了本技能。
- 选择了类型映射(第2节),并且开始画面/HUD 标签与之匹配。
- 追逐摄像机 A/D 玩家可见测试通过(第5a节)。
-
window.__controlsTest(或等效项)在开发/QA 中可用并已使用一次。 - 相对于滚转/转向输入,没有反转的倾斜网格。
- 键位为按住状态 + dt 缩放;没有来自单次空格点击的粘性推力,除非有意识且已标注。
如果任何框未选中,则游戏未完成。