Screeps Arena 实战编程:从零构建你的RTS对战AI
1. 从零开始:理解Screeps Arena的核心循环
如果你玩过《星际争霸》或者《帝国时代》,那你对RTS(即时战略游戏)一定不陌生。但Screeps Arena把这一切都交给了代码。想象一下,你不是用鼠标和键盘去框选单位、点击建造,而是坐在指挥官的位置上,用JavaScript写下一套规则和策略,然后看着你的“代码军团”自动执行采集、建造、进攻。这就是Screeps Arena的魅力所在,它是一款纯粹的编程RTS游戏。
很多刚接触的朋友会有点懵,觉得这门槛太高了。我刚开始也这么想,但上手后发现,它的核心其实就一个东西:游戏循环(Game Loop)。你可以把它理解为你AI大脑的“心跳”。游戏世界以“tick”(游戏刻)为单位向前推进,每过一 tick,服务器就会执行一遍你写的loop()函数。你的所有决策、所有单位的行动指令,都必须在这个函数里安排好。
这和我们平时写网页或者做后端服务很不一样。那里的事件驱动模型是“有事发生我才响应”。而在Screeps Arena里,你是“主动思考,持续决策”。每一 tick 你都得重新审视战场:我的采集工在哪儿?敌人有没有偷袭?资源够不够造新兵?然后根据当前情况,给你的单位下达新的指令。指令不是永久的,只持续当前这一 tick,下一 tick 你得重新判断,重新下令。这种“实时决策”的感觉,正是编程对战的精髓。
那么,这个核心的loop()函数长什么样呢?它就在你的主文件main.mjs里。一个最简单的、能通过第一关的AI,可能只有两行代码:
import { getTicks } from 'game/utils'; export function loop() { console.log('Current tick:', getTicks()); }别小看这几行。import语句引入了游戏提供的工具函数getTicks,用来获取当前是第几个游戏刻,这对于调试和制定基于时间的策略非常有用。export function loop()是游戏引擎寻找的入口,你必须导出这个函数。里面的console.log会在游戏内的控制台面板输出信息,是你调试AI的眼睛。
我刚开始写的时候,经常在里面写死循环或者阻塞操作,结果就是AI直接“卡死”,一个单位都不动。切记,loop()函数必须快速执行完毕,它只是下达命令,真正的移动、攻击等动作会在函数执行完毕后由游戏引擎统一处理。如果你的loop()里有个没完没了的while循环,那你的AI在这一 tick 就“掉线”了。
提示:强烈建议使用 VS Code 等本地编辑器编写代码,而不是游戏内嵌的编辑器。将游戏模式文件夹作为项目根目录打开,并安装官方提供的类型定义文件,你会获得非常棒的代码补全和API提示,效率能提升好几倍。
理解并驾驭这个每秒可能执行几十上百次的loop(),是你从“教程学习者”迈向“AI指挥官”的第一步。接下来,我们就要在这个循环里,塞进我们的智慧和策略了。
2. 单位操控基础:让你的爬虫动起来
在Screeps Arena的世界里,你的基本作战单位叫做“爬虫”(Creep)。它们可以是工人、士兵、医生,角色完全由你赋予。而操控它们的第一步,就是让它们听你的话,走到该去的地方。
游戏提供了creep.moveTo(target)方法来让爬虫向目标移动一步。注意,是“一步”。如果你想让爬虫从地图左下角走到右上角,就需要在连续多个 tick 的loop()里,每次都对这个爬虫调用moveTo。这听起来很麻烦,但正是这种精细控制,为复杂的战术(比如“hit-and-run”边打边跑)提供了可能。
那么,在loop()里,我们怎么找到“我的爬虫”呢?游戏提供了一个强大的函数getObjectsByPrototype。你可以把它理解为一个全局对象扫描器。比如,getObjectsByPrototype(Creep)会返回地图上所有的爬虫对象数组。但这里面有你的,也有敌人的。怎么区分?每个爬虫对象都有一个my属性,如果为true,就代表它是你的单位。
import { getObjectsByPrototype } from 'game/utils'; import { Creep, Flag } from 'game/prototypes'; export function loop() { // 找到我所有的爬虫 const myCreeps = getObjectsByPrototype(Creep).filter(creep => creep.my); // 找到地图上所有的旗帜(可能是目标点) const flags = getObjectsByPrototype(Flag); // 假设我们让第一个爬虫走向第一面旗帜 if (myCreeps.length > 0 && flags.length > 0) { myCreeps[0].moveTo(flags[0]); } }上面这段代码就是一个最简单的移动AI。但它在实战中非常脆弱。如果flags[0]不存在(比如被摧毁了),或者myCreeps[0]在移动过程中死掉了,你的代码就可能报错,导致整个loop()执行中断,其他单位也会停止行动。所以,健壮的错误处理是AI稳定性的基石。在实际编写中,我会习惯性地检查目标是否存在、单位是否存活。
更高级的移动会涉及到寻路。游戏地图上有平原、沼泽、道路和墙壁。不同的地形对移动速度影响巨大。一个身体臃肿(CARRY部件装满了资源,或者非MOVE部件很多)的爬虫在沼泽里会寸步难行。这时候,你可以使用爬虫自带的findClosestByPath方法,它会自动计算当前地形下的最短路径,返回一个最近的目标对象,比你自己手动算坐标要方便和准确得多。
for (let creep of myCreeps) { // 为每个爬虫寻找最近的可攻击敌人 const enemies = getObjectsByPrototype(Creep).filter(c => !c.my); const closestEnemy = creep.findClosestByPath(enemies); if (closestEnemy) { creep.moveTo(closestEnemy); } }让单位动起来是基础,但让它们“聪明地”动起来,就需要你根据战场形势动态计算目标。是去采集资源,还是回防基地,还是集火某个敌人?这就要引出我们下一个话题:如何为爬虫设计不同的身体和角色。
3. 兵种设计与角色分配:打造你的专属军团
在Screeps Arena里,没有预设的“机枪兵”或“护士”。你想要的任何兵种,都需要自己用“身体部件(Body Parts)”来组装。这就像给你的机器人选择装备模块。每个部件都有特定功能,并且消耗不同数量的能量来生产。
- MOVE(移动):让爬虫能移动。没有MOVE部件,爬虫就是固定炮台。移动速度取决于MOVE部件数量与总“负重”(其他所有非MOVE部件)的比例。
- WORK(工作):用于采集能量(Harvest)和建造建筑(Build)。部件越多,单次采集或建造效率越高。
- CARRY(携带):增加资源携带容量。空的CARRY部件不增加负重,但装满资源后会变重。
- ATTACK(近战攻击):允许进行贴身格斗。每个ATTACK部件增加固定伤害。
- RANGED_ATTACK(远程攻击):允许攻击3格范围内的敌人。同样,部件越多伤害越高。
- HEAL(治疗):可以治疗自身或友方单位。
- TOUGH(坚韧):最便宜的部件,只增加生命值,没有特殊功能,常用于充当“肉盾”。
假设你现在有550点能量,你可以选择造一个强大的战士[MOVE, MOVE, ATTACK, ATTACK, ATTACK](50+50+80+80+80=340能量),也可以造一个快速的采集工[MOVE, MOVE, WORK, WORK, CARRY](50+50+100+100+50=350能量),或者造五个廉价的侦察兵[MOVE, ATTACK]x 5。不同的配比,决定了爬虫在战场上的角色。
生产爬虫是通过你的主基地(Spawn)完成的。代码大概长这样:
import { MOVE, ATTACK, WORK, CARRY } from 'game/constants'; import { StructureSpawn } from 'game/prototypes'; let soldier; // 在loop外声明,用于在tick间记住这个单位 export function loop() { const mySpawns = getObjectsByPrototype(StructureSpawn).filter(s => s.my); if (mySpawns.length === 0) return; // 没有基地就别造了 const mySpawn = mySpawns[0]; // 如果士兵还没造出来,且基地能量够 if (!soldier && mySpawn.store.energy >= 340) { const result = mySpawn.spawnCreep([MOVE, MOVE, ATTACK, ATTACK, ATTACK]); if (result.ok) { soldier = result.object; // 记住新造出来的爬虫对象 soldier.role = 'soldier'; // 可以给它自定义属性,比如角色 } } // 如果士兵已存在,就命令它 if (soldier) { // ... 这里写士兵的战斗逻辑 } }这里有个关键点:spawnCreep不是瞬间完成的,它需要时间。result.ok只表示指令下达成功,result.object才是那个正在被孵化的爬虫对象,你可以立刻持有它的引用并为其分配任务(比如让它一出生就去某个位置站岗),但它真正能行动,要等到下一个tick。
角色分配通常通过给爬虫对象添加自定义属性来实现,比如creep.role = 'harvester'(采集者)、creep.role = 'builder'(建造者)。然后在loop()里,根据角色来执行不同的行为模块。我常用的一个简单框架是这样的:
export function loop() { const myCreeps = getObjectsByPrototype(Creep).filter(c => c.my); for (const creep of myCreeps) { switch (creep.role) { case 'harvester': runHarvester(creep); // 执行采集者逻辑 break; case 'soldier': runSoldier(creep); // 执行士兵逻辑 break; case 'builder': runBuilder(creep); // 执行建造者逻辑 break; default: // 没有角色?给它分配一个! if (creep.body.some(p => p.type === WORK)) { creep.role = 'harvester'; } else if (creep.body.some(p => p.type === ATTACK)) { creep.role = 'soldier'; } } } } function runHarvester(creep) { // 具体的采集逻辑... }通过身体部件的组合和角色分配,你的AI就从控制“一个单位”升级为指挥“一支各司其职的军队”。接下来,这支军队需要资源才能运转,这就是我们经济系统的核心。
4. 资源管理与经济系统:运营是胜利之本
无论你的战术多么精妙,没有资源,一切都是空谈。在Screeps Arena里,核心资源是能量(RESOURCE_ENERGY)。它用于生产爬虫、建造建筑、为防御塔充能。建立一个稳定、高效、能抗干扰的经济系统,是AI能否在持久战中胜出的关键。
能量主要来自地图上的能量源(Source)。一个带有WORK部件的爬虫可以执行creep.harvest(source)来采集能量。这里有个细节:能量源的能量是无限的,但采集有冷却时间,WORK部件越多,单次采集量越大,但冷却时间也相应变长。你需要根据能量源的再生速度来优化采集工的数量和身体配置,避免“人多手杂”效率反而降低。
采集到的能量会存储在爬虫自身的store里。爬虫需要把能量运回基地(Spawn)或者容器(Container)、扩展(Extension)等存储建筑中,使用creep.transfer(target, RESOURCE_ENERGY)。一个经典的采集-运输逻辑闭环如下:
import { RESOURCE_ENERGY, ERR_NOT_IN_RANGE } from 'game/constants'; function runHarvester(creep) { // 如果爬虫的存储没满 if (creep.store.getFreeCapacity(RESOURCE_ENERGY) > 0) { // 寻找最近的能量源 const sources = getObjectsByPrototype(Source); const closestSource = creep.findClosestByPath(sources); if (!closestSource) return; // 尝试采集,如果距离不够就移动过去 if (creep.harvest(closestSource) === ERR_NOT_IN_RANGE) { creep.moveTo(closestSource); } } else { // 存储满了,寻找最近的存储点(比如主基地) const spawns = getObjectsByPrototype(StructureSpawn).filter(s => s.my); const closestSpawn = creep.findClosestByPath(spawns); if (!closestSpawn) return; // 尝试转移能量,如果距离不够就移动过去 if (creep.transfer(closestSpawn, RESOURCE_ENERGY) === ERR_NOT_IN_RANGE) { creep.moveTo(closestSpawn); } } }这个逻辑很简单:没能量就去采,采满了就回去存。但在实战中,你需要考虑更多。比如,存储点满了怎么办?你应该让采集工原地等待,还是去寻找其他存储建筑?我通常的做法是维护一个“能量需求列表”,采集工会优先将能量运送到最需要能量的建筑(比如正在孵化的Spawn或者能量见底的防御塔)。
中期开始,你需要建造容器(Container)来充当能量中转站。采集工把能量存到容器里,专门的运输工(身体主要是MOVE和CARRY)再把能量从容器分发到各个需求点。这样能提高采集工的效率,避免它长途跋涉。
经济系统的另一个维度是扩张与防御。你不能把所有资源都用来造采集工,还需要留出资源生产士兵保卫基地,以及升级科技(比如建造更高级的建筑)。我常用的一个简单策略是设置一个“能量缓冲区”:当总能量超过某个阈值(比如800)时,才允许生产士兵或建造建筑;低于某个阈值(比如200)时,则全力保障经济单位的生产。这能防止你一时上头把能量全造了兵,结果经济崩盘。
资源管理AI写得好不好,直接体现在你的“每分钟采集量”和“资源分配效率”上。一个常见的坑是“交通堵塞”:多个采集工挤在一个能量源,或者运输工堵在存储建筑门口。解决方法是使用更智能的寻路(findClosestByPath会考虑拥堵),或者为每个单位分配独立的工作目标。
5. 建筑学与防御体系:构筑你的钢铁阵地
当你的经济初步运转起来后,就要考虑把资源转化为长期优势了,这就是建造建筑。在Screeps Arena里,你可以建造多种建筑,初期最重要的是防御塔(StructureTower)。它能耗能进行远程范围攻击,是防守基地、抵御早期骚扰的利器。
建造建筑分两步。第一步是创建建筑工地(ConstructionSite)。你需要指定坐标和建筑类型。
import { createConstructionSite } from 'game/utils'; import { StructureTower } from 'game/prototypes'; // 在坐标 (30, 25) 创建一个防御塔的建筑工地 const result = createConstructionSite({x: 30, y: 25}, StructureTower); if (result.ok) { const site = result.object; // 获取工地对象 }第二步是派遣带有WORK部件的爬虫去建造(Build)。建造会消耗爬虫携带的能量。
function runBuilder(creep) { // 优先建造未完成的建筑工地 const sites = getObjectsByPrototype(ConstructionSite); const closestSite = creep.findClosestByPath(sites); if (closestSite) { // 如果爬虫没能量了,就去取能量 if (creep.store[RESOURCE_ENERGY] === 0) { // ... 去容器或能量源取能量的逻辑 } else { // 有能量,尝试建造 if (creep.build(closestSite) === ERR_NOT_IN_RANGE) { creep.moveTo(closestSite); } } return; // 有工地就专心建造,不干别的 } // 没有工地,建造工可以暂时去干点别的,比如升级控制器或者当临时运输工 }建筑学(Building Placement)是个大学问。防御塔建在哪里覆盖范围最大?存储容器放在能量源旁边还是基地旁边?道路(能加速单位移动)该如何铺设来优化物流?我踩过的坑是,早期随手把塔建在了基地正中心,结果射程覆盖不到关键的入口,敌人几个远程兵就能在外面白嫖我的采集工。后来我学乖了,会用代码动态计算最佳位置,比如寻找能覆盖多个资源点或交通要道的路口。
防御体系不光是建筑,也包括城墙(StructureWall)。虽然教程里没细说,但城墙在高级对战中非常重要。你可以用城墙把基地围起来,只留一个用防御塔重点防守的入口,形成“瓮城”。敌人近战单位会被卡住,远程单位则会暴露在你的塔火下。建造城墙同样需要建筑工地和建造工。
一个完整的防御循环是这样的:侦察单位(可能是廉价的移动爬虫)发现敌人接近 -> 预警系统(可以通过检查一定范围内是否有非我方单位实现)触发 -> 所有空闲的建造工立刻在受威胁方向补建城墙或维修受损建筑 -> 防御塔自动锁定进入射程的敌人开火 -> 战斗单位向遇袭地点集结。把这套逻辑用代码实现出来,看到你的基地自动应对攻击,那种成就感是无与伦比的。
6. 战斗与战术AI:从脚本到智能
终于到了最激动人心的部分:战斗。让你的爬虫移动和攻击很简单,但让它们像一支军队一样协同作战,就需要战术AI了。战斗AI的层次,可以大致分为:个体脚本、小队协同、全局战略。
个体脚本就是给每个战斗单位写死一套行为。比如近战兵发现敌人就冲上去砍,远程兵保持距离射击,治疗兵跟着受伤最重的单位跑。这通过检查身体部件很容易实现:
function runSoldier(creep) { const enemies = getObjectsByPrototype(Creep).filter(c => !c.my); const closestEnemy = creep.findClosestByPath(enemies); if (!closestEnemy) { // 没有敌人,去巡逻或者回防 creep.moveTo(mySpawn); return; } // 根据身体部件决定行为 if (creep.body.some(p => p.type === ATTACK)) { // 近战兵 if (creep.attack(closestEnemy) === ERR_NOT_IN_RANGE) { creep.moveTo(closestEnemy); } } else if (creep.body.some(p => p.type === RANGED_ATTACK)) { // 远程兵,尝试保持3格距离 const distance = Math.max(Math.abs(creep.x - closestEnemy.x), Math.abs(creep.y - closestEnemy.y)); if (distance > 3) { // 距离大于3,靠近敌人 creep.moveTo(closestEnemy); } else if (distance < 2) { // 距离太近,远离敌人(防止被近战贴脸) // 计算远离的方向,这里简化处理 const fleeDir = ...; // 需要根据坐标计算 creep.move(fleeDir); } // 在2-3格距离时,原地攻击 if (distance <= 3) { creep.rangedAttack(closestEnemy); } } else if (creep.body.some(p => p.type === HEAL)) { // 治疗兵,寻找受伤的友军 const injuredAllies = getObjectsByPrototype(Creep).filter(c => c.my && c.hits < c.hitsMax); const target = injuredAllies.length > 0 ? creep.findClosestByPath(injuredAllies) : closestEnemy; // 没伤员就跟着部队 if (target) { if (creep.heal(target) === ERR_NOT_IN_RANGE) { creep.moveTo(target); } } } }小队协同就更进一步了。比如,你可以设计一个“坦克+输出+治疗”的铁三角。让带有TOUGH和ATTACK的肉盾顶在前面,RANGED_ATTACK单位在后面输出,HEAL单位专心治疗坦克。这需要单位之间能互相识别和配合。我常用的方法是给同一小队的单位设置一个共同的squadId,然后在loop()里,让它们根据小队ID集结、选择共同的目标、保持阵型。
// 假设我们有一个小队 const squad = { id: 1, members: [tankCreep, dpsCreep1, dpsCreep2, healerCreep], target: null, formation: 'triangle' // 阵型 }; // 在loop中更新小队行为 if (!squad.target) { // 寻找小队目标,比如最近的敌人建筑或高价值单位 squad.target = findSquadTarget(squad.members); } // 每个成员根据自己在小队中的角色和位置行动 for (const member of squad.members) { actAsSquadMember(member, squad); }全局战略则是最高层级。你的AI需要判断:现在是应该爆经济快速发展,还是应该早期Rush(快攻)?敌人主力在哪里?是应该正面决战,还是派小分队去偷袭对方经济?这需要收集战场信息(侦察)、分析敌我实力对比、并做出决策。你可以用有限状态机(FSM)来模拟AI的“心态”,比如状态 = { 早期发展, 中期扩张, 全军出击, 防守反击 },根据当前游戏刻、资源数量、单位数量、发现的敌人强度等信息来切换状态,每个状态对应一套不同的生产优先级和单位行为模式。
写战斗AI最有趣也最头疼的就是调试。你写了复杂的逻辑,一开战你的部队却像无头苍蝇一样乱跑。这时候,多用console.log输出关键变量的状态(比如squad.target是什么,某个单位为什么选择逃跑),或者用Game.map.visual系列API在游戏地图上画线、画圈来可视化你的AI的决策过程(比如画出单位的索敌范围、移动路径),能极大提升调试效率。
7. 调试、优化与进阶思考
当你把采集、建造、战斗的模块都拼凑起来,一个能自动运行的AI就初具雏形了。但这时候它可能笨笨的,效率低下,或者有各种奇怪的bug。接下来就是打磨和优化的阶段。
调试是你的最佳伙伴。除了console.log,一定要善用游戏提供的Game.map.visual。比如,你可以让每个采集工在头顶显示它的状态(harvesting,transporting),或者用线画出它打算移动的路径。对于战斗单位,可以画出它的攻击范围和治疗范围。视觉化的反馈能让你一眼看出AI的逻辑哪里出了问题。我经常在代码里写这样的调试函数:
function debugVisuals() { const visuals = Game.map.visual; const myCreeps = getObjectsByPrototype(Creep).filter(c => c.my); for (const creep of myCreeps) { // 在爬虫头上显示其角色和状态 visuals.text(creep.role || '?', creep.x, creep.y - 1, {color: 'cyan', fontSize: 10}); // 如果是士兵,显示其目标 if (creep.memory.targetId) { const target = Game.getObjectById(creep.memory.targetId); if (target) { visuals.line(creep.x, creep.y, target.x, target.y, {color: 'red'}); } } } // 画出所有能量源和存储的位置 const sources = getObjectsByPrototype(Source); sources.forEach(s => visuals.circle(s.x, s.y, {fill: 'yellow', opacity: 0.2})); }性能优化在游戏后期单位众多时至关重要。getObjectsByPrototype是一个相对较慢的操作,尤其是在地图上对象很多的时候。避免在每一 tick 为每个爬虫都调用它来查找敌人或资源。一个常见的优化模式是“缓存”:在loop()开头一次性获取所有你需要查询的对象数组,然后在循环中复用这些数组。
let cachedEnemies = null; let cacheTick = 0; export function loop() { const currentTick = getTicks(); // 每5 tick更新一次敌人缓存,避免每tick都扫描 if (!cachedEnemies || currentTick - cacheTick > 5) { cachedEnemies = getObjectsByPrototype(Creep).filter(c => !c.my); cacheTick = currentTick; } // 现在所有单位都可以使用 cachedEnemies 了 for (const creep of myCreeps) { // 使用 cachedEnemies 而不是重新扫描 const target = creep.findClosestByPath(cachedEnemies); // ... } }代码结构也会随着AI变复杂而越来越重要。不要把所有的逻辑都堆在loop()函数里。按照功能模块化,比如role.harvester.js、role.soldier.js、manager.spawn.js、strategy.offensive.js。使用ES6的模块化语法导入导出,让代码清晰可维护。这样当你想到一个新的战术时,只需要修改或新增一个模块,而不是在几千行的main.mjs里大海捞针。
最后,多看、多学、多对战。Screeps Arena社区有很多高手分享他们的AI代码。去观摩他们的思路,看看他们是如何解决寻路优化、资源分配、动态编队这些复杂问题的。然后把你学到的技巧融入自己的AI中。最直接的提升方式就是去多人天梯对战,输了一局后,不要急着改代码,先完整地看一遍回放,用对手的视角看看你的AI在哪里露出了破绽。是经济被骚扰崩了?还是主力部队被地形卡住了?还是决策犹豫被各个击破了?找到问题根源,再有的放矢地修改。
从写出一行让爬虫移动的代码,到指挥一支能自主决策、协同作战的智能军团,这个过程充满了挑战和乐趣。你的AI就像你的数字分身,它在竞技场中的每一次胜利,都是对你编程和策略思维的一次肯定。记住,没有“完美”的AI,只有不断进化的AI。每一次失败,都是让它变得更强的机会。现在,打开你的编辑器,开始构建属于你的第一个RTS对战AI吧。
