
简介这份JavaScript小游戏代码汇总面向有一定HTML与JS基础、希望入门网页游戏开发的初学者与自学者通过一份可直接运行的完整示例帮助理解游戏循环、对象管理与事件交互等核心概念。压缩包内共1个doc文件约152KB以文档形式集中呈现源码与知识点说明便于边看边对照练习。内容涵盖HTML文档声明与根元素结构、formPosition、getMapObj、createYu、randomZL等函数的分工敌方对象数组与玩家对象的属性设计随机数生成、DOM动态创建与样式布局控制以及游戏状态维护和循环优化思路完整还原了一款躲避类小游戏的实现脉络。目前已有1892人学习下载适合作为课程设计、个人练手或教学演示的参考素材帮助读者快速掌握用原生JavaScript搭建小游戏的基本方法。1. 从一份 2010 年的 DOM 小游戏源码说起2010 年前后浏览器还没有 requestAnimationFrame 的普遍支持Canvas 在 IE 下基本不可用很多人做网页小游戏就是靠一堆绝对定位的div加setTimeout循环。这份「JavaScript 小游戏代码汇总」里的核心示例就是一个典型的 DOM 版「大鱼吃小鱼」鼠标控制蓝色方块地图上随机刷出不同大小、颜色的敌方方块吃掉比自己小或同级的方块积累生长值碰到更大的方块掉生命值生命值归零就 Game Over。它没有用任何框架全部逻辑压在一个script里靠createElement、appendChild、style.left/top和碰撞检测函数驱动。对今天的人来说它的价值不在于游戏本身而在于它把「游戏循环、对象池、碰撞判定、状态机」这些概念用最原始的方式摊开给你看。想理解现代引擎替你封装了什么从这份代码读起反而最快。2. DOM 游戏循环与对象建模createYu 与 moveYu 怎么配合2.1 为什么用 setTimeout 而不是 setInterval这份代码的驱动核心是moveYu()末尾的setTimeout(moveYu, 50)。它没有用setInterval这是一个有意的选择。setInterval会按固定间隔往任务队列里塞回调如果某一帧的计算比如遍历 20 个敌人做碰撞检测超过了 50ms回调就会堆积表现为画面突然「跳一下」。而setTimeout是在当前帧逻辑跑完之后才排下一次天然形成「跑完再等」的节奏不会堆积。// 每帧逻辑执行完毕后再排下一帧避免回调堆积 function moveYu() { for (var i 0; i arrXY.length; i) { // ... 移动与碰撞逻辑 } setTimeout(moveYu, 50); // 50ms 约等于 20 FPS }这里的50是帧间隔单位毫秒直接决定游戏速度。调小到16接近 60 FPS但 2010 年的机器上 20 个 div 每帧重排样式会明显卡顿调到100则移动像幻灯片。我一般会把这个值抽成常量FRAME_MS方便按设备调。要注意setTimeout的实际间隔永远大于等于设定值它只保证「不早于」不保证精确。2.2 敌人对象的属性设计createYu()里每个敌人是一个div但真正让它「活」起来的是挂在 DOM 对象上的自定义属性。这种做法今天看有点野但省去了维护独立数据数组的同步成本。属性含义取值来源tp敌人类型/等级randomZL()返回1~5sdx向左移动速度getRandom(10)1~9sdy逃跑时的纵向速度初始 0被吓到变 10qdy追击时的纵向偏移初始 0靠近时变 ±10style.left/top当前位置每帧计算function createYu() { for (var i 0; i xyNum; i) { var xyObj document.createElement(div); arrXY[i] xyObj; var sx randomZL().split(*); // 拆出 宽*高*颜色*类型 xyObj.qdy 0; // 追击偏移默认不追 xyObj.tp sx[3]; // 类型决定强弱 xyObj.sdx getRandom(10); // 横向速度 xyObj.sdy 0; // 纵向逃跑速度 xyObj.style.position absolute; xyObj.style.left getMapWidth() px; // 从右边界外进场 xyObj.style.top getRandom(getMapHeight()) px; xyObj.style.width sx[0]; xyObj.style.height sx[1]; xyObj.style.backgroundColor sx[2]; getMapObj().appendChild(xyObj); } createMyYu(); moveYu(); }randomZL()用getRandom(10)的结果做区间映射1~3 出青色小方块tp14~5 出黄色tp26~7 灰色tp38 黑色tp49~10 红色tp5补血道具。这个概率分布不是均匀的——小方块出现概率 30%补血方块 20%中间等级各 20%。想调难度改这里的区间边界就行比如把黑色大方块的概率从 10% 提到 20%游戏立刻变难。2.3 敌人移动与边界重置moveYu()里敌人的横向移动是单调递减的left left - sdx。纵向则有个「上下反弹」的近似处理——在地图上半部分向上飘下半部分向下飘靠getMapHeight()/2判断。当敌人跑出左边界或上下边界时resetMove()把它重新扔回右边界相当于一个手写的对象池复用。function resetMove(yuobj) { var sx randomZL().split(*); yuobj.tp sx[3]; yuobj.sdx getRandom(10); yuobj.sdy 0; yuobj.style.width sx[0]; yuobj.style.height sx[1]; yuobj.style.backgroundColor sx[2]; yuobj.style.left getMapWidth() px; // 回到右边界外 yuobj.style.top getRandom(getMapHeight()) px; }注意resetMove没有重新createElement而是复用已有的 div只改属性。这是 DOM 游戏里最朴素的性能优化创建和销毁节点会触发重排复用则只触发重绘。20 个敌人如果每帧都重建浏览器很快就扛不住。3. 碰撞检测与状态机isChongDie 的圆判定和 tp 等级比较3.1 圆心距离判定法isChongDie()用的是圆与圆的重叠判定而不是矩形 AABB。虽然方块是方的但作者用「圆心距 ≤ 两半径之和」来近似视觉上更宽容手感更顺。function isChongDie(obj1, obj2) { // 取各自中心点坐标 var obj1left getObjWaH(obj1, left) getObjWaH(obj1, width) / 2; var obj2left getObjWaH(obj2, left) getObjWaH(obj2, width) / 2; var obj1top getObjWaH(obj1, top) getObjWaH(obj1, width) / 2; var obj2top getObjWaH(obj2, top) getObjWaH(obj2, width) / 2; // 勾股定理算圆心距 var jl Math.sqrt( Math.abs(obj1left - obj2left) * Math.abs(obj1left - obj2left) Math.abs(obj1top - obj2top) * Math.abs(obj1top - obj2top) ); // 距离小于半径和即判定重叠 if (jl getObjWaH(obj1, width) / 2 getObjWaH(obj2, width) / 2) return true; else return false; }这里有个隐藏 bugobj1top用的是width而不是height来算中心。因为游戏里所有方块都是正方形宽高相等所以没暴露问题。如果哪天改成矩形纵向中心就错了。参数上getObjWaH(obj, left)是把style.left的px去掉转成数字这是全文反复出现的取值模式。3.2 用 tp 做等级状态机游戏的核心规则全压在tp这个数字上。myObjI.tp是玩家等级arrXY[i].tp是敌人等级两者相减决定三种结果吃、被吃、逃跑。if (isChongDie(myObjI, arrXY[i])) { if (myObjI.tp - 0 arrXY[i].tp - 0) { // 玩家等级 敌人吃掉按敌人类型加生长值 var _szz 0; if (arrXY[i].tp 1) _szz 10; else if (arrXY[i].tp 2) _szz 15; else if (arrXY[i].tp 3) _szz 20; else if (arrXY[i].tp 4) _szz 25; var nowszz getObjWaH(document.getElementById(szz), width) _szz; if (nowszz 500) { // 生长值满升级 if (myObjI.tp - 0 5) { myObjI.tp myObjI.tp - 0 1; myObjI.style.width getObjWaH(myObjI, width) 20 px; myObjI.style.height getObjWaH(myObjI, height) 20 px; document.getElementById(szz).style.width 10px; } else { alert(哈哈...顺我者昌逆我者亡); window.location.href window.location.href; } } else { document.getElementById(szz).style.width nowszz px; document.getElementById(szz).innerHTML nowszz; } resetMove(arrXY[i]); } else if (arrXY[i].tp - 0 5) { // 补血道具加 50 生命值 var nowsmz getObjWaH(document.getElementById(smz), width) 50; if (nowsmz 500) document.getElementById(smz).style.width 500px; else document.getElementById(smz).style.width nowsmz px; document.getElementById(smz).innerHTML document.getElementById(smz).style.width.replace(px, ); resetMove(arrXY[i]); } else { // 被更大的敌人吃掉扣血 var _smz 0; if (arrXY[i].tp 1) _smz 10; else if (arrXY[i].tp 2) _smz 20; else if (arrXY[i].tp 3) _smz 50; else if (arrXY[i].tp 4) _smz 100; var nowsmz getObjWaH(document.getElementById(smz), width) - _smz; if (nowsmz 0) { document.getElementById(smz).style.width 0px; document.getElementById(smz).innerHTML 0; alert(over); window.location.href window.location.href; } else { document.getElementById(smz).style.width nowsmz px; document.getElementById(smz).innerHTML nowsmz; } } }tp - 0这种写法是把字符串转数字的土办法等价于parseInt。因为randomZL()返回的tp是字符串直接比较会出问题。生长值满 500 升级玩家方块宽高各加 20px同时生长值条重置为 10px。这里有个细节升级后tp加 1但方块尺寸只加 20px而敌人 tp2 是 40px、tp3 是 60px尺寸和等级并不是严格对应所以视觉上会有「我明明变大了却还是打不过灰色方块」的错觉——因为等级才是判定依据尺寸只是表现。3.3 追击与逃跑的触发条件敌人不是无脑左移它有一个「感知范围」——myObjII这个 120×120 的隐形框。当敌人进入这个框就根据等级决定行为比玩家弱就逃sdy 10比玩家强就追qdy ±10。if (isChongDie(myObjII, arrXY[i])) { if (myObjI.tp - 0 arrXY[i].tp - 0) arrXY[i].sdy 10; // 敌人比我弱逃跑 else if (arrXY[i].tp - 0 ! 5) { // 补血道具不追人 if (getObjWaH(myObjI, top) getObjWaH(arrXY[i], top)) { if (arrXY[i].qdy 0) arrXY[i].qdy -10; // 向上靠近 } else { if (arrXY[i].qdy 0) arrXY[i].qdy 10; // 向下靠近 } } } else { arrXY[i].qdy 0; // 离开感知范围停止追击 }qdy 0这个判断是为了防止每帧都重置方向导致抖动。一旦设定了追击方向就保持不变直到离开感知范围才归零。这种「锁存」思路在游戏 AI 里很常见避免状态在边界反复横跳。4. 鼠标跟随与坐标换算mouseMove 里的边界钳制4.1 clientX 到地图坐标的转换玩家方块跟随鼠标但鼠标坐标是相对视口的clientX/clientY而方块定位是相对地图容器的。中间要减掉地图的偏移量。function mouseMove(e) { // 玩家方块中心对准鼠标所以要减去自身宽高的一半 var myObjIleft e.clientX - getMapLeft() - getObjWaH(myObjI, width) / 2; if (myObjIleft 0) myObjIleft 0; // 左边界钳制 if (myObjIleft getMapWidth() - getObjWaH(myObjI, width)) myObjIleft getMapWidth() - getObjWaH(myObjI, width); // 右边界钳制 myObjI.style.left myObjIleft px; // 感知框 myObjII 同样跟随但尺寸更大 var myObjIIleft e.clientX - getMapLeft() - getObjWaH(myObjII, width) / 2; if (myObjIIleft 0) myObjIIleft 0; if (myObjIIleft getMapWidth() - getObjWaH(myObjII, width)) myObjIIleft getMapWidth() - getObjWaH(myObjII, width); myObjII.style.left myObjIIleft px; // 纵向同理 var myObjItop e.clientY - getMapTop() - getObjWaH(myObjI, height) / 2; if (myObjItop 0) myObjItop 0; if (myObjItop getMapHeight() - getObjWaH(myObjI, height)) myObjItop getMapHeight() - getObjWaH(myObjI, height); myObjI.style.top myObjItop px; var myObjIItop e.clientY - getMapTop() - getObjWaH(myObjII, height) / 2; if (myObjIItop 0) myObjIItop 0; if (myObjIItop getMapHeight() - getObjWaH(myObjII, height)) myObjIItop getMapHeight() - getObjWaH(myObjII, height); myObjII.style.top myObjIItop px; }getMapLeft()和getMapTop()读的是地图容器的style.left/top这两个值由formPosition()在窗口大小变化时重算实现地图水平居中。formPosition()里(clientw - w) / 2就是经典的居中公式。4.2 为什么感知框不挂到 DOM 上createMyYu()里有一行被注释掉的//getMapObj().appendChild(myObjII);。感知框myObjII只用来做碰撞判断不需要显示所以不挂到 DOM 树里。但它的style.left/top仍然在mouseMove里被更新因为isChongDie读的就是这些样式值。这是一个「逻辑对象不渲染」的简化处理——省了一次 appendChild也避免了透明 div 参与重排。注意myObjII不挂 DOM 时getObjWaH(myObjII, width)读的是内联样式里设的120px能正常取到。但如果哪天改成从 CSS 类读宽度未挂载的节点offsetWidth会是 0判定就废了。4.3 事件绑定与窗口自适应页面用onload同时触发formPosition()和createYu()onresize只触发formPosition()。地图的onmousemove绑定mouseMove(event)。body onloadformPosition(),createYu() onresizeformPosition() div idmapid styleposition:absolute;width:800px;height:400px;overflow:hidden; onmousemovemouseMove(event)/div /bodyoverflow:hidden是关键敌人从右边界外进场时会被裁掉不会撑出滚动条。地图宽高写死在 800×400formPosition只负责水平居中不负责缩放。想改成响应式得把getMapWidth()改成读clientWidth同时把敌人的left按比例换算改动量不小。5. 把这份代码跑起来并做三个可验证的改造5.1 最小可运行文件与启动方式把源码拆成单个 HTML 文件即可运行不需要构建工具。结构是head里放scriptbody里放三个 div状态栏fbid、地图mapid、规则栏gzid。直接双击用浏览器打开就能玩。如果遇到方块不显示先查mapid的position是不是absolute再查createYu有没有在onload里被调用。# 本地起个静态服务避免某些浏览器对 file:// 的限制 python3 -m http.server 8080 # 然后访问 http://localhost:8080/game.html5.2 改造一把帧率从 20 提到 60把setTimeout(moveYu, 50)改成setTimeout(moveYu, 16)同时把敌人的sdx从getRandom(10)改成getRandom(4)。因为帧率提高后同样的速度值会让敌人移动快 3 倍必须同步降低单帧位移。// 帧间隔改为 16ms速度值相应缩小 xyObj.sdx getRandom(4); // 原来是 getRandom(10) // ... setTimeout(moveYu, 16);验证方法打开浏览器性能面板看每帧的 scripting 时间。如果超过 16ms说明 20 个敌人的碰撞检测O(n) 遍历成了瓶颈可以考虑把isChongDie的调用从每帧每敌人都做改成隔帧做。5.3 改造二用 requestAnimationFrame 替换 setTimeout现代浏览器下requestAnimationFrame会在页面不可见时自动暂停省电且不堆积。改法是把moveYu末尾的setTimeout换成requestAnimationFrame(moveYu)但要注意 rAF 的回调时间戳和固定步长的区别——原来 50ms 一帧的逻辑现在变成约 16ms 一帧所有速度值都要重新标定。function moveYu() { for (var i 0; i arrXY.length; i) { // ... 移动与碰撞逻辑保持不变 } requestAnimationFrame(moveYu); // 替代 setTimeout(moveYu, 50) }5.4 改造三给碰撞检测加空间剪枝20 个敌人时 O(n) 遍历无所谓但如果把xyNum提到 200每帧 200 次isChongDie就会卡。一个低成本优化是先按left排序只检测left在玩家附近 ±120px 范围内的敌人。// 只对横向距离在感知范围内的敌人做精确碰撞 for (var i 0; i arrXY.length; i) { var dx Math.abs(getObjWaH(arrXY[i], left) - getObjWaH(myObjI, left)); if (dx 120) continue; // 横向太远直接跳过 if (isChongDie(myObjI, arrXY[i])) { // ... 原有碰撞逻辑 } }这个120就是感知框的宽度和myObjII保持一致。跳过逻辑放在isChongDie之前省掉的是开方运算收益在敌人数量大时才明显。改完可以用console.time(frame)/console.timeEnd(frame)包住整个moveYu对比改造前后的单帧耗时。本文还有配套的精品资源点击获取