Web原生游戏开发:裸写Canvas+Web Audio实现60FPS搜打撤玩法
发布时间:2026/9/24 22:02:54来源:尧图网络
1. 这不是“小游戏”而是一次对 Web 原生能力的硬核验证你有没有试过在打开一个网页的瞬间手指刚搭上键盘角色就已翻滚着撞开第一扇门——没有加载条、没有黑屏等待、没有“正在初始化资源”的提示只有 Canvas 里像素级抖动的鸭子影子、键盘按下时毫秒级触发的枪声波形、以及敌人被击中时那一帧精准的粒子炸裂这不是 Unity 或 Godot 导出的 WebAssembly 包也不是 Three.js 封装的 3D 场景。它就躺在一个不到 80KB 的单 HTML 文件里!doctype htmlhtml langzh-cn开头canvas idgame为画布所有逻辑由原生 JavaScript 驱动音效靠 Web Audio API 实时合成UI 用纯 CSS Grid 精确锚定在视口坐标系中。这就是《逃离鸭科夫》风格搜打撤游戏的底层真相——它根本不需要游戏引擎。我做这类项目超过七年从最早用requestAnimationFrame手写帧循环调试卡顿到后来给 Canvas 加上离屏渲染缓冲区解决闪烁再到用AudioContext动态生成枪声包络避免预加载音频文件。这次重写《逃离鸭科夫》式玩法核心目标很明确剥离所有中间层抽象让每一行代码都直面浏览器内核的调度机制。Canvas 2D 不是“简陋替代品”它是 Web 平台最接近硬件光栅化管线的接口Web Audio 不是“网页音效播放器”它是带采样精度控制、延迟可压至 12ms 以内的实时音频图谱处理器。所谓“不用游戏引擎”本质是拒绝把update()和render()封装成黑盒而是亲手把时间戳拆解成 deltaT、把键盘事件映射成向量加速度、把子弹轨迹算成带碰撞检测的线段相交——这些事引擎帮你做了但你也因此永远失去了调整帧间插值系数、修改音频混响衰减曲线、或在特定帧插入性能探针的机会。这个项目适合三类人一是刚学完 DOM 操作想突破“点击按钮弹窗”阶段的前端新人它会告诉你canvas.getContext(2d)返回的对象到底能干多少事二是做过 React/Vue 项目但对“为什么 setState 有延迟”始终模糊的中级开发者它用纯 JS 主循环让你亲眼看见浏览器渲染流水线如何被requestAnimationFrame绑定三是独立游戏开发者如果你正评估 Web 平台能否承载核心玩法原型这个项目就是一份可测量的性能基线报告——它在 2018 款 MacBook Pro 上稳定跑满 60FPS内存占用峰值压在 42MB 以内且所有资源含音频波形均在首帧前完成初始化。接下来的内容不会教你“怎么用 Phaser”而是带你一帧一帧地把鸭子从像素点变成有物理反馈的角色。2. 架构设计为什么放弃一切封装选择“裸写”主循环2.1 核心矛盾引擎便利性 vs. 控制粒度市面上主流游戏引擎Phaser、PixiJS、Babylon.js对 Canvas 2D 的封装层级极高。它们提供scene.add.sprite()、physics.arcade.enable()、sound.play()这类语义化 API极大降低入门门槛。但当你需要实现《逃离鸭科夫》的关键机制时问题立刻浮现搜打撤节奏控制玩家需在“搜索房间→发现敌人→开火→撤退”四步间保持严格的时间窗口例如发现敌人后 1.2 秒内未开火则触发警报。引擎的update()回调无法保证精确时间间隔其内部帧同步机制会因 GC 或主线程阻塞产生 ±8ms 波动而人类对 100ms 内的节奏偏差极其敏感子弹物理拟真游戏中子弹需带初速度衰减、空气阻力系数、命中不同材质木门/铁门/玻璃产生差异化音效与弹道偏转。引擎的刚体系统默认采用简化碰撞模型AABB 或圆形包围盒无法处理子弹这种高速细长物体的精确线段-多边形碰撞音频动态响应开火音效需随玩家当前持枪类型手枪/霰弹枪/步枪实时切换波形参数且枪声在走廊与空旷房间需有不同混响效果。预加载多个.mp3文件会增加首屏加载时间而引擎的音频管理器通常不暴露ConvolverNode的直接控制权。我最终选择“裸写”主循环是因为它把三个关键控制点完全交还给开发者时间控制权requestAnimationFrame(timestamp)提供高精度时间戳我们可计算deltaTime timestamp - lastTimestamp并用此值驱动所有运动方程渲染控制权ctx.clearRect()ctx.drawImage()的组合允许我们按需清除局部区域如只重绘角色移动轨迹而非强制全屏刷新音频控制权AudioContext的createOscillator()和createGainNode()可在运行时动态构建音频图谱无需预存任何音频文件。提示放弃引擎不等于放弃工具链。我们仍会使用 ESLint 规范代码、Vite 构建开发服务器、Rollup 打包压缩但这些属于工程化支持与游戏逻辑无关。2.2 分层架构从像素到玩法的五层抽象整个项目采用严格分层设计每层只依赖下层接口杜绝跨层调用层级名称职责关键技术点数据流向L1像素层直接操作 Canvas 2D Contextctx.fillStyle,ctx.fillRect(),ctx.drawImage()输出到 Canvas 画布L2几何层定义基础图形与变换DOMMatrix,Path2D,ctx.setTransform()输入L1输出L3L3实体层管理游戏对象生命周期Entity类、Component模式Position, Velocity, Sprite输入L2输出L4L4系统层处理核心游戏逻辑InputSystem,PhysicsSystem,AudioSystem输入L3输出L5L5场景层协调关卡状态与流程Scene类、状态机Search/Combat/Retreat输入L4输出L4这种分层不是为了炫技而是为了解决实际问题。例如当需要优化性能时我们可单独对 L2 层做批处理将 20 个敌人的绘制指令合并为一次ctx.drawImages()调用当调试碰撞问题时只需在 L3 层的Entity.update()中插入断点无需穿透引擎源码当更换音效方案时仅需重写 L4 的AudioSystem.playSound()方法其他层完全无感。2.3 性能红线60FPS 的硬性约束与取舍Web 平台的渲染性能有明确物理极限60FPS 对应每帧 16.67ms。我们必须在此框架内分配时间预算输入采集≤ 0.3msKeyboardEvent事件监听器内仅记录键位状态不做逻辑物理更新≤ 2.1ms包含 12 个敌人、8 发子弹、4 扇门的碰撞检测与位置更新音频调度≤ 0.8msAudioContext.currentTime查询 gainNode.gain.setValueAtTime()调用渲染绘制≤ 10.2msCanvas 清屏 32 个精灵绘制 2 个 UI 文字渲染余量缓冲≥ 3.2ms应对偶发 GC 或主线程阻塞实测发现ctx.drawImage()在绘制大量小图时存在显著性能瓶颈。解决方案不是换库而是重构绘制逻辑将所有静态元素地板纹理、墙壁贴图预先绘制到离屏 CanvasOffscreenCanvas主 Canvas 仅负责绘制动态元素角色、子弹、粒子。这使渲染耗时从 12.4ms 降至 8.7ms余量提升至 4.9ms。注意不要迷信“Canvas 2D 性能差”。它的瓶颈从来不在 API 本身而在开发者是否理解ctx.save()/restore()的栈操作开销、ctx.globalAlpha的混合模式代价、以及ImageBitmap对图像解码的加速作用。本项目中所有角色精灵均转为ImageBitmap加载耗时减少 63%。3. 核心模块实现从 Canvas 初始化到搜打撤状态机3.1 Canvas 初始化与渲染管线搭建第一步不是写游戏逻辑而是构建一个可控的渲染环境。以下代码是项目入口文件index.html的精简骨架!doctype html html langzh-cn head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title逃离鸭科夫/title style * { margin: 0; padding: 0; box-sizing: border-box; } body { overflow: hidden; background: #000; font-family: Courier New, monospace; } #game { display: block; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); image-rendering: pixelated; /* 保留像素风锯齿 */ width: 800px; height: 600px; } /style /head body canvas idgame width800 height600/canvas script typemodule src./src/main.js/script /body /html关键细节解析image-rendering: pixelated是像素艺术游戏的生命线。它强制浏览器禁用双线性插值避免缩放时出现模糊边缘。若省略此属性角色移动时会出现“果冻效应”jello effectcanvas的width/height属性设为 800×600这是 CSS 像素尺寸也是 Canvas 内部坐标系的逻辑分辨率。后续所有绘制坐标均基于此比例无需适配不同 DPR 设备transform: translate(-50%, -50%)居中画布比margin: auto更可靠尤其在移动端 Safari 中。在main.js中初始化 Canvas Contextconst canvas document.getElementById(game); const ctx canvas.getContext(2d); // 启用抗锯齿优化仅对文字和线条有效 ctx.imageSmoothingEnabled false; // 关闭图片缩放平滑 ctx.textBaseline top; ctx.font 16px monospace; // 创建离屏缓冲区用于静态背景 const offscreenCanvas new OffscreenCanvas(800, 600); const offscreenCtx offscreenCanvas.getContext(2d); // 预绘制地板网格10×10 像素单元 offscreenCtx.strokeStyle #333; offscreenCtx.lineWidth 0.5; for (let x 0; x 800; x 10) { offscreenCtx.beginPath(); offscreenCtx.moveTo(x, 0); offscreenCtx.lineTo(x, 600); offscreenCtx.stroke(); } for (let y 0; y 600; y 10) { offscreenCtx.beginPath(); offscreenCtx.moveTo(0, y); offscreenCtx.lineTo(800, y); offscreenCtx.stroke(); }此处体现一个关键经验静态内容永远优先离屏绘制。地板网格在游戏全程不变若每帧都在主 Canvas 上重绘会浪费约 1.2ms CPU 时间。离屏 Canvas 在首次绘制后后续只需ctx.drawImage(offscreenCanvas, 0, 0)一次调用耗时仅 0.03ms。3.2 输入系统键盘状态快照与防抖处理《逃离鸭科夫》要求精准的“按键时长”识别如长按 Shift 键进入潜行模式。浏览器原生keydown/keyup事件存在两个致命缺陷重复触发长按按键时keydown会以操作系统级速率重复触发Windows 默认 500ms 延迟 33ms 间隔导致角色移动速度失控事件队列延迟事件进入 JS 主线程需经过浏览器事件队列可能比实际按键晚 2~5ms。解决方案是建立键盘状态快照系统class InputSystem { constructor() { this.keys {}; // 键码 → { pressed: boolean, startTime: number, duration: number } this.lastFrameKeys {}; // 上一帧的键状态副本 this.frameCount 0; window.addEventListener(keydown, (e) { if (!this.keys[e.code]) { this.keys[e.code] { pressed: true, startTime: performance.now(), duration: 0 }; } else { this.keys[e.code].pressed true; } e.preventDefault(); // 阻止默认行为如滚动 }); window.addEventListener(keyup, (e) { if (this.keys[e.code]) { this.keys[e.code].pressed false; } e.preventDefault(); }); } update(deltaTime) { // 计算按键持续时间 for (const code in this.keys) { if (this.keys[code].pressed) { this.keys[code].duration performance.now() - this.keys[code].startTime; } } // 生成帧快照供物理系统使用 this.lastFrameKeys { ...this.keys }; this.frameCount; } // 获取当前帧按键状态防抖后 isPressed(keyCode) { return this.lastFrameKeys[keyCode]?.pressed || false; } // 获取按键持续时间毫秒 getPressDuration(keyCode) { return this.lastFrameKeys[keyCode]?.duration || 0; } // 检测按键上升沿仅在松开瞬间返回 true wasReleased(keyCode) { return !this.lastFrameKeys[keyCode]?.pressed this.keys[keyCode]?.pressed false; } } // 全局输入实例 export const input new InputSystem();此设计带来三大优势帧一致性update()在主循环中调用确保所有系统看到的是同一时刻的按键状态防抖鲁棒性wasReleased()方法可精准捕获“松开”动作避免因事件延迟导致的误判性能友好lastFrameKeys是浅拷贝比深拷贝或 Map 结构快 3 倍以上。实测中玩家按住 W 键移动角色时速度波动控制在 ±0.8px/frame 以内远优于原生事件直接驱动的 ±3.2px/frame。3.3 物理系统子弹线段碰撞与材质响应《逃离鸭科夫》的子弹系统是性能与精度的平衡典范。我们不使用 AABB 碰撞太粗糙也不用 SAT 算法太重而是采用线段-多边形精确碰撞检测// 子弹实体定义 class Bullet { constructor(x, y, vx, vy, damage, material) { this.x x; this.y y; this.vx vx; this.vy vy; this.damage damage; this.material material; // wood, metal, glass this.life 1000; // 存活时间毫秒 this.trail []; // 尾迹点数组 } update(deltaTime) { // 更新位置带空气阻力 const drag 0.995; this.vx * drag; this.vy * drag; this.x this.vx * deltaTime; this.y this.vy * deltaTime; // 更新尾迹 this.trail.push({ x: this.x, y: this.y }); if (this.trail.length 8) this.trail.shift(); this.life - deltaTime; } // 检测与墙壁的碰撞墙壁由 Polygon 表示 checkWallCollision(wall) { // 将子弹轨迹视为线段[prevX, prevY] → [this.x, this.y] const prevX this.x - this.vx * deltaTime; const prevY this.y - this.vy * deltaTime; // 遍历墙壁所有边线段 for (let i 0; i wall.vertices.length; i) { const j (i 1) % wall.vertices.length; const v1 wall.vertices[i]; const v2 wall.vertices[j]; // 线段-线段相交检测标准算法 const denom (v1.x - v2.x) * (prevY - this.y) - (v1.y - v2.y) * (prevX - this.x); if (Math.abs(denom) 1e-6) continue; // 平行线 const t ((v1.x - prevX) * (prevY - this.y) - (v1.y - prevY) * (prevX - this.x)) / denom; const u ((v1.x - prevX) * (v1.y - prevY) - (v1.y - prevY) * (v1.x - prevX)) / denom; if (t 0 t 1 u 0 u 1) { // 计算碰撞点 const hitX prevX t * (this.x - prevX); const hitY prevY t * (this.y - prevY); // 根据材质计算反射与音效 this.handleMaterialImpact(hitX, hitY, wall.material); return { hitX, hitY, material: wall.material }; } } return null; } handleMaterialImpact(x, y, material) { // 不同材质的物理响应 switch (material) { case wood: this.vx * -0.6; // 60% 速度反弹 this.vy * -0.6; audioSystem.playWoodImpact(x, y); break; case metal: this.vx * -0.85; this.vy * -0.85; audioSystem.playMetalImpact(x, y); break; case glass: this.vx 0; this.vy 0; // 碎裂停止 audioSystem.playGlassShatter(x, y); break; } } }关键创新点轨迹回溯通过prevX/prevY计算上一帧位置将子弹运动建模为线段而非点避免高速子弹“穿墙”材质驱动响应碰撞后不仅改变速度还触发对应音效且音效参数如金属音的高频衰减率随材质动态调整无依赖设计checkWallCollision()方法只接收wall对象不依赖任何全局状态便于单元测试。实测表明该算法在 12 个敌人8 发子弹4 扇门的场景下单帧碰撞检测耗时稳定在 0.42ms远低于 2.1ms 预算。3.4 Web Audio 系统实时合成枪声与环境混响Web Audio API 的强大在于它能绕过文件加载直接在内存中生成波形。《逃离鸭科夫》的枪声系统包含三个核心组件振荡器网络为不同枪械生成基础波形包络控制器模拟真实枪声的 ADSRAttack-Decay-Sustain-Release特性空间化处理器根据玩家位置动态调整混响参数。class AudioSystem { constructor() { this.ctx new (window.AudioContext || window.webkitAudioContext)(); this.masterGain this.ctx.createGain(); this.masterGain.gain.value 0.7; this.masterGain.connect(this.ctx.destination); // 创建混响处理器走廊环境 this.convolver this.ctx.createConvolver(); this.convolver.buffer this.createCorridorImpulseResponse(); this.convolver.connect(this.masterGain); // 创建延迟线用于多普勒效应 this.delay this.ctx.createDelay(1.0); this.delay.delayTime.value 0.0; } // 生成走廊脉冲响应简化版 createCorridorImpulseResponse() { const length this.ctx.sampleRate * 0.3; // 300ms 混响 const buffer this.ctx.createBuffer(2, length, this.ctx.sampleRate); const channelData buffer.getChannelData(0); // 模拟早期反射前 50ms for (let i 0; i 0.05 * this.ctx.sampleRate; i) { channelData[i] Math.random() * 0.3; } // 模拟后期混响指数衰减 for (let i 0.05 * this.ctx.sampleRate; i length; i) { const decay Math.exp(-(i - 0.05 * this.ctx.sampleRate) / (0.1 * this.ctx.sampleRate)); channelData[i] (Math.sin(i * 0.01) * 0.1 Math.random() * 0.05) * decay; } return buffer; } // 播放手枪音效 playPistolShot(x, y) { const now this.ctx.currentTime; const oscillator this.ctx.createOscillator(); const gainNode this.ctx.createGain(); // 手枪波形短促的方波 高频噪声 oscillator.type square; oscillator.frequency.setValueAtTime(120, now); oscillator.frequency.exponentialRampToValueAtTime(800, now 0.02); // 包络极快攻击1ms快速衰减50ms gainNode.gain.setValueAtTime(0.0, now); gainNode.gain.linearRampToValueAtTime(0.8, now 0.001); gainNode.gain.linearRampToValueAtTime(0.0, now 0.05); // 空间化根据距离调整音量与混响 const distance Math.sqrt((x - player.x) ** 2 (y - player.y) ** 2); const volume Math.max(0.1, 1.0 - distance / 300); // 300px 内衰减 gainNode.gain.setValueAtTime(volume * 0.6, now); oscillator.connect(gainNode); gainNode.connect(this.convolver); oscillator.start(now); oscillator.stop(now 0.05); } } export const audioSystem new AudioSystem();此实现的关键价值零文件依赖所有音效均在运行时生成首屏加载无需等待音频资源参数化控制playPistolShot()的x,y参数不仅用于定位还参与音量衰减计算实现真实的“距离感”混响可编程createCorridorImpulseResponse()返回的AudioBuffer可随时替换为其他环境如空旷仓库、地下停车场只需修改函数内部逻辑。实测音频延迟从oscillator.start()到声音发出稳定在 12.3ms满足实时反馈需求。3.5 搜打撤状态机四状态流转与超时保护《逃离鸭科夫》的核心玩法循环由四个状态构成每个状态有明确进入/退出条件与超时保护状态触发条件退出条件超时保护UI 反馈Search游戏开始或成功撤退后发现敌人视野内检测30 秒无交互自动警告屏幕右下角倒计时AlertSearch 状态超时或触发警报敌人进入攻击范围5 秒内未开火则失败屏幕泛红 警报音Combat开火或敌人主动攻击所有敌人被消灭或玩家死亡无战斗持续至结束子弹计数器 生命值RetreatCombat 状态胜利后到达安全出口15 秒内未抵达则失败出口高亮 倒计时状态机实现代码class GameStateMachine { constructor() { this.state Search; this.stateTimer 0; this.stateStartTime performance.now(); } update(deltaTime) { this.stateTimer deltaTime; switch (this.state) { case Search: this.updateSearchState(deltaTime); break; case Alert: this.updateAlertState(deltaTime); break; case Combat: this.updateCombatState(deltaTime); break; case Retreat: this.updateRetreatState(deltaTime); break; } } updateSearchState(deltaTime) { // 检测视野内敌人简化版距离 200px const visibleEnemies enemies.filter(enemy distance(player.x, player.y, enemy.x, enemy.y) 200 ); if (visibleEnemies.length 0) { this.transitionTo(Alert); audioSystem.playAlertSiren(); return; } // 超时保护 if (this.stateTimer 30000) { this.transitionTo(Alert); audioSystem.playTimeoutWarning(); } } updateAlertState(deltaTime) { // 检测是否开火 if (input.isPressed(Space)) { this.transitionTo(Combat); return; } // 超时失败 if (this.stateTimer 5000) { this.transitionTo(GameOver); audioSystem.playFailureSound(); } } transitionTo(newState) { console.log(State transition: ${this.state} → ${newState}); this.state newState; this.stateTimer 0; this.stateStartTime performance.now(); // 状态专属初始化 switch (newState) { case Combat: player.ammo 12; // 重置弹药 break; case Retreat: audioSystem.playRetreatMusic(); break; } } } export const gameState new GameStateMachine();状态机设计要点时间驱动而非事件驱动所有状态流转基于stateTimer避免因事件丢失导致状态卡死显式状态日志console.log输出便于调试上线时可移除状态专属初始化transitionTo()中的switch确保每次进入新状态时重置相关变量。实操心得状态机必须配合 UI 反馈。我们在Search状态下每 3 秒在屏幕右下角显示一次倒计时数字30...27...24...字体颜色从绿色渐变为黄色再变红色。这种视觉暗示比单纯计时器更有效引导玩家行为。4. 实战问题排查从 Canvas 撕裂到音频卡顿的 7 个真实案例4.1 问题 1Canvas 渲染撕裂Tearing现象角色移动时出现水平方向的错位条纹尤其在快速转向时明显。排查过程首先确认是否启用requestAnimationFrame是排除定时器误差检查ctx.clearRect()是否在draw()开头调用是排除残留绘制使用 Chrome DevTools 的 Rendering 面板开启 “Paint flashing”发现每帧绘制区域不一致最终定位ctx.drawImage()绘制角色时sprite图像未对齐像素网格浏览器进行亚像素渲染导致反锯齿。解决方案// 绘制角色时强制像素对齐 const roundedX Math.round(player.x); const roundedY Math.round(player.y); ctx.drawImage( player.sprite, roundedX - player.width/2, roundedY - player.height/2, player.width, player.height );原理Canvas 2D 的drawImage()在源图像坐标非整数时会启用双线性插值。强制Math.round()使坐标为整数触发最近邻采样消除撕裂。4.2 问题 2Web Audio 延迟突增从 12ms 到 80ms现象连续快速射击时第 3~5 发子弹音效明显滞后。排查过程使用performance.now()记录oscillator.start()时间戳发现延迟发生在start()调用后检查AudioContext状态ctx.state为running排除自动暂停问题发现createOscillator()被频繁创建销毁每发子弹新建一个查阅 MDNOscillatorNode创建开销大且频繁 GC 会阻塞音频线程。解决方案// 预创建 8 个振荡器池 class OscillatorPool { constructor() { this.pool []; for (let i 0; i 8; i) { const osc audioSystem.ctx.createOscillator(); const gain audioSystem.ctx.createGain(); osc.connect(gain); gain.connect(audioSystem.convolver); this.pool.push({ osc, gain, used: false }); } } acquire() { for (const item of this.pool) { if (!item.used) { item.used true; return item; } } return null; // 池满降级处理 } release(item) { item.osc.stop(); item.used false; } }效果音频延迟稳定在 12~14ms池化后 CPU 占用下降 18%。4.3 问题 3键盘输入在 macOS Safari 中失效现象在 Safari 浏览器中WASD 移动完全无响应但空格键开火正常。排查过程检查event.codeSafari 中方向键返回ArrowUp而非KeyW但input.isPressed(KeyW)仍为true发现preventDefault()在 Safari 中对方向键无效导致页面滚动干扰进一步测试keydown事件监听器绑定在document而非windowSafari 的事件冒泡规则不同。解决方案// 统一绑定到 document并增强 preventDefault document.addEventListener(keydown, (e) { // 阻止所有可能影响游戏的默认行为 if ([ArrowUp, ArrowDown, ArrowLeft, ArrowRight, ].includes(e.code)) { e.preventDefault(); } // 其他键处理逻辑... });补充技巧在index.html中添加body style-webkit-user-select: none;防止 Safari 双击选中文本。4.4 问题 4Canvas 在高 DPR 设备上模糊现象iPhone 13 上游戏画面整体发虚像素边缘不锐利。排查过程检查window.devicePixelRatio返回 3但 Canvaswidth/height仍为 800×600发现canvas.style.width/style.height未适配 DPR导致浏览器拉伸渲染。解决方案function setupCanvas() { const dpr window.devicePixelRatio || 1; const rect canvas.getBoundingClientRect(); // 设置 Canvas 内部分辨率 canvas.width rect.width * dpr; canvas.height rect.height * dpr; // 设置 CSS 显示尺寸保持逻辑分辨率 canvas.style.width ${rect.width}px; canvas.style.height ${rect.height}px; // 缩放上下文以匹配 DPR ctx.scale(dpr, dpr); }注意ctx.scale(dpr, dpr)必须在所有绘制前调用否则坐标系错乱。4.5 问题 5敌人 AI 在低帧率下“瞬移”现象当 FPS 掉到 30 时敌人从 A 点直接跳到 B 点无视路径。排查过程检查enemy.update()使用deltaTime计算位移公式正确发现deltaTime在低帧率下波动剧烈如 16ms → 33ms导致单步位移过大敌人移动逻辑未做最大位移限制。解决方案// 在敌人移动逻辑中加入位移钳制 const maxStep 2.0; // 每帧最大移动像素 const stepX velocityX * deltaTime; const stepY velocityY * deltaTime; enemy.x Math.max(-maxStep, Math.min(maxStep, stepX)); enemy.y Math.max(-maxStep, Math.min(maxStep, stepY));原理物理引擎常用技巧避免数值积分误差累积。4.6 问题 6CSS UI 文字在 Canvas 上层被裁剪现象血条、弹药数等 UI 文字显示不全右侧被截断。排查过程检查z-indexCanvas 和 UI div 的z-index均设置但无效发现canvas使用position: absolute而 UI div 使用 position:
网站建设高端定制企业官网