某天我发现 Chrome 断网后那只像素小恐龙又出现在屏幕上,跑着跑着就撞上了仙人掌。旁边同事随口一句“这玩意儿要是能自动跑就好了”,我倒真的动了心思。研究了两天之后,我用脚本让小恐龙自己跳过了所有障碍物,速度拉满也能跑十几万分不翻车。整个过程没碰任何逆向破解,纯粹是借助浏览器自带的调试通道,读取游戏内部状态、调用游戏自己的接口来操作角色。今天就把这套思路、代码和调参经验完整写出来,关注 Chrome、小恐龙游戏这类自动化玩法的朋友可以直接照着抄。
先说清楚这个项目到底在做什么。所谓“自动玩小恐龙游戏”,本质上就是把人类靠眼睛判断、靠手指按键的过程,换成程序去判断、去触发。它能解决的真实需求有两个:一是纯粹想娱乐一下,看看最高能跑多少分;二是练习浏览器自动化技术,用最轻量的方式把“CDP 控制 Chrome”这条链路跑通。不管你是编程新手还是老手,这套方案的核心都值得学,因为从里面可以延伸出页面自动操作、数据采集、UI 自动化等一系列玩法。
1. 内容整体设计与思路拆解
1.1 自动玩小恐龙的四条技术路线
先说说整体设计。自动玩小恐龙,本质就一件事:不断判断前方障碍物离恐龙多远、长什么样,然后决定起跳、下蹲还是原地等。但实现这件事,有四种常见路线,我一开始全都试过,各有取舍。
第一种是纯图像识别加模拟按键。用 OpenCV 截取游戏画面,通过模板匹配找到恐龙和障碍物的位置,距离够近就调用pyautogui发一个空格键。优点是不碰游戏内部逻辑,原理最简单,甚至不用理解代码。缺点也明显:截图和识别有延迟,游戏速度一快就反应不过来;而且窗口焦点一旦被抢走,模拟按键直接失效。我实测在速度达到中等水平以后,这种方案失误率非常高,只适合做演示玩具。
第二种是浏览器扩展注入脚本。写一个 Chrome 扩展,在 content script 里往页面上下文塞 JS,直接读取游戏内部变量。听起来很优雅,但实际很折腾:要打开chrome://extensions/开启开发者模式,要处理 manifest 版本兼容,还会遇到“无法安装扩展程序”这类权限问题;更麻烦的是chrome://dino这种内置页面很多扩展 API 不生效,需要额外绕路。如果你遇到的是旧版 Chrome 或者受限环境,这条路很容易卡死。
第三种是 Chrome DevTools Protocol(CDP)加 JavaScript 注入,也是我最终采用的方案。CDP 是 Chrome 原生提供的远程调试协议,通过 WebSocket 连接调试端口,就能远程执行脚本、模拟输入、读取页面状态。它不需要安装扩展,不依赖窗口焦点,而且从老版本 Chrome 开始就一直存在,兼容性非常好。缺点是理解门槛稍高,你要搞清楚协议里几个关键方法,但学一次能用在无数自动化场景里。
第四种是用 Puppeteer 这类封装库。Puppeteer 底层就是 CDP,只是把连接、命令、事件都封装成了好用的 API。用起来最省事,但如果你想把自动玩做成长驻脚本,或者想用别的语言(比如 vba、Python)远程控制一个已经打开的 Chrome,还是得回到 CDP 本身。
这四种路线我做了个对比,方便你按需要选:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 图像识别 + 模拟按键 | OpenCV 找障碍物,pyautogui 按空格 | 不碰游戏内部逻辑,思路直观 | 反应慢、受窗口焦点影响,高速段必翻车 |
| 浏览器扩展注入 | content script 注入 JS 操作游戏对象 | 不依赖调试端口,门槛中 | 扩展加载麻烦,chrome:// 内置页限制多 |
| CDP + JS 注入 | 调试协议远程执行脚本 | 快、稳、可控,适合长期跑 | 需要理解 WebSocket 和协议调用 |
| Puppeteer 封装 | 同一套 CDP,API 更友好 | 开发效率高,代码直观 | 依赖 Node 库,排查问题时多一层封装 |
1.2 为什么最终选用 CDP + 页面内自循环
选 CDP 方案,最关键的原因是响应速度。小恐龙游戏的速度会随着分数不断上涨,最高速的时候,恐龙左右两侧的可用反应距离从最初的几十个像素压缩到十几个像素。如果每一步判断都要“页面执行脚本—把结果传回 Node—Node 再发下一个命令”,来回的 WebSocket 延迟足够让恐龙撞上一百次。
所以我的设计是:通过 CDP 只做一次“启动注入”,把完整的自动玩家脚本塞进页面上下文里,让它在页面内部用requestAnimationFrame自循环。这样每一帧游戏状态的读取、判断、动作触发,全部在浏览器进程内部完成,几乎没有额外延迟。CDP 只在两件事上发挥作用:注入前定位页面,以及游戏撞车后重新拉起脚本。
这种做法还有一个好处,就是可以任意扩展逻辑。因为脚本跑在页面上下文里,可以直接调用游戏源码里所有公开的方法和属性,比如读取当前速度、判断恐龙是否在跳跃、拿到障碍物的精确坐标,甚至可以修改游戏加速度来制造训练场景。这些能力用图像识别方案是完全做不到的。
1.3 先拆开游戏看看:Runner 对象里藏着什么
小恐龙游戏本质上是一个 Canvas 跑酷游戏,逻辑都在trex-runner这套脚本里。页面加载后,游戏会创建一个Runner实例,并保存在全局变量Runner.instance_里。这个实例是整套实现的核心入口,上面挂了几个关键属性。
tRex是恐龙对象,控制着角色的一切;horizon是障碍物管理器,所有仙人掌和翼龙都挂在它下面的obstacles数组里;currentSpeed是当前游戏速度,速度越大障碍物移动越快,跳跃的提前量也要越大;playing和crashed是运行状态标志;distanceRan是已经跑过的距离,可以用来做进阶的统计。
恐龙对象tRex提供了两个核心动作方法:startJump(speed)让恐龙起跳,setDuck(true)让恐龙下蹲。这两个方法我在后面会重点讲。障碍物对象则暴露了xPos、yPos、width、typeConfig等字段,其中xPos代表障碍物在画布上的水平坐标,值越小说明离恐龙越近;typeConfig里存着障碍物类型,判断是不是翼龙就靠它。
知道了这些结构,自动玩家的核心逻辑就非常简单了:每帧找到离恐龙最近的、还没越过去的障碍物,算出距离,决定是跳还是蹲,然后调用对应方法。
2. 核心逻辑解析与动作决策要点
2.1 游戏状态判断:开始、运行中、撞车
写自动玩家之前,先把游戏状态机理清楚。小恐龙游戏打开后并不会自动开跑,它会显示一个“Press Space to play”的等待画面。页面里监听的是键盘按键,收到空格才会进入运行状态。我在注入脚本里直接用 JavaScript 模拟空格键,代码是这样的:
document.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 32, code: 'Space', key: ' ', which: 32 })); setTimeout(() => { document.dispatchEvent(new KeyboardEvent('keyup', { keyCode: 32, code: 'Space', key: ' ', which: 32 })); }, 50);这里有两个坑。第一个是KeyboardEvent的keydown和keyup都得触发,否则游戏可能认为按键没松开;第二个是构建事件对象时,最好把keyCode、code、key、which都带上,因为不同版本 Chrome 对事件字段的依赖有细微差别。
游戏运行过程中,Runner.instance_.playing为true;撞上障碍物后,crashed会变成true,同时playing变成false。自动玩家需要在这两种状态间切换:撞车后等一小段时间,调用Runner.instance_.restart()重新开始。这样脚本就能从游戏开始一直跑下去,不用人工干预。
2.2 障碍物检测:别被“看不见”的障碍物骗了
障碍物检测是自动玩的核心,也是最容易踩坑的地方。一开始我写的是“遍历所有障碍物,找 xPos 最小的那个”,结果恐龙经常在已经越过一个障碍物后,又回头去跳后面的旧障碍物,表现就是莫名其妙地原地乱跳。原因很简单:障碍物的 xPos 在游戏中是逐渐减小的,已经跑到恐龙左边的障碍物,xPos 依然存在于数组里,但它已经没有任何威胁了。
正确的过滤条件是:只保留那些obs.xPos + obs.width > tRex.xPos的障碍物,也就是障碍物的右边缘还没有越过恐龙左边缘,然后再从中找 xPos 最小的一个作为目标。这样每帧都能锁定“下一个真正会撞到我的障碍物”,判断起来非常干净。
得到目标后,计算距离dist = target.xPos - tRex.xPos。这个距离就是决策的依据。还有一个细节:小恐龙的 xPos 实际不是 0,头部还占着一定宽度,所以判断时机时把恐龙自身宽度考虑进去会更准。我的做法是把过滤阈值放宽 10 个像素,给判断留一点余量,实测下来失误率明显下降。
2.3 跳跃与下蹲的阈值到底怎么定
决策逻辑是整段脚本的“大脑”。对普通仙人掌和地面障碍物,策略很简单:距离小于阈值就起跳。但这个阈值不能写死,因为游戏速度会逐渐加快,同样的像素距离,在低速时很安全,在高速时就是“看到再按已经晚了”。
我最初的实现是threshold = speed * 12,然后用Math.min(220, Math.max(55, threshold))限制范围。意思是:最慢的时候大约 55 像素开外就跳,中速在大约 120 像素起跳,极速则提前到 200 像素以上。具体数值我整理成了一张参考表:
| 当前速度 currentSpeed | 跳跃触发距离(像素) | 我的说明 |
|---|---|---|
| 6 ~ 7(开局低速) | 60 ~ 80 | 速度慢,跳跃滞空时间长,离近一点跳也能过 |
| 10(中速) | 120 左右 | 大约在恐龙前方一个身位时起跳 |
| 13(较快) | 150 ~ 160 | 预留反应时间,否则起跳瞬间已到障碍物头顶 |
| 16 以上(极速) | 200 以上 | 宁可跳早了落地再跳,也不要晚跳 |
翼龙的处理要单独说。翼龙有高飞和低飞两种形态,判断依据是target.yPos。在游戏坐标系里,障碍物越靠上,yPos的负值越大;低飞翼龙和地面距离比较近,yPos通常靠近 0。我的策略是:低飞翼龙到了阈值距离就下蹲,高飞翼龙不用管;如果是普通地面障碍物,一律起跳。
3. 完整实操:从启动 Chrome 到注入自动玩家
3.1 环境准备:一条命令启动带调试端口的 Chrome
先准备环境。理论上任何带有 DevTools 的 Chrome 版本都能跑,我这边用的 Chrome 109 测试稳定,老版本和离线安装包也能工作。需要提前装好 Node.js,随便一个较新版本都行,Windows 和 macOS 操作方式一样。
第一步是关闭正在运行的 Chrome,然后打开命令行,执行这条命令启动带调试端口的 Chrome:
chrome.exe --remote-debugging-port=9222 --user-data-dir=D:\chrome-dino-profilemacOS 的命令稍有不同,执行的是 Chrome 应用目录里的二进制:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-dino-profile这里必须强调:--user-data-dir参数不能省。如果不指定独立目录,Chrome 会直接复用你现有的默认用户目录,然后命令行参数里的调试端口会失效,后面连不上 9222 端口,白折腾半天。指定一个独立的临时目录,相当于开了一个“干净的新浏览器”,和日常使用的 Chrome 互不干扰。
启动后访问chrome://dino,确认能看到小恐龙游戏页面。这时候你已经可以在浏览器里手动玩一把,确认页面无误。注意:如果地址栏访问chrome://dino时提示无法访问,多半是当前 Chrome 版本不支持直接访问彩蛋页,可以换成访问chrome://network-error/-106,或者打开一个任意网页然后断网,效果相同。
3.2 用 Node.js 连接 CDP 并定位游戏页
Chrome 开启远程调试后,默认会在本机 9222 端口开一个 HTTP 接口,访问http://127.0.0.1:9222/json可以拿到所有标签页的列表。列表里每个标签页有url、title、webSocketDebuggerUrl等字段,后者就是连接 CDP 的 WebSocket 地址。
先建一个项目目录,初始化 npm 并安装 ws 库:
mkdir dino-auto-play cd dino-auto-play npm init -y npm install ws然后写一段 Node 脚本,用来定位游戏页、拿到 WebSocket 地址:
const WebSocket = require('ws'); async function findDinoPage() { const tabs = await (await fetch('http://127.0.0.1:9222/json')).json(); const target = tabs.find((t) => t.url.includes('dino')) || tabs[0]; if (!target) { throw new Error('没有找到可调试的页面,先打开 chrome://dino'); } return target.webSocketDebuggerUrl; }拿到 WebSocket 地址后,就可以向 CDP 发送命令了。最常用的是Runtime.evaluate,它能在页面上下文里执行任意 JavaScript 字符串。我写了一个封装函数,每次调用都会新建一个 WebSocket 连接,执行完表达式后关闭连接:
async function evaluate(wsUrl, expression) { return new Promise((resolve, reject) => { const ws = new WebSocket(wsUrl); ws.on('open', () => { ws.send(JSON.stringify({ id: 1, method: 'Runtime.evaluate', params: { expression, returnByValue: true } })); }); ws.on('message', (data) => { const msg = JSON.parse(data.toString()); if (msg.id === 1) { ws.close(); resolve(msg.result.result.value); } }); ws.on('error', (err) => reject(err)); }); }这个函数对低频操作足够了。但我要再强调一次:自动玩家的主循环一定不要通过这种“每帧一条 CDP 命令”的方式跑,延迟太高。CDP 在这里只负责两件事,一是确认页面正常,二是把自动玩家脚本注入进去。
3.3 注入自动玩家脚本(可直接复制)
下面这段是核心,把它保存成一个inject.js文件,内容是一个 IIFE 字符串。它的作用是在页面内部启动一个requestAnimationFrame循环,每一帧检查游戏状态、障碍物距离,并执行跳跃或下蹲。
const autoPlayerCode = ` (() => { const GAME = window.Runner; if (!GAME || !GAME.instance_) return 'game not ready'; const run = GAME.instance_; let restartTimer = null; function simulateSpace() { document.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 32, code: 'Space', key: ' ', which: 32 })); setTimeout(() => { document.dispatchEvent(new KeyboardEvent('keyup', { keyCode: 32, code: 'Space', key: ' ', which: 32 })); }, 50); } function tick() { if (run.crashed) { if (!restartTimer) { restartTimer = setTimeout(() => { restartTimer = null; if (run && !run.playing) run.restart(); }, 400); } requestAnimationFrame(tick); return; } if (!run.playing) { simulateSpace(); requestAnimationFrame(tick); return; } const obstacles = run.horizon.obstacles; const tRex = run.tRex; const speed = run.currentSpeed; let target = null; for (const obs of obstacles) { if (obs.xPos + obs.width > tRex.xPos + 10) { if (!target || obs.xPos < target.xPos) target = obs; } } if (target) { const dist = target.xPos - tRex.xPos; const threshold = Math.min(220, Math.max(55, speed * 12)); const isPterodactyl = target.typeConfig && target.typeConfig.type === 'pterodactyl'; if (isPterodactyl && target.yPos < -10 && dist < threshold) { tRex.setDuck(true); setTimeout(() => tRex.setDuck(false), 220); } else if (!isPterodactyl && dist < threshold && !tRex.jumping) { tRex.startJump(speed); } } requestAnimationFrame(tick); } requestAnimationFrame(tick); return 'auto player injected'; })(); `;注入的方式很简单,把autoPlayerCode作为表达式传给刚才写的evaluate函数就行:
const wsUrl = await findDinoPage(); const result = await evaluate(wsUrl, autoPlayerCode); console.log(result); // 正常情况下输出 auto player injected注入成功后,你会看到页面里的小恐龙自己开始跑了。如果当前页面处于等待开始状态,脚本会自动触发空格键;撞车后脚本会等 400 毫秒,然后自动重开。这套逻辑可以一直跑下去。
这里特别说一下tRex.startJump(speed)。它是游戏源码里自带的起跳方法,参数是当前速度。源码内部会根据速度算出起跳速度和滞空时间,所以不需要我们自己写物理模型。如果这段代码运行时报startJump is not a function,说明你打开的页面版本比较旧,改用tRex.jump()也能达到类似效果。
3.4 数据观察与调参:怎么知道它是不是要撞了
自动玩家跑起来之后,你大概率会遇到一个问题:恐龙还是经常撞。这时候第一件事不是瞎改阈值,而是把决策过程“可视化”。CDP 允许我们用Runtime.consoleAPICalled事件监听页面里的console.log,但更省事的方式是直接把调试信息写到页面的document.title上,目光一扫就能看到。
我调参时会在脚本里临时加一行:
document.title = 'dist=' + Math.round(dist) + ' speed=' + speed + ' target=' + (target ? target.xPos : '-');然后在 Chrome 标签页顶部实时观察距离和速度的变化。撞车前的那一帧,dist到多少了?是不是每次撞的时候速度都在一个区间?把这些数据记录下来,再微调threshold公式里的系数,比闭着眼睛猜靠谱得多。
如果你连 Chrome 的标签页都不想盯着看,可以把同样的信息写入localStorage,每 10 帧存一条,撞车后拉出来复盘。这种“先记录、再调整”的习惯,在做任何浏览器自动化项目时都非常值钱。
4. 常见问题与排查技巧实录
4.1 调试端口连不上、页面打不开
最先遇到的坑,大概率是http://127.0.0.1:9222/json打不开,或者访问报错。绝大多数原因是你启动 Chrome 时没有指定独立的--user-data-dir,导致新启动的 Chrome 进程把自己让给了后台已有的老进程,调试端口根本没有建立。解决办法是彻底退出所有 Chrome 进程,再带上独立参数重新启动。
还有一种情况是端口被占用。如果你之前跑过调试服务,9222 端口可能被别的程序占着,启动 Chrome 时静默失败。可以换成别的端口,比如 9223,再重新打开地址栏测试。最后,如果访问列表里找不到chrome://dino标签页,大概率是页面没有打开成功,直接在地址栏再访问一次即可。
4.2 起跳时机不准,老撞仙人掌
如果脚本注入成功,但恐龙频繁撞仙人掌,问题多半出在阈值公式上。记住一个原则:速度越快,提前量越多。Math.max(55, speed * 12)这个下限是在低速时试出来的,不同分辨率和窗口大小下,游戏画布的逻辑像素可能不同,所以数值需要按实际环境微调。
调参建议是:先把公式里的 12 改成 10,观察撞车时机;如果发现“距离太近来不及跳”,就把系数上调到 13 或 14;如果发现“跳得太早,落下来正好砸在仙人掌上”,就往下调。每次只改一个系数,跑几轮看记录,不要一次动多个变量。另外,如果你发现恐龙经常会“空中顿一下”或者连续跳跃时有间隔,可能是tRex.jumping的判断和实际状态不一致,把起跳条件改成if (dist < threshold && !tRex.jumping && tRex.yPos >= 0)会更稳。
4.3 翼龙低飞处理不好,下蹲判断失效
翼龙的判断依赖target.yPos,但这个数值在不同游戏版本里不完全一样。我在一个版本里写死yPos < -10是低飞,换到本地部署的开源版本后又完全不生效,打印出来发现翼龙的yPos是另一套坐标系。遇到这种情况,先把所有障碍物的typeConfig.type和yPos打印到document.title,跑一次游戏看真实数值,再重新设定高度阈值。
另一个容易忽略的点是下蹲的持续时长。翼龙比仙人掌宽,你下蹲 200 毫秒可能不够,翼龙还没飞过去恐龙就站起来了。给setDuck(false)的延迟设成 300 到 350 毫秒,再观察是否还会撞到翼龙的尾部。如果还撞,可以在下蹲期间持续检测翼龙的xPos,等它完全越过恐龙后再松开。
4.4 速度拉满后失误率升高怎么办
小恐龙游戏到后期速度很快,连续两个甚至三个障碍物挨得很近,脚本经常是刚跳过一个,还没落地就必须再跳。我实测在速度 16 以上,失误率会明显上升。排查下来发现一个问题:起跳动作本身有冷却时间,如果上一跳还没落地,startJump不会立即生效,等落地再调用就已经晚了。
解决思路是我测试下来比较有效的“连续跳跃排队”:维护一个queuedJump标志,当检测到障碍物时无论恐龙是否在跳跃,都先把“我要跳”这个意图记下来,然后在requestAnimationFrame里循环检查,一旦tRex.jumping变成false且tRex.yPos === 0,立刻执行下一跳。这份代码我标注一下,属于需要额外接入的进阶逻辑,网上很多版本没提过这个细节:
if (dist < threshold && !isPterodactyl) { queuedJump = true; } if (queuedJump && !tRex.jumping && tRex.yPos === 0) { tRex.startJump(speed); queuedJump = false; }另外,如果把自动玩家当成长期运行的“挂机脚本”,游戏最高速度其实可以通过修改代码参数压低,把run.config.ACCELERATION改大或改小,就能控制速度爬升曲线。这算是我个人爱用的一个调试技巧,想快速验证脚本稳定性时,把加速度调大,让游戏很快进入极速阶段;想看脚本在低速里刷分时,把加速度调成接近 0,让它在极低速度下无限跑,分数能刷到很高。
我自己实测的记录是 13 万 6 千分,最后是在一次翼龙贴脸加两连仙人掌的组合里翻的车。从“能跑”变成“稳定地跑”,最花时间的其实不是代码,而是阈值和容错逻辑的打磨。如果你也想折腾这个项目,建议耐着性子把决策过程记录下来,一次只改一个参数。这套流程练熟之后,你会发现不只是小恐龙,任何基于浏览器运行的循环类游戏,只要你找得到它的内部变量和状态机,都能用同样的思路实现自动操作。