前端实战

用 Vite + PixiJS + Three.js 写一个纯前端 FPS 瞄准训练器

大部分瞄准训练器在练「准」,preaim-rush-peek 只练一件事:击杀之后转向下一个目标的那一瞬间。它把范围收得极窄——场上永远只有一个可击杀目标——然后把工程力气全部花在「这个 loop 的手感」上。整站纯前端,无后端、无框架、无运行时依赖,构建产物可以直接丢到任意静态服务器。

训练目标决定规则#

先说清楚它在练什么:无畏契约(VALORANT)风格的 rush 场景——「击杀 → 转火 → 再击杀」。转火(target switching)的关键不是精度,而是在完成一次击杀之后,准星能不能立刻落到下一个目标上。围绕这个目标,规则被设计得非常克制:

规则说明
目标数量场上同时只有 1 个可击杀目标
刷新击杀即刷新,对侧刷新 + 左右严格交替
击杀判定单击击杀,打空触发惩罚性重刷新
时长单局 60 秒,Ghost 态(0.28s)预瞄提示

逐条看,每条规则都在服务同一个训练目标:

  • 只有一个目标,是为了不让你有「挑一个近的打」的余地——每次都必须全力转火。
  • 对侧刷新 + 左右严格交替,是为了让转火幅度保持在一个稳定的、可训练的量级上,而不是随机乱跳。
  • 打空触发惩罚性重刷新,是为了让「提前开枪」付出代价。没有这条,玩家会用扫射碰运气,而碰运气练不出预瞄。
  • Ghost 态 0.28 秒的预瞄提示,是给你一个「目标即将出现在这里」的窗口——练的正是把准星提前放到那个位置。
mermaid 图 1 · 主循环:击杀 → 对侧刷新 → 再击杀
flowchart TD START["开局:单局 60 秒"] --> GHOST["Ghost 态 0.28s<br/>预瞄提示"] GHOST --> TARGET["目标出现<br/>场上同时只有 1 个"] TARGET -->|"单击命中"| KILL["击杀"] TARGET -->|"打空"| MISS["惩罚性重刷新"] KILL --> SWAP["对侧刷新,左右严格交替"] MISS --> SWAP SWAP --> GHOST START -.->|"60 秒到"| END["结算"]
循环里只有四种事件,每一种都在服务同一个训练目标:把“击杀之后转向下一个目标”这一瞬间练成条件反射。打空会付出惩罚性重刷新的代价,所以扫射碰运气在这里是负收益。

技术选型:三个依赖,没有框架#

依赖清单短到可以全部念出来:

json
{
  "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 与代码分层#

目录结构本身就是这份设计文档:

tree
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 是纯文本格式,体积偏大,所以生产构建里加了一步后处理:

json
"build": "vite build && node scripts/compress-obj.mjs"

scripts/compress-obj.mjs 会把 OBJ 压缩成 .obj.gz。这一步放在构建阶段而不是运行时,是因为它只跟资源内容有关,跟用户操作无关——构建一次压一次,用户每次打开都省一次。这也是这类纯静态项目最舒服的地方:所有能提前做的事,都可以在构建时做完。

构建与部署#

开发和生产两条命令,没有任何额外步骤:

bash
# 开发环境
npm install
npm run dev        # http://localhost:5173

# 生产构建
npm run build      # vite build + 压缩 OBJ 为 .obj.gz
npm run preview    # 本地预览构建产物

构建产物输出到 dist/,可以直接用最朴素的方式验证:

bash
python3 -m http.server 5173 --directory dist

能这样验证,本身就说明了一件事:产物里没有任何需要服务端配合的东西。没有 API、没有环境变量、没有反向代理规则——这也是「无后端、无框架、无运行时依赖」这三句话在部署环节的实际兑现。

版权边界

README 的版权声明写得很直接:VALORANT 的特工立绘、头像、音效均为 Riot Games 版权素材,仅用于个人学习与内容创作,禁止商业分发。

前面那套「本地覆盖」机制,正是为了让这句话在工程上站得住:仓库本身不携带版权素材,用的是程序化兜底(剪影 + 合成音效);想要完整体验的人,自行在本地放入素材。于是代码开源和素材不可分发这两件互相冲突的事,被目录约定分开了。

仓库里还附了 PRD_PREAIM_Rush_Peek.md(产品需求)与 DEVELOPMENT.md(开发文档)两份文档——在一个个人项目里同时维护 PRD 和开发文档并不常见,但对「玩法规则本身就是核心设计」的项目来说,先把规则写清楚再写代码是划算的。

小结#

这个项目最能说明问题的一点是:把一个训练器的范围收窄到「只练转火」,反而让它的工程细节变得清晰。规则只有四条,但每条都有明确的训练意图;依赖只有三个,但没有一个是为了「看起来更专业」而加的。

如果要把它的经验带走,我会选这两条:一是游戏逻辑与渲染必须解耦,否则 2D 和 3D 无法共用同一套规则;二是把「素材缺失」当成正常状态设计——兜底不是补丁,兜底是这个项目能开源的前提。