前端实战
用 Vite + PixiJS + Three.js 写一个纯前端 FPS 瞄准训练器
大部分瞄准训练器在练「准」,preaim-rush-peek 只练一件事:击杀之后转向下一个目标的那一瞬间。它把范围收得极窄——场上永远只有一个可击杀目标——然后把工程力气全部花在「这个 loop 的手感」上。整站纯前端,无后端、无框架、无运行时依赖,构建产物可以直接丢到任意静态服务器。
训练目标决定规则#
先说清楚它在练什么:无畏契约(VALORANT)风格的 rush 场景——「击杀 → 转火 → 再击杀」。转火(target switching)的关键不是精度,而是在完成一次击杀之后,准星能不能立刻落到下一个目标上。围绕这个目标,规则被设计得非常克制:
| 规则 | 说明 |
|---|---|
| 目标数量 | 场上同时只有 1 个可击杀目标 |
| 刷新 | 击杀即刷新,对侧刷新 + 左右严格交替 |
| 击杀判定 | 单击击杀,打空触发惩罚性重刷新 |
| 时长 | 单局 60 秒,Ghost 态(0.28s)预瞄提示 |
逐条看,每条规则都在服务同一个训练目标:
- 只有一个目标,是为了不让你有「挑一个近的打」的余地——每次都必须全力转火。
- 对侧刷新 + 左右严格交替,是为了让转火幅度保持在一个稳定的、可训练的量级上,而不是随机乱跳。
- 打空触发惩罚性重刷新,是为了让「提前开枪」付出代价。没有这条,玩家会用扫射碰运气,而碰运气练不出预瞄。
- Ghost 态 0.28 秒的预瞄提示,是给你一个「目标即将出现在这里」的窗口——练的正是把准星提前放到那个位置。
技术选型:三个依赖,没有框架#
依赖清单短到可以全部念出来:
{
"dependencies": {
"pixi.js": "^8.6.6",
"three": "^0.186.0"
},
"devDependencies": {
"vite": "^5.4.10"
}
}
PixiJS 负责 2D 渲染,Three.js 负责 3D 模式,Vite 负责开发和构建。没有 UI 框架——界面是手写的 DOM(src/ui/UI.js),因为这里需要的交互很有限:菜单、HUD、结算、设置四块。引入 React 之类的框架,收益是几十行模板代码,成本是一整套构建与心智负担。
另一个隐含的收益是构建产物可以纯静态部署:没有后端、没有服务端渲染、没有运行时依赖,dist/ 拷到任何静态服务器上就能跑。
2D / 3D 双模式与射线命中判定#
同一个游戏有两种模式:默认的 2D 几何房间,以及用 ?mode=3d 打开的 3D 真三维训练房。两者共用同一套规则,差别在于渲染与命中判定的实现:
| 维度 | 2D 模式 | 3D 模式 |
|---|---|---|
| 进入方式 | 默认 | URL 加 ?mode=3d |
| 渲染 | PixiJS 几何房间 | Three.js 三维训练房 |
| 命中判定 | 平面坐标判断 | 射线命中判定 |
用 URL 参数切换模式是个很务实的选择:不需要额外的设置界面,链接可以直接分享,也方便自己固定用一种模式练习。代价是模式状态不在界面上体现,所以 HUD 里需要明确显示当前处于哪个模式。
3D 模式必须走射线命中判定:从摄像机出发沿准星方向发射一条射线,与场景中的目标求交,最近的交点决定打中了谁。这也意味着 3D 模式下「准星指向」与「实际命中」之间多了一层真实的几何关系——这恰恰是二维模式给不了的东西。
核心 loop 与代码分层#
目录结构本身就是这份设计文档:
rush/
├── index.html # 结构(菜单 / HUD / 结算 / 设置)
├── style.css # 主题样式
├── public/
│ ├── agents/{id}.png # 29 名特工头像
│ ├── agents_3d/{id}/ # 特工 3D 模型(OBJ)
│ └── sounds/*.mp3 # 真实音效(可选覆盖)
├── scripts/compress-obj.mjs # OBJ gzip 压缩后处理
└── src/
├── main.js # 入口
├── config/ # 特工 / 场景 / 准星 / i18n 配置
├── core/ # 状态机 + 主循环 + 射击判定
├── three/ # three.js 3D 模式
├── audio/Sound.js # 音效
└── ui/UI.js # DOM UI
四层职责的划分值得注意:config/ 是纯数据(特工、场景、准星、i18n),core/ 是纯逻辑(状态机、主循环、射击判定),three/ 与 ui/ 是表现层,audio/ 单独成块。
把「状态机 + 主循环 + 射击判定」放在一起,是这类小游戏最重要的一条经验:游戏逻辑必须和渲染解耦。因为它俩的更新频率和职责完全不同——逻辑关心「什么时候刷新目标」,渲染关心「这一帧画在哪」,而射击判定关心的是一次输入事件该不该被算作命中。混在一起写,2D 和 3D 就没法共用同一套规则。
参数化准星:把职业选手的配置搬过来#
准星在这个项目里不是一张贴图,而是一组参数:颜色、长度、粗细、间隙、描边、中心点……全部可调。更有意思的是它支持导入职业准星代码,格式形如 0;P;c;1;...。
这是一个很聪明的产品决定:目标用户(FPS 玩家)手里本来就有一串自己惯用的准星代码,能直接粘贴导入,比让人一个个滑块去调要友好得多。从工程上看,这件事的价值在于把「准星」从美术资源变成了纯配置数据——一份代码字符串 = 一组参数 = 一个可复现的状态,不需要任何图片文件。
分轨音效,以及缺省时的回退#
音效被拆成五条独立音轨:击杀 / 爆头 / 连杀 / miss / 局末。分轨的意义不只是「声音不同」,而是每一类事件都能被单独替换和调整——比如你只想换掉 miss 的音效,不需要动其他四条。
更有意思的是缺省策略:public/sounds/*.mp3 里的文件是可选的。如果它们不存在,程序不会报错,也不会静默无声,而是自动回退到合成音效。
这个「回退」的设计在这里至关重要,因为音效素材(见下一节)是不能随仓库分发的:如果程序硬依赖 MP3,那么一个 clone 下来的仓库开箱就是哑的,甚至可能直接抛错。把「缺省」当成一种必须被正常处理的状态,是这类项目能不能被别人顺利跑起来的关键。
资源覆盖机制:放入即生效,不放则兜底#
仓库里有一套统一的资源策略,README 称之为「本地覆盖」:放入即生效,不放则用程序化兜底(剪影 + 合成音效)。
| 资源 | 放入位置 | 缺省时的兜底 |
|---|---|---|
| 特工头像 | public/agents/{id}.png | 剪影 |
| 音效 | public/sounds/*.mp3 | 合成音效 |
| 3D 模型 | public/agents_3d/{id}/ | 按目录约定加载 |
功能上,击杀目标会在 29 名特工头像之间随机轮换,也支持上传自定义头像。头像、音效、3D 模型三者的覆盖机制一致,于是「用官方的」和「用我自己的」在代码路径上没有任何区别——这对一个带有版权敏感素材的项目来说,是唯一可行的结构。
3D 模型与构建时 gzip#
3D 模式用的特工模型是 TripoSR 生成的顶点着色 OBJ。选 OBJ + 顶点着色而不是带贴图的格式,理由很实际:顶点着色不依赖外部材质与贴图文件,一个 .obj 就是完整的模型,加载链路最短。
OBJ 是纯文本格式,体积偏大,所以生产构建里加了一步后处理:
"build": "vite build && node scripts/compress-obj.mjs"
scripts/compress-obj.mjs 会把 OBJ 压缩成 .obj.gz。这一步放在构建阶段而不是运行时,是因为它只跟资源内容有关,跟用户操作无关——构建一次压一次,用户每次打开都省一次。这也是这类纯静态项目最舒服的地方:所有能提前做的事,都可以在构建时做完。
构建与部署#
开发和生产两条命令,没有任何额外步骤:
# 开发环境
npm install
npm run dev # http://localhost:5173
# 生产构建
npm run build # vite build + 压缩 OBJ 为 .obj.gz
npm run preview # 本地预览构建产物
构建产物输出到 dist/,可以直接用最朴素的方式验证:
python3 -m http.server 5173 --directory dist
能这样验证,本身就说明了一件事:产物里没有任何需要服务端配合的东西。没有 API、没有环境变量、没有反向代理规则——这也是「无后端、无框架、无运行时依赖」这三句话在部署环节的实际兑现。
版权边界#
版权边界
README 的版权声明写得很直接:VALORANT 的特工立绘、头像、音效均为 Riot Games 版权素材,仅用于个人学习与内容创作,禁止商业分发。
前面那套「本地覆盖」机制,正是为了让这句话在工程上站得住:仓库本身不携带版权素材,用的是程序化兜底(剪影 + 合成音效);想要完整体验的人,自行在本地放入素材。于是代码开源和素材不可分发这两件互相冲突的事,被目录约定分开了。
仓库里还附了 PRD_PREAIM_Rush_Peek.md(产品需求)与 DEVELOPMENT.md(开发文档)两份文档——在一个个人项目里同时维护 PRD 和开发文档并不常见,但对「玩法规则本身就是核心设计」的项目来说,先把规则写清楚再写代码是划算的。
小结#
这个项目最能说明问题的一点是:把一个训练器的范围收窄到「只练转火」,反而让它的工程细节变得清晰。规则只有四条,但每条都有明确的训练意图;依赖只有三个,但没有一个是为了「看起来更专业」而加的。
如果要把它的经验带走,我会选这两条:一是游戏逻辑与渲染必须解耦,否则 2D 和 3D 无法共用同一套规则;二是把「素材缺失」当成正常状态设计——兜底不是补丁,兜底是这个项目能开源的前提。