news 2026/9/24 22:09:48

纯 Canvas 2D 手写游戏开发:复刻《逃离鸭科夫》手感与音效

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
纯 Canvas 2D 手写游戏开发:复刻《逃离鸭科夫》手感与音效

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 新手;还有就是喜欢把浏览器当画布、享受“Ctrl+S → F5”即时反馈的创意 coder。你不需要会 WebGL 着色器,但得明白requestAnimationFrame不是setTimeout的高级马甲,得知道CanvasRenderingContext2Dsave()/restore()是状态栈不是魔法,更得接受——这里没有“自动处理碰撞”,你得自己写if (duck.x < enemy.x + enemy.w && duck.x + duck.w > enemy.x && ...)。但正因如此,当第一颗子弹真正击中敌人、它抽搐两下倒地、同时播放音效、同时在倒地处生成三帧血迹粒子时,那种掌控感,是任何引擎文档都给不了的。

2. 整体架构设计:为什么放弃引擎,选择裸写 Canvas?

2.1 核心思路:用“帧驱动”替代“组件驱动”

《逃离鸭科夫》的魔力在于它的“呼吸感”——鸭子走路时左右晃动的幅度、射击后枪口自然的后坐位移、敌人被击中时身体的僵直延迟,这些都不是靠预设动画帧,而是靠每帧计算的微小偏移量叠加实现的。引擎的组件系统(如 Unity 的 Animator、Phaser 的 SpriteSheet)擅长管理复杂状态机,但会天然增加一层抽象:你要先定义“Idle”、“Walk”、“Shoot”状态,再配置过渡条件,最后还要调试 Blend Tree。而裸 Canvas 的思路是:所有状态都是变量,所有动画都是数学

比如鸭子行走:

// 引擎方案:配置 AnimationClip,设置 speed=0.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 或纯 DOM?

  • SVG 的致命伤是层级性能:《逃离鸭科夫》里常有 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; } }

这里bodyOffsetYheadOffsetXdrawDuck中被使用,实现了“走路时身体上下起伏、头部左右摇摆”的生物感。而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 -> destination;noise -> 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 lang="zh-cn"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.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 id="game-container"> <canvas id="game-canvas" width="800" height="600"></canvas> <div id="ui-overlay"> <div id="health-bar"><div id="health-fill"></div></div> <div id="ammo-display">AMMO: <span id="ammo-count">30</span></div> <div id="start-screen"> <h1>逃离鸭科夫</h1> <p>用 WASD 或方向键移动,空格键射击</p> <button id="start-btn" class="btn">开始游戏</button> </div> <div id="game-over-screen" style="display:none;"> <h1>任务失败!</h1> <p>鸭子已被捕获...</p> <button id="restart-btn" class="btn">再试一次</button> </div> </div> </div> <script> // 所有 JavaScript 代码将在此处编写 </script> </body> </html>

注意:<meta name="viewport">是移动端适配的关键,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
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/24 22:09:11

YooAsset深度解析:从AssetBundle痛点看资源管理框架设计哲学

1. 先还原AssetBundle时代的痛点&#xff0c;YooAsset究竟在解决什么聊YooAsset之前&#xff0c;我强烈建议你先别急着看API、看接入文档&#xff0c;而是停下来想想&#xff1a;过去用Unity自带的AssetBundle&#xff08;简称AB&#xff09;做资源管理&#xff0c;到底痛在哪。…

作者头像 李华
网站建设 2026/9/24 22:08:59

2026年9月第2周GitHub热榜:6款AI与开发者效率工具实测指南

每周翻 GitHub 已经成了我雷打不动的习惯&#xff0c;这周从周一刷到现在&#xff0c;收藏夹又多了十几个仓库。2026年9月第2周的热榜很有意思&#xff0c;明显能感觉到 AI 工具已经从"玩具阶段"往"生产工具阶段"冲了&#xff0c;好几个项目都在解决真实工…

作者头像 李华
网站建设 2026/9/24 22:08:17

MongoDB从入门到实战:文档模型、索引优化与安全加固全解析

接手一个用户行为日志项目的那段时间&#xff0c;我对着 MySQL 里越来越臃肿的 JSON 字段发了无数次呆。每条日志的结构都不一样&#xff0c;有的带嵌套数组&#xff0c;有的带动态属性&#xff0c;为了在关系型表里存这些东西&#xff0c;我建了好几张关联表&#xff0c;查询时…

作者头像 李华
网站建设 2026/9/24 22:07:55

RoLabelImg旋转框标注与格式转换实战指南

简介&#xff1a;2022-RoLabelImg 是一款面向计算机视觉与机器学习研发者的图像标注工具&#xff0c;尤其针对 Windows 用户做了安装与运行优化&#xff0c;解决了以往版本常见的兼容性故障&#xff0c;开箱即用。它提供直观的图形界面&#xff0c;支持矩形、多边形、圆形、点与…

作者头像 李华
网站建设 2026/9/24 22:07:14

用Dart analyzer打造鸿蒙化适配自动化工具链

Flutter 生态里的 analyzer 这个包&#xff0c;很多人的印象停留在“IDE 语法检查的后台引擎”&#xff0c;但真正把它玩透之后&#xff0c;你会发现它完全能扛起鸿蒙化适配里最脏最累的活儿&#xff1a;扫源码、建 AST、自动生成桥接代码、做合规自检。这篇文章我结合自己在鸿…

作者头像 李华