实测 Qoder 神秘模型 Qoduck:我用纯 Vibe Coding 搓了个 3D 物理引擎游戏
1. 前言
最近一段时间太忙了,忙着驻场、忙着工作、忙着生活,现在漫天的AI,漫天的Vibe Coding。想必大家这段时间也是跟我一样吧,以前10个人做一个项目,现在一个人做10个项目。忙得都忘记了我还有个“七维大脑”的IP在。
遥想上次更新居然还是25年的8月份。这次重启更新,看到了很多的私信留言,很多温暖的话(部分截图)





就想着,要不把之前的知识结合着AI做一次更新呢?或者分享一些AI、Vibe Coding相关的工具、知识、技巧?前阵子正好拿到了 Qoder 大使的身份,搞到了可观的Credits,要不试着做一下!
正好 Qoder 今天上线了一款叫 Qoduck 的匿名模型 (每日赠送 Qoduck 免费使用额度,Credits 余额为 0 的账号也可以用),讲实话我也不知道是什么,官方讲“模型身份暂不公布”,正好拿来当我第一篇回归文吧!
最近我装了个WinXP的虚拟机,WinXP当年捆绑的《三维弹球》让人充满了回忆哇,我接触到最早的系统就是WinXP,当时最喜欢的游戏就是《三维弹球》。呐!熟悉我的朋友都知道,平时我写博客主要是写教程,给高校同学和刚入门的朋友排坑。我不喜欢去念官方那些参数跑分,一个模型好不好用,拉出来跑个稍微复杂点的实战项目就全知道了。最近正好也有在用Three.js给公司项目做3D大屏。今天我就全程纯靠Vibe Coding,让 Qoduck 帮我从零手搓一个基于 Three.js 和 Cannon 物理引擎的 3D 弹珠台游戏,看看它会不会翻车。
2. 准备工作
- 开发工具:Qoder或者Qoder CN(已切换至 Qoduck 模型)
- 技术栈:HTML5 + Three.js(WebGL 渲染)+ Cannon-es(刚体物理模拟)
- 测试重点:
- 能否一次性把 3D 空间视角、光影网格搭建正确,不报错、不穿模;
- 能否准确同步物理刚体与 3D 渲染模型的坐标;
- 大量小球连续碰撞时,会不会导致浏览器卡死或显存泄露。

3. 实测过程
3.1 第一步:场景初始化与画布渲染
打开 Qoder,在对话框内切换到 Qoduck 模型,输入第一轮 Prompt:
我发给 AI 的提示词:
“请使用原生 HTML5 + Three.js(通过 CDN 引入),帮我构建一个单文件的 3D 弹珠游戏运行骨架。包含全屏自适应、暗黑科技风网格地面、带弹性阻尼的轨道控制器(OrbitControls),以及倾斜的弹珠底板。”
接下来看操作与 AI 生成反馈:


本以为它只会简简单单扔出一个正方体和网格,结果这波操作着实还是可以的:
- 具备主动自愈意识:它直接创建了
pinball.html并启动本地服务器预览,我看回复内容,Qoduck 自己捕获到了“窄视口下底部左右两处 HUD 文字重叠”的细节问题,并主动进行了修复。 - 视觉、细节良好:
- 赛博风格、暗角遮罩以及四个边角括号 HUD;
- 球场台面没有用单色敷衍,看代码是通过原生 Canvas 动态绘制了一整套带有霓虹光圈、弹射通道分隔线和脉冲光环的贴图
- 居然把弹珠也做出来了,在右侧发射通道内,看了一下代码,两个挡板也专门包了一层
pivot旋转中心组,后续可以用来接物理碰撞,架构方面可以的,有点惊喜。
(我们很多时候都是只Vibe Coding,不看代码,这个习惯不好,大家感兴趣的话,最好在AI任务结束后,自己去看一下代码,架构怎么样、这么写后面会不会有坑啊等等)

3.2 第二步:接入 Cannon 物理世界与发射逻辑
有了渲染骨架,接下来最容易翻车的就是“物理刚体与 3D 渲染的同步”。很多人写 Web 物理模拟,小球要么穿模飞天,要么反弹像棉花。
继续向 Qoduck 投喂第二轮提示词:(在我的观念里,提示词写的越详细,效果越好,大家用任何模型、AI工具的时候都可以这么做。有句话说得好:“模型(AI)的能力上限,取决于人的能力上限!”)
我发给 AI 的提示词:
“引入 Cannon-es 物理引擎:
通过 CDN 引入 Cannon-es 最新稳定版。
创建 Cannon 物理世界,设置沿底板倾斜方向的重力分量(让小球能自然向下滚落)。
刚体绑定与碰撞体设置:
为底板、外围围栏、弹射通道隔离板设置对应的刚体碰撞体(高弹性、适当摩擦)。
为场上的 3 个圆柱 Bumper 创建刚体,赋予超高反弹系数(restitution),撞击时能把小球有力弹飞,并瞬间增强该 Bumper 的发光强度(发光脉冲闪烁)。
发射机制与按键交互:
发射弹珠(空格键 / Space):长按蓄力或点击空格,给弹射通道内的小球一个强力的向上冲量(Impulse),从小球通道直冲顶端滑入游戏场内。
左右挡板控制(键盘 A/D 键 或 左右方向键):按住 A 键左挡板沿 pivot 向上迅速挥击,松开平滑回落;按住 D 键右挡板向上挥击。
主循环状态同步:
在 renderer.setAnimationLoop 中执行固定时间步长的物理更新(world.step(dt)),将小球与挡板的刚体位置和四元数精准同步到 Three.js 网格。
请直接在原有代码上增量升级,给出完整可运行的 HTML 代码。。。”
接下来看代码生成表现:

这轮实测中,Qoduck 展现出的物理工程功底还是可以的。
-
底板局部坐标系降维计算:
传统的做法是在世界坐标系强行做斜角物理,极难计算。它直接把CANNON.World挂在底板局部坐标系下,把重力分解为:
{ g y = − G ⋅ cos ( 7 ∘ ) (垂直分量,负责压住小球不乱飘) g z = + G ⋅ sin ( 7 ∘ ) (沿斜面分量,驱动球自然向玩家滚落) \begin{cases} g_y = -G \cdot \cos(7^\circ) & \text{(垂直分量,负责压住小球不乱飘)} \\ g_z = +G \cdot \sin(7^\circ) & \text{(沿斜面分量,驱动球自然向玩家滚落)} \end{cases} {gy=−G⋅cos(7∘)gz=+G⋅sin(7∘)(垂直分量,负责压住小球不乱飘)(沿斜面分量,驱动球自然向玩家滚落)
代码写得极度优雅[cite: 5]。 -
避开了物理引擎最经典的“冲量抵消坑”:
在处理 Bumper 反弹时,很多开发者喜欢在collide碰撞事件里用applyImpulse强行加冲量。但 Qoduck 在内部调试时直接指出了致命坑点:collide事件发生在物理求解器之前,强加冲量会被求解器基于新速度重新计算反而抵消反弹。 它用的是restitution: 1.6的弹性系数配合闪烁衰减。 -
运动学刚体动量传递:
底部的两个红色挡板,它定义为KINEMATIC刚体,并且通过 Shape 偏移实现了精准绕 Pivot 枢轴旋转。不仅视觉在动,还赋予了角速度,当小球滚下来时,按下 A/D 键能把小球狠“抽”回台面顶端。 -
防穿模限速机制:
针对 Cannon-es 在高速运动下容易穿透薄围栏的通病,它直接加了一句速度截断:// 限速防穿透,避免小球高速下穿模飞出台面 const spd = ballBody.velocity.length(); if (spd > 22) ballBody.velocity.scale(22 / spd, ballBody.velocity);

看实际运行效果:HUD 顶部增加了动态 POWER 蓄力槽,长按空格键松开,小球从右侧弹射通道入场,连续撞击发光光圈,手感还挺顺畅的。就是感觉少了点力道。

3.3 第三步:音效合成、火花粒子池与性能防泄露优化
能发射、能弹跳还不够,要想变成一个真正能玩的小游戏,必须补全音效、打击特效和计分系统。
继续向 Qoduck 投喂第三轮收尾提示词:
我发给 AI 的提示词:
“开始做最后一步工程收尾和视听功能:
1.Web Audio原生合成音效(不要加载外部音频文件):使用原生AudioContext+OscillatorNode;发射音效:松开空格时,合成一段向上的微频扫频脉冲声;Bumper撞击音效:碰撞时合成清脆的金属共振‘叮’声(高频正弦波伴随指数衰减);挡板挥击音效:按A/D键挥板时,触发低沉的机械摆动声。
2.碰撞粒子火花特效:当小球撞击Bumper或挡板发生强碰撞时,在碰撞接触点生成 15~20 颗向外炸开的发光粒子火花,带微弱重力并在0.3秒内淡出销毁;
3.游戏计分系统与HUD增强:撞击Bumper每次+100分(闪烁加成),连击有倍率;左上角HUD增加:实时当前得分、最高分、以及实时渲染帧率;
4.性能防泄露:确保粒子产生与销毁规范释放Geometry/Material;限制场上最大火花粒子总数,防止长时间游玩产生内存堆积;”
接下来看图操作与终端反馈:

很强啊。
-
纯算法声音合成,Bumper 撞击声它采用 1700~2400Hz 的高频正弦波,叠加上 2.76 倍和 5.4 倍的非谐波泛音,配合指数衰减,模拟出了极其逼真的金属共振“叮”声;然后是机械点击感:预先生成了 0.1 秒的白噪声缓冲区,按 A/D 挥板时配合带通滤波器输出清脆的机械敲击声;
-
也遵守浏览器的 Autoplay 策略,在第一次按键手势时懒加载并激活 AudioContext,并且所有振荡器节点在播放结束后自动执行 disconnect() 释放。可以的。
-
接触点触发火花粒子,它没有在小球中心随手生成粒子,而是提取了 Cannon 物理接触点的精确坐标:
// 精确取接触表面坐标,生成贴合碰撞面的火花
spawnSparks(e.contact.bi.position.vadd(e.contact.ri), b.ring.material.emissive.getHex());
- 做了双重防泄露保障,做粒子特效最忌讳无限 new 对象导致网页崩掉。它设置了 SPARK_MAX = 180 的硬上限,超限时通过 FIFO 回收最旧的粒子簇。粒子寿命结束(0.3s)后,严格调用:
function killSpark(i) {
const s = sparks[i];
table.remove(s.points);
s.points.geometry.dispose(); // 释放几何体显存
s.points.material.dispose(); // 释放材质显存
sparkTotal -= s.count;
sparks.splice(i, 1);
}
- 再一次做了自检发现问题后进行了修复,主动修复了一个冷门边界缺陷:原本判断页面加载失败的逻辑依赖 requestAnimationFrame 驱动的标记,但在后台标签页打开时浏览器会自动暂停 rAF,容易造成误报。它这轮给改成了基于 ES 模块同步执行完成的 __loaded 标记。
最终运行效果看图,左上角集成了 SCORE、HI 最高分、FPS 实时帧率以及连击倍率提示,视觉和打击手感全部到位:


4. 完整代码分享
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NEON PINBALL · 3D 弹珠骨架</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&display=swap" rel="stylesheet">
<style>
:root {
--cyan: #35e0ff;
--magenta: #ff2d78;
--ink: #04060c;
--hud: #9fdceb;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; overflow: hidden; background: var(--ink); }
canvas { display: block; }
#hud {
position: fixed; inset: 0; pointer-events: none;
font-family: 'Share Tech Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--hud); user-select: none;
}
.top-left { position: absolute; top: 24px; left: 28px; }
.top-left h1 {
font-size: 20px; font-weight: 400; letter-spacing: 6px; color: #eafcff;
text-shadow: 0 0 14px rgba(53, 224, 255, .8);
}
.top-left p { margin-top: 6px; font-size: 11px; letter-spacing: 3px; opacity: .55; }
.stats { margin-top: 14px; font-size: 12px; letter-spacing: 2px; line-height: 1.9; }
.stats .lbl { opacity: .55; display: inline-block; width: 52px; }
.stats span { color: #eafcff; }
#score { color: var(--cyan); font-size: 15px; text-shadow: 0 0 10px rgba(53,224,255,.7); }
#combo { min-height: 18px; margin-top: 4px; color: var(--magenta); font-size: 12px; letter-spacing: 3px; text-shadow: 0 0 10px rgba(255,45,120,.8); }
.top-right {
position: absolute; top: 26px; right: 28px; font-size: 11px; letter-spacing: 3px;
display: flex; align-items: center; gap: 8px;
}
.dot {
width: 7px; height: 7px; border-radius: 50%; background: var(--cyan);
box-shadow: 0 0 10px var(--cyan); animation: blink 1.6s infinite;
}
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }
.bottom-left { position: absolute; bottom: 24px; left: 28px; font-size: 11px; letter-spacing: 2px; opacity: .75; }
.bottom-left .dim { margin-top: 5px; font-size: 10px; opacity: .55; }
.bottom-right { position: absolute; bottom: 24px; right: 28px; font-size: 11px; letter-spacing: 2px; opacity: .4; }
@media (max-width: 760px) {
.bottom-right { display: none; }
.bottom-left { font-size: 10px; letter-spacing: 1px; }
.top-left h1 { font-size: 16px; letter-spacing: 4px; }
}
#power {
position: absolute; bottom: 70px; left: 50%; transform: translateX(-50%);
width: 220px; opacity: 0; transition: opacity .15s;
}
#power.on { opacity: 1; }
#power .track { height: 10px; border: 1px solid rgba(53,224,255,.5); padding: 1px; }
#powerFill {
height: 100%; width: 0%;
background: linear-gradient(90deg, var(--cyan), var(--magenta));
box-shadow: 0 0 10px rgba(53,224,255,.6);
}
#power span { display: block; text-align: center; font-size: 10px; letter-spacing: 5px; margin-top: 6px; opacity: .7; }
.corner { position: absolute; width: 26px; height: 26px; border: 1px solid rgba(53, 224, 255, .45); }
.c-tl { top: 12px; left: 12px; border-right: none; border-bottom: none; }
.c-tr { top: 12px; right: 12px; border-left: none; border-bottom: none; }
.c-bl { bottom: 12px; left: 12px; border-right: none; border-top: none; }
.c-br { bottom: 12px; right: 12px; border-left: none; border-top: none; }
.scanlines {
position: absolute; inset: 0;
background: repeating-linear-gradient(0deg, rgba(255,255,255,.022) 0 1px, transparent 1px 3px);
}
.vignette {
position: absolute; inset: 0;
background: radial-gradient(ellipse at center, transparent 52%, rgba(0,0,0,.6) 100%);
}
#fatal {
position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
background: var(--ink); color: var(--hud); z-index: 10;
font-family: 'Share Tech Mono', ui-monospace, monospace; text-align: center;
font-size: 14px; letter-spacing: 2px; line-height: 2;
}
#fatal[hidden] { display: none; }
#fatal b { color: var(--magenta); }
</style>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/",
"cannon-es": "https://cdn.jsdelivr.net/npm/cannon-es@0.20.0/dist/cannon-es.js"
}
}
</script>
</head>
<body>
<div id="hud">
<div class="scanlines"></div>
<div class="vignette"></div>
<div class="corner c-tl"></div><div class="corner c-tr"></div>
<div class="corner c-bl"></div><div class="corner c-br"></div>
<div class="top-left">
<h1>NEON PINBALL</h1>
<p>3D SKELETON // PHYSICS · AUDIO · BUILD 0.3.0</p>
<div class="stats">
<div><span class="lbl">SCORE</span><span id="score">0000000</span></div>
<div><span class="lbl">HI</span><span id="hi">0000000</span></div>
<div><span class="lbl">FPS</span><span id="fps">--</span></div>
<div id="combo"></div>
</div>
</div>
<div class="top-right"><span class="dot"></span>SYS.ONLINE</div>
<div id="power"><div class="track"><div id="powerFill"></div></div><span>POWER</span></div>
<div class="bottom-left">
<div>SPACE 蓄力发射 · A/D 或 ←/→ 挡板 · R 重置弹珠</div>
<div class="dim">左键拖拽 · 旋转 / 滚轮 · 缩放 / 右键拖拽 · 平移</div>
</div>
<div class="bottom-right">THREE r160 · CANNON-ES 0.20</div>
</div>
<div id="fatal" hidden>
<div><b>LOAD FAILED</b><br>Three.js / Cannon-es CDN 加载失败,请检查网络后刷新<br>cdn.jsdelivr.net</div>
</div>
<script>
// 以模块是否执行完毕判断加载失败(__booted 依赖 rAF,后台标签页会暂停,不能用作判据)
setTimeout(() => { if (!window.__loaded) document.getElementById('fatal').hidden = false; }, 8000);
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import * as CANNON from 'cannon-es';
const BOARD = { w: 12, l: 22, t: 0.6, tilt: THREE.MathUtils.degToRad(7), y: 4.6 };
const COL = { cyan: 0x35e0ff, magenta: 0xff2d78, teal: 0x2dd4bf, dark: 0x0c1424 };
const BALL_R = 0.35;
const BALL_SPAWN = [5.14, BALL_R, 9.6];
// ---------- 渲染器 / 场景 / 相机 ----------
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x04060c);
scene.fog = new THREE.Fog(0x04060c, 45, 160);
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 400);
camera.position.set(13, 13, 19);
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(renderer), 0.04).texture;
// ---------- 轨道控制器(弹性阻尼) ----------
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 4.2, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.06;
controls.rotateSpeed = 0.65;
controls.minDistance = 8;
controls.maxDistance = 60;
controls.minPolarAngle = 0.12;
controls.maxPolarAngle = Math.PI * 0.49;
// ---------- 灯光 ----------
scene.add(new THREE.HemisphereLight(0x24405e, 0x05070c, 0.7));
const key = new THREE.DirectionalLight(0xbfe9ff, 2.4);
key.position.set(12, 20, 10);
key.castShadow = true;
key.shadow.mapSize.set(2048, 2048);
Object.assign(key.shadow.camera, { left: -18, right: 18, top: 18, bottom: -18, near: 2, far: 60 });
key.shadow.bias = -0.0005;
scene.add(key);
const magLight = new THREE.PointLight(COL.magenta, 80, 45);
magLight.position.set(-10, 7, -8);
scene.add(magLight);
const cyanLight = new THREE.PointLight(COL.cyan, 60, 40);
cyanLight.position.set(10, 6, 9);
scene.add(cyanLight);
const underglow = new THREE.PointLight(0x00e5ff, 30, 16);
underglow.position.set(0, 1.4, 0);
scene.add(underglow);
// ---------- 暗黑科技网格地面 ----------
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(300, 300),
new THREE.MeshStandardMaterial({ color: 0x05080f, roughness: 0.95, metalness: 0.1 })
);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -0.02;
floor.receiveShadow = true;
scene.add(floor);
const gridFine = new THREE.GridHelper(240, 120, 0x0e2a38, 0x0a1f2c);
gridFine.material.transparent = true;
gridFine.material.opacity = 0.55;
scene.add(gridFine);
const gridMajor = new THREE.GridHelper(240, 24, 0x2aa5c4, 0x12405a);
gridMajor.material.transparent = true;
gridMajor.material.opacity = 0.8;
gridMajor.position.y = 0.01;
scene.add(gridMajor);
// 漂浮微粒
const pCount = 350;
const pPos = new Float32Array(pCount * 3);
for (let i = 0; i < pCount; i++) {
pPos[i * 3] = (Math.random() - 0.5) * 140;
pPos[i * 3 + 1] = Math.random() * 35 + 1;
pPos[i * 3 + 2] = (Math.random() - 0.5) * 140;
}
const pGeo = new THREE.BufferGeometry();
pGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3));
const particles = new THREE.Points(pGeo, new THREE.PointsMaterial({
color: COL.teal, size: 0.14, transparent: true, opacity: 0.55
}));
scene.add(particles);
// ---------- Cannon 物理世界 ----------
// 物理在底板局部坐标系中进行(底板视为水平),
// 重力分解为:垂直分量把球压在盘面上,+z 分量驱动球沿倾斜底板向玩家方向滚落
const G = 16;
const world = new CANNON.World();
world.gravity.set(0, -G * Math.cos(BOARD.tilt), G * Math.sin(BOARD.tilt));
world.broadphase = new CANNON.SAPBroadphase(world);
world.solver.iterations = 10;
const matBall = new CANNON.Material('ball');
const matTable = new CANNON.Material('table');
const matBumper = new CANNON.Material('bumper');
world.addContactMaterial(new CANNON.ContactMaterial(matBall, matTable, {
friction: 0.25, restitution: 0.55
}));
world.addContactMaterial(new CANNON.ContactMaterial(matBall, matBumper, {
friction: 0.05, restitution: 1.6 // 超高反弹系数
}));
function staticBox(w, h, d, x, y, z, ry = 0) {
const body = new CANNON.Body({ type: CANNON.Body.STATIC, material: matTable });
body.addShape(new CANNON.Box(new CANNON.Vec3(w / 2, h / 2, d / 2)));
body.position.set(x, y, z);
body.quaternion.setFromAxisAngle(new CANNON.Vec3(0, 1, 0), ry);
world.addBody(body);
return body;
}
// ---------- 倾斜弹珠底板(视觉组) ----------
const table = new THREE.Group();
table.position.y = BOARD.y;
table.rotation.x = BOARD.tilt;
scene.add(table);
const base = new THREE.Mesh(
new THREE.BoxGeometry(BOARD.w, BOARD.t, BOARD.l),
new THREE.MeshStandardMaterial({ color: COL.dark, metalness: 0.55, roughness: 0.45 })
);
base.position.y = -BOARD.t / 2;
base.castShadow = base.receiveShadow = true;
table.add(base);
staticBox(BOARD.w, BOARD.t, BOARD.l, 0, -BOARD.t / 2, 0);
// 用 Canvas 绘制霓虹球场贴图
function makePlayfieldTexture() {
const c = document.createElement('canvas');
c.width = 512; c.height = 1024;
const g = c.getContext('2d');
const px = x => (x + 6) / 12 * 512;
const py = z => (z + 11) / 22 * 1024;
g.fillStyle = '#0a1120';
g.fillRect(0, 0, 512, 1024);
g.fillStyle = 'rgba(53,224,255,0.05)';
for (let x = 16; x < 512; x += 32)
for (let y = 16; y < 1024; y += 32)
g.fillRect(x, y, 2, 2);
g.strokeStyle = 'rgba(53,224,255,0.7)';
g.lineWidth = 3;
g.strokeRect(10, 10, 492, 1004);
// 弹射通道分隔线
g.strokeStyle = '#35e0ff';
g.lineWidth = 4;
g.beginPath(); g.moveTo(px(4.55), py(1.5)); g.lineTo(px(4.55), py(10.9)); g.stroke();
// bumper 光圈
const rings = [[-2.6, -5.2, '#35e0ff'], [2.4, -6.0, '#ff2d78'], [-0.2, -2.6, '#35e0ff']];
for (const [x, z, col] of rings) {
g.strokeStyle = col; g.lineWidth = 3;
g.beginPath(); g.arc(px(x), py(z), 55, 0, Math.PI * 2); g.stroke();
g.globalAlpha = 0.4;
g.beginPath(); g.arc(px(x), py(z), 72, 0, Math.PI * 2); g.stroke();
g.globalAlpha = 1;
}
// 中央虚线环
g.strokeStyle = 'rgba(53,224,255,0.5)';
g.setLineDash([10, 8]);
g.beginPath(); g.arc(px(0), py(0.5), 85, 0, Math.PI * 2); g.stroke();
g.setLineDash([]);
// 底部箭头
g.fillStyle = 'rgba(255,45,120,0.85)';
for (const x of [-2.5, 0, 2.5]) {
g.beginPath();
g.moveTo(px(x) - 12, py(4.2)); g.lineTo(px(x) + 12, py(4.2)); g.lineTo(px(x), py(4.2) + 22);
g.closePath(); g.fill();
}
g.fillStyle = '#35e0ff';
g.font = '26px monospace';
g.textAlign = 'center';
g.fillText('NEON // PIN', 256, 64);
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
return tex;
}
const playTex = makePlayfieldTexture();
const field = new THREE.Mesh(
new THREE.PlaneGeometry(BOARD.w, BOARD.l),
new THREE.MeshStandardMaterial({
map: playTex, emissive: 0x9adfff, emissiveMap: playTex, emissiveIntensity: 0.25, roughness: 0.8
})
);
field.rotation.x = -Math.PI / 2;
field.position.y = 0.012;
field.receiveShadow = true;
table.add(field);
// ---------- 围栏(视觉网格 + 物理刚体共用同一份定义) ----------
const RAILS = [
[0.4, 1.0, BOARD.l, -5.8, 0, 0], // 左外轨
[0.4, 1.0, BOARD.l, 5.8, 0, 0], // 右外轨
[BOARD.w, 1.0, 0.4, 0, -10.8, 0], // 顶轨
[3.8, 1.0, 0.4, -4.1, 10.8, 0], // 底轨左(中央留出落球口)
[3.8, 1.0, 0.4, 4.1, 10.8, 0], // 底轨右
[0.25, 0.9, 9.4, 4.55, 6.2, 0], // 弹射通道隔离板
[0.25, 0.9, 3.4, -3.45, 6.45, 0.53], // 左导向
[0.25, 0.9, 3.4, 3.45, 6.45, -0.53], // 右导向
[0.25, 0.9, 3.0, 4.4, -9.65, 0.955], // 通道顶端导向(把球折入游戏场)
];
const railMat = new THREE.MeshStandardMaterial({ color: 0x101a2e, metalness: 0.7, roughness: 0.35 });
const neonMat = new THREE.MeshBasicMaterial({ color: COL.cyan });
for (const [w, h, d, x, z, ry] of RAILS) {
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), railMat);
m.position.set(x, h / 2, z);
m.rotation.y = ry;
m.castShadow = m.receiveShadow = true;
table.add(m);
staticBox(w, h, d, x, h / 2, z, ry);
}
// 侧轨霓虹灯带
for (const x of [-5.8, 5.8]) {
const strip = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.06, 21.6), neonMat);
strip.position.set(x, 1.02, 0);
table.add(strip);
}
// ---------- Bumper(网格 + 刚体 + 撞击闪烁) ----------
const bumpers = new Map(); // body -> { body, ring, flash }
[[-2.6, -5.2, COL.cyan], [2.4, -6.0, COL.magenta], [-0.2, -2.6, COL.cyan]].forEach(([x, z, col]) => {
const bodyMesh = new THREE.Mesh(
new THREE.CylinderGeometry(0.85, 0.95, 0.7, 32),
new THREE.MeshStandardMaterial({ color: 0x101a2e, metalness: 0.7, roughness: 0.3 })
);
bodyMesh.position.set(x, 0.35, z);
bodyMesh.castShadow = true;
const ring = new THREE.Mesh(
new THREE.TorusGeometry(0.8, 0.07, 12, 40),
new THREE.MeshStandardMaterial({ color: 0x0a0f1a, emissive: col, emissiveIntensity: 2 })
);
ring.rotation.x = Math.PI / 2;
ring.position.set(x, 0.72, z);
table.add(bodyMesh, ring);
// cannon-es 的 Cylinder 与 Three.js 一样沿 Y 轴,无需旋转
const body = new CANNON.Body({ type: CANNON.Body.STATIC, material: matBumper });
body.addShape(new CANNON.Cylinder(0.85, 0.95, 0.7, 16));
body.position.set(x, 0.35, z);
world.addBody(body);
bumpers.set(body, { body, ring, flash: 0 });
});
// ---------- 挡板(运动学刚体,绕 pivot 挥击) ----------
const UP = new CANNON.Vec3(0, 1, 0);
const flipperMat = new THREE.MeshStandardMaterial({
color: 0x1a0f1e, emissive: COL.magenta, emissiveIntensity: 0.9, metalness: 0.4, roughness: 0.4
});
function makeFlipper(side) {
const pivot = new THREE.Group();
pivot.position.set(2.3 * side, 0.32, 8.7);
const mesh = new THREE.Mesh(new THREE.BoxGeometry(2.3, 0.55, 0.7), flipperMat);
mesh.position.x = -side * 0.95;
mesh.castShadow = true;
pivot.add(mesh);
pivot.rotation.y = side * 0.5;
table.add(pivot);
const body = new CANNON.Body({ type: CANNON.Body.KINEMATIC, material: matTable });
body.addShape(
new CANNON.Box(new CANNON.Vec3(1.15, 0.275, 0.35)),
new CANNON.Vec3(-side * 0.95, 0, 0) // shape 偏移,使刚体绕 pivot 旋转
);
body.position.set(2.3 * side, 0.32, 8.7);
body.quaternion.setFromAxisAngle(UP, side * 0.5);
world.addBody(body);
return { pivot, body, side, angle: side * 0.5, pressed: false };
}
const flipL = makeFlipper(-1);
const flipR = makeFlipper(1);
const flipList = [flipL, flipR];
function updateFlipper(f, dt) {
const target = f.pressed ? -f.side * 0.55 : f.side * 0.5;
const speed = f.pressed ? 14 : 6; // 挥击快、回落平滑
const prev = f.angle;
const delta = target - prev;
f.angle += Math.sign(delta) * Math.min(Math.abs(delta), speed * dt);
// 运动学刚体:角速度用于把挥击动量传递给球,四元数由动画角度精确驱动
f.body.angularVelocity.set(0, (f.angle - prev) / dt, 0);
f.body.quaternion.setFromAxisAngle(UP, f.angle);
}
// ---------- 弹珠(网格 + 动态刚体) ----------
const ball = new THREE.Mesh(
new THREE.SphereGeometry(BALL_R, 48, 32),
new THREE.MeshStandardMaterial({ color: 0xdfe9f5, metalness: 1, roughness: 0.12 })
);
ball.castShadow = true;
table.add(ball);
const ballBody = new CANNON.Body({
mass: 1,
material: matBall,
shape: new CANNON.Sphere(BALL_R),
position: new CANNON.Vec3(...BALL_SPAWN),
linearDamping: 0.01,
angularDamping: 0.08,
});
world.addBody(ballBody);
function resetBall() {
ballBody.position.set(...BALL_SPAWN);
ballBody.velocity.set(0, 0, 0);
ballBody.angularVelocity.set(0, 0, 0);
ballBody.quaternion.set(0, 0, 0, 1);
}
// ---------- Web Audio 原生合成音效(无外部音频文件) ----------
let audioCtx = null, master = null, noiseBuf = null;
function ensureAudio() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
master = audioCtx.createGain();
master.gain.value = 0.5;
master.connect(audioCtx.destination);
// 预生成白噪声采样,供机械点击声复用
const len = Math.floor(audioCtx.sampleRate * 0.1);
noiseBuf = audioCtx.createBuffer(1, len, audioCtx.sampleRate);
const d = noiseBuf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
}
if (audioCtx.state === 'suspended') audioCtx.resume().catch(() => {});
}
function tone(type, f0, f1, dur, vol) {
if (!audioCtx) return;
const t = audioCtx.currentTime;
const o = audioCtx.createOscillator();
const g = audioCtx.createGain();
o.type = type;
o.frequency.setValueAtTime(f0, t);
if (f1) o.frequency.exponentialRampToValueAtTime(f1, t + dur);
g.gain.setValueAtTime(vol, t);
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
o.connect(g); g.connect(master);
o.start(t); o.stop(t + dur + 0.02);
o.onended = () => { o.disconnect(); g.disconnect(); };
}
function noiseHit(dur, vol, freq) {
if (!audioCtx) return;
const t = audioCtx.currentTime;
const src = audioCtx.createBufferSource();
src.buffer = noiseBuf;
const f = audioCtx.createBiquadFilter();
f.type = 'bandpass'; f.frequency.value = freq; f.Q.value = 1.2;
const g = audioCtx.createGain();
g.gain.setValueAtTime(vol, t);
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
src.connect(f); f.connect(g); g.connect(master);
src.start(t); src.stop(t + dur + 0.02);
src.onended = () => { src.disconnect(); f.disconnect(); g.disconnect(); };
}
const sfx = {
launch(p) { // 向上扫频脉冲
tone('sawtooth', 160, 700 + p * 600, 0.26, 0.22);
tone('square', 90, 240, 0.12, 0.1);
},
bumper(v) { // 金属共振"叮":高频正弦 + 非谐波泛音,指数衰减
const f = 1700 + Math.random() * 700;
const vol = Math.min(0.45, 0.16 + v * 0.025);
tone('sine', f, null, 0.32, vol);
tone('sine', f * 2.76, null, 0.18, vol * 0.35);
tone('sine', f * 5.4, null, 0.09, vol * 0.15);
},
flipper() { // 低沉机械摆动:低频三角波下滑 + 噪声点击
tone('triangle', 140, 55, 0.12, 0.35);
noiseHit(0.045, 0.18, 900);
},
};
// ---------- 计分系统 ----------
let gameTime = 0;
const state = { score: 0, hi: 0, combo: 0, lastHit: -10 };
// localStorage 在隐私/受限模式下可能抛异常,仅作边界防护
try { state.hi = +localStorage.getItem('neon-pinball-hi') || 0; } catch {}
const scoreEl = document.getElementById('score');
const hiEl = document.getElementById('hi');
const fpsEl = document.getElementById('fps');
const comboEl = document.getElementById('combo');
function renderScore() {
scoreEl.textContent = String(state.score).padStart(7, '0');
hiEl.textContent = String(state.hi).padStart(7, '0');
}
renderScore();
// ---------- 碰撞火花(总量上限 + 销毁即释放 Geometry/Material) ----------
const SPARK_MAX = 180; // 场上火花粒子总数上限
const SPARK_LIFE = 0.3;
const sparks = []; // { points, vels, life, count }
let sparkTotal = 0;
function spawnSparks(pos, color) {
const n = 15 + Math.floor(Math.random() * 6); // 15~20 颗
while (sparkTotal + n > SPARK_MAX && sparks.length) killSpark(0); // 超限先回收最旧的
const positions = new Float32Array(n * 3);
const vels = new Float32Array(n * 3);
for (let i = 0; i < n; i++) {
positions[i * 3] = pos.x; positions[i * 3 + 1] = pos.y; positions[i * 3 + 2] = pos.z;
const angle = Math.random() * Math.PI * 2;
const speed = 2.5 + Math.random() * 3.5;
const horiz = 0.4 + Math.random() * 0.6;
vels[i * 3] = Math.cos(angle) * horiz * speed;
vels[i * 3 + 1] = (0.5 + Math.random() * 0.8) * speed * 0.6;
vels[i * 3 + 2] = Math.sin(angle) * horiz * speed;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.PointsMaterial({
color, size: 0.14, transparent: true, opacity: 1,
blending: THREE.AdditiveBlending, depthWrite: false
});
const points = new THREE.Points(geo, mat);
points.frustumCulled = false; // 顶点每帧移动,跳过包围球剔除
table.add(points);
sparks.push({ points, vels, life: 0, count: n });
sparkTotal += n;
}
function killSpark(i) {
const s = sparks[i];
table.remove(s.points);
s.points.geometry.dispose();
s.points.material.dispose();
sparkTotal -= s.count;
sparks.splice(i, 1);
}
function updateSparks(dt) {
for (let i = sparks.length - 1; i >= 0; i--) {
const s = sparks[i];
s.life += dt;
if (s.life >= SPARK_LIFE) { killSpark(i); continue; }
const attr = s.points.geometry.attributes.position;
const arr = attr.array;
for (let j = 0; j < s.count; j++) {
s.vels[j * 3 + 1] -= 6 * dt; // 微弱重力
arr[j * 3] += s.vels[j * 3] * dt;
arr[j * 3 + 1] += s.vels[j * 3 + 1] * dt;
arr[j * 3 + 2] += s.vels[j * 3 + 2] * dt;
}
attr.needsUpdate = true;
s.points.material.opacity = 1 - s.life / SPARK_LIFE;
}
}
// ---------- 碰撞事件:计分 / 音效 / 火花 ----------
// 注意:collide 在求解器之前触发,此处不要施加冲量,否则会抵消 restitution 反弹
const flipBodies = new Set([flipL.body, flipR.body]);
ballBody.addEventListener('collide', (e) => {
const v = Math.abs(e.contact.getImpactVelocityAlongNormal());
const b = bumpers.get(e.body);
if (b) {
if (v < 0.8) return;
// 连击倍率:2.5 秒内连续撞击 Bumper,倍率 +1(上限 ×8)
state.combo = (gameTime - state.lastHit < 2.5) ? Math.min(state.combo + 1, 8) : 1;
state.lastHit = gameTime;
let pts = 100 * state.combo;
if (b.flash > 0.25) pts += 50 * state.combo; // 闪烁加成:击中仍在闪烁的 Bumper
state.score += pts;
if (state.score > state.hi) {
state.hi = state.score;
try { localStorage.setItem('neon-pinball-hi', String(state.hi)); } catch {}
}
renderScore();
b.flash = Math.min(1.2, 0.5 + v * 0.06);
sfx.bumper(v);
if (v > 2.5) spawnSparks(e.contact.bi.position.vadd(e.contact.ri), b.ring.material.emissive.getHex());
return;
}
// 挡板强碰撞 → 火花
if (flipBodies.has(e.body) && v > 3) {
spawnSparks(e.contact.bi.position.vadd(e.contact.ri), 0xffc266);
}
});
// ---------- 按键交互 ----------
let charging = false;
let power = 0;
const powerEl = document.getElementById('power');
const powerFill = document.getElementById('powerFill');
const ballInLane = () => ballBody.position.x > 4.7 && ballBody.position.z > 1.2;
addEventListener('keydown', (e) => {
ensureAudio(); // AudioContext 需在用户手势中创建/恢复
switch (e.code) {
case 'Space':
e.preventDefault();
if (!e.repeat && ballInLane()) { charging = true; power = 0; }
break;
case 'KeyA': case 'ArrowLeft':
e.preventDefault();
if (!e.repeat) sfx.flipper();
flipL.pressed = true; break;
case 'KeyD': case 'ArrowRight':
e.preventDefault();
if (!e.repeat) sfx.flipper();
flipR.pressed = true; break;
case 'KeyR':
resetBall(); break;
}
});
addEventListener('keyup', (e) => {
switch (e.code) {
case 'Space':
if (charging) {
// 沿通道向上(局部 -z)发射,蓄力越久冲量越大
ballBody.applyImpulse(new CANNON.Vec3(0, 0, -(9 + power * 13)));
sfx.launch(power);
charging = false;
}
break;
case 'KeyA': case 'ArrowLeft': flipL.pressed = false; break;
case 'KeyD': case 'ArrowRight': flipR.pressed = false; break;
}
});
// ---------- 自适应 ----------
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
});
// ---------- 主循环:固定时间步长物理 + 刚体到网格同步 ----------
// 调试钩子:控制台可直接检查物理/计分/粒子状态
window.__game = { world, ballBody, flipL, flipR, bumpers, resetBall, state, sparks, spawnSparks, renderer };
const FIXED_DT = 1 / 60;
const clock = new THREE.Clock();
let fpsFrames = 0, fpsAcc = 0;
renderer.setAnimationLoop(() => {
const dt = Math.min(Math.max(clock.getDelta(), 1e-4), 0.05);
const t = clock.elapsedTime;
gameTime = t;
controls.update();
// 蓄力
if (charging) power = Math.min(1, power + dt / 1.1);
powerEl.classList.toggle('on', charging);
powerFill.style.width = (power * 100) + '%';
// 挡板动画 → 运动学刚体
flipList.forEach(f => updateFlipper(f, dt));
// 固定步长物理(内部按 dt 做最多 3 个子步)
world.step(FIXED_DT, dt, 3);
// 限速,防止高速穿透薄围栏
const spd = ballBody.velocity.length();
if (spd > 22) ballBody.velocity.scale(22 / spd, ballBody.velocity);
// 刚体位置/四元数 → Three.js 网格(同为底板局部坐标)
ball.position.copy(ballBody.position);
ball.quaternion.copy(ballBody.quaternion);
flipList.forEach(f => {
f.pivot.position.copy(f.body.position);
f.pivot.quaternion.copy(f.body.quaternion);
});
// 落球 / 飞出台面 → 重置到弹射通道
const p = ballBody.position;
if (p.z > 11.4 || p.y < -2 || Math.abs(p.x) > 8 || p.z < -13) resetBall();
// Bumper 常亮脉冲 + 撞击闪烁衰减
let i = 0;
for (const b of bumpers.values()) {
b.flash = Math.max(0, b.flash - dt * 2.5);
b.ring.material.emissiveIntensity = 1.6 + Math.sin(t * 2.6 + (i++) * 2.1) * 0.9 + b.flash * 6;
}
// 火花粒子更新
updateSparks(dt);
// FPS(每 0.5s 刷新一次)与连击提示
fpsFrames++; fpsAcc += dt;
if (fpsAcc >= 0.5) {
fpsEl.textContent = Math.round(fpsFrames / fpsAcc);
fpsFrames = 0; fpsAcc = 0;
}
const comboText = (state.combo > 1 && t - state.lastHit < 2.5) ? 'COMBO ×' + state.combo : '';
if (comboEl.textContent !== comboText) comboEl.textContent = comboText;
particles.rotation.y += dt * 0.012;
renderer.render(scene, camera);
window.__booted = true;
});
window.__loaded = true;
</script>
</body>
</html>
5. 模型体验总结
连续三轮实战测试下来(未进行任何纠正的情况下),这个神秘模型 Qoduck 给人的感觉还是可以的,写文章之前也做了其他的实验以及对比。给我的感觉是要比qwen3.8max要强一点,很期待后续表现。
单看这三轮,三维几何与物理直觉极强,在处理斜面重力分解、刚体旋转中心偏移(Pivot)、四元数同步等数学问题时,逻辑非常严密,没有出现坐标系错乱的低级 Bug。
Agent标配的自愈能力感觉也很强,还发现了一个我看代码未发现的bug,不仅能写代码,还会主动起服务在真实浏览器中做断言测试,能自主捕获到诸如“窄视口 HUD 重叠”、“后台标签页 rAF 暂停误判”、“求解器冲量抵消”等极具工程深度的隐蔽 Bug 并当场修复。
很规范的资源生命周期,从音频节点的 disconnect 到 WebGL 粒子的 dispose,防泄漏意识很强。
作为一名常年搞全栈开发的码农,这次实测体验可以说是相当可圈可点了。如果你平时也喜欢自己折腾小项目、做大作业/毕设,强烈建议去 Qoder 里面切到 Qoduck 模型实际蹬一下,反正是免费用,使劲蹬呗哈哈。
更多推荐



所有评论(0)