1. 这不是游戏引擎但真能做出《逃离鸭科夫》那种味儿你有没有试过点开一个网页没加载任何框架、没引入 Phaser 或 Three.js就靠原生canvas标签几行 HTML、一段 CSS、一堆手写的 JavaScript突然间——一只歪嘴鸭子从屏幕左边滑进来头顶飘着“”气泡你按空格键它立刻掏出霰弹枪朝右扫射子弹拖着橙色残影撞上砖墙炸出像素碎屑同时一声短促的“啪咔”音效从 Web Audio API 里精准触发没有 webpack没有 npm install连package.json都没建就一个.html文件双击打开它就活了。这就是我们今天要干的事不用游戏引擎纯 Canvas 2D HTML/CSS/JavaScript Web Audio复刻《逃离鸭科夫》Duckoff那种粗粝、幽默、节奏紧凑、操作反馈爆炸的搜打撤Search-Engage-Exfiltrate体验。关键词不是“高性能渲染”而是“手感”、“节奏感”、“即刻响应”和“用最少代码撬动最大表现力”。它不追求 60fps 的丝滑但要求你按下空格的 83 毫秒内枪口火光必须亮起它不模拟物理引擎但鸭子蹬墙跳时的滞空感、落地时的微小弹跳全靠两行y gravity * dt; if (onGround) y groundY;算出来。这不是炫技是回归本质——就像当年用 Turbo Pascal 在 DOS 下写《指挥官基恩》所有逻辑都在你脑子里所有像素都听你指挥。适合谁首先是被 Unity/Unreal 封装层隔得太远、想亲手摸一摸“帧循环”心跳的中阶前端其次是刚学完 DOM 操作、对“动画不是 setInterval”还半信半疑的 JS 新手还有就是喜欢把浏览器当画布、享受“CtrlS → F5”即时反馈的创意 coder。你不需要会 WebGL 着色器但得明白requestAnimationFrame不是setTimeout的高级马甲得知道CanvasRenderingContext2D的save()/restore()是状态栈不是魔法更得接受——这里没有“自动处理碰撞”你得自己写if (duck.x enemy.x enemy.w duck.x duck.w enemy.x ...)。但正因如此当第一颗子弹真正击中敌人、它抽搐两下倒地、同时播放音效、同时在倒地处生成三帧血迹粒子时那种掌控感是任何引擎文档都给不了的。2. 整体架构设计为什么放弃引擎选择裸写 Canvas2.1 核心思路用“帧驱动”替代“组件驱动”《逃离鸭科夫》的魔力在于它的“呼吸感”——鸭子走路时左右晃动的幅度、射击后枪口自然的后坐位移、敌人被击中时身体的僵直延迟这些都不是靠预设动画帧而是靠每帧计算的微小偏移量叠加实现的。引擎的组件系统如 Unity 的 Animator、Phaser 的 SpriteSheet擅长管理复杂状态机但会天然增加一层抽象你要先定义“Idle”、“Walk”、“Shoot”状态再配置过渡条件最后还要调试 Blend Tree。而裸 Canvas 的思路是所有状态都是变量所有动画都是数学。比如鸭子行走// 引擎方案配置 AnimationClip设置 speed0.5绑定到 Animator // 裸 Canvas 方案 const walkCycle Math.sin(Date.now() * 0.005) * 3; // 生成 -3~3 的周期性偏移 duck.x moveSpeed * deltaTime; duck.yOffset walkCycle; // 直接赋值给渲染用的 Y 偏移量这里Math.sin()不是“播放动画”而是实时生成符合生物力学的摆动曲线。deltaTime帧间隔时间的引入让移动速度与设备性能解耦——在 30fps 的老笔记本上deltaTime变大duck.x增量自动放大鸭子依然保持相同物理速度。这种“变量即状态”的设计让逻辑极度透明你想改走路晃动幅度调*3这个系数想加快节奏改*0.005这个频率。没有 XML 配置没有 Inspector 面板只有代码里的数字所见即所得。2.2 技术选型背后的硬逻辑为什么是 Canvas 2D而不是 SVG 或纯 DOMSVG 的致命伤是层级性能《逃离鸭科夫》里常有 20 敌人同屏、子弹轨迹、爆炸粒子、UI 文字同时存在。SVG 每个元素都是独立 DOM 节点更新 20 个circle的cx/cy属性浏览器要重排重绘整个 SVG 树实测在低端安卓机上帧率直接跌破 20fps。而 Canvas 2D 是位图绘制ctx.fillRect(x,y,w,h)只是一次 GPU 命令批量绘制 100 个矩形和绘制 1 个性能几乎无差别。DOM 的定位灾难用div模拟角色你得为每个敌人设置position: absolute再用transform: translate()移动。但transform触发的是合成层大量元素会吃光内存更糟的是DOM 元素无法做像素级旋转rotate(45deg)是 CSS 变换不是真正的位图旋转而《逃离鸭科夫》里鸭子射击时枪管要随鼠标角度旋转子弹轨迹要有精确的斜向位移——Canvas 的ctx.rotate(angle)和ctx.translate(x,y)是原生支持的精度到小数点后 5 位。Web Audio 的不可替代性引擎的音频系统往往封装过深难以做到“子弹出膛瞬间触发音效且音高随射击力度微调”。Web Audio API 允许你创建OscillatorNode动态生成脉冲波模拟枪声用GainNode实时控制音量衰减甚至用ConvolverNode加载真实枪声采样卷积——这一切都在毫秒级完成无需等待音频文件加载完成。我们项目里每次射击都会执行const osc audioCtx.createOscillator(); const gain audioCtx.createGain(); osc.connect(gain); gain.connect(audioCtx.destination); osc.frequency.setValueAtTime(120, audioCtx.currentTime); // 初始音高 osc.frequency.exponentialRampToValueAtTime(80, audioCtx.currentTime 0.1); // 0.1秒内降到80Hz gain.gain.setValueAtTime(0.7, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime 0.3); osc.start(); osc.stop(audioCtx.currentTime 0.3);这段代码生成的不是“播放一个 mp3”而是一个实时合成的、带物理特性的枪声这是任何预制音效库做不到的。2.3 架构分层极简但绝不简陋整个项目严格遵循三层结构每层职责清晰无交叉渲染层Renderer只负责一件事——把当前世界状态画到canvas上。它不关心鸭子是否在移动、子弹是否击中只接收entities数组包含所有可渲染对象的位置、大小、颜色、纹理等属性遍历调用drawEntity(ctx, entity)。drawEntity内部用ctx.save()保存坐标系ctx.translate(entity.x, entity.y)定位ctx.rotate(entity.angle)旋转再ctx.drawImage(texture, ...)绘制。画完ctx.restore()。关键原则渲染层绝对不修改任何数据它是纯粹的“观察者”。游戏逻辑层GameLoop核心是update(deltaTime)函数每帧调用一次。它处理输入检测键盘状态快照非事件监听——避免按键重复触发物理模拟位置更新、碰撞检测、重力计算AI 行为敌人巡逻路径、追击逻辑、攻击冷却状态管理鸭子生命值、弹药数、关卡进度所有数学计算在此发生所有 if/else 判断在此执行。例如碰撞检测function checkCollision(a, b) { return a.x b.x b.width a.x a.width b.x a.y b.y b.height a.y a.height b.y; } // 遍历所有子弹 vs 所有敌人 bullets.forEach(bullet { enemies.forEach(enemy { if (checkCollision(bullet, enemy)) { enemy.health - 1; bullet.active false; // 标记为失效后续清理 createBloodParticles(enemy.x, enemy.y); // 触发视觉反馈 } }); });资源管理层AssetManager不搞动态加载所有资源图片、音频在页面加载时预取并缓存。用Promise.all([fetch(duck.png), fetch(shot.wav)])确保所有资源就绪才启动游戏。图片用Image对象加载音频用AudioContext.decodeAudioData()解码为AudioBuffer。关键技巧音频解码必须在用户交互如点击按钮后触发否则 Chrome 会静音——我们在“开始游戏”按钮的 click 事件里初始化AudioContext。这三层之间通过纯数据对象通信无直接依赖。你可以把Renderer替换成 WebGL 渲染器只要输入entities格式不变逻辑层完全不用改。这种解耦不是为未来扩展而是为今天调试——当你发现鸭子移动卡顿你只需盯死GameLoop.update()的执行时间不用在渲染管线里大海捞针。3. 核心细节解析从零构建鸭子、子弹与音效3.1 鸭子实体不只是一个会动的方块《逃离鸭科夫》的鸭子灵魂在于它的“性格”——歪嘴、小眼睛、走路时屁股一扭一扭。这不能靠一张静态图得用多层 Canvas 绘制实现。我们定义鸭子实体为一个对象const duck { x: 100, y: 300, width: 40, height: 40, speed: 150, // px/s health: 3, facing: right, // left or right isShooting: false, shootCooldown: 0, // 关键用于绘制的偏移量 bodyOffsetY: 0, headOffsetX: 0, eyeOffsetY: 0 };绘制鸭子的drawDuck(ctx, duck)函数是精髓function drawDuck(ctx, duck) { ctx.save(); ctx.translate(duck.x, duck.y); // 1. 绘制身体基础椭圆 ctx.fillStyle #FFD700; ctx.beginPath(); ctx.ellipse(0, 0, duck.width/2, duck.height/2, 0, 0, Math.PI * 2); ctx.fill(); // 2. 绘制头部偏移的小圆 ctx.save(); ctx.translate(duck.headOffsetX, -5); // 头部向上偏移并随身体晃动 ctx.fillStyle #FFA500; ctx.beginPath(); ctx.arc(0, 0, 12, 0, Math.PI * 2); ctx.fill(); ctx.restore(); // 3. 绘制歪嘴关键 ctx.save(); ctx.translate(duck.headOffsetX, 3); // 嘴巴在头部下方 ctx.rotate(duck.facing right ? 0.1 : -0.1); // 向右看时嘴向右歪 ctx.fillStyle #8B4513; ctx.fillRect(-8, 0, 16, 4); // 一个长方形嘴 ctx.restore(); // 4. 绘制小眼睛随鸭子朝向翻转 ctx.save(); ctx.translate(duck.headOffsetX, -2); ctx.scale(duck.facing right ? 1 : -1, 1); // 镜像翻转 ctx.fillStyle #000; ctx.beginPath(); ctx.arc(-3, 0, 2, 0, Math.PI * 2); // 左眼 ctx.arc(3, 0, 2, 0, Math.PI * 2); // 右眼 ctx.fill(); ctx.restore(); ctx.restore(); }提示ctx.scale(-1,1)是实现镜像翻转的最轻量方式比用ctx.transform()设置矩阵简单得多。duck.facing决定缩放方向这样鸭子转向时眼睛和嘴自动适配无需维护两套纹理。让鸭子“活起来”的 update 逻辑function updateDuck(duck, deltaTime, keys) { // 走路晃动用三角函数生成平滑周期 duck.bodyOffsetY Math.sin(Date.now() * 0.01) * 2; duck.headOffsetX Math.cos(Date.now() * 0.015) * 3; // 射击时的额外抖动 if (duck.isShooting) { duck.bodyOffsetY Math.random() * 4 - 2; // 随机抖动 } // 移动 if (keys[ArrowLeft] || keys[a]) { duck.x - duck.speed * deltaTime; duck.facing left; } if (keys[ArrowRight] || keys[d]) { duck.x duck.speed * deltaTime; duck.facing right; } // 射击冷却 if (duck.shootCooldown 0) { duck.shootCooldown - deltaTime; } }这里bodyOffsetY和headOffsetX在drawDuck中被使用实现了“走路时身体上下起伏、头部左右摇摆”的生物感。而isShooting触发的随机抖动模拟了后坐力——不是固定动画而是每帧重新计算所以每次射击抖动都不同。3.2 子弹系统从发射到击中的完整生命周期子弹不是“飞出去就不管了”它有明确的生命周期生成 → 飞行 → 碰撞 → 消失。每个阶段都要有视觉和听觉反馈。子弹对象定义function createBullet(x, y, angle, speed) { return { x, y, width: 4, height: 4, speed, angle, vx: Math.cos(angle) * speed, // X方向速度分量 vy: Math.sin(angle) * speed, // Y方向速度分量 life: 1.0, // 生命值用于渐隐效果 trail: [] // 存储历史位置用于绘制拖尾 }; }关键拖尾效果的实现。不是用粒子系统而是用数组记录最近 5 帧的位置绘制渐变线段function drawBullet(ctx, bullet) { // 绘制拖尾从旧到新颜色由浅到深 if (bullet.trail.length 1) { ctx.beginPath(); ctx.moveTo(bullet.trail[0].x, bullet.trail[0].y); for (let i 1; i bullet.trail.length; i) { const alpha i / bullet.trail.length * 0.7; // 越新的点越不透明 ctx.strokeStyle rgba(255, 165, 0, ${alpha}); ctx.lineWidth 2; ctx.lineTo(bullet.trail[i].x, bullet.trail[i].y); ctx.stroke(); ctx.beginPath(); ctx.moveTo(bullet.trail[i].x, bullet.trail[i].y); } } // 绘制子弹本体 ctx.fillStyle #FFA500; ctx.beginPath(); ctx.arc(bullet.x, bullet.y, bullet.width/2, 0, Math.PI * 2); ctx.fill(); }飞行与碰撞更新function updateBullet(bullet, deltaTime) { // 更新位置 bullet.x bullet.vx * deltaTime; bullet.y bullet.vy * deltaTime; // 记录拖尾位置每3帧记录一次避免太密 if (frameCount % 3 0) { bullet.trail.push({x: bullet.x, y: bullet.y}); if (bullet.trail.length 5) bullet.trail.shift(); // 保持最多5个点 } // 生命衰减用于渐隐 bullet.life - deltaTime * 0.5; if (bullet.life 0) { return false; // 标记为销毁 } // 边界检测飞出屏幕就销毁 if (bullet.x 0 || bullet.x canvas.width || bullet.y 0 || bullet.y canvas.height) { return false; } return true; // 继续存活 }注意return false是销毁信号主循环会将该子弹从bullets数组中splice()掉。这种“标记-清除”模式比实时filter()更高效因为filter()每帧都要创建新数组。3.3 Web Audio 音效让每一次射击都“有重量”《逃离鸭科夫》的音效不是背景音乐是游戏反馈的核心器官。枪声必须有“重量感”爆炸要有“空间感”受伤音效要带“痛感”。Web Audio API 让我们精确控制每一个参数。音频上下文初始化必须在用户交互后let audioCtx null; document.getElementById(start-btn).addEventListener(click, () { if (!audioCtx) { audioCtx new (window.AudioContext || window.webkitAudioContext)(); } // 此时 audioCtx 已激活可安全播放 startGame(); });构建枪声音效生成器function playShotSound() { if (!audioCtx) return; const now audioCtx.currentTime; const osc audioCtx.createOscillator(); const gain audioCtx.createGain(); // 主振荡器脉冲波模拟枪管爆鸣 osc.type pulse; osc.frequency.setValueAtTime(180, now); osc.frequency.exponentialRampToValueAtTime(120, now 0.05); // 增益包络快速起音短促衰减 gain.gain.setValueAtTime(0.6, now); gain.gain.exponentialRampToValueAtTime(0.001, now 0.15); // 添加低频噪声增强“砰”感 const noise audioCtx.createBufferSource(); const noiseBuffer audioCtx.createBuffer(1, audioCtx.sampleRate * 0.1, audioCtx.sampleRate); const noiseData noiseBuffer.getChannelData(0); for (let i 0; i noiseData.length; i) { noiseData[i] Math.random() * 2 - 1; } noise.buffer noiseBuffer; noise.loop false; // 连接osc - gain - destinationnoise - gain - destination osc.connect(gain); noise.connect(gain); gain.connect(audioCtx.destination); osc.start(now); osc.stop(now 0.15); noise.start(now); noise.stop(now 0.15); }这段代码生成的不是一个音效文件而是一个实时合成的、带物理特性的声音事件。osc.frequency.exponentialRampToValueAtTime()创建了音高快速下降的效果模拟枪声从高频爆鸣到低频轰鸣的瞬态gain.gain.exponentialRampToValueAtTime()实现了指数衰减比线性衰减更接近真实声音的能量衰减规律叠加的白噪声则填充了中高频让声音更“炸”。爆炸音效的层次感function playExplosionSound(x, y) { if (!audioCtx) return; const now audioCtx.currentTime; // 1. 低频冲击波超低频振荡器 const bassOsc audioCtx.createOscillator(); bassOsc.type sawtooth; bassOsc.frequency.setValueAtTime(40, now); bassOsc.frequency.exponentialRampToValueAtTime(20, now 0.3); // 2. 中频碎片声多个短脉冲 const pulseOsc audioCtx.createOscillator(); pulseOsc.type pulse; pulseOsc.frequency.setValueAtTime(300, now); pulseOsc.frequency.exponentialRampToValueAtTime(800, now 0.2); // 3. 高频嘶嘶声噪声源 const hiss audioCtx.createBufferSource(); const hissBuffer audioCtx.createBuffer(1, audioCtx.sampleRate * 0.5, audioCtx.sampleRate); const hissData hissBuffer.getChannelData(0); for (let i 0; i hissData.length; i) { hissData[i] (Math.random() - 0.5) * 0.3; } hiss.buffer hissBuffer; // 增益控制让爆炸声随距离衰减 const distance Math.sqrt((x - canvas.width/2)**2 (y - canvas.height/2)**2); const maxDistance 300; const volume Math.max(0.1, 1 - distance / maxDistance); const gain audioCtx.createGain(); gain.gain.setValueAtTime(volume * 0.8, now); bassOsc.connect(gain); pulseOsc.connect(gain); hiss.connect(gain); gain.connect(audioCtx.destination); bassOsc.start(now); bassOsc.stop(now 0.3); pulseOsc.start(now); pulseOsc.stop(now 0.2); hiss.start(now); hiss.stop(now 0.5); }这里通过三个并行的音频源低频锯齿波、中频脉冲波、高频噪声叠加模拟爆炸的复合频谱volume计算实现了基于距离的音量衰减让玩家在屏幕边缘听到的爆炸声比中心小增强了空间沉浸感。4. 实操过程从空白 HTML 到可玩原型的完整步骤4.1 初始化 HTML 结构极简但完备我们从一个干净的index.html开始不引入任何外部 CSS/JS所有内容内联。这是为了确保“双击即运行”也是为了强制你理解每个标签的作用。!doctype html html langzh-cn head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title逃离鸭科夫 - Canvas 2D 原生版/title style * { margin: 0; padding: 0; box-sizing: border-box; } body { background: linear-gradient(to bottom, #87CEEB, #E0F7FA); font-family: Courier New, monospace; overflow: hidden; display: flex; justify-content: center; align-items: center; height: 100vh; color: #1A237E; } #game-container { position: relative; width: 800px; height: 600px; box-shadow: 0 0 20px rgba(0,0,0,0.3); border-radius: 8px; overflow: hidden; } canvas { display: block; background: #FFFFFF; } #ui-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; } #health-bar { position: absolute; top: 20px; left: 20px; width: 200px; height: 20px; background: #BDBDBD; border-radius: 10px; overflow: hidden; } #health-fill { height: 100%; background: linear-gradient(90deg, #4CAF50, #8BC34A); border-radius: 10px; width: 100%; transition: width 0.2s ease; /* 平滑过渡 */ } #ammo-display { position: absolute; top: 20px; right: 20px; font-size: 18px; font-weight: bold; text-shadow: 1px 1px 2px rgba(0,0,0,0.5); } #start-screen, #game-over-screen { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.85); display: flex; flex-direction: column; justify-content: center; align-items: center; color: white; text-align: center; pointer-events: all; } #start-screen h1, #game-over-screen h1 { font-size: 48px; margin-bottom: 20px; color: #FFD700; text-shadow: 0 0 10px #FF8C00; } .btn { background: #FF5722; color: white; border: none; padding: 12px 32px; font-size: 18px; font-weight: bold; border-radius: 50px; cursor: pointer; margin-top: 20px; transition: all 0.2s; text-transform: uppercase; letter-spacing: 2px; } .btn:hover { background: #E64A19; transform: scale(1.05); box-shadow: 0 0 15px rgba(255,87,34,0.7); } /style /head body div idgame-container canvas idgame-canvas width800 height600/canvas div idui-overlay div idhealth-bardiv idhealth-fill/div/div div idammo-displayAMMO: span idammo-count30/span/div div idstart-screen h1逃离鸭科夫/h1 p用 WASD 或方向键移动空格键射击/p button idstart-btn classbtn开始游戏/button /div div idgame-over-screen styledisplay:none; h1任务失败/h1 p鸭子已被捕获.../p button idrestart-btn classbtn再试一次/button /div /div /div script // 所有 JavaScript 代码将在此处编写 /script /body /html注意meta nameviewport是移动端适配的关键box-sizing: border-box让 CSS 宽高计算更直观。UI 层#ui-overlay用pointer-events: none确保鼠标事件穿透到 Canvas但按钮需要pointer-events: all显式开启。4.2 Canvas 上下文获取与基础渲染循环在script标签内首先获取 Canvas 和 2D 上下文const canvas document.getElementById(game-canvas); const ctx canvas.getContext(2d); // 游戏状态 let gameState start; // start, playing, gameover let lastTime 0; let frameCount 0; // 主渲染循环 function gameLoop(timestamp) { const deltaTime timestamp - lastTime; lastTime timestamp; // 清屏用纯色填充比 clearRect() 更快无 alpha 混合 ctx.fillStyle #FFFFFF; ctx.fillRect(0, 0, canvas.width, canvas.height); // 根据状态执行不同逻辑 if (gameState playing) { update(deltaTime); render(); } else if (gameState start) { // 显示开始界面不更新游戏逻辑 } else if (gameState gameover) { // 显示结束界面 } requestAnimationFrame(gameLoop); } // 启动循环 requestAnimationFrame(gameLoop);关键优化点ctx.fillRect()清屏比ctx.clearRect()快因为后者要处理 alpha 通道混合。deltaTime是帧间隔时间毫秒用于所有物理计算保证跨设备一致性。gameState控制流程避免在非游戏状态下执行无谓的update()。4.3 输入系统键盘状态快照拒绝事件监听陷阱很多新手用keydown/keyup事件监听结果导致“按住方向键只移动一格”。正确做法是维护一个全局键盘状态对象在update()中读取const keys {}; window.addEventListener(keydown, (e) { keys[e.key.toLowerCase()] true; // 防止空格键触发页面滚动 if (e.key ) e.preventDefault(); }); window.addEventListener(keyup, (e) { keys[e.key.toLowerCase()] false; }); // 在 update() 中使用 function update(deltaTime) { if (keys[arrowleft] || keys[a]) { duck.x - duck.speed * deltaTime; } // ... 其他逻辑 }注意e.preventDefault()阻止空格键默认滚动行为这是网页游戏的基本礼仪。keys对象是布尔值映射update()每帧读取确保按住键时持续响应。4.4 完整可运行的最小化代码整合将所有模块组合起来形成一个可直接双击运行的index.html。以下是script标签内的完整代码精简核心省略部分注释const canvas document.getElementById(game-canvas); const ctx canvas.getContext(2d); const startScreen document.getElementById(start-screen); const gameOverScreen document.getElementById(game-over-screen); const startBtn document.getElementById(start-btn); const restartBtn document.getElementById(restart-btn); const healthFill document.getElementById(health-fill); const ammoCountEl document.getElementById(ammo-count); // 游戏状态 let gameState start; let lastTime 0; let frameCount 0; let audioCtx null; // 键盘状态 const keys {}; // 鸭子 const duck { x: 100, y: 300, width: 40, height: 40, speed: 150, health: 3, facing: right, isShooting: false, shootCooldown: 0, bodyOffsetY: 0, headOffsetX: 0, eyeOffsetY: 0 }; // 子弹数组 let bullets []; let ammo 30; // 敌人数组 let enemies []; // 生成一个敌人 function spawnEnemy() { enemies.push({ x: canvas.width 20, y: 250 Math.random() * 100, width: 30, height: 30, speed: 80 Math.random() * 40, health: 1 }); } // 初始化敌人 for (let i 0; i 5; i) { spawnEnemy(); } // 音频初始化 function initAudio() { if (!audioCtx) { audioCtx new (window.AudioContext || window.webkitAudioContext)(); } } // 播放射击音效简化版 function playShotSound() { if (!audioCtx) return; const now audioCtx.currentTime; const osc audioCtx.createOscillator(); const gain audioCtx.createGain(); osc.type pulse; osc.frequency.setValueAtTime(180, now); osc.frequency.exponentialRampToValueAtTime(120, now 0.05); gain.gain.setValueAtTime(0.6, now); gain.gain.exponentialRampToValueAtTime(0.001, now 0.15); osc.connect(gain); gain.connect(audioCtx.destination); osc.start(now); osc.stop(now 0.15); } // 更新鸭子 function updateDuck(deltaTime) { duck.bodyOffsetY Math.sin(Date.now() * 0.01) * 2; duck.headOffsetX Math.cos(Date.now() * 0.015) * 3; if (keys[arrowleft] || keys[a]) { duck.x Math.max(duck.width/2, duck.x - duck.speed * deltaTime); duck.facing left; } if (keys[arrowright] || keys[d]) { duck.x Math.min(canvas.width - duck.width/2, duck.x duck.speed * deltaTime); duck.facing right; } if (keys[ ] duck.shootCooldown 0 ammo 0) { // 计算射击角度朝向鼠标或固定方向 const angle duck.facing right ? 0 : Math.PI; bullets.push(createBullet