对枪训练器 – 定位 / 反应练习 (Aim Trainer)
limitless现在提供了一个仅基于浏览器的上班摸鱼小工具,特别适合平时练习对枪的定位和反应速度。
体验请点击下方卡片链接:
仅支持电脑!不支持手机,平板等移动设备!需要鼠标和键盘!
对枪训练器 – 定位 / 反应练习 (Aim Trainer) – limitless
操控类似于R6,支持wasd移动,qe侧头,r换弹,Lshift冲刺,ctrl趴下,c蹲下,Lalt静步,以及R6中没有的空格跳跃。
设置比较详细,我不知道常见的什么枪械的参数,因此需要用户自己去调整参数,来模拟场景操作手感。
场景有几个能切换,什么迷宫啊,训练场啊啥的。
有些软墙拿枪是能打烂的。门也是直接打几枪就开了。
配置是基于cookie保存的,默认保存至少90天。
未来开发计划:可能考虑一个简单的联机策略。
特性
一个基于 Three.js 的纯前端 FPS 对枪训练器 – 定位 / 反应练习,零网络依赖,所有资源本地加载。
LIMITLESS ELECTRONIC TECHNOLOGY GROUP
HDD黑车小队娱乐基金项目
2026年9月19日
法律声明:仅用于技术学习体验,这不是一个游戏,也不是一个盈利项目。该项目的源代码许可为GPLv3.如果需要源代码,请自行尝试从limitless下载,或者联系网站管理员。
- 1:1 鼠标输入 :PointerLock API,无任何平滑/加速
- cm/360 灵敏度 :支持 cm/360 和 raw 两种模式,FOV 补偿
- 可配置程序化地图 :三种风格可选——分房(常见 FPS 布局,带门/狗洞/脚洞的房间与过道)/ 窄走廊迷宫(走廊净宽可调,随机挖出房间)/ 开阔竞技;隔墙密度、掩体密度、可破坏物比例均可调;保存设置后自动重生成
- 环境与光影 :户外模式为蓝色到地平线的渐变天空穹顶 + 可调太阳仰角/方位角/颜色/强度(太阳平行光实时跟随玩家以保证阴影分辨率),室内模式为格子吊顶 + 发光灯板 + 点光源;环境光/半球光强度、雾、阴影分辨率、吊顶高度/颜色均可调(逆光时可提高环境光提亮暗部)
- 可见边界墙 :地图四周生成一圈可见墙体(高度随户外/室内模式自适应),避免”世界边缘”观感
- 特殊结构 :狗洞(0.62m 高,站立被挡、蹲趴可匍匐通过)、脚洞(贴地小洞,可射击/观察)、矮墙(约 1m,可翻越/架枪);房间隔墙与建筑外墙会随机生成这些洞口
- 2~3 层可通行建筑 :含楼板(楼梯井开洞)、贯通楼梯(靠玩家自动上台阶走动,无需跳跃)、门(可破坏门板与无门门框,可被子弹/近战破坏后限时恢复);建筑层数、层高、是否生成门/楼梯、门可破坏比例均可调
- NPC 路网巡逻 :地图生成时按高度层采样构建可行走路网(含楼梯链路),walker 目标沿路网在相邻节点间巡逻,可经楼梯上下楼、跨楼层行动
- 可破坏环境 :V 键敲洞,穿洞递归判定,墙体碎裂;碎裂的墙默认 10s 后自动恢复(可配置),恢复时重新加入碰撞体。墙/门血量按 NPC 血量折算(墙 3 个、门 0.3 个 NPC 血量),子弹按身体伤害扣血(默认门两枪即破)、近战按
melee.damage扣血 - 弹药包 :备弹有限(总量模式)且低于阈值(默认 60)时,地图中随机生成可拾取弹药包,单包默认 45 发、同时最多 10 个(总量上限 1200);阈值、单包量、数量上限、总量上限、拾取半径均可调
- 4 种目标 AI :静态、peeker、walker、crouch spam;目标身高为玩家站立眼高的 0.8~1.2 倍随机值(可配置),身体半径与头球半径可调,目标默认 10s 重生
- 隐蔽随机配色 :目标默认从与墙体/掩体接近的灰、棕、橄榄、沙灰调色板中随机取色并做逐通道扰动(每次生成/重生重新随机),不再使用刺眼的固定红橙;可在设置面板关闭以恢复醒目色
- 血量随机 :目标实际 HP = 基础 HP × 均匀随机倍率(默认 1.0~1.2,即 100~120),倍率上下限可调,每次生成/重生重新随机
- 血条可开关 :目标头顶血条默认显示,按
O可随时开关(设置面板亦可在「目标」组关闭),状态随配置持久化 - 状态与罗盘 HUD :左下角实时显示姿态(站姿/蹲姿/趴姿)与动作状态(地面/跳跃中/下落中/疾跑/翻越中/左倾/右倾),下方为圆形罗盘(盘随朝向旋转、方位文字保持水平,顶部固定指针 + 八方位中文标签 + 度数读数)
- 完整武器系统 :射速控制、弹匣管理、散布、后坐力回落;爆头伤害 = 目标基础血量 × 可调倍率(默认 1.5,血牛 NPC 可提高其血量倍率以免被一枪爆头);后坐力支持「自动回正」模式(大狙式,开火后自动把视角拉回开火前角度、速度可调,默认关闭)
- 右键瞄准(ADS) :按住右键 FOV 平滑聚焦;ADS 灵敏度按「屏幕空间一致」缩放(
tan(当前FOV/2)/tan(基准FOV/2)),倍率 1 时瞄准与腰射在屏幕上移动同样距离、体感一致,且不随瞄准 FOV 大小而改变;可用「瞄准灵敏度倍率」(1~2,默认 1,越大越灵敏)调节;可在中心清晰半径外叠加可调高斯模糊与黑色半透明遮罩(可见性与不透明度可调,默认 80%),形成聚焦式瞄准视野(瞄准支持 hold/toggle 切换) - 多样式准星 :十字 / 圆点 / 线点线 / 圆圈十字 / 小三角指示 / T 字 / 圆圈,间距、长度、粗细、中心点、颜色均可调
- 姿态系统 :站/蹲/趴 + QE 侧倾;蹲下与侧倾默认切换模式(hold/toggle 可配置)
- 速度与冲刺视野 :默认行走 2.3 m/s,疾跑 4.0 m/s,静步(默认左 Alt)为行走的 40%;跳跃过程中的水平速度上限与地面一致(疾跑起跳≈疾跑速度、不疾跑≈行走速度),空中仅通过较低的
airControl与无摩擦体现机动受限;冲刺时视野平滑放大到move.sprintFov(默认 100°) - 疾跑自动回正 :无论处于蹲姿、趴姿还是 QE 侧倾,起跑都会强制回到站姿并取消侧倾;停止疾跑后保持站姿(不恢复起跑前的姿态/侧倾)
- 无限弹药显示 :弹药模式为「弹匣(无限备弹)」或「无限」时,HUD 对应位置显示 ∞ 而非固定整数
- 一键复位 :暂停菜单「复位位置」按钮或 P 键可把玩家放回出生点,解决卡死、掉出地板等情况
- 平滑翻越 :靠近矮台(顶面 ≤
move.climbHeight)按空格以平滑弧线翻上去(非瞬移),跳跃带输入缓冲与土狼时间,按住空格不会自动连跳,普通移动不会自动爬墙/踩上方块,走下台面边缘自动坠落 - 弹孔限时与对象绑定 :子弹在地面、方块、隔墙、可破坏墙与目标上均可留下弹孔(默认保留 10s,可配置);墙碎裂或目标被击杀后,其身上的弹孔立即清除,不残留悬浮弹孔;碎裂的墙会同步移除碰撞体,不会留下隐形墙
- 弹道枪线与烟雾 :每次开火生成持续约 0.6s(可配置
weapon.tracerLifetime)的曳光枪线,并沿弹道附带宽高烟雾;烟雾与火星均使用固定容量对象池,长时间连射无 GC 抖动 - 命中火星 :子弹打中墙面、地面、方块等硬表面时溅射黄色火星(可关闭
weapon.sparkEnabled) - 实时统计 :命中率、TTK、反应时间、爆头率
- 设置面板 :所有参数可调,3 个预设槽,键位重绑定;键位绑定置顶,左侧悬浮目录、右侧悬浮保存按钮(Ctrl+S,固定于屏幕不随滚动)无需滚动到底部;编辑的是草稿,保存才生效并自动重生成地图;按 Esc 关闭时若有未保存改动会弹出确认(Y 保存 / N 放弃 / 再按 Esc 等于保存并关闭);暂停页按 Esc 直接返回
- 纯代码音效 :Web Audio API 合成枪声/命中/换弹等
部署方式
1. 启动本地服务器
cd aim-trainer
python3 -m http.server 8080
2. 打开浏览器
访问 http://localhost:8080/index.html
3. 开始训练
点击”开始”按钮,鼠标将被锁定。按 Esc 解锁鼠标。
操作说明
| 按键 | 动作 |
|---|---|
W/A/S/D | 移动 |
Space | 跳跃;靠近矮台时以平滑弧线翻越上去(带输入缓冲,按住不会连跳) |
C | 蹲下(默认切换,hold/toggle 可切换) |
左 Ctrl | 趴下(toggle);再次按下恢复进入趴下前的姿态(站姿进回站姿、蹲姿进回蹲姿) |
Shift | 疾跑(hold/toggle 可切换;冲刺时视野放大) |
左 Alt | 静步(慢走,速度为行走的 40%) |
Q / E | 左/右侧倾(默认切换,hold/toggle 可配置) |
| 鼠标左键 | 开火 |
| 鼠标右键 | 瞄准(ADS,FOV 聚焦;默认按住,可切换为 toggle) |
R | 换弹 |
V | 敲洞(近战) |
T | 重生成地图 |
P | 复位位置(卡死/掉出地板时用) |
O | 切换目标血条显示(开/关) |
Esc | 解锁鼠标 / 关闭设置面板(有未保存改动时提示保存) |
Ctrl+S | 设置面板内保存 |
项目结构
aim-trainer/
├── index.html # 入口页面
├── css/
│ ├── main.css # 全局样式、开始界面
│ ├── hud.css # HUD 样式(准星、弹药、统计)
│ └── settings.css # 设置面板样式
├── js/
│ ├── config.js # 配置管理(深合并、预设槽、Cookie 持久化)
│ ├── utils.js # 工具函数(AABB、随机数、灵敏度换算)
│ ├── audio.js # Web Audio 音效合成
│ ├── input.js # PointerLock、键位、灵敏度
│ ├── player.js # 玩家移动、姿态、侧倾、碰撞
│ ├── weapon.js # 武器射击、弹药、后坐力、曳光
│ ├── environment.js # 环境与光影(天空/吊顶、太阳角度、室内灯、雾)
│ ├── map.js # 程序化地图(分房/迷宫/竞技 + 多层建筑 + 洞口 + 路网)
│ ├── destruction.js # 破坏系统(敲洞、穿洞、碎裂、限时恢复)
│ ├── pickups.js # 弹药包生成与拾取
│ ├── targets.js # 目标 AI(4 种行为)
│ ├── hud.js # HUD(准星、hitmarker、弹药、feed)
│ ├── stats.js # 统计(命中率、TTK、反应时间)
│ ├── settings-panel.js # 设置面板(自动生成控件)
│ ├── main.js # 主循环、模块协调
│ └── browser-tests.js # 浏览器环境测试
├── lib/
│ └── three.module.js # Three.js v0.160.0(本地引用)
└── tests/
├── config.test.js # 配置模块单元测试
├── utils.test.js # 工具模块单元测试
├── sens.test.js # 灵敏度换算单元测试
└── browser-tests.html # 浏览器测试页面
运行测试
Node.js 单元测试
cd aim-trainer
node --test tests/config.test.js tests/utils.test.js tests/sens.test.js
浏览器测试
访问 http://localhost:8080/tests/browser-tests.html
架构设计
模块依赖关系
main.js (协调者)
├── config.js (配置)
├── utils.js (工具,无依赖)
├── audio.js (音效)
├── input.js (输入,依赖 config + utils)
├── player.js (玩家,依赖 input + config + utils)
├── weapon.js (武器,依赖 input + config + destruction + targets + hud + stats)
├── map.js (地图,依赖 config + utils + destruction)
├── destruction.js (破坏,依赖 config + audio + utils)
├── pickups.js (弹药包,依赖 config + utils)
├── targets.js (目标,依赖 config + utils + audio + stats)
├── hud.js (HUD,依赖 config + utils)
├── stats.js (统计,无依赖)
└── settings-panel.js (设置面板,依赖 config + input + weapon + hud + audio)
主循环设计
- 物理步进 :120Hz 固定时间步进(
FIXED_DT = 1/120) - 渲染循环 :requestAnimationFrame,与物理解耦
- 累积器 :
_accumulator += dt,每达到FIXED_DT执行一次物理步进
灵敏度换算
cm/360 模式下:
countsPer360 = (cm360 / 2.54) × dpi
sensPerCount = 2π / countsPer360
FOV 补偿:
sens_new = sens_base × tan(fov_base/2) / tan(fov_new/2)
配置说明
所有配置以 Cookie 持久化,默认有效期 90 天(可通过设置面板「存储 → 设置保存天数」调整,storage.maxAgeDays)。Cookie 键名:主配置 aimtrainer_config、键位 aimtrainer_config_keybinds、预设槽 aimtrainer_preset_1/2/3、最佳记录 aimtrainer_best;值均经 URL 编码,作用域 path=/、SameSite=Lax(本地 http 下不加 Secure)。
关键配置项
| 路径 | 默认值 | 说明 |
|---|---|---|
fov | 90 | 视野角度 |
sensMode | “raw” | 灵敏度模式(raw / cm360) |
cm360 | 40 | cm/360 物理距离(cm360 模式用) |
dpi | 800 | 鼠标 DPI |
rawSens | 0.001 | raw 模式每 count 弧度(默认约对应 800DPI 下 20cm/360;设置页有单位说明) |
move.walk | 2.3 | 行走速度 (m/s) |
move.sprint | 4.0 | 疾跑速度 (m/s) |
move.slowFactor | 0.4 | 静步速度 = 行走 × 该系数 |
move.crouch | 1.0 | 蹲下速度 (m/s) |
move.prone | 0.4 | 趴下速度 (m/s) |
move.sprintFov | 100 | 冲刺时视野角度 (°) |
move.crouchMode | “toggle” | 蹲下模式(hold/toggle) |
move.sprintMode | “hold” | 疾跑模式(hold/toggle) |
move.climbHeight | 1.6 | 按空格可翻越的最大台面高度 (m),0=禁用 |
lean.leanMode | “toggle” | QE 侧倾模式(hold/toggle) |
move.stepHeight | 0.45 | 可直接迈上的台阶最大高度 (m),用于楼梯自动上行 |
map.style | “rooms” | 地图风格(rooms/maze/arena) |
map.wallDensity | 0.6 | 隔墙/迷宫墙密度 0~1 |
map.coverDensity | 0.5 | 掩体密度 0~1 |
map.destructibleChance | 0.5 | 掩体为可破坏物的概率 0~1 |
map.wallHpMult | 3 | 可破坏墙血量 = NPC 基础血量 × 该倍率(默认 3 个 NPC 血量,子弹按身体伤害扣血) |
map.doorHpMult | 0.3 | 可破坏门血量 = NPC 基础血量 × 该倍率(默认 0.3 个 NPC 血量,约两枪破) |
melee.damage | 50 | 近战对可破坏墙的单次伤害 |
map.wallRespawnTime | 10 | 可破坏墙恢复时间 (s),0=不恢复 |
map.boundaryWalls | true | 是否生成可见边界墙 |
map.corridorWidth | 1.8 | 迷宫走廊净宽 (m) |
map.buildings | 1 | 多层建筑数量(0~3) |
map.buildingFloors | 2 | 建筑层数(2~3) |
map.floorHeight | 3.0 | 建筑层高 (m) |
map.doors | true | 是否生成门 |
map.doorDestructibleChance | 0.6 | 门为可破坏门板的比例(其余为无门门框) |
map.dogHoles | true | 是否生成狗洞 |
map.lowWalls | true | 是否生成矮墙 |
map.footHoles | true | 是否生成脚洞 |
map.specialCoverChance | 0.35 | 房间掩体替换为特殊墙体(狗洞/脚洞/矮墙)的概率 |
env.mode | “sky” | 环境模式(sky 户外天空 / ceiling 格子吊顶) |
env.skyTop / env.skyHorizon | 蓝 / 浅蓝 | 天空渐变颜色 |
env.sunElevation / env.sunAzimuth | 42 / 135 | 太阳仰角/方位角 (°) |
env.sunIntensity / env.ambientIntensity | 1.0 / 0.45 | 太阳 / 环境光强度(逆光提亮) |
env.ceilingHeight | 8.0 | 吊顶高度 (m) |
env.ceilingLightIntensity | 1.2 | 吊顶灯光强度 |
env.shadowQuality | 1024 | 阴影贴图分辨率 (px) |
target.pathPatrol | true | 走动目标是否沿路网巡逻 |
target.patrolSpeed | 1.4 | 巡逻速度 (m/s) |
pickup.enabled | true | 是否启用弹药包 |
pickup.threshold | 60 | 备弹低于该值时生成弹药包 |
pickup.packAmount | 45 | 单个弹药包弹药量 |
pickup.maxCount | 10 | 同时存在的弹药包数量上限 |
pickup.maxTotal | 1200 | 同时存在的弹药包弹药总量上限 |
pickup.pickupRadius | 1.2 | 拾取半径 (m) |
weapon.impactLifetime | 10 | 子弹弹孔保留时间 (s),0=永久;对象被摧毁/击杀时立即清除 |
weapon.tracerLifetime | 0.6 | 弹道枪线(曳光 + 烟雾)持续时间 (s) |
weapon.tracerSmoke | true | 弹道是否附带烟雾 |
weapon.sparkEnabled | true | 命中硬表面是否产生火星 |
weapon.rpm | 660 | 射速 (发/分) |
weapon.recoilPitch | 1.9 | 每发垂直后坐力 (°),范围 0~10 |
weapon.recoilYaw | 0.15 | 每发水平后坐力 (°) |
weapon.recoilRecenter | false | 后坐力自动回正(开火后视角自动回到开火前角度,大狙式) |
weapon.recoilRecenterSpeed | 8 | 自动回正速度(越大越快) |
weapon.headshotHpMult | 1.5 | 爆头伤害 = 目标基础血量 × 该倍率 |
weapon.magSize | 30 | 弹匣容量 |
weapon.adsFov | 55 | 瞄准时的 FOV |
weapon.adsSpeed | 0.15 | 瞄准过渡时间 (s) |
weapon.adsSensMult | 1.3 | 瞄准灵敏度倍率(1=与腰射屏幕体感一致,范围 1~2,越大越灵敏) |
weapon.aimMode | “hold” | 瞄准模式(hold/toggle) |
crosshair.style | “cross” | 准星样式(cross/dot/lineDotLine/circleCross/triangle/T/circle) |
crosshair.adsBlur | true | 瞄准时是否启用中心外高斯模糊 |
crosshair.adsBlurRadius | 230 | 模糊清晰半径 (px) |
crosshair.adsBlurStrength | 6 | 模糊强度 (px) |
crosshair.adsMask | true | 模糊半径外是否叠加黑色半透明遮罩 |
crosshair.adsMaskOpacity | 0.8 | 遮罩不透明度 0~1 |
target.count | 6 | 目标数量 |
target.bodyRadius | 0.18 | 目标身体半径(宽度,m) |
target.headRadius | 0.11 | 目标头球半径 (m) |
target.respawnTime | 10 | 目标重生时间 (s) |
target.heightMin | 0.8 | 目标身高下限(× 玩家站立眼高) |
target.heightMax | 1.2 | 目标身高上限(× 玩家站立眼高) |
target.hp | 100 | 目标基础 HP |
target.hpMultMin | 1.0 | 实际 HP = 基础 HP × 均匀随机倍率,倍率下限 |
target.hpMultMax | 1.2 | 血量随机倍率上限(默认实际 HP 约 100~120) |
target.randomColor | true | 是否使用与墙体接近的随机隐蔽配色(false 为固定红橙) |
target.showHpBar | true | 是否显示目标头顶血条(快捷键 O 开关) |
storage.maxAgeDays | 90 | Cookie 保存天数(设置/键位/预设/最佳记录) |
技术栈
- Three.js v0.160.0(本地 ES Module 引用)
- Web Audio API(纯代码音效合成)
- PointerLock API(1:1 鼠标输入)
- ES Modules(无打包工具,原生模块)
- Node.js test runner(单元测试)
license
GPLv3
项目:Web 对枪训练器 – 定位 / 反应练习——最终开发规格书
你是一名资深前端/游戏开发工程师。请按照本文档实现一个多文件结构的网页 FPS 对枪训练器 – 定位 / 反应练习(HTML/CSS/JS 分离,ES Modules 组织,Three.js 本地引用)。项目部署于 HTTP 服务器(用户自有服务器,python -m http.server 或任意静态服务器均可)。最终交付零网络依赖、零外部素材(图片/音频全部代码生成)的完整项目目录。
执行前必读(遵守用户全局规则)
- 所有代码注释使用中文;所有 UI 文字、提示信息、README 使用中文。 代码风格保持一致(本 plan 第 3 节有约定)。
- 每个任务只针对一个需求或同一源代码区域, 严格禁止一次修改海量代码 。按本 plan 的任务编号逐个执行,每个任务完成后自测再进入下一个。
- 读取文件时一次读取整个文件,不要按行分段读取。
- 每完成功能增改,同步更新
README.md(如存在)。 - 本项目属于组件化/系统化代码, 必须为纯逻辑模块实现自动测试 (详见”测试策略”),测试代码保留不删除。
- 开发过程中发现的通用性经验(环境坑、构建方式、易错点),追加写入
.agents/下的 Markdown 持久记忆文件(见”自进化记忆”任务)。 - 不引入任何构建工具(无 webpack/vite/打包器),纯静态文件。
- 如发现严重问题导致修复工作量与新增功能差一个数量级,或者存在需求错配/不清楚/冲突的情况,请务必先停下来提醒用户本轮只修复问题,或者确认实际功能需求,理清楚后再继续。
总原则:
- 严格按第 1 节文件结构组织代码,每个 JS 文件一个模块,职责单一,
import/export显式声明依赖,禁止循环依赖。 - 每个任务结束必须保证:页面无报错、可玩、已有功能不退化。
- 所有可调参数集中在
js/config.js的 CONFIG 对象,运行时通过设置面板修改并持久化到 localStorage。 - 物理更新用固定时间步进(120Hz),渲染用 requestAnimationFrame,二者解耦。
- 目标不攻击玩家;只服务于练枪体验。
文件结构(严格遵守)
aim-trainer/
├── index.html
├── README.md # 项目文档(遵循用户文档规范,见第 12 节)
├── css/
│ ├── main.css # 全局样式、开始界面、游戏容器
│ ├── settings.css # 设置面板样式(深色半透明、分组、滑杆)
│ └── hud.css # DOM 部分 HUD(提示条、击杀 feed 等)样式
├── js/
│ ├── main.js # 入口:初始化、游戏主循环、模块协调
│ ├── config.js # CONFIG 默认值 + localStorage 读写 + 预设槽
│ ├── audio.js # AudioContext、全部音效合成
│ ├── input.js # PointerLock + 键位表 + 键位重绑定
│ ├── player.js # 移动、姿态 FSM、QE 侧倾、碰撞解算
│ ├── weapon.js # 射速、弹匣、换弹、散布、后坐力、开火
│ ├── map.js # 程序化地图生成(BSP、泊松采样、纹理)
│ ├── destruction.js # V 键敲墙、破洞管理、穿洞射线递归
│ ├── targets.js # 目标生成、行为 AI、命中与重生
│ ├── hud.js # 2D Canvas HUD 绘制 + DOM 提示控制
│ ├── stats.js # 命中率、TTK、反应时间统计
│ ├── settings-panel.js # 设置面板 UI(根据 CONFIG 自动生成控件)
│ └── utils.js # 共享工具:随机数、AABB、贴花、粒子公共类
├── lib/
│ └── three.module.js # 本地 three.js ESM 构建
├── tests/ # 自动测试(保留,不删除)
│ ├── config.test.js # 配置深合并 / 路径读写 / 预设槽测试
│ ├── utils.test.js # AABB 碰撞解算 / 随机数 / 数学工具测试
│ ├── sens.test.js # cm/360 灵敏度换算测试
│ └── browser-tests.html # 需要浏览器环境的冒烟测试页(可选)
└── .agents/
└── dev-notes.md # AI 开发长期记忆(自进化经验,见任务 T-final)
模块通信约定
- index.html 只引入一个入口:
<script type="module" src="js/main.js"></script>。 - 依赖方向(禁止循环依赖):
main → 所有模块weapon → destruction, targets, audio, hud, input, config, utilsplayer → input, config, audio, utilstargets → map, config, utils, player(仅 collideAABB)destruction → map, audio, utilsmap → config, utilshud → configsettings-panel → config(与 main 之间只通过事件通信,不直接 import)
settings-panel修改配置后window.dispatchEvent(new CustomEvent('config-changed', {detail:{path, value}})),由 main 监听并应用即时变更(FOV、灵敏度等)。- main.js 维护
Game单例对象持有scene/camera/renderer/player/weapon/targets等引用,模块通过参数注入依赖,不做全局变量。
three.js 本地化
- 开发时一次性下载
https://unpkg.com/three@0.160.0/build/three.module.js保存为lib/three.module.js随项目提交(0.160 是最后提供 ESM 独立文件的稳定版本之一,直接可用)。 - 各模块统一
import * as THREE from '../lib/three.module.js'。 - 交付前自查:grep 所有 html/js/css 文件中的
http://与https://,除注释外不允许出现 (three.module.js 库内部注释除外)。 - main.js 顶部对 import 做兜底:加载失败时在页面显示”lib/three.module.js 缺失”的友好中文错误提示。
- 不引入
examples/jsm下任何 three 附加模块。
代码风格约定(保持一致性)
- 全项目统一:4 空格缩进、分号结尾、双引号字符串、
const优先。 - 注释统一中文;每个文件顶部一段中文块注释说明模块职责。
- 函数注释格式统一:
/** 一句话说明(中文) */;复杂算法(穿洞递归、BSP、泊松采样)在实现处写详细中文说明注释。
核心规格
场景与玩家(player.js)
- 地面 40×40m,CanvasTexture 程序化网格纹理(256×256 repeat);四周高 3m 不可见空气墙;HemisphereLight + DirectionalLight(阴影开关,默认开)。
- 胶囊碰撞(半径 0.35m),姿态高度 站 1.8 / 蹲 1.2 / 趴 0.5;眼高 站 1.65 / 蹲 1.15 / 趴 0.35,切换 0.25s smoothstep 插值(
transitionTime可调)。 - 移动:
vel += wishDir × accel × dt; vel ×= friction(地面 accel=60、friction=10;空中airControl权重默认 0.3);速度 clamp 到姿态上限(走 4.2 / 蹲 2.0 / 趴 0.8 / 疾跑 6.5;sprintModehold/toggle 可选)。 - 跳跃 Space 初速 4.5 m/s,重力 -15 m/s²。
export function collideAABB(pos, radius, height, colliders):逐轴分离(X→Z→Y)推出;导出复用给 targets 的 walker。- 姿态 FSM:
stand --Ctrl(toggle/hold 可选)--> crouch --Z--> prone,反向同理。 - QE 侧倾:相机沿本地 ±X 平移至
lean.offset(默认 0.5m,0.15s 插值),可选 roll ±8°; 只移动眼睛不移动碰撞体 (片头 peek 本质)。 - 脚步声:步频 = 当前速度 / 0.75,触发
audio.footstep()。
视点与灵敏度(input.js)
- PointerLock +
e.movementX/Y→ yaw/pitch(clamp ±89°),相机顺序 YXZ, 无任何平滑,1:1 输入 。 - 灵敏度两种模式(面板二选一):
// cm/360 + DPI 换算(rad/count)
export function sensPerCount(cfg) {
if (cfg.sensMode === 'cm360') {
const countsPer360 = (cfg.cm360 / 2.54) * cfg.dpi;
return THREE.MathUtils.degToRad(360 / countsPer360);
}
return cfg.rawSens;
}
- FOV 变更灵敏度补偿(保持 cm/360 不变,开关
fovSensCompensation默认开):newSens = oldSens × tan(oldFov/2)/tan(newFov/2)。 - 键位表(
e.code存储)+rebindStart(action)(面板点击 → 等待下一 keydown → 写入)。默认绑定:
| 键 | 动作 |
|---|---|
| LMB | fire;RMB 预留 ADS |
| W/A/S/D | 移动;Q/E |
| Space | 跳;Shift |
| R | 换弹;V |
| Tab/Esc | 设置面板;F1~F3 |
武器系统(weapon.js)
- 开火逻辑在固定时间步进内执行,保证任意帧率射速一致;
fireMode: auto/semi/burst,RPM 默认 660。 - 每发:相机位置 + 前向 0.3m 发射 Raycaster,命中按距离排序取最近有效者:
- 命中墙 →
destruction.checkWallHit(hit, ray, depth)(可能递归穿洞) - 命中目标(
userData.part)→targets.applyHit(hit, damageMult)
- 命中墙 →
- 散布:base + 移动惩罚 + 连射递增(全部可调可归零,练枪默认 0)。
- 后坐力:每发 pitch +0.35°、yaw ±0.15°,松开 0.3s 后回落
recoilRecovery比例(可关)。 - 三种弹药模式:
ammoMode | 行为 |
|---|---|
infinite | 永不换弹,HUD 显示 ∞ |
mag | 匣 30,备弹无限;打空自动换弹或手动 R |
total | 匣 30 + 备 90;耗尽需 T 重置场景 |
- 换弹 2.1s(0.5~5 可调),期间禁射击禁敲墙,准星画进度环。
- 曳光(Line 存活 2 帧)、弹着点(InstancedMesh 池 200 循环复用)、命中粒子(6~10 个带重力 0.5s)。
- 爆头伤害 100(即死)、身体 25;穿洞命中伤害 ×
penDamageMult(默认 0.5)。
可破坏墙体(destruction.js)
- 墙实体字段:
destructible / hp / holes[],每洞{center: Vector3, radius}。 checkWallHit(hit, ray, depth)核心算法:
// 命中不可破坏墙 → 直接 blocked
// 命中可破坏墙:检查命中点是否落在任一破洞半径内(含 0.02m 口径容差)
// 在洞内 → 从命中点沿方向偏移 0.01m 继续二次射线(递归,最多 3 层穿透)
// 在实墙 → 生成弹孔贴花,墙 hp-1(子弹破坏),射线终止
- V 键近战敲洞 :冷却 0.8s;相机前 1.5m 扇形 ±30° 球形检测命中 destructible 墙 → 注册洞(半径 0.22m)→ 洞贴花(CircleGeometry + 程序化锯齿边 CanvasTexture,每次随机)+
meleeWall()音效 + hp-1;hp≤0 → 整墙替换为 10~15 个碎片 InstancedMesh(0.5s 飞散后移除)+ 移除碰撞。 punchHole(wall, worldPos)导出给 map.js:生成 drywall 时按peekHoleChance: 0.5概率预打 1~2 个洞(洞高 0.8~1.3m 随机,对齐蹲/站头线)。
程序化地图
generateMap(scene),每次 T 重置重新生成:
- 地面 + 空气墙。
- 递归二分 BSP(深度 2~3)→ 4~8 个区域;隔墙高 2.2m 厚 0.2m,每面随机留 1.2m 门洞; BFS 验证全图连通,不可达则打通一墙 。
- 每区域泊松圆盘采样(Bridson,最小间距 2.5m,k=30)放掩体,模板池随机 + 0°/90° 旋转:
concrete_barrier [2.0,1.1,0.4] 不可破坏
wood_crate [1.2,1.2,1.2] 可破坏 hp:2
drywall [2.5,2.2,0.15] 可破坏 hp:3, peekHoleChance:0.5
oil_drum [0.6,1.0,0.6] 不可破坏
low_cover [1.8,1.1,0.5] 不可破坏(蹲姿掩体)
sandbag [1.5,0.6,0.6] 不可破坏(趴姿掩体)
- 材质全 CanvasTexture(噪点 + 颜色 + AO 暗角);静态几何合并材质降低 draw call。
- 验证:掩体不与玩家出生点(中心)重叠、不堵门洞(AABB 重叠检测,重叠丢弃)。
- 产出:
colliders(AABB 数组)、spawnPoints(掩体旁 0.5m、门洞旁、房间角落,含掩体引用供 peek 用)。 regenerateMap():清理旧场景对象、geometry.dispose()/material.dispose()防内存泄漏。
目标系统
- 头:球体半径 0.11m (可调 0.08~0.15)亮橙自发光;身体:圆柱半径 0.16m,高 站 0.8/蹲 0.55/趴 0.35,灰蓝。
userData.part='head'/'body'、userData.targetId。 - 头部高度对齐玩家眼高:站 1.65 / 蹲 1.15 / 趴 0.35(练预瞄)。数量默认 6(1~20 可调)。
- 行为 FSM(按
behaviors开关随机分配):
| 模式 | 行为 |
|---|---|
static | 固定在掩体旁 |
peeker | 隐藏 1.5~3s → 0.4s 缓动沿掩体法线探出 0.6m → 暴露 0.6~1.5s → 缩回 |
walker | 3~5 随机路径点游走 1.5m/s,复用 collideAABB 滑墙,每点停留 0.5~2s |
crouchSpam | 原地蹲/站 0.5s 周期切换 |
- 命中:头即死、身体 -25;头顶 Sprite 血条(CanvasTexture 动态重绘)。死亡闪白 0.1s → 消失 → 0.8s 后随机 spawnPoint 重生(避开玩家 3m),新姿态新行为。
applyHit(hit, mult)返回命中类型,通知 stats/hud(击杀 feed、HEADSHOT 提示)。
音频(audio.js,全部 Web Audio 纯合成)
initAudio()在用户点击开始时创建 AudioContext + 主 GainNode。- 枪声三层合成 (初始化时预生成 3 个随机变体 AudioBuffer 缓存,射击时 createBufferSource + 随机 playbackRate ±5% 避免连射死板):
- L1 低频砰:sine 120→40Hz 指数扫频 50ms,gain 1→0.001 (80ms)
- L2 中频爆裂:白噪声 80ms,bandpass 1800Hz Q0.7,gain 0.8→0.01 (120ms)
- L3 高频嘶:白噪声 40ms,highpass 4500Hz,gain 0.5→0.01 (50ms)
- 尾部:ConvolverNode + 程序化 IR(0.6s 指数衰减白噪声),湿增益 0.15
- 其他音效(即时合成,短促无性能压力):
reload(duration):三段(退匣”咔” @0s、进匣 80Hz 方波”哐” @duration×0.5、上膛”嚓” @duration×0.9)headshot():sine 1300Hz 微下滑 80ms”叮”bodyHit():sine 220Hz 60ms 闷响meleeWall():白噪声 150ms + lowpass 600Hz + 100Hz 方波共振footstep():白噪声 60ms + lowpass 500Hz
- 白噪声共享 1 秒 buffer(初始化生成一次,按需切片),不要每次现算。
HUD 与统计
- Canvas 每帧绘制:准星(style:cross/dot/circle/cross+dot,动态扩散 gap 随散布)、hitmarker(X 四短线,爆头红/身体白,120ms 淡出)、换弹进度环。
- DOM(值变化时才更新,避免每帧改 DOM):左下
HP | 姿态、右下弹药27 / 90、左上实时统计、右上击杀 feed(5 条上限)、中上提示条(1s 淡出)。 - 接口:
toast(text)、killFeed(type)、showHitmarker(isHead)、draw()。 - stats 记录:发射数、命中数(爆头/身体分计)、击杀数、击杀耗时、peeker 反应时间(开始探出→被击中);
snapshot()供 HUD;会话最佳存 localStorage;地图重生成时reset()。
配置默认值(config.js)
export const CONFIG_DEFAULT = {
fov: 90, sensMode: "cm360", cm360: 34, dpi: 800, rawSens: 0.002,
fovSensCompensation: true,
move: { walk: 4.2, sprint: 6.5, crouch: 2.0, prone: 0.8, airControl: 0.3,
sprintMode: "hold", crouchMode: "hold", jumpVel: 4.5, gravity: 15 },
pose: { standEye: 1.65, crouchEye: 1.15, proneEye: 0.35, transitionTime: 0.25 },
lean: { offset: 0.5, speed: 0.15, roll: 8 },
weapon: { name: "AR", rpm: 660, fireMode: "auto", baseSpread: 0,
recoilPitch: 0.35, recoilYaw: 0.15, recoilRecovery: 0.5,
magSize: 30, reserve: 90, reloadTime: 2.1, ammoMode: "mag",
headDamage: 100, bodyDamage: 25, penDamageMult: 0.5 },
melee: { range: 1.5, cooldown: 0.8, holeRadius: 0.22, wallHP: 3 },
target: { count: 6, headRadius: 0.11, hp: 100, respawnTime: 0.8,
behaviors: { static: true, peeker: true, walker: true, crouchSpam: true } },
crosshair: { style: "cross", gap: 6, length: 8, thickness: 2,
color: "#00ff88", dynamicSpread: false },
audio: { master: 0.6, gun: 1, hit: 1, ui: 0.8 },
leanRollEnabled: true, shadows: true, showTracers: true, showStats: true
};
loadConfig()深合并 localStorage(嵌套逐 key 合并,不整体覆盖);saveConfig()/resetConfig();savePreset(slot)/loadPreset(slot)(keyaimtrainer_preset_N);get(path)/set(path, value)路径式读写。
设置面板
- 控件根据 CONFIG 结构自动生成 (分组:灵敏度/移动/姿态/武器/弹药/目标/准星/音频/键位),不手写每个滑杆。
- 每项:label + slider/number/select;
input事件 →config.set(path, v)→ dispatchconfig-changed→ 防抖写 localStorage(keyaimtrainer_config)。 - 键位重绑定:点击键位框 → 显示”按下新按键…” → 捕获
e.code。 - 顶部按钮:重置默认 / 保存预设 ×3 / 读取预设 / 重新生成地图。
- 显示时机:Esc/Tab 退出 PointerLock 时显示;重新点击画布请求 PointerLock 时隐藏。
主循环
let acc = 0; const FIXED = 1/120;
function frame(now) {
const dt = Math.min((now - last) / 1000, 0.1); last = now;
acc += dt;
while (acc >= FIXED) { updatePhysics(FIXED); acc -= FIXED; }
updateVisuals(dt); // eyeY 插值、粒子、曳光、目标动画
renderer.render(scene, camera);
hud.draw();
requestAnimationFrame(frame);
}
- main 职责:renderer/scene/camera(挂
#game-container,antialias:false、pixelRatio 上限 1.5)、按序初始化并注入依赖、pointerlockchange 状态切换、resize、开始按钮(AudioContext + PointerLock)、监听config-changed、T 键流程(regenerateMap → 弹药重置 → stats 清零 → targets 重生)。
index.html 结构
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>练枪训练场</title>
<link rel="stylesheet" href="css/main.css">
<link rel="stylesheet" href="css/settings.css">
<link rel="stylesheet" href="css/hud.css">
</head>
<body>
<div id="game-container"></div>
<canvas id="hud-canvas"></canvas>
<div id="start-screen">…中文标题 + 键位速查表 + "点击开始"按钮…</div>
<div id="settings-panel" hidden>…由 settings-panel.js 动态填充…</div>
<div id="toast-container"></div>
<div id="kill-feed"></div>
<script type="module" src="js/main.js"></script>
</body>
</html>
#hud-canvas:position:fixed; inset:0; pointer-events:none; z-index:10。- 开始界面点击后:创建 AudioContext、请求 PointerLock、隐藏自身。
测试策略
纯逻辑模块使用 node 内置测试器(node --test tests/)实现单元测试;依赖 DOM/渲染的模块通过 tests/browser-tests.html 做冒烟测试并在验收阶段人工核查。测试代码全部保留。
tests/config.test.js:深合并不覆盖嵌套 key;路径读写get/set(含创建中间层);预设槽存取;localStorage 键名正确。(config.js 不得依赖 THREE/DOM,保证 node 可测——文件内只导出纯数据与纯函数。)tests/utils.test.js:collideAABB逐轴推出正确性(含重叠、不重叠、角落三种用例);随机数区间与分布冒烟;smoothstep/插值函数边界值。tests/sens.test.js:sensPerCount换算——34cm/800DPI 下移动 34cm 物理距离应累计旋转 360°;rawSens 模式直通;FOV 补偿公式正确。(若 sensPerCount 位于 input.js 且依赖 THREE,则将该纯函数移至 utils.js 供测试,input.js 引用之。)- 每个测试用例前写 中文注释说明测试目的与预期 (供自动文档生成测试报告使用)。
- 视觉/交互验收用 playwright 截图(
downloadsDir固定为E:\LLM\playwright_data\cache,非无头模式),人工比对画面与控制台零报错。
任务拆分与执行顺序
每个任务只动一个需求或同一代码区域,完成后自测(页面无报错 + 已有功能不退化),再进入下一个。 阶段一:可玩骨架
- T1:创建完整目录结构 + 空模块文件 + 下载
three.module.js到lib/+ index.html + 三个 css 骨架 + THREE 加载失败兜底提示。验收:页面打开空白但无 404、无报错。 - T2:
config.js全量实现(纯数据与纯函数,无 THREE 依赖)。 - T3:
tests/config.test.js+ 通过node --test全绿。 - T4:开始界面 + PointerLock + 鼠标视点(cm/360 换算放 utils.js)+ FOV 生效。
- T5:
tests/sens.test.js全绿。 - T6:
utils.js数学/AABB 工具 +tests/utils.test.js全绿。 - T7:
main.js主循环骨架(fixed timestep + renderer/scene/camera/光照/地面)。 - T8:
player.jsWASD + 重力/跳跃 + 地面碰撞(仅地面/空气墙)。 - T9:
targets.js最小版:静态球体 ×6 +weapon.jsLMB 射线 + 爆头/身体判定 + hitmarker。 - T10:
audio.js枪声三层合成(含 buffer 缓存)+ 爆头/命中音。 - T11:
hud.js准星 + 弹药显示 + 基本统计。 - 阶段验收:部署到 HTTP 服务器,能走、能瞄、能打、有声音,零报错。 阶段二:姿态与武器完整
- T12:蹲/趴 FSM + eyeY 插值 + 姿态速度 + HUD 姿态显示。
- T13:QE 侧倾(offset 插值 + roll)。
- T14:三种弹药模式 + R 换弹 + 进度环。
- T15:换弹音效(三段调度)。
- T16:后坐力 + 散布(可调可归零)。
- T17:曳光 + 弹着点 InstancedMesh 池 + 命中粒子。
- 阶段验收:三弹药模式行为正确,姿态切换流畅。 阶段三:地图与目标 AI
- T18:
map.js程序化纹理(CanvasTexture 工具函数)。 - T19:BSP 分房 + 门洞 + BFS 连通性校验。
- T20:泊松采样掩体 + 碰撞注册 + spawnPoints 登记。
- T21:目标四行为(static/peeker/walker/crouchSpam)+ walker 滑墙(复用 collideAABB)。
- T22:目标血条 Sprite + 重生 + 死亡闪白。
- 阶段验收:掩体不重叠不堵门,peeker 可探头可被打。 阶段四:破坏系统
- T23:
destruction.js墙实体数据结构 + destructible 标记接入 map。 - T24:V 键近战敲洞(冷却、贴花、音效、hp)。
- T25:墙 hp≤0 碎裂动画 + 碰撞移除。
- T26:
checkWallHit穿洞递归判定 + 穿透伤害衰减。 - T27:预制对枪小洞(map 调
punchHole)。 - 阶段验收:敲的洞和预生成的洞都能穿射击中墙后目标。 阶段五:面板、文档与打磨
- T28:
settings-panel.js自动生成控件 +config-changed事件链路 + 防抖持久化。 - T29:键位重绑定 + 预设槽 + 重置默认按钮。
- T30:脚步声、击杀 feed、HEADSHOT 提示、stats 完善、T 键重生成全流程。
- T31:性能优化(材质合并、阴影开关、dispose 检查)+ grep 全部文件无网络 URL + 中端设备 100fps+ 验证(playwright 截图留档)。
- T32:编写
README.md(见下节大纲,遵循用户文档规范)。 - T-final(自进化记忆):将开发过程中的通用经验(环境配置、部署方式、three 本地化注意、测试运行方式、踩过的坑)追加写入
.agents/dev-notes.md;若.agents/不存在则创建。 阶段验收:改任何设置即时生效且刷新后保留。
README.md 文档规范(T32 产出)
遵循用户全局文档规范:
- 开头:标题 + 一段摘要(解决的现实问题:无素材、零成本复现主流 FPS 手感的练枪环境;关键技术:PointerLock 1:1 输入、cm/360 换算、程序化地图与破坏、Web Audio 纯合成枪声)。
- 摘要段后空行加
[TOC]自动目录标记。 - 章节之间不加分割线、 章节不手工编号 。按参考大纲组织:快速开始(含
python -m http.server或静态部署的最小运行说明)、详细说明(键位表、玩法、弹药模式)、系统架构说明(mermaid 模块依赖图,图注用引用格式)、算法说明(灵敏度换算、穿洞递归、BSP+泊松)、开发说明(环境配置、测试运行方式node --test tests/、ai 开发准则、自动测试准则、部署交付准则、易坑说明)。 - 文末:
wyb 日期(协议未指定则不写)。
最终验收清单
- 文件结构与规格完全一致;HTML/CSS/JS 分离;ES Modules 无循环依赖
lib/three.module.js本地引用;grep 全部文件无 CDN/网络 URL- HTTP 服务器部署运行,控制台零报错
- 全部注释与 UI 文字为中文,代码风格一致
node --test tests/全部通过,测试文件保留且用例有中文注释- README.md 符合文档规范([TOC]、无手工编号章节、mermaid 架构图、快速开始)
.agents/dev-notes.md已写入通用开发经验- PointerLock 1:1 输入无平滑;34cm/800DPI 下物理移动 34cm 转 360°
- WASD/QE/蹲/趴/跳/疾跑全工作,速度与眼高随姿态变化
- 三种弹药模式 + 手动/自动换弹 + 进度环正确
- V 敲洞可穿射,墙 HP 耗尽碎裂;预生成小洞墙可用
- 地图重生成结构合理(连通、不重叠、不堵门),无内存泄漏
- 目标头球 0.11m,四行为自然
- 全部音效纯代码合成,无外部文件
- 设置面板全部项生效 + localStorage 持久化 + 键位重绑定
- HUD 完整(准星 4 样式、hitmarker、弹药、姿态、feed、统计)
- 中端设备稳定 100fps+ 遇到本文档未覆盖的细节,选择对”练枪手感还原度”最有利的方案实现,不要留 TODO。
已知问题与待确认事项
本文档记录开发过程中发现的任务书未明确之处、已做出的实现决策、以及已知的技术限制。
一、任务书未明确之处的实现决策
1. 灵敏度 FOV 补偿公式
任务书要求:FOV 变化时灵敏度按公式补偿,保持 cm/360 不变。
未明确:具体补偿公式。
实现决策:采用 sens_new = sens_base × tan(fov_base/2) / tan(fov_new/2) 公式。这是 FPS 游戏中常用的”保持屏幕中心区域 cm/360 不变”的近似公式。往返补偿测试已验证可回到原值。
2. 后坐力应用方式
任务书要求:后坐力有 pitch 和 yaw 分量,松开 0.3s 后开始回落。
未明确:后坐力是”累积式”还是”增量式”应用。
实现决策:采用增量式应用。每次射击时直接调用 input.applyRecoil(pitchDelta, yawDelta) 修改视角,同时累积总量用于回落计算。回落时反向应用增量。这种方式避免了”每帧重新设置视角”导致的与鼠标输入冲突问题。
3. 蹲下/趴下/疾跑/瞄准的 hold/toggle 模式
任务书要求:蹲下和疾跑支持 hold/toggle 模式。 未明确:趴下(Z 键)与瞄准(ADS)的模式。 实现决策:
- 蹲下默认采用 toggle(切换) 模式,可在设置面板改为按住;趴下固定为 toggle(边沿触发),因为趴下状态不适合持续按住。
- 疾跑保持 hold/toggle 可选。
- 瞄准新增
weapon.aimMode(hold/toggle),默认按住;toggle 时在按下瞬间切换 ADS 状态,解锁 PointerLock 时自动复位。 - 所有 hold/toggle 功能均在设置面板提供选择框。
4. 目标重生位置
任务书要求:目标击杀后 0.8s 重生。 未明确:重生位置是否与原位置相同。 实现决策:重生在原 spawn point 位置,行为类型保持不变。未来可改为随机选择新 spawn point。
5. 弹药模式
任务书要求:支持 mag/bullet 两种弹药模式。 未明确:bullet 模式的具体行为。 实现决策:mag 模式下换弹消耗整个弹匣(备弹 -magSize),bullet 模式下换弹只补充实际打出的子弹数。当前实现中两种模式的差异仅在备弹计算逻辑上。
6. 地图 BSP 分房的具体参数
任务书要求:BSP 递归深度 2~3,生成 4~8 个区域。 未明确:门洞大小、走廊宽度。 实现决策:门洞宽度 2.0m,高度 2.2m。BSP 分割时在墙体中间留出门洞区域。
7. 穿洞递归判定的最大深度
任务书要求:射线穿洞时递归查找下一个墙。 未明确:最大递归深度。 实现决策:当前实现为单层穿透(命中洞后继续向后查找最近的墙,但不再递归穿透第二层)。未来可扩展为多层递归,但需注意性能。
8. 持久化方式由 localStorage 改为 Cookie
任务书要求:所有可调参数持久化到 localStorage。
变更决策:应需求改为 Cookie 持久化,默认保存 90 天,天数可在设置面板「存储 → 设置保存天数」(storage.maxAgeDays)调整,作用于配置/键位/预设/最佳记录。
实现细节:Cookie 值经 encodeURIComponent 编码;path=/; SameSite=Lax,本地 http 服务下不加 Secure(否则写入被丢弃)。单条编码后大小 config ≈1922B、keybinds ≈460B、preset ≈1922B,均在 4096B 上限内。
说明:不做旧 localStorage 数据迁移;docs/任务书.md 中的旧描述保留为历史规格,以本条决策为准。
二、已知技术限制
1. PointerLock 兼容性
- 某些浏览器(如 Firefox)在 PointerLock 下
movementX/Y可能有微小差异。 - 用户必须先点击页面才能激活 PointerLock(浏览器安全限制)。
- Esc 键会自动退出 PointerLock,暂停。
2. Web Audio 自动播放策略
- AudioContext 必须在用户交互(点击开始按钮)后创建,否则浏览器会阻止。
- 已在
startGame()中调用initAudio()和resumeAudio()。
3. 程序化纹理性能
- 当前每个掩体生成独立的 CanvasTexture(256×256),大量掩体时可能占用较多内存。
- 未来优化:相同模板的掩体共享纹理。
4. AABB 碰撞精度
- 玩家碰撞使用 AABB 近似(胶囊体简化为方盒),在角落可能略有穿模。
- 旋转的掩体使用包围盒碰撞,实际视觉模型可能比碰撞体小。
5. 目标 AI 寻路
- walker 行为目前是简单的朝玩家直线移动,没有 A* 寻路。
- walker 可能卡在掩体后面(无避障逻辑)。
三、待验证事项
1. 运行时验证
由于开发环境限制(headless 浏览器无法访问宿主机 localhost),以下功能仅通过语法检查和单元测试验证,未在真实浏览器中运行测试:
- Three.js 场景渲染
- PointerLock 鼠标锁定
- 射击命中检测
- 目标 AI 行为
- 破坏系统穿洞
- 设置面板交互
建议:用户在本地浏览器中打开 http://localhost:8080/index.html 进行验证。
2. 性能验证
- 120Hz 固定步进物理在低端设备上可能掉帧。
- 大量目标(>10)和破坏墙时可能影响性能。
3. 跨浏览器兼容性
- 仅在 Chromium 内核浏览器中测试过基本加载。
- Firefox 和 Safari 未测试。
四、代码质量备注
1. 模块间依赖
main.js作为协调者,将 destruction/targets/hud/stats 模块的函数注入 weapon 模块,避免循环依赖。input.js暴露applyRecoil()和applyToCamera()接口供 weapon/player 模块调用。
2. 边沿触发机制
input.js维护_pressedActionsSet,keydown 时记录,consumeAction()消费。- 用于 reload/melee/regen/settings 等需要边沿触发的动作。
3. 配置热更新
- 设置面板修改配置后,调用
_applyAllChanges()通知各模块刷新。 input._recomputeSens()重新计算灵敏度。weapon.refreshFromConfig()刷新武器参数。hud.rebuildCrosshair()重建准星。
开发笔记
开发环境
- Node.js v22+(用于单元测试)
- Python 3(用于启动本地 HTTP 服务器)
- Three.js v0.160.0(本地 ES Module 引用,零网络依赖)
关键设计决策
1. 模块间通信
为避免循环依赖,采用”依赖注入”模式:
main.js作为协调者,将各模块的函数引用注入到需要它们的模块中。- 例如:
initWeapon(scene, camera, destruction, targetsModule, hud, stats)将 destruction/targets/hud/stats 的函数注入 weapon 模块。 input.js暴露applyRecoil()和applyToCamera()接口,供 weapon/player 模块直接调用修改视角。
1.1 开始/暂停菜单与 PointerLock 的同步
- 坑:
_startGame在“已开始”分支只调用requestPointerLock()就 return,从未隐藏#start-screen;_onLockChange也只在 解锁 时显示菜单、没有在 重新锁定 时隐藏菜单。结果 Esc 暂停后点“继续游戏”,视角已可移动但开始菜单仍停留。 - 正确做法:菜单显隐统一由
_onLockChange驱动——locked && _gameStarted时display="none",!locked && _gameStarted且设置面板不可见时display="flex"。不要再在_startGame里各自维护菜单显隐。
1.2 Esc 解锁后重新锁定的 1.25s 冷却
- 坑:按 Esc 退出 PointerLock 后,Chromium 会在约 1.25s 内拒绝任何
requestPointerLock()(Chromium issue 40148473 官方把冷却降到 1.25s),requestPointerLock()返回的 Promise 以SecurityErrorreject。表现为:Esc 进菜单后立刻点“继续游戏”无效,等一会儿(或再按一次 Esc 后稍等)才成功。 - 关键认知:Chromium 的冷却计时从 按 Esc 解锁 那一刻起算,与被拒的请求无关(失败的
requestPointerLock不会重置计时)。因此“失败后固定等 1300ms 再试”是错误策略——若用户在冷却结束前(如 1s)点击,会先被拒、再等到点击后 1300ms 才重试,实际要等约 2.3s,比什么都不做还慢。用户实测反馈正是如此。 - 正确做法:
main.js的_requestLock()同步发起请求以保留用户手势,Promise.catch里调用_scheduleLockRetry(),然后 高频轮询 (LOCK_RETRY_INTERVAL=150ms,最多LOCK_MAX_RETRIES=20 次≈3s)。冷却一结束,轮询的下一发就成功,总等待≈冷却剩余时长+150ms。_scheduleLockRetry用_lockRetryTimer做幂等去重,并额外监听pointerlockerror事件兜底旧浏览器。锁定成功后_onLockChange(true)里清零重试计数、清定时器。 - 不要用
setTimeout(0)/延迟调度来做“正常”锁定:会丢掉用户手势(transient activation),首次进入可能直接被拒。必须先同步调用,失败再重试。 - UX 兜底:
index.html中作为#start-screen兄弟节点的#lock-waiting(.lock-overlay+.lock-card,转圈 + “正在恢复控制…”)是 独立悬浮弹窗 ,z-index 200 盖在开始菜单(100)之上,不要在开始卡片内部嵌提示。点击“继续游戏”/关闭设置面板时_setLockWaiting(true)立刻显示并禁用按钮(防重复点击),锁定成功_onLockChange(true)或再次解锁时_setLockWaiting(false)隐藏;重试耗尽也隐藏以允许用户重点。_setLockWaiting(true)会顺带重置_lockRetries并清掉残留定时器,保证每次用户发起恢复都有全新重试预算。 - 验证经验:无头 Chromium 中
page.keyboard.press("Escape")不会 退出 PointerLock,document.exitPointerLock()也 不触发 重锁定冷却(立即重锁只需约 126ms)。因此无法在无头环境复现真实冷却,只能通过 monkeypatchElement.prototype.requestPointerLock模拟冷却:拒绝请求直到距退出时刻满__cooldown(1250ms) 为止。实测在退出后约 200ms 点击“继续游戏”,会在冷却结束的下一发轮询(约 1.3~1.4s)成功锁定、菜单隐藏、零控制台报错(旧策略需约 2.3s)。真实 Esc 冷却仍需人工在带界面浏览器确认。 - 复现/验证脚本:
E:\ngc13_temp\opencode\lock-retry-e2e.cjs(headless,playwright-core 复用 npx 缓存)
2. 边沿触发动作
某些动作(reload、melee、regen、settings)需要边沿触发(按下瞬间触发一次,而非持续触发)。
input.js维护_pressedActionsSet。- keydown 时(非 repeat),将动作名加入 Set。
consumeAction(action)消费并返回是否触发。- main.js 的物理步进中调用
consumeAction()处理这些动作。
3. 后坐力增量式应用
后坐力采用增量式应用,而非累积式重新设置:
- 每次射击时,
weapon.js调用input.applyRecoil(pitchDelta, yawDelta)。 input.js直接将增量加到_pitch和_yaw上。- 回落时,
weapon.js计算回落增量,反向调用applyRecoil()。 - 优点:不会与鼠标输入冲突,视角变化平滑。
4. 物理与渲染解耦
- 物理步进:120Hz 固定时间步进,使用累积器。
- 渲染循环:requestAnimationFrame,每帧调用。
- 视觉更新(插值、粒子、HUD)在渲染循环中执行。
- 物理更新(移动、碰撞、AI)在物理步进中执行。
5. 配置热更新
设置面板修改配置后:
setConfig(path, value)写入运行时配置并持久化。_applyAllChanges()通知各模块刷新: –input._recomputeSens()重新计算灵敏度。 –weapon.refreshFromConfig()刷新武器参数。 –hud.rebuildCrosshair()重建准星。 –audio.setMasterVolume()更新音量。
6. 鼠标动作统一映射
- 历史 bug:
KEYBINDS_DEFAULT漏了fire,且鼠标事件从不映射为动作,导致isDown("fire")恒为 false,左键无法射击。 - 现方案:
input.js的_onMouseDown/_onMouseUp把按键转为伪键码"Mouse" + e.button(0 左/1 中/2 右),写入_keys并复用_markActionDown(),与键盘走同一条动作解析路径。 - 因此
isDown("fire")(默认Mouse0)、isDown("aim")(默认Mouse2)与键位重绑定对鼠标一律适用;新增鼠标动作只需加默认绑定。 loadConfig()深合并会为旧存档补齐缺失的默认键位,无需迁移脚本。
7. ADS 瞄准(FOV 聚焦)
- 配置
weapon.adsFov(默认 55)、weapon.adsSpeed(默认 0.15s)定义聚焦目标与过渡时长。 input.updateAim(dt)在 视觉帧 执行:按住aim时对_adsT(0~1)线性过渡再smoothstep,把 FOV 从_baseFov插值到adsFov,写相机投影矩阵并_recomputeSens()。- 灵敏度补偿: 第八轮已改 ,不再按 ADS 的
_currentFov补偿(会让小 FOV 过于灵敏)。现基于未 ADS 的_currentBaseFov补偿 +weapon.adsSensMult(默认 1=与腰射一致)。详见 §35。 - 基准 FOV 变更统一走
input.setBaseFov()(会重置_adsT并同步相机),设置面板_applyAllChanges()调用它——原先 FOV 设置根本没作用到相机,一并修复。
8. 准星多样式与 ADS 边缘模糊
- 历史坑:
hud.js早已生成.crosshair-part/.crosshair-dot/.crosshair-circle,但hud.css从未定义这些类,准星实际不可见。已补齐完整 CSS。 - 准星构建为纯 DOM(容器
width:0;height:0,子元素以中心为原点用left/top/right/bottom+calc()定位),尺寸全部走 CSS 变量:--gap/--length/--thickness/--dot-size/--triangle-size/--circle-size/--color。 - 样式字段
crosshair.style支持:cross/dot/lineDotLine/circleCross/triangle/T/circle;三角取max(length,6),圆圈直径取(gap+length)*2以与十字端点对齐。 - 朝向约定(按用户要求修正,勿再“想当然”翻转):
triangle= 中心上方 空心朝上的^,只有两条斜边、没有底边(用.crosshair-triangle容器 +::before/::after两条 45° 旋转线段实现,height = triangle-size/2,线段长triangle-size*0.7071);T= 横线在上、竖线朝下 (bottom+left+right,即⊤)。曾经triangle是实心朝下▼、T是竖线在上(倒 T),被用户指出“弄反了”。 - ADS 边缘模糊:
#hud-container内先放.ads-blur(backdrop-filter: blur(var(--ads-blur-strength))),再用mask-image: radial-gradient(transparent 0 → transparent R → #000 R+40px)只保留半径外区域,实现“中心清晰、外圈高斯模糊”。opacity由updateCrosshairADS(t)按input.getAdsProgress()的_adsT在视觉帧驱动淡入淡出(main._visualUpdate)。 - 层级(相对
#hud-container):.ads-blurz-index 0 且在 DOM 中最先插入,其余 HUD(弹药/统计等,z-index auto)在其后插入因而绘制在其上、保持清晰;.crosshairz-index 2 保证准星不被模糊掉。 --ads-blur-radius半径与强度均为 CSS px,设置面板可直接调(40~600 / 1~20)。
9. 玩家碰撞:水平/垂直分离(防自动爬墙)
- 历史坑:旧
collideAABB逐轴 X→Z→Y 解算,且不重新检查重叠。玩家侧面撞上矮台时,X/Z 已把人推开,但 Y 轴仍按“穿透更浅”继续解算,把玩家pos.y += box.max.y抬到台面顶——表现为 自动爬墙/自动踩上方块 ,且_onGround长期为 false 导致卡住。 - 现方案(
utils.js):collideAABBHorizontal(pos, r, h, colliders):只改 X/Z,绝不改 y;跳过box.max.y <= pos.y+0.02(脚底以下的可站台面)与box.min.y >= pos.y+h(头顶以上)的盒子。findGroundY(pos, r, prevFeetY, colliders, floorY):返回脚下最高支撑面。关键:仅当 移动前脚底不低于平台顶 (box.max.y <= prevFeetY+0.02)才算支撑,所以从侧面靠近矮台不会被托起,只有从上方落下才站上去。findCeilingY(...):上升顶到平台/门框底面时压回。
player.js步进顺序改为:重力 → 跳跃/翻越 → 水平积分+水平解算 → 边界 → 垂直积分+支撑/天花板 。prevY在垂直积分前采集(且在翻越之后),否则翻越那一帧会被判成空中。- 走下平台边缘时足迹不再与平台重叠,
findGroundY回落到地面,重力自然让人掉下去(自动跳下)。 - 空格翻越:
_tryVault(fwd)在正前方reach=radius+0.35处试探碰撞体,顶面在(脚底+0.15, 脚底+cfg.move.climbHeight]内且落点上方无其他碰撞体时,把玩家放到台面顶。cfg.move.climbHeight默认 1.6,0=禁用。房间隔墙 2.2m、空气墙 3m 都超过阈值,不可翻越。 - 回归测试:
tests/utils.test.js的「玩家碰撞(防自动爬墙)」组;端到端脚本E:\ngc13_temp\opencode\movement-destroy-e2e.cjs(无头 Chromium,动态 import/js/player.js后注入已知 box 走/翻/掉落)。
10. 可破坏墙碎裂必须移除碰撞体
- 历史坑:
map.js把掩体 AABB push 进_colliders,_shatterWall只移除网格, 没有移除碰撞体 。墙打碎后原地留下一面隐形实体墙,再叠加第 9 条的自动爬墙,玩家会“站在半空/悬浮弹孔上”,误以为弹孔是实体。 - 现方案:
map.js导出removeCollider(aabb)(indexOf+splice);main.js在initDestruction(_scene, removeCollider)注入;_shatterWall中调用回调并把wall.aabb = null。注意map._colliders与player._colliders是同一个数组引用,splice后两边同时生效。 - 地图重生成时
_clearMap()会重新_colliders = [],removeCollider读的是模块变量当前值,不会指向旧数组。
11. 子弹弹孔限时消失
- 历史坑:
weapon.js的_impactPool(InstancedMesh 200 槽循环复用)从不回收,墙被打碎后弹孔悬浮在空中。 - 现方案:
_impactLives(Float32Array 200)记录每个槽剩余寿命;_spawnImpact写入getConfig().weapon.impactLifetime;updateWeaponVisuals(视觉帧)递减,到期把该实例矩阵缩放为 0 隐藏。impactLifetime=0表示永久保留。设置面板「武器」组可调(默认 10s)。 - 弹孔只是视觉实例, 从来不是碰撞体 ;之前能“站上去”是第 10 条的隐形墙所致。
12. 弹孔与命中对象绑定(对象消失即清除)
- 坑:
weapon._impactPool的弹孔是 世界坐标 的独立实例,与墙/目标无关联。墙被打碎、目标被击杀后,弹孔仍停在原地,看起来“悬浮”。 - 现方案:
_impactObjects[200]记录每个弹孔槽位命中的对象(hit.object)。_impactObjects与_impactLives一一对应。- 导出
clearImpactsForObject(object):遍历槽位,把_impactObjects[i] === object的实例矩阵缩放为 0(隐藏)并把生命置 0、对象引用置 null。 - 目标击杀:
weapon._fireRay内applyHit返回killed时,对tgt.body和tgt.head各调用一次(同一目标可能两个部位都有弹孔)。 - 墙碎裂:
destruction._shatterWall在把wall.mesh置 null 之前 捕获shatteredMesh,通过_onWallDestroyed(aabb, mesh)回调传出;main.js的回调里removeCollider(aabb)+clearImpactsForObject(mesh)。
- 注意回调签名:
initDestruction(scene, onWallDestroyed)的第二参现在是(aabb, mesh),不是只传 aabb。改这里时别忘了 main.js 的包装函数。 - 弹孔槽位靠引用比较,所以不能用「按名字/位置」清理;也别在对象已 dispose 后再比较,引用还在但已无意义(槽位复用后会被覆盖)。
13. 目标身高随机缩放
- 需求:NPC 太矮,改为人形身高 = 随机(
target.heightMin~heightMax) × 玩家站立眼高pose.standEye(默认 0.8~1.2 → 约 1.32~1.98m)。 - 实现:
targets._spawnOne里npcHeight = randRange(rng, min, max) * cfg.pose.standEye,scale = npcHeight / TARGET_BASE_HEIGHT(常量 1.8),模型按 1.8m 基础人形建模后group.scale.setScalar(scale)。 - 原来几何量是硬编码(胶囊 0.6、头 y=1.0、血条 1.3),已改为按基础人形比例推导:
bodyCenterY/bodyTopY/headY/hpY由 bodyR=0.18、bodyLen=1.15、headR 推出,保证整体到头顶约 1.77m,缩放后比例正确。 - crouchSpam 的蹲起位移要乘
t.scale(crouch*0.4*scale),否则高个 NPC 蹲起幅度相对难看。 - 头部半径随整体缩放,
target.headRadius成为「1.8m 基准下的半径」,命中盒同步缩放。
14. 设置面板:键位绑定置顶 + 左侧悬浮目录
_buildPanel改为构造sections数组, 键位绑定固定放第一个 ,其余按CONFIG_SCHEMA顺序;每个分区加id="settings-sec-<i>"和class="settings-group"。- 左侧
.settings-nav是#settings-panel的子节点(不是 wrap 内),position: fixed; left: 16px; top: 50%,按钮点击document.getElementById(...).scrollIntoView({behavior:"smooth", block:"start"});#settings-panel本身是滚动容器,所以能正确定位。 - nav 必须在 wrap 之前 append,且
z-index: 1,否则会被后绘制的 wrap 背景盖住。窄屏(≤1080px)media query 隐藏 nav。 .settings-group { scroll-margin-top: 40px; }避免标题贴顶。
15. 跳跃/翻越改为平滑物理(替换瞬移)
- 坑:旧
_tryVault一帧内直接改_pos.x/y/z把玩家放上台面,观感是瞬移;且isDown("jump") && _onGround按住空格会落地即自动连跳,像卡顿。 - 现方案(
player.js):- 跳跃 边沿触发 (
_prevJumpDown)+ 输入缓冲 (_jumpBuffer,0.12s)+ 土狼时间 (_coyote,0.1s)。按住空格不会连跳,落地前一瞬按下也能起跳。 - 翻越改为
_startMantle(target)+_updateMantle(dt):记录起终点,smoothstep插值位置并叠加sin(kπ)*0.12的弧线,MANTLE_TIME=0.32s完成;期间updatePlayerPhysics在姿态 FSM 之后直接return,屏蔽常规移动/重力/碰撞。_findVault只返回落点不再改位置。 _mantle/_prevJumpDown/_jumpBuffer/_coyote必须在initPlayer与resetPlayer中复位(_coyote初始为满额)。
- 跳跃 边沿触发 (
- 回归脚本:
E:\ngc13_temp\opencode\features-e2e.cjs(headless,逐帧断言单步位移 <0.15m 且最终到 1.2m,证明无瞬移)。旧的movement-destroy-e2e.cjs断言「一帧内 vaultedY≈1.2」已过时,勿再据此判断。
16. 瞄准 hold/toggle
input.updateAim(dt)里按weapon.aimMode决定目标:hold 用isDown("aim");toggle 用_prevAimDown边沿翻转_aimToggleState。- 解锁 PointerLock 时
_onPointerLockChange要复位_prevAimDown=false; _aimToggleState=false;,否则再次锁定时 toggle 状态残留。
17. 持久化统一改为 Cookie(90 天可调)
- 需求变更:原用
localStorage,现全部(配置/键位/预设/最佳记录)改 Cookie,默认保存 90 天,天数可调。 - 实现:
config.js导出readCookie/writeCookie/removeCookie(全项目唯一触碰document.cookie的地方),stats.js从config.js导入;键名沿用aimtrainer_config/aimtrainer_config_keybinds/aimtrainer_preset_N/aimtrainer_best。 - 新增配置
CONFIG_DEFAULT.storage.maxAgeDays(默认 90),设置面板「存储 → 设置保存天数」暴露;writeCookie默认取该值,回退常量COOKIE_DEFAULT_DAYS=90。 - 关键坑:
- 值必须
encodeURIComponent,否则 JSON 里的;会截断 cookie、"/{/}非法;读回时decodeURIComponent。 - 实测编码后 config ≈1922B、keybinds ≈460B、每个 preset ≈1922B,均远低于单条 cookie 4096B 上限,无需分片。
- 不能加
Secure:本地python -m http.server是 http,加Secure写入会被浏览器丢弃。用path=/; SameSite=Lax。 document.cookie读不到 max-age 等属性 ,只能拿到name=value;验证有效期要用 Playwrightcontext.cookies()的expires(脚本E:\ngc13_temp\opencode\cookie-persist-e2e.cjs)。- node 单元测试无
document,config.js的 cookie 访问必须typeof document守卫 +try/catch;测试文件注入假globalThis.document.cookie。
- 值必须
- 不做旧
localStorage数据迁移(用户确认直接切换);file://下部分浏览器 cookie 行为不一致,务必走 http 服务。
18. 非可破坏物弹孔 / 弹道枪线烟雾 / 火星
- 历史坑:
weapon._targets只包含targets.getTargetMeshes()+destruction.getWalls(), 地面、房间隔墙、不可破坏掩体从来不参与射线检测 ,所以打它们既不终止曳光(曳光画到 100m),也不生成弹孔/火星。修复:map.js导出getStaticMeshes()(_mapObjects里过滤掉userData.destructible的),main.js._physicsStep把它们追加进setTargets。 必须过滤可破坏物 :它们由getWalls()提供,碎裂后_walls会移除,若从_mapObjects再进一次会留下已从场景移除的“幽灵网格”。 weapon._fireRay现在把hit.face.normal用transformDirection(hit.object.matrixWorld)转成世界法线再传给弹孔/火星(掩体带 rotation,直接用局部法线偏移方向是错的)。- 弹道枪线:
_spawnTracer读取weapon.tracerLifetime(默认 0.6s),LineBasicMaterial.opacity按life/maxLife线性淡出;_tracers项带maxLife。 - 烟雾:
_smokePool预建SMOKE_POOL_SIZE=64个 Sprite(共享一张程序化径向渐变贴图,但 每个 Sprite 独立材质 以便逐实例调 opacity;Sprite 无法进 InstancedMesh 逐实例透明),循环复用、靠visible开关,运行时零分配。沿弹道按max(2, min(8, round(dist/1.5)))采样,边上升边扩散(sin(kπ)*0.35)淡入淡出。 - 火星:
_sparkPool单个 InstancedMesh(240 槽,AdditiveBlending),_sparkData[i]记录位置/速度/寿命/缩放;沿命中点世界法线向外溅射 + 随机扩散、受重力与空气阻力、按剩余寿命缩放为 0。 注意 :_spawnSparks只写_sparkData,实例矩阵要等updateWeaponVisuals写入,因此开火后第一帧(物理同帧的视觉更新)才可见,属正常。 - 命中目标仍走旧的
_spawnParticles(橙色碎屑),命中非userData.part的一律走火星。 - 新增配置:
weapon.tracerLifetime/weapon.tracerSmoke/weapon.sparkEnabled,设置面板「武器」组暴露。 - 验证脚本:
E:\ngc13_temp\opencode\impacts-tracer-sparks-e2e.cjs(headless,隔离场景scene.updateMatrixWorld(true)后开火,断言 impacts=1、tracer 存活到 +0.3s 且在 +0.97s 消失、sparks 先增后归零)。 坑 :不渲染的临时场景必须手动updateMatrixWorld,否则 wall 停在原点,射线打空(表现为只有曳光到远处、无弹孔)。
测试策略
单元测试(Node.js)
tests/config.test.js:配置深合并、路径读写、预设槽、Cookie 键名/有效期/编码。tests/utils.test.js:AABB 碰撞、随机数、smoothstep、距离计算。tests/sens.test.js:cm/360 换算、raw 模式、FOV 补偿。tests/stats.test.js:命中率/击杀统计、最佳记录经 Cookie 持久化与恢复。
浏览器测试
tests/browser-tests.html:验证 THREE 加载、AudioContext、PointerLock API、localStorage 等。- 端到端验证可用 playwright + MCP 缓存自带 Chromium(
C:\Users\ngc13\AppData\Local\ms-playwright\chromium-1243\chrome-win64\chrome.exe,executablePath启动,headless 亦可 PointerLock)。流程:起python -m http.server 8080→ 打开index.html→ 点开始 →mouse.down开火 → 断言 HUD.ammo-mag从 30 递减 → 按 R 断言回满 → 右键截图对比 FOV。 - Edge channel 在本机会 handoff 到已有会话而启动失败,勿用;Chrome 未安装。
- 务必避免的真实鼠标被“困住”问题 :
index.html点击“开始游戏”会调用requestPointerLock()(main.js:151)。有头浏览器在 PointerLock 状态下会真正捕获操作系统光标;若脚本直接browser.close()/崩溃退出而不释放锁,Windows 会把光标一直限制在某个区域或隐藏,直到用户打开开始菜单等操作重置焦点才恢复。规避手段(按优先级):- 测试一律
headless: true(无头无真实窗口,不抓 OS 光标)——本项目的 e2e 均用无头真机 Chromium。 - 若必须用有头模式,脚本结束前显式解锁:
await page.evaluate(() => document.exitPointerLock()); await page.waitForTimeout(200);再browser.close();或在addInitScript里把Element.prototype.requestPointerLock覆盖为 no-op(仅适用于不需要真实 PointerLock 的用例)。 - 不要调用
tests/browser-tests.html里会触发 PointerLock 的交互后就关窗。 - 万一仍被困:按一次
Esc,或打开/关闭开始菜单、Ctrl+Alt+Del取消以重置焦点。
- 测试一律
模块导入导出匹配验证
使用自定义脚本验证所有模块的 import/export 是否匹配,确保无遗漏。
已知限制
- 运行时验证 :Three.js 场景渲染、PointerLock、射击命中检测已通过 playwright + 自带 Chromium(headless,见“测试策略”)端到端验证;仍建议在真实交互浏览器中人工复查手感。
- 目标 AI 寻路 :walker 行为是简单直线移动,无 A* 寻路。
- 穿洞递归 :当前为单层穿透,未实现多层递归。
- 程序化纹理 :每个掩体独立生成纹理,未共享。
后续优化方向
- 添加 A* 寻路给 walker 目标。
- 实现多层穿洞递归。
- 相同模板掩体共享纹理。
- 添加更多武器类型。
- 添加计时挑战模式。
- 添加排行榜系统。
新增功能实现经验(第二轮需求)
19. 弹药无限显示 ∞
- 弹药模式
weapon.ammoMode:mag=弹匣有限/备弹无限、total=总量消耗备弹、infinite=弹匣与备弹都无限。 - 显示统一走
weapon.getAmmoInfo():返回{ mag, reserve, magInfinite, reserveInfinite, reloading, reloadProgress }。magInfinite = ammoMode==="infinite";reserveInfinite = ammoMode==="mag" || ammoMode==="infinite"。HUDupdateAmmo(info)根据标记显示∞。 - 不要再沿用旧的
reserve < 0 ? "∞"判断 (旧代码从不产生负值,导致无限也显示数字)。拾取/逻辑用真实的getReserveAmmo(),不要用显示值。
20. 弹药包(pickups.js)
- 触发条件:
ammoMode === "total"(有限备弹)且reserve < pickup.threshold,且当前弹药包总量< pickup.maxTotal,才会有新包;单包量min(packAmount, maxTotal - 当前总量),因此总量恒 ≤ maxTotal。 - 依赖注入:
initPickups(scene, { getReserve, isReserveInfinite, addReserve, getColliders, getPlayerPos });玩家位置用player.getPosition()(脚底)。 - 生成点随机且需远离 colliders(margin 0.6)与已有包(≥2m);拾取按
pickupRadius平面距离判定,weapon.addReserve()仅在 total 模式生效。 - 地图重生成/开始时必须
clearPickups(),否则残留。 - 池化不是必须(数量少),但必须在
clearPickups中 dispose。
21. 地图风格可配置(map.js)
- 配置
map.style:rooms(BSP 分房 + 门洞)/maze(6×6 递归回溯迷宫 + 随机拆墙)/arena(无隔墙)。 coverDensity→ 泊松采样最小间距2.0 + (1-density)*3.0(密度越高越密);destructibleChance→ 随机把可破坏模板替换为非可破坏模板;wallDensity→ rooms 的隔墙跳过概率 / maze 的拆墙概率。- 所有风格都避开玩家出生点
(0,5)半径 ~2m,防止出生卡墙。 map.wallRespawnTime恢复时间也在地图配置组。- 设置面板保存后由
main._onSettingsSave()调_regenerateMap()自动重生成。
22. 可破坏墙限时恢复(destruction.js)
initDestruction(scene, onWallDestroyed, onWallRestored)现在有第三个回调;main 注入removeCollider/addCollider与clearImpactsForObject。_shatterWall不能 dispose 几何/材质 (当wallRespawnTime>0),否则恢复时资源已释放;只从场景移除并置wall.mesh=null。修复了原代码先置wall.aabb=null导致碎片永不生成的 bug(改用保存的shatteredAabb)。_shattered记录{wall,aabb,mesh,timer};updateDestructionPhysics倒计时后_restoreWall重新加入场景/_walls/aabb。clearWalls()必须同时清空_shattered,否则地图重生成后会恢复幽灵墙。map.js新增addCollider(aabb)与removeCollider(aabb)配对。
23. 设置面板:草稿 + 悬浮保存 + Esc 确认(settings-panel.js)
- 重大行为变更:输入控件只写内存草稿
_draft/_draftKeybinds, 不再即时setConfig。_commit()→config.applyConfigDraft(draft, keybinds)才写运行时并持久化,然后_applyAllChanges()且回调main._onSettingsSave()重生成地图。 - 新增
config.readPreset(slot)(返回深合并配置、不提交)供“载入预设”到草稿;savePreset(slot, cfg)可选传草稿;applyConfigDraft深合并默认值(旧草稿缺新字段自动补齐)。 - 悬浮保存按钮
.settings-save-fab是#settings-panel子节点,position: fixed; right: 28px; top: 50%(面板本身是滚动容器,fixed 相对视口,不受滚动影响)。 - Esc/Ctrl+S/Y/N 走
document的 capture keydown(_onPanelKeyDown); 必须if (isRebinding()) return;,否则会吞掉键位重绑定按键。 - 未保存确认弹窗
.settings-confirm挂在document.body、z-index: 300。 - 打开面板要
exitLock()(main._toggleSettings先判断isPanelVisible()决定 open/close),否则指针仍被锁定无法点击;关闭由_onClose→main 重新 requestLock。 - 冒烟测试脚本:
E:\ngc13_temp\opencode\aim-trainer-features-e2e.cjs(UI 流程)与aim-trainer-modules-e2e.cjs(动态 import 验证地图/弹药包/墙恢复)。
24. 复位位置(P 键/暂停按钮)
- 键位
reset默认KeyP;main_resetPlayerPosition()调player.resetPlayer(),物理步进用consumeAction("reset"),暂停菜单#reset-btn直接调用。 player.resetPlayer()需复位新增的_leanToggle/_prevLeanLeftDown/_prevLeanRightDown/_prevSprintDown。
25. 冲刺 FOV / 侧倾 toggle / 目标体宽
input.updateAim(dt, sprinting)第二参由 main 传入player.isSprinting()(避免 input→player 循环依赖)。内部维护_sprintT,最终 FOV =baseFov + (sprintFov-baseFov)*smoothstep(_sprintT),再叠加 ADS 插值(瞄准优先)。setBaseFov要复位_sprintT。灵敏度补偿仍基于_baseFov↔_currentFov,故冲刺时 cm/360 不变。player._isSprinting()的 toggle 原来有 bug(永远无法关闭),已改为用_prevSprintDown边沿翻转。- 侧倾
lean.leanMode默认toggle;updatePlayerVisuals中用_leanToggle+ 左右键边沿切换。 - 目标身体半径
target.bodyRadius替换原先硬编码0.18,头部/血条位置由它推导。 - 目标/墙默认恢复时间均为 10s(
target.respawnTime/map.wallRespawnTime)。
26. 解锁时清空边沿动作队列
input.js的_onPointerLockChange在解锁时除清_keys/_mouseButtons外,必须_pressedActions.clear()。否则暂停/设置面板期间按下的键(如 Tab)会在重新锁定的瞬间被consumeAction消费,误触发动作(例如刚关闭设置又被重新打开)。
27. 环境与光影模块(environment.js)
- 新增
js/environment.js:initEnvironment(scene, renderer)/refreshEnvironment()/updateEnvironment(dt, cameraPos)。main 在_initModules初始化;设置保存时_onSettingsSave()先refreshEnvironment()再重生成地图;_renderLoop每帧调updateEnvironment(dt, camera.position)。 scene.background/fog/灯光全部由本模块负责,main._initThree不再 建灯(否则切换模式会残留)。- 天空:
SphereGeometry(450)+ShaderMaterial(BackSide、fog:false、depthWrite:false),uniformtopColor/bottomColor/offset/exponent做地平线渐变。 不要 用scene.background做渐变(拿不到方向)。 - 吊顶:
PlaneGeometry(64)在y=ceilingHeight,程序化格子纹理;发光灯板用MeshStandardMaterial({emissive});真实照明只用 少量PointLight(≤6,castShadow=false),样式仍靠 emissive 出效果。 - 太阳:
DirectionalLight+sun.target加入场景;每帧把 target 设为玩家水平取整坐标、position = target + 方向×60,使阴影正交相机(±32)始终覆盖玩家,避免大地图阴影失真。仰角/方位角决定方向向量。 - 关键坑:
refreshEnvironment()重建灯光/穹顶前必须_clearEnvironment()释放旧的shadow.map(sun.shadow.map.dispose()),否则多次切换模式会泄漏;改阴影分辨率后要重建 light(本模块每次 refresh 都重建,天然满足)。 - 逆光:默认
ambientIntensity/hemiIntensity提供填充光,太阳低时靠环境光提亮暗部;sunElevation越小阴影越长越易逆光,属预期可调项。
28. 多层建筑 / 楼梯 / 洞口 / 路网(map.js 重写)
- 贯通楼梯 :一栋建筑用 一条 连续楼梯从地面直达最高层(
totalRise=(floors-1)*floorHeight),而不是每层一段(每层一段会因楼梯井开洞导致上层起点悬空)。楼板(level < floors-1的顶板)在楼梯脚印处开洞;最高层顶板是屋顶、不开洞。 - 玩家自动上台阶 :
utils.findStepUpY(x,z,radius,height,colliders,stepHeight,feetY)返回可迈上的最高台面(含净空检查);player.updatePlayerPhysics先做水平移动,若被挡且在地面,则抬高到台面并重试。下行用「贴地下台阶吸附」(wasGrounded && vel.y<=0 && pos.y-groundY<=stepHeight)。单级台阶高度必须 ≤move.stepHeight(默认 0.45,楼梯 rise≈0.22)。 不要 把楼梯做成斜坡碰撞体(本工程 AABB-only)。 - 开洞算法 :
_addWallWithOpenings把墙沿主轴切成区间,每个区间按覆盖它的 opening 生成「下段+上段」(门/窗/狗洞/脚洞通吃)。 同一位置不要放两个 x 区间重叠的 opening (ops.find只认第一个,第二个会被忽略——曾把脚洞放在窗同中心导致脚洞永不生成)。 - 洞口尺寸:门高 2.1、狗洞高 0.62(站立被过梁挡、趴下 0.5 可过)、脚洞高约 0.33。狗洞/脚洞/矮墙也作为「特殊掩体」以
_placeSpecialWall独立放置(map.specialCoverChance)。 - 路网 :
_buildPathGraph()对每个可行走高度层做 1m 网格采样(_hasSupport需有顶面≈y 的碰撞体;_cellFree用半径 0.3/高 1.55 探针),连接 4 邻域并检查utils.segmentClearXZ。楼梯另建链式 waypoint 节点并逐级接入同层网格节点,getPathGraph()导出{nodes:[{x,y,z}],edges:[[i,j]]},main 注入targets.setPathGraph()。 - 两个必踩的坑 :
- 楼层高度集合必须是
0 .. floors-1(floors*fh是屋顶,曾把屋顶当可行走层,生成一堆孤立节点,NPC 会刷在屋顶下不来)。 - 楼梯接入同层节点时,
_nearestNode必须 排除楼梯链自身节点 (否则最近节点就是链节点自己,_addEdge(a,a)无操作,导致上层完全不可达)。
- 楼层高度集合必须是
- 建筑正门强制
noPanel:true(无门门框),保证一层可进入、路网与外部连通(否则可破坏门板作碰撞体把建筑内部变成孤立连通分量,NPC 与玩家都进不去)。内部隔墙门则按doorDestructibleChance生成可破坏门板。 - 纹理缓存:
_getWallTexture(color)/_getCoverTexture(color,name)共享 canvas 贴图;_clearMap不再 逐个 disposematerial.map,改为统一 dispose 缓存并清空,否则共享贴图会被提前释放。 - 验证脚本:
E:\ngc13_temp\opencode\features2-e2e.cjs(三风格 + 2/3 层建筑的连通分量/可达最高层、环境切换、玩家自动上台阶集成)、features2-shot.cjs(天空/吊顶截图)。
29. NPC 路网巡逻(targets.js)
setPathGraph(graph)保存_graph并构建邻接表_adj。_spawnOne记录baseY(出生点 y),_respawn复位pathNode/pathNext。- walker 在
cfg.pathPatrol && _graph时走_walkerPatrol:朝当前目标节点移动,到达(<0.2m)后从邻接表随机选下一节点;y 用+= (to.y-p.y)*min(1,dt*6)平滑跟随,从而自然走楼梯上下楼。否则回退旧的「保持 5~8m 距离」逻辑。 - 出生点来自路网节点(
_collectSpawnPoints),因此目标会分布在各楼层;crouchSpam的蹲起位移现在是baseY + crouch*0.4*scale( 不要 只写position.y=...,否则多层目标会被拽回地面)。 - 各连通分量相互独立(闭合门会切断),walker 只在自己分量内巡逻,属正常;把门打穿/破坏后连通性变化不会重建路网(下次重生成地图才更新)。
30. 水平碰撞改为逐轴解算(修复贴墙跳变/卡进墙)+ 速度减半迁移
- 历史坑:
utils.collideAABBHorizontal用「取 X/Z 最短穿透轴推出」。当玩家贴着墙的 端角 斜向移动时,某个轴的最短穿透会在两帧之间来回翻转,玩家被突然横向弹出最多约一个半径(0.35m),表现为「靠近墙时视角/位置跳变」;深度嵌入时还可能被推穿到另一侧或卡在墙里。 - 现方案:新增
utils.moveAndCollideXZ(pos, radius, height, colliders, dx, dz),Quake 风格 逐轴 解算——先pos.x += dx且只沿 X 推出,再pos.z += dz且只沿 Z 推出。因为上一帧结束时恰好贴在面边界(overlapXZ用严格>,接触不算重叠),所以另一轴移动不会误判碰撞,贴墙斜向移动可以平滑滑行。函数内还带一个兜底:若移动后两轴穿透都 >5mm(确实嵌入,如翻越落点异常),再按最短穿透轴推一次。 player.js不再调用collideAABBHorizontal(保留它是为了兼容tests/utils.test.js旧用例)。翻越动画_updateMantle完成时用moveAndCollideXZ(...,0,0)做一次落点去嵌入。- 台阶自动上行的重试逻辑要 先回到移动前位置再抬高 y,然后重新应用 dx/dz (不能从已贴墙的边界位置再移动一次,否则越过目标);若抬高后仍被挡则整体回退,避免嵌进相邻墙。成功后用
_stepSmoothOffset += 旧y - 新y给相机做视觉高度补偿(在updatePlayerVisuals里以 0.1s 时间常数收敛到 0),消除台阶瞬跳;initPlayer/resetPlayer要重置该值。 - 速度:
move.walk由 2.1 减半到 1.05 ,sprint改为行走的 2 倍 2.1 ;为保持相对手感,crouch/prone同步减半到 1.0 / 0.4 (注意 crouch 必须 < walk,否则蹲着比走路快)。 - 旧存档迁移:
CONFIG_VERSION=2,loadConfig里_migrateConfig仅在旧值 恰好等于 v1 默认 时才替换(walk 2.1/sprint 4.2/crouch 2.0/prone 0.8 → 新值),用户自定义的数值不动。CONFIG_DEFAULT.configVersion会随 Cookie 一起保存。 - 回归脚本:
E:\ngc13_temp\opencode\collision-speed-e2e.cjs(贴墙斜向滑行单帧位移 <0.03m、不嵌入、行走/疾跑速度≈1.05/2.1)、stairs-e2e.cjs(自动上 6 级台阶 maxY≈1.32、单帧垂直变化 0.22)、smoke-e2e.cjs(整体冒烟);单元测试见tests/utils.test.js的「逐轴水平碰撞(防贴墙横向跳变)」组与tests/config.test.js的「旧存档速度迁移」组。
31. NPC 隐蔽配色 / 血量随机 / 左下状态罗盘 / 趴下回退上一姿态(第四轮需求)
- 隐蔽随机配色 (
targets.js):target.randomColor默认true,从CAMO_PALETTE(水泥灰/土棕/橄榄/沙灰/蓝灰,与map.js墙体色同一色系)随机取基准,再用utils.jitterColorHex(base, rng, amount)逐通道扰动(body ±18、head ±14,head 取CAMO_HEAD_PALETTE暗肤色)。关闭开关时用固定0xff4444/0xffaa44(旧色)。每次_spawnOne与_respawn都调用_applyTargetAppearance,所以重生也会换色。jitterColorHex是放在utils.js的纯函数(不依赖 THREE),因此可直接单元测试(通道夹 0~255、amount=0 不变)。- 绝对 不要 把隐蔽色做成纯
setHSL随机色相:会跑出荧光色,与墙体不搭。用「基准色 + 小扰动」才能保证隐蔽。
- 血量随机 :用户确认「1~1.2」是 倍率 而非绝对值。实际 HP =
target.hp(基础,默认100) ×randRange(min(hpMultMin,hpMultMax), max(...)),默认 1.0~1.2 → 100~120,均匀分布,上下限在设置面板可调(target.hpMultMin/hpMultMax),每次重生重随机。- 副作用:默认 headDamage=100,对 120HP 目标爆头不再必杀(用户接受)。改这里时别忘
_randomHp(tcfg)在 spawn/respawn 两处都要调用。
- 副作用:默认 headDamage=100,对 120HP 目标爆头不再必杀(用户接受)。改这里时别忘
- 左下状态 + 罗盘 (
hud.js):updatePose(pose, lean)已被updateStatus(info)取代 (info={pose,lean,onGround,mantling,vy,sprinting,yaw}),main 在_visualUpdate里从player新增的isOnGround()/isMantling()/getVerticalSpeed()与input.getViewAngles()取数据注入。改签名或调用点务必同步(只剩 main 一处调用)。- 姿态文案:站立→「站姿」、蹲→「蹲姿」、趴→「趴姿」。动作优先级:翻越中 > 跳跃中(vy>0.1) > 下落中(离地) > 疾跑 > 地面,再追加「· 左倾/右倾」。
- 罗盘为横条:初始化时一次性在
.compass-track里铺满 -180°~540° 的刻度(每 15°)与方位标签(每 45°,中文八方位),每帧只改track.style.transform = translateX(COMPASS_WIDTH/2 - (heading - START)*PX_PER_DEG)。范围必须超出 0~360 才能环绕无空白。COMPASS_WIDTH=220必须与css/hud.css的.compass{width:220px}保持一致。 - 方位约定:
utils.yawToHeadingDeg(yaw),0=北(-Z)、顺时针增大(鼠标右移 yaw 减小 → heading 增大),归一化 [0,360)。 别把符号写反 ,否则左右颠倒。 - 每帧只对变化项赋
textContent/transform,避免无谓 reflow。
- 趴下切换回上一姿态 (
player.js):新增_poseBeforeProne。进入 PRONE 前记录当前姿态,再按prone时_transitionTo(_poseBeforeProne)(原先硬编码回Pose.CROUCH,导致站→趴→再按变成蹲)。initPlayer/resetPlayer都要复位为Pose.STAND。 - e2e 坑 :
page.keyboard.press("KeyZ")是同步 keydown+keyup,中间没有 rAF,120Hz 物理轮询isDown("prone")会 漏掉 这次按键(表现为姿态不切换)。必须用keyboard.down→waitForTimeout(120)→keyboard.up保持一小段时间。验证脚本E:\ngc13_temp\opencode\aim-trainer-camo-status-e2e.cjs(无头,断言 body 色≠ff4444、站↔趴↔蹲回退正确、无控制台报错)。
32. 第五轮需求(圆形罗盘 / 默认值 / 蹲趴互切 / 偏头碰撞 / 墙HP / 保存按钮 / Esc 语义)
- 圆形罗盘 (
hud.js+hud.css):由横条改为直径 120px 的圆形刻度盘。DOM:.compass > .compass-track(旋转盘)+.compass-pointer(顶部固定指针)+.compass-hub;盘内 24 个刻度(每 15°,45° 为主刻度)+ 8 个方位文字(每 45°)。_buildCompass()一次性构建,_updateCompass(heading)每帧:盘rotate(-heading),方位文字额外rotate(heading - a)反向自旋保持水平(角度 a 存在label.dataset.angle)。COMPASS_TICK_R=52/COMPASS_LABEL_R=38必须与 CSS 中.compass的 120px 尺寸相协调。旧的COMPASS_WIDTH/PX_PER_DEG/START/END常量与.compass-center已删除,勿再引用。 - 默认值变更 :
move.walk=2.3、move.sprint=4.0(不再是 walk 的 2 倍,测试也一并改了);蹲下默认KeyC、趴下默认ControlLeft(旧组合ControlLeft+KeyZ自动迁移,loadConfig里做,仅当二者恰为旧默认);准星crosshair.length=20、adsBlurRadius=230。 - ADS 黑色遮罩 :新增
crosshair.adsMask(true) /adsMaskOpacity(0.8)。实现是在既有.ads-blur层上加backgroundColor: rgba(0,0,0,opacity)——该层用径向mask-image只保留中心半径以外的区域,所以背景色自然只出现在外圈;整体opacity仍由updateCrosshairADS(t)按 ADS 进度驱动。 - 蹲/趴互相切换 (
player.js._updatePoseFSM):蹲下键在趴姿时也要切到蹲姿(旧代码只在 stand↔crouch 之间切,趴姿按蹲键无反应)。toggle 模式:_pose===CROUCH→STAND,否则→CROUCH;hold 模式:按住且非 CROUCH 就 →CROUCH,松开时若在 CROUCH →STAND。趴键逻辑不变(趴↔_poseBeforeProne)。 - 偏头(侧倾)增大碰撞体积 (
player.js._collisionRadius()):有效半径 =_radius + |_leanAmount|,用于moveAndCollideXZ/findGroundY/findCeilingY/findStepUpY/_findVault/_updateMantle。因为相机沿右向水平偏移最多lean.offset(0.5),半径不跟着加就会被相机探入墙内(“偏头卡进墙里”)。注意_leanAmount在视觉帧更新、物理帧读取,会有 1 帧延迟,可接受;窄走廊里偏头会被推开属预期。 - 墙 HP 可配 (
config.map.wallHP默认 300,destruction._wallMaxHP()):所有可破坏掩体/门板在注册时hp=_wallMaxHP(),扣血/恢复都读该配置;旧的melee.wallHP已删除(设置面板里该项移到「地图」组)。 - 弹药包 :
pickup.packAmount=60、maxCount=10(新增,生成前判_packs.length >= maxCount)、maxTotal=600。 - 移除黑色球标记 (
destruction._punchHole):原先每个洞口都生成一个SphereGeometry(holeRadius)的纯黑球,薄墙(drywall 厚 0.15,球半径 0.22)会明显外凸,是“悬浮黑球”的来源,且掩盖了软墙配色。现只记录wall.holes数据(穿透判定仍正常),不再生成任何可视化标记。可破坏软墙改由自身纹理区分:map._createCoverTexture对wood_crate/drywall画一圈橙色警示虚线边框。 - 保存按钮固定 (
settings-panel.js+settings.css):#settings-panel带backdrop-filter,会成为position: fixed后代的包含块,导致悬浮保存按钮随面板滚动。改为把.settings-save-fab挂到document.body(initSettingsPanel创建一次,_openPanel显示 /hidePanel隐藏),CSSz-index提到 210(面板 200、确认弹窗 300)。_buildPanel不再创建按钮,避免每次重建累积。 - Esc 语义 :设置页 Esc 进入确认弹窗后,再按 Esc = 保存并关闭 (
_onPanelKeyDown里 Escape 与 KeyY 同分支;KeyN 才是放弃)。游戏暂停页(解锁后显示的开始/暂停菜单)再按 Esc 直接_startGame()回到游戏:在main._bindEvents加 document keydown,条件_gameStarted && !isLocked() && !isPanelVisible() && startScreen 可见(确认弹窗期间面板仍可见,需让位给面板的 capture 处理)。 - e2e :
E:\ngc13_temp\opencode\aim-trainer-round5-e2e.cjs(圆形罗盘 DOM、默认值、蹲↔趴互切、保存按钮 parent=BODY+fixed、Esc 两段语义、暂停页 Esc 回游戏、零控制台报错)与aim-trainer-round5-visual.cjs(准星 20px、ADS 遮罩rgba(0,0,0,0.8)且完全不透明)。CONFIG_VERSION=3,tests/config.test.js新增迁移/默认值断言,全量node --test= 106 用例全绿。
33. 爆头 1200 / 疾跑自动回正 / 静步键(第六轮需求)
- 爆头伤害 :
weapon.headDamage100→ 1200 (设置面板上限放宽到 5000)。targets.applyHit逻辑不变((isHead?headDamage:bodyDamage)*damageMult),1200 对任意随机血量目标都必杀。 - 疾跑自动回正 (
player.js):_updatePoseFSM最前面判断_isSprinting(),为真则_poseBeforeProne=STAND、_crouchToggleState=false、_leanToggle=0、_lean=0、_transitionTo(STAND),然后_updatePoseTransition后return——即蹲/趴/QE 侧倾下起跑都强制站姿并取消侧倾。updatePlayerVisuals里同样在_isSprinting()时把targetLean=0且重置_leanToggle/_prevLean*。停止疾跑后不恢复起跑前状态(因为 toggle 已被清零,没有“记忆”)。- 姿势过渡插值提取为
_updatePoseTransition(dt, pose),两个分支共用。 _isSprinting()在 toggle 模式下有边沿副作用,但同帧多次调用幂等(第一次翻转后_prevSprintDown已更新),故物理/视觉多处调用安全。- hold 模式下若同时按住蹲下键与疾跑键,会持续被强制站姿;松开疾跑后按住的蹲下键会立即恢复蹲姿,属预期。
- 姿势过渡插值提取为
- 静步键 :
KEYBINDS_DEFAULT.walkSlow="AltLeft"(左 Alt),新增配置move.slowFactor=0.4。updatePlayerPhysics在“站立且未疾跑”时若isDown("walkSlow")则maxSpeed = walk * slowFactor;疾跑优先级更高,蹲/趴不受影响。设置面板「键位绑定」加「静步(慢走)」项、「移动」组加「静步速度系数」。- 新增字段无需
CONFIG_VERSION迁移:loadConfig/applyConfigDraft的 deepMerge 会自动补齐默认值,旧 Cookie 里缺失的walkSlow/slowFactor自动补齐。
- 新增字段无需
- e2e :
E:\ngc13_temp\opencode\aim-trainer-sprint-slow-e2e.cjs(无头,断言趴姿起跑→站姿、停跑不回落、侧倾起跑回正且停跑保持 0、静步位移显著小于正常行走且无控制台报错)。
34. 爆头倍率 / 后坐力 1.9°+自动回正 / 墙门血量倍率 / 弹药包(第七轮需求)
- 踩坑复盘(重要) :上一轮把
weapon.headDamage从 100 改成 1200,但用户仍反馈”两枪爆头才死”。原因: 保存的 Cookie 覆盖默认值 ——旧存档里存着headDamage:100,deepMerge时旧值优先,新默认永远不生效。凡是”修改已有字段的默认值”都必须走_migrateConfig迁移(仅当旧值等于某个历史默认时替换);而”新增字段”会被 deepMerge 自动补默认,无需迁移。这轮因此把CONFIG_VERSION升到 4 并迁移 recoilPitch(0.35→1.9)与弹药包(15/60→45、100/600→1200)。 - 爆头改为基础血量倍率 :删除
weapon.headDamage,新增weapon.headshotHpMult(默认 1.5)。targets.applyHit里damage = isHead ? target.hp(基础) × headshotHpMult : bodyDamage,再乘穿透damageMult。默认 1.5 对实际 1.0~1.2 倍血量目标必杀;把target.hpMultMax调高(如 2.0)即可造出”血牛”免于爆头一枪。设置面板该项为 range 0.1~5(×)。 - 后坐力默认 :
weapon.recoilPitch0.35→ 1.9 ,设置范围放宽到 0~10。 - 后坐力自动回正 (
weapon.js):新增weapon.recoilRecenter(默认 false,保持原行为)与recoilRecenterSpeed(默认 8,越大越快)。开启后:在”按下扳机边沿”记录getViewAngles()为回正目标并清零累积;停火后每物理步setViewAngles(lerp)平滑拉回目标角度,接近时_recenterActive=false。注意setViewAngles(yaw, pitch)的参数顺序 (先 yaw 后 pitch),写反会导致回正到错误角度。开启时跳过旧的_recoilPitchAccum回落逻辑(二选一,避免打架)。resetAmmo复位_recenterActive。- 用户操作优先(重要) :回正过程中一旦用户动鼠标或按 WASD,必须 立即停止回正 ,否则回正会与用户拉枪对抗、短时间内难以改变朝向。实现:
input.getLastMouseMoveTime()记录最近真实鼠标移动时间(_onMouseMove里仅非零位移才更新),weapon 每步判now - getLastMouseMoveTime() < 0.05或isDown("forward"/"back"/"left"/"right"),命中则_recenterActive=false并清零累积。e2e 断言:停火后移动鼠标,视角不再被拉回(dpitch=0.17保持偏移)。
- 用户操作优先(重要) :回正过程中一旦用户动鼠标或按 WASD,必须 立即停止回正 ,否则回正会与用户拉枪对抗、短时间内难以改变朝向。实现:
- 墙/门血量拆成两个倍率字段 :删除
map.wallHP,新增map.wallHpMult(默认 3)与map.doorHpMult(默认 0.3)。血量 =target.hp(基础) × 倍率,destruction._wallMaxHP(wall)按wall.template.name === "door"区分门/墙;map._coverHP()/_doorHP()在注册时写hp。因此改target.hp会让墙/门同步变厚(符合”3 个 / 0.3 个 NPC 血量”的语义)。 - 扣血单位(重要,曾因此出 bug) :墙 HP 的口径是”NPC 血量”,因此子弹/近战必须按 伤害值 扣血,不能固定 -1。
destruction.checkWallHit改为wall.hp -= weapon.bodyDamage(默认 25),_punchHole改为wall.hp -= melee.damage(新增配置,默认 50)。这样 30HP 的门两枪(25+25)即破、300HP 的墙 12 枪破。旧代码固定-1会让 30HP 的门要 30 枪、300HP 的墙要 300 枪,用户反馈”打半天”。另:punchHole的公开入口(map 预生成探头洞)传applyDamage=false,不扣血也不碎裂,避免墙一出生就掉血。 - 弹药包 :
pickup.packAmount=45、pickup.maxTotal=1200(maxCount仍 10)。 - e2e :
E:\ngc13_temp\opencode\aim-trainer-round7-e2e.cjs(默认值、动态 importtargets.applyHit验证爆头必杀、destruction.getWalls()验证墙 299~300/门 30、开启recoilRecenter后开火停火回到开火前角度、零控制台报错)。全量node --test= 109 用例全绿。
35. 墙按伤害值扣血 / ADS 灵敏度修正倍率(第八轮需求)
- 墙/门按伤害值扣血(重要) :墙 HP 沿用”NPC 血量”口径后,扣血必须按伤害值而非固定
-1。destruction.checkWallHit(子弹)改为wall.hp -= weapon.bodyDamage(默认 25)→ 30HP 的门两枪即破、300HP 的墙约 12 枪;_punchHole(近战)改为wall.hp -= melee.damage(新增配置默认 50)。punchHole公开入口(map 预生成探头洞)传applyDamage=false,不扣血、不碎裂。历史上固定-1导致 30HP 门要 30 枪、”打半天”。 - ADS 灵敏度修正倍率 (
input.js+weapon.adsSensMult,默认 1,范围 1~2):- 核心公式(勿再弄反) :屏幕每像素对应的视角 ∝
tan(fov/2),所以「瞄准与腰射在屏幕上移动同距离、体感一致」要求 ADS 灵敏度按tan(currentFov/2)/tan(baseFov/2)缩放——即缩小 FOV(放大画面)时灵敏度必须 减小 。旧逻辑compensateFovSens(s, _baseFov, _currentFov)给的是tan(base/2)/tan(ads/2)(>1),方向正好相反,导致 ADS 反而更灵敏(90→55 约放大 3.7× 屏幕灵敏度),用户反馈”腰射低、瞄准后很高”。 - 现改为:FOV 补偿只基于 未 ADS 的当前基准 FOV (新增
_currentBaseFov,由updateAim每帧写入)处理冲刺;ADS 额外乘zoom = tan(_currentFov/2)/tan(_baseFov/2)与multEase = 1+(adsSensMult-1)*smoothstep(_adsT)。默认 1 时 ADS 与腰射屏幕体感一致,且与adsFov大小无关;调到 2 约 2×。_currentFov用插值后的实际 FOV,故过渡平滑。 - 关闭
fovSensCompensation时不做 zoom,只应用adsSensMult。 updateAim里加了_getAdsSensMult(cfg) !== _lastAdsSensMult检测,外部(设置面板/预设/脚本setConfig)改倍率也能立即重算;_adsT !== prevAdsT保证即使adsFov==baseFov也能随瞄准进度重算。- e2e:
E:\ngc13_temp\opencode\aim-trainer-ads-sens-e2e.cjs(无头,测量移动 200px 的 yaw 变化再除以tan(fov/2)换算成屏幕量:默认 ADS/腰射屏幕比 =1.00、改 adsFov=30 后仍 =1.00、倍率 2 时 =2.00)。全量node --test= 110 用例全绿。
- 核心公式(勿再弄反) :屏幕每像素对应的视角 ∝
36. 灵敏度默认改 raw / 瞄准倍率 1.3(第九轮需求)
- 默认值 :
sensMode="raw"、cm360=40、rawSens=0.0005、weapon.adsSensMult=1.3。rawSens=0.0005在默认 800 DPI 下约等于 40cm/360(cm = 2π/rawSens / (dpi/2.54)),与cm360默认手感一致。raw 模式与 DPI 无关,实际 cm/360 取决于真实鼠标 DPI。 - 迁移 :
CONFIG_VERSION=5,_migrateConfig新增 v4→v5:仅当旧值等于历史默认时才替换——sensMode==="cm360"→"raw"、cm360 34→40、rawSens 0.002→0.0005、weapon.adsSensMult 1→1.3;用户自定义的值不动。 再次强调:改已有字段默认值必须走迁移,否则旧 Cookie 会压制新默认。 - 设置面板 :
_formatValue改为按step决定小数位(decimals = ceil(-log10(step))),否则rawSens=0.0005会因旧的toFixed(2)显示成0.00。 - 全量
node --test= 112 用例全绿。
37. rawSens 0.001 / 设置页单位说明 / 跳跃保持地面速度(第十轮需求)
- 默认 rawSens 0.001 :
CONFIG_VERSION=6,_migrateConfig增加 v5→v6:rawSens [0.0005] → 0.001(仅旧值恰为 v5 默认时替换)。v<5 迁移的旧 0.002 也会落到CONFIG_DEFAULT.rawSens(现 0.001)。0.001 在 800 DPI 下约 20cm/360。 - 设置页单位说明 :
settings-panel.js的CONFIG_SCHEMA分组新增可选note(HTML 字符串),_buildPanel在分组<h3>下渲染为.settings-note(css/settings.css)。当前「灵敏度」组用note解释 cm/360 与 rawSens 的含义、公式与默认换算。以后任何分组都可直接加note。 - 跳跃水平速度与地面一致 (
player.js):把疾跑速度上限的&& _onGround去掉——maxSpeed只按姿态/疾跑/静步计算,与是否在空中无关。因此疾跑起跳的空中上限=疾跑速度、不疾跑=行走速度(蹲/趴/静步同理),不会再出现“跳跃全程一个固定速度”。空中仍靠accel = 60*airControl与无摩擦体现机动受限, 不要 再给 maxSpeed 加_onGround。 - e2e :
E:\ngc13_temp\opencode\aim-trainer-round9-e2e.cjs(默认 rawSens、按地面速度起跳后空中速度≈疾跑/行走、设置页说明文本、零报错)。全量node --test= 113 用例全绿(含 v5→v6 迁移)。
38. 目标血条可开关(第十一轮需求)
- 新增配置
target.showHpBar(默认 true)与键位toggleHpBar="KeyO"。 targets._updateHpBar(t)开头按getConfig().target.showHpBar决定hpBarBg/hpBarFg.visible,关闭时提前 return(每物理步执行,故切换即时生效,无需遍历目标)。main._physicsStep用consumeAction("toggleHpBar")调_toggleHpBar():翻转并setConfig持久化,addKillFeed("血条:开/关")反馈。设置面板「目标」组加复选框、「键位绑定」加「血条开关」。- 新增字段/键位无需
CONFIG_VERSION迁移(deepMerge 自动补默认)。 - e2e:
E:\ngc13_temp\opencode\aim-trainer-hpbar-e2e.cjs(默认可见、O 关、再 O 开、配置同步)。全量node --test= 114 用例全绿。
39. 弹药模式默认改为 total(第十二轮需求)
- 用户要求默认使用「弹药有限」的选项而非「无穷备弹」。
weapon.ammoMode旧默认mag(弹匣有限、备弹 ∞)改为total(总量,消耗备弹,弹匣与备弹都有限) 。 CONFIG_VERSION=7,_migrateConfig增加 v6→v7:仅当weapon.ammoMode === "mag"(恰为旧默认)时替换为total,用户自选的infinite等不受影响。 再次强调改已有字段默认值必须走迁移 。total模式才会触发弹药包生成(见 §20);默认下游戏中会有可拾取弹药包,属预期。- 测试:
tests/config.test.js新增「v6 弹药模式默认值迁移到 v7」「自定义弹药模式不被迁移覆盖」,并把所有configVersion === 6断言更新为 7。全量node --test= 116 用例全绿。
40. 后坐力自动回正默认改为开启(第十三轮需求)
- – 用户要求默认开启后坐力自动回正。
weapon.recoilRecenter·旧默认false改为true;weapon.recoilRecenterSpeed默认仍为 8。 CONFIG_VERSION=8,_migrateConfig增加 v7→v8:仅当weapon.recoilRecenter === false(恰为旧默认)时替换为true。再次强调改已有字段默认值必须走迁移,否则旧 Cookie 会压制新默认(旧存档里存着false,不做迁移则新默认永远不生效)。- 注意:旧默认与「用户主动关闭」在存档里都是
false,无法区分,因此迁移会把所有false都改为true。这是当前约定的取舍(与历史 sensMode/ammoMode 迁移一致)。 - 测试:
tests/config.test.js默认值断言由false改true,新增「v7 后坐力回正默认值迁移到 v8」,并把所有configVersion === 7断言更新为 8。全量node --test= 117 用例全绿。 - README / 说明书 配置表中的默认值同步更新为
true。
