我大概半年前在某个不怎么更新的开源仓库里翻到一个叫Caveman的小游戏,点开之后直接被拉回了九十年代那种像素游戏的质感里。一个长着乱糟糟头发、披着兽皮的原始人,拎着棒子追着恐龙跑,场景里全是洞穴、骨头和篝火。这个项目最让我上瘾的不是它的画面,而是它的代码思路——整款游戏的核心机制拆开来看,就是状态机、矩形碰撞、简单的巡逻AI和一张由数字组成的关卡地图。四样东西拼在一起,居然就能产生一个能玩一下午的完整世界。这篇内容我就拿Caveman当起点,把它的核心玩法机制拆开聊透,再手把手带大家用纯JavaScript在网页上把这种“原始人挥舞棒子打恐龙”的手感做出来,最后分享几个我在复刻过程中踩过的坑和从中学到的极简开发经验。不管你是想做复古风格小游戏,还是想深入学习游戏循环、碰撞检测这类基础概念,这篇都值得往下看。
1. Caveman为什么值得拆:一个老游戏的可学习性远大于它的画面
很多人看到这种三十多年前的像素小游戏,第一反应是“这东西有什么好研究的”。但如果真的把代码铺开看,你会发现这一类游戏的架构是游戏开发里最典型的“麻雀虽小五脏俱全”。Caveman表面上是动作冒险小游戏,实际里面包含了角色状态管理、物理模拟、碰撞检测、敌人AI、关卡数据解析、资源加载、动画切换这一整套流程。在没有任何现代游戏引擎的年代,这些逻辑全部靠开发者一行行手写,数据结构简单到几乎是透明的,正好适合用来做学习材料。
1.1 老游戏的核心代码反而更容易读懂
我见过不少新手拿着Unity或Godot这类引擎做游戏,第一件事是拖一个Character Controller组件,再挂一个碰撞体,然后就开始“游戏跑起来了”。这当然没问题,但问题是,如果哪一步出了问题,很多人根本不知道去哪里查,因为你以为的“某些魔法”其实都是引擎里封装好的系统在替你干活。Caveman这类老游戏就完全没有这个问题,引擎就是一块画布和一段主循环,角色的移动、跳跃、攻击全部是明确写在代码里的。你看到的每一帧发生了什么,都可以直接对应到代码的某一行。这种透明性是现代引擎给不了的。
换句话说,看Caveman的代码就像拆解一个手动机械表,所有齿轮都露在外面,转起来一目了然。而用大型引擎做游戏,更像坐进了一辆自动驾驶汽车,车是能走,但你不知道底下是怎么换挡的。对于想真正入门游戏开发的人来说,先拆一个老游戏,比先学一整套引擎要扎实得多。
1.2 这类游戏可以拆出四个标准零件
我拆过的复古小游戏不算少,最后发现它们几乎都共享同一套零件清单。Caveman也不例外,它至少可以拆成下面四块:
- 主循环和渲染:每隔固定时间刷新画面,处理输入、更新逻辑、绘制场景。
- 角色与物体的状态管理:用一组状态值描述角色当前在做什么,比如待机、走路、跳跃、挥棒。
- 物理和碰撞:处理重力、速度、位移,以及角色和平台之间的碰触关系。
- 敌人与关卡的简单逻辑:敌人按照预设路线巡逻,玩家进入范围后追击,关卡本体由数字矩阵拼出来。
把这四块搞清楚,一个“能玩”的Caveman就立在眼前了。至于美术、音效、UI这些都是锦上添花的东西,核心玩法手感和这一套逻辑强相关。所以下面我选择用纯JavaScript和Canvas来一步步还原这些零件——不是因为我不能直接用引擎,而是因为用最原始的方式写一遍,才能真正理解那些引擎替你做了什么事。
2. 拆开Caveman:四个零件组成一个完整的原始人世界
想完全弄懂老游戏,第一件事就是把它的代码“格式化”之后像读文章一样读一遍。下面我会用符号化的代码逻辑来讲Caveman这一类游戏的工作方式,但不会一开始就给完整代码,而是先把关键机制讲透。
2.1 角色状态机:用一段switch管住整个原始人
几乎所有动作游戏的主角,本质上都是一台状态机。所谓状态机,说白了就是:玩家当前只能处于一张允许切换的状态表中的某一种状态,每种状态下能做什么、不能做什么,都是提前写死的。
Caveman里的原始人大概有这么几种状态:待机(idle)、走路(walk)、跳跃(jump)、攻击(attack)、受伤(hurt)。每帧检查状态并执行对应逻辑,经典写法长这样:
switch (player.state) { case 'idle': // 不做移动,原地播放待机动画 break; case 'walk': // 根据方向移动,播放走路动画 break; case 'jump': // 应用重力,更新垂直位置 break; case 'attack': // 播放挥棒动画,在特定帧生成攻击判定框 break; case 'hurt': // 进入短暂无敌帧,被击退 break; }为什么状态机这么重要?因为游戏里的所有逻辑都是基于“此刻玩家正在做什么”来判定的。如果不用状态机管理,你很容易在跳跃的时候还能挥棒,在挥棒途中还能转身,在受伤的时候还能继续走路,一堆bug冒出来还找不到原因。Caveman这类游戏之所以逻辑清晰,就是因为它在很早期就用这种直白的switch把所有人的行为管得服服帖帖。
2.2 矩形碰撞检测:上世纪就定下来的“精确物理”
Caveman里的碰撞检测其实就是矩形与矩形之间是否重叠,用一句话就能描述:两个矩形只要在x轴和y轴方向上的区间都有重叠,就说明它们撞在一起了。算法逻辑写成代码也就几行:
function rectsOverlap(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; }就这么简单,但那个年代的游戏都是靠它跑起来的。不过实战里有个关键点:矩形碰撞检测应该“分轴处理”。也就是先更新x轴位置,检测x轴碰撞并处理;再更新y轴位置,检测y轴碰撞并处理。如果一次性把x和y都加进位置里再检测,就会出现角色斜着插入墙壁,然后被卡住或者弹飞的现象。这是一个非常经典的老游戏代码优化,也是我后来做复刻时最容易踩的坑之一。
2.3 敌人AI:从巡逻到见人就追
Caveman里的敌人恐龙看起来挺猛,其实AI逻辑简单到只有三个状态:巡逻、追击、攻击。巡逻状态下,恐龙在设定的两个点之间来回走;一旦发现玩家的x坐标与自己足够近(通常就是一定水平距离内),立刻切换成追击状态;追到身边就攻击。而玩家此时只需要挥棒子打它,恐龙被打几下之后掉血、死亡、消失。
写成逻辑大概这样:
if (dino.state === 'patrol') { dino.x += dino.dir * dino.speed * dt; if (dino.x < patrolMin || dino.x > patrolMax) { dino.dir *= -1; } if (Math.abs(player.x - dino.x) < activeRange) { dino.state = 'chase'; } }这种极简AI在手机上用触屏跑起来,手感依然在线。由此也能看出游戏设计的一个道理:敌人不需要真的有多聪明,但行动模式必须让玩家能“读出来”——能预测,就能应对,能应对,就有乐趣。
2.4 关卡地图:一维数组里的整个世界
Caveman的关卡地图不是图片,而是一堆数字。比如数字0代表空地,1代表土地,2代表石头,3代表骨头,4代表敌人出生点。地图文件本身就是一个很长的数组,程序按行列去读,遇到数字1就把对应的平台方块绘制到屏幕相应位置。这种设计的好处是多到数不清:关卡数据体积小、好编辑、加载快,而且想改地形只需要改数字,不用动任何绘制代码。
const levelData = [ 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, // ... ]; const TILE_SIZE = 32; function getTile(col, row) { return levelData[row * levelWidth + col]; }这个小节我特别想强调:关卡数据用数组表达的概念,放到今天依然成立。无论是Tilemap编辑器导出的JSON,还是Unity里的2D Tilemap,本质都还是“给每个格子一个ID”。理解了Caveman的地图数组,你就理解了游戏关卡数据的底层范式。以后去用任何引擎的Tilemap,心里都会很踏实:这不就还是那张数字表格么。
3. 用纯JS把Caveman的核心手感捞出来:一个最小可玩原型
前面把机制拆明白了,下面进入动手阶段。我从零写了一个最小化可玩的Caveman风格原型,技术栈没有任何依赖,就一个HTML文件加原生JavaScript和Canvas。跑通核心玩法所需的全部代码,加起来不超过三百行。
3.1 最小骨架:canvas和游戏主循环
一切游戏的开场仪式都一样:拿到canvas上下文,启动requestAnimationFrame循环,每帧把画面清空然后重新绘制。关键是要用时间增量dt来计算位移,而不是直接用帧数,否则在不同刷新率的显示器上游戏速度会完全不同。
const canvas = document.getElementById('game'); const ctx = canvas.getContext('2d'); let lastTime = 0; function gameLoop(timestamp) { const dt = Math.min((timestamp - lastTime) / 1000, 0.05); lastTime = timestamp; update(dt); render(); requestAnimationFrame(gameLoop); } requestAnimationFrame(gameLoop);那句Math.min(..., 0.05)是经验之谈,防止玩家切换窗口或者卡顿一下再回来时,dt过大致使角色直接撞穿地图。我用它处理过好多次“顿一下人就飞天”的诡异现象。
3.2 用“固定半高矩形”描述角色:避坑从一开头开始
画原始人的像素造型其实不需要真实美术资源。为了在代码里先捋清逻辑,角色先用一个矩形表示就够了。不过实践下来我强烈建议:用比视觉矩形稍微小一点的逻辑矩形来做碰撞。什么意思呢?假如视觉上角色占了32x48像素,那碰撞用的矩形就设为24x40或28x44,顶部留一点空。
const player = { x: 64, y: 300, vx: 0, vy: 0, width: 24, height: 40, groundY: 380, // 地面高度 grounded: false, state: 'idle', facing: 1, attackTimer: 0, };这样做的原因特别实在:像素画的角色会有一些多余的装饰性像素,如果碰撞体和视觉完全重合,玩家会觉得明明没碰到障碍物却被卡住了——触觉上像是撞上了空气。而稍微缩一点,手感会宽容很多。这是像素游戏里一个经典得不能再经典的调参秘诀。
3.3 移动、重力、跳跃:三行代码把原始人的腿做出来
有了角色数据,移动逻辑就特别直白了。左右键改vx,重力每帧加到vy上,然后根据当前状态决定是否允许跳跃,最后分轴更新位置:
function updatePlayer(dt) { // 水平移动 let input = 0; if (keys.left) input -= 1; if (keys.right) input += 1; player.vx = input * 180; // 重力 player.vy += 900 * dt; player.y += player.vy * dt; // 平台碰撞 if (player.y > player.groundY - player.height) { player.y = player.groundY - player.height; player.vy = 0; player.grounded = true; } else { player.grounded = false; } // 跳跃 if (keys.up && player.grounded) { player.vy = -420; player.grounded = false; } player.x += player.vx * dt; }注意一个细节:我先更新y轴做落地处理,再更新x轴,这对应前面提到的“分轴碰撞”思想。这里的地面只有一个水平线,所以先y后x问题不大。等地图换成数组格子的tile碰撞时,这个顺序就变得极为关键,否则会时不时地卡墙。
3.4 挥棒攻击:在正确的帧打出正确的攻击判定框
挥棒是Caveman里的核心操作,实现起来有几个讲究。第一,攻击有持续时间,这个状态不能瞬间结束,要让玩家“看见”角色在挥棒;第二,攻击判定框不是全程都存在,而是在动画中间某几帧才激活,否则判定过于宽松,游戏会变得没有节奏感。
function startAttack() { if (player.state === 'attack') return; player.state = 'attack'; player.attackTimer = 0.25; // 攻击持续0.25秒 } function updateAttack(dt) { if (player.state !== 'attack') return; player.attackTimer -= dt; const activeDuration = 0.3 - player.attackTimer; // 攻击判定只在动画中间的0.08s~0.16s内有效 const isActive = activeDuration >= 0.08 && activeDuration <= 0.16; if (isActive) { const attackBox = { x: player.x + (player.facing > 0 ? player.width : -40), y: player.y, width: 40, height: 30, }; if (rectsOverlap(attackBox, dinoBox)) { dino.hp -= 1; } } if (player.attackTimer <= 0) { player.state = 'idle'; } }这个“攻击判定只在动画中间若干帧激活”的设计,几乎能直接迁移到横版格斗游戏的连招系统里。如果你做动作游戏感觉手感泛泛的,可以先检查一下自己的攻击判定是不是全程生效或者完全不生效。一个合适的“判定窗口”是手感的关键。
3.5 让原始人不再孤单:一只会巡逻的恐龙
敌人的逻辑前面已经给了伪代码,这里直接补个完整爆炸版:
const dino = { x: 480, y: 364, dir: -1, speed: 50, state: 'patrol', hp: 3, width: 40, height: 36, baseY: 364, }; function updateDino(dt) { if (dino.hp <= 0) return; // 已经死亡,暂时不做移除动画 if (dino.state === 'patrol') { dino.x += dino.dir * dino.speed * dt; if (dino.x < 432 || dino.x > 560) dino.dir *= -1; if (Math.abs(player.x - dino.x) < 160) dino.state = 'chase'; } else if (dino.state === 'chase') { dino.dir = player.x > dino.x ? 1 : -1; dino.x += dino.dir * 110 * dt; if (Math.abs(player.x - dino.x) > 240) dino.state = 'patrol'; } }追到身边之后恐龙干嘛?最省事的是和玩家互相掉血,不过为了练手也可以给它加一个“攻击前摇”状态。别小看这只简单恐龙,它已经是完整的小型游戏AI了,从巡逻到追击再到放弃目标,三个阶段切换,逻辑闭环。
3.6 把画面画出来:一个带洞穴氛围的极简渲染
渲染部分不用很多艺术细胞,关键是让画面的信息清晰。找一个深褐色做背景模拟洞穴,地面用一排石头色方块,玩家用一个棕色矩形加一个圆脑袋表示,恐龙用绿色矩形加尾巴三角就够。我觉得在这个阶段,画面糙一点反而是优点,因为你能更容易地把注意力放在“手感”上。等手感对了,再往上面套美术资源会顺利得多。
function render() { ctx.fillStyle = '#2a1f1b'; ctx.fillRect(0, 0, canvas.width, canvas.height); // 地面 ctx.fillStyle = '#5c4033'; ctx.fillRect(0, player.groundY, canvas.width, canvas.height - player.groundY); // 玩家 ctx.fillStyle = '#8b5e3c'; ctx.fillRect(player.x, player.y, player.width, player.height); ctx.fillStyle = '#e6b422'; ctx.beginPath(); ctx.arc(player.x + player.width / 2, player.y - 6, 10, 0, Math.PI * 2); ctx.fill(); // 恐龙 if (dino.hp > 0) { ctx.fillStyle = '#4c7a34'; ctx.fillRect(dino.x, dino.y, dino.width, dino.height); } }到这里,一个能跑、能跳、能挥棒打恐龙的极简Caveman原型已经完整成型了。整个工程就一个HTML文件,双击就能打开玩。我先跑起来之后,最大的感受是:无论引擎多复杂,游戏开发的核心骨架就是这么几件事,早点亲手写一遍,后面碰任何工具都会游刃有余。
4. 从“原始人代码”里偷师:极简开发三原则
代码敲完,我开始反思一个问题:为什么Caveman这类老游戏的代码结构普遍清晰好读,而我们用现代框架写游戏时反而常被抽象层绕晕?答案不是“老开发者更厉害”,而是他们被迫只能在极有限的资源下解决问题,自然长出了一套极简开发的原则。这些原则到今天依然好使,我总结成了三条。
4.1 原则一:第一版只追求“能跑”,先啃下来再说
很多人学新东西有“完美主义综合征”,画个角色要画全套像素帧,写个物理系统要研究刚体约束,折腾半天游戏还没成形的影子。Caveman的原始人走起路来一颠一颠,跳跃物理也和“真实”完全不沾边,但它能玩、有乐趣——这比什么都重要。
我建议第一版把视觉全部用纯色矩形代替,角色就是一个方块加一个头,敌人就是一个绿方块,全部特效忽略。只要核心循环“移动-跳跃-打怪-死亡”跑得通,你就拥有了一个可以继续往里面添东西的地基。完成比完美重要,这句口号在游戏开发展里喊了二十年,真执行的人是少数。
4.2 原则二:状态数据全部摆在明处,别绕弯子
看Caveman代码的时候,你永远知道角色现在在哪、在干什么、速度是多少。因为所有变量都是明文写在顶部的普通对象。这种“全局变量”在工程规范里通常被骂得体无完肤,但小游戏复刻项目里它其实很合适。尤其是我们做学习型小游戏,数据一目了然比封装优雅重要得多。
我见过太多同学为了搞对象导向,把Player写成一个class,一个角色拆出五个文件,setter/getter满天飞。结果改了第几帧的攻击判定,要跨三个文件去找攻击逻辑在哪。不是说不应该用OOP,而是说当代码量才三百行时,过度设计带来的心智负担远大于收益。所以我的建议是:第一版老老实实用一个对象存所有角色状态,等逻辑复杂到确实需要拆分时,再动手重构都来得及。
4.3 原则三:一次只加一个特性,每改一点就验证一次
这是我能给所有游戏开发初学者最重要的一条建议:永远不要一次性加三个新功能。Caveman的开发者也不傻,他们绝对是一个状态一个状态地加、一个敌人一个敌人地调的。如果我在原型里同时加了挥棒攻击、恐龙追逐、掉落伤害三个功能,一旦出bug,我根本不知道是谁导致的。一次加一个,每次加完都跑一遍,把“新功能是否破坏旧功能”这个问题挡在第一时间。
5. 复刻Caveman踩过的坑:像素游戏没有想象中简单
最后这部分我提几个真实的坑,都是我亲手踩过又解决的。很多问题看起来微不足道,但如果你一个人闷头写,可能卡一整晚。
5.1 按键延迟的真相:没有做“跳跃缓冲”
先说一个手感差异巨大的细节:玩家在空中按下跳跃键,落地的瞬间才算生效,但那时可能已经过了100毫秒,玩家会觉得有延迟、不跟手。解决办法是做个“跳跃缓冲”——玩家在空中的一小段时间(比如100~150毫秒)内按过跳跃键就记一笔账,等落到地面那一帧立刻弹跳起来。Caveman这种老游戏有没有做这个细节我不知道,但现代动作游戏手感好几乎全靠这种微小的输入预判。实测加上缓冲之后,跳崖、连跳的手感立刻变得丝滑。
let jumpBufferTimer = 0; if (keys.up) jumpBufferTimer = 0.12; jumpBufferTimer -= dt; if (player.grounded && jumpBufferTimer > 0) { player.vy = -420; jumpBufferTimer = 0; }5.2 碰撞体大于视觉体:玩家总觉得被无形的手推开
这个我在前面提到了一句,这里展开讲。矩形碰撞检测如果直接用角色精灵的完整尺寸,那些披着兽皮、拖着尾巴的像素边缘会让人产生大量不必要的擦碰。解决办法很简单:碰撞矩形始终比视觉矩形缩一圈。玩家不会看到你代码里的碰撞框,但他能真切感受到“怎么这里过不去”。缩了之后,手感立刻从“卡”变“顺”。Caveman那种粗犷像素风里的恐龙,个头不小,但实际碰撞区域肯定比它看起来更小,这几乎是老游戏手感通感的标准配置。
5.3 攻击判定与动画错帧:挥出去的棒子“空挥”
攻击判定窗口放错位置,会出现很离谱的现象——画面上棒子明明已经抡过去挥中恐龙了,恐龙却一点事没有;或者棒子还没挥出去,恐龙已经掉血了。这里的关键就是“判定帧”的概念。我建议调试时配合一个调试开关,把攻击判定的矩形画到屏幕上,比如用半透明的黄色覆盖,这样你就能肉眼看到判定框在哪几帧出现、在哪几帧消失。这个方法我强烈推荐给所有做动作游戏的人,包括用Unity和Godot的,无论引擎帮你做了多少事,攻击判定的帧对齐永远是手调的活。
5.4 极简代码的边界:识别“够用”和“将该重构”
虽然我一直推崇极简开发,但任何事都有边界。当我发现一个文件里的代码超过600行,或者一个角色的状态机扩展出七八个状态时,就该考虑适当拆分和引入工具了——把地图编辑器做成JSON格式、把渲染拆成单独模块、把碰撞检测提取成通用函数。Caveman那种古老代码里其实也有这种“自然的进化过程”。这也提醒我们:极简原则所追求的是“不复用则不抽象”,等确实有复用和扩展需求时,重构是健康且必要的。千万别因为追求极简而把代码硬憋成一坨。
最后分享一件小事
写这个Caveman复刻原型的过程中,最有满足感的时刻其实不是代码跑通,而是我关掉调试工具、回忆起小时候玩那种像素游戏时那股“明明画得很糙却特别好玩”的劲头。现在我更能理解开发者的苦心了:手感、节奏、规则和一点点免费娱乐的诚意,比什么都重要。如果你也想通过做小游戏来练手,我真心建议从拆一款老游戏开始,照着它的玩法逻辑用最原始的方式写一遍,你会发现很多关于游戏设计、代码结构和玩家体验的细节,全都会在动手时自动浮现出来。这篇文章就是这条路的起点,下一步就看你打第一棒了。