プログラミングの備忘録

プログラムをつくる過程を残すもの

「Screeps: Arena」を始めてみる

こんにちは。
今回は「Screeps: Arena」というゲームをやってみます。


目次


Screeps: Arena とは

「Screeps: Arena」は2022年4月8日に発売されたゲームで、Steam で入手できます。
(実は発売当初から知っており気になってはいたのですが、チュートリアルの始めの方だけ触って満足し、本格的にやるのは今度にしようと考えていました。)

store.steampowered.com

現在は2050円で販売されていますが「Screeps: Arena Demo」という無料のものもあるので、お試しでやってみるのに良いかと思います。


さて、肝心のゲームの内容ですが、簡単に言うと「JavaScript でロボットを動かす対戦ゲーム」です。
ちなみに、「screeps」は「scripting creeps」(「creep」は「這う・忍び寄る」) を意味するそうです。

「一歩進む」とか「左 (右) に曲がる」とかのブロックをつなげてロボットを動かす、みたいなゲームを見た・やったことがある方もいらっしゃるかと思います。そんな感じです。


ちなみに、2016年には「Screeps: World」というサンドボックスゲームも発売されています。

store.steampowered.com

こちらには体験版は無いようですが、1480円と Arena よりは安いです。


今回はとりあえずお試しでやってみようということで、無料の「Screeps: Arena Demo」を触ってみます。


本記事では、基本的にはチュートリアルをなぞっていきます。
また、筆者は JavaScript は触ったことはありませんが、p5.js はそれなりに触っているので多分できるだろうという考えやっています。
(気になる部分は適宜調べて補足します。)


Tutorial

Introduction

ゲーム内にコードエディタは実装されておらず、 VSCode など外部のコードエディタを使うことが推奨されています。

コードはローカルフォルダに保存され、左側の「PLAY」ボタンを押すことで保存したコードがサーバーに送信されます。
エラーが無ければゲームが実行され、その結果を見ることができます。


Loop and import

Script loop

ゲームではターン(「tick (ティック)」と呼ぶことにする) ごとに loop() という関数が実行されます。
実際のコードは左側の「open」ボタンを押して開かれるフォルダの「main.mjs」というファイルに書いていきます。

export function loop(){
    // Your code goes here
}


Import stuff

現在のティックを得る関数 getTicks() が用意されています。
使う際には「game/utils」モジュールからインポートする必要があります。
(他の機能は Documentation から確認することができます。)

console.log() で現在のティックを出力するようにしてみます。

import {getTicks} from 'game/utils';

export function loop(){
    console.log("Current tick: ", getTicks());
}


これを「main.mjs」に書いて PLAY すると、

エラー無く実行されたようです。

結果を Watch replay で確認してみます。

Console に現在のティックが出力されていく様子が見られます。


Simple move

各章でコードのあるフォルダは異なるので、毎回開き直さないといけません。
(ファイルの場所は任意に変えることはできます。)


ではいよいよ「creep (クリープ)」を扱ってみましょう。

documentation を見てわかるように、クリープは様々なアクションを起こすことができます。
例えば、

creep.moveTo(target);

この関数 moveTo() では、クリープが目標に向かって進みます。
(クリープの動きはこの関数が実行されてすぐに反映されるのではなく、loop() が実行され終わるまで反映されないことに注意してください。)


実際には、getObjectsByPrototype() を用いて、指定したプロトタイプやクラスのオブジェクトを配列の形で得ることになります。

では、Creep についてどのようなオブジェクトが存在しているか見てみましょう。

import { getObjectsByPrototype } from 'game/utils';
import { Creep } from 'game/prototypes';

export function loop(){
    var creeps = getObjectsByPrototype(Creep);
    console.log(creeps);
}

PLAY すると(目標未達成のため)「FAILD」と表示されてしまいますが、Watch replay で確認してみると、

Console にクリープの情報が出力されていました。


この章では、Flag というプロトタイプ名を持ったオブジェクトも実装されています。

目標: moveTo() を用いて、クリープを旗まで移動させよ。


解答

getObjectsByPrototype() で返されるのは配列であり、今回は要素がひとつしかないため、

import { getObjectsByPrototype } from 'game/utils';
import { Creep, Flag } from 'game/prototypes';

export function loop() {
    var creeps = getObjectsByPrototype(Creep);
    var flags = getObjectsByPrototype(Flag);
    
    creeps[0].moveTo(flags[0]);
}

creeps[0]flags[0] に向かって進ませることで達成できます。


First attack

Screeps Arena はマルチプレイヤーPvPゲームです。
本章から、いよいよクリープ同士の戦いが見られます。

JavaScript の標準機能である Array.filter()Array.find() を使って、指定した基準を満たす要素を得ます。
自分のクリープは my が true になっており、それで判別できます。

import { getObjectsByPrototype } from '/game/utils';
import { Creep } from '/game/prototypes';

export function loop(){
    var creeps = getObjectsByPrototype(Creep);
    var myCreep = creeps.find(creep => creep.my);
    var enemyCreep = creeps.find(creep => !creep.my);
    console.log("mine: ", myCreep);
    console.log("enemy: ", enemyCreep);
}


Array.find() は引数に関数をとるようです。
上ではアロー関数の形で書きましたが、

var myCreep = creeps.find(function (creep) { return creep.my });

とか

function findMyCreep(creep){
    return creep.my == true;
}

と関数を定義しておいて

var myCreep = creeps.find(findMyCreep);

のようにしても問題ありません。


では、attack() を使ってみましょう。
攻撃可能範囲の外では ERR_NOT_IN_RANGE が返されるので、初めは moveTo() を使って近くまで移動する必要があります。

目標: 敵のクリープを倒せ。


解答

自分のクリープに対して、攻撃可能範囲内になるまで移動し、敵のクリープを攻撃する、という命令を出すことで達成できます。

import { getObjectsByPrototype } from 'game/utils';
import { Creep } from 'game/prototypes';
import { ERR_NOT_IN_RANGE } from 'game/constants';

export function loop(){
    var creeps = getObjectsByPrototype(Creep);
    var myCreep = creeps.find(creep => creep.my);
    var enemyCreep = creeps.find(creep => !creep.my);
    
    if(myCreep.attack(enemyCreep) == ERR_NOT_IN_RANGE){
        myCreep.moveTo(enemyCreep);
    }
    else{
        myCreep.attack(enemyCreep);
    }
}

else 以下の処理は無くても動くようですが、無いと無いで気持ちが悪いので一応書くことにしました。


Creeps bodies

クリープに対して様々な関数を呼び出すことができますが、すべてのクリープで可能なわけではなく、そのボディパーツに依っています。

  • MOVE - クリープの移動を可能にする

  • ATTACK - 一定範囲内での攻撃を可能にする

  • RANGED_ATTACK - 3タイルの範囲内での攻撃を可能にする

  • HEAL - 自分や他のクリープの回復を可能にする

  • WORK - worker creep は建築やエネルギーの回収が可能

  • CARRY - 運べる資源の量を増やす

  • TOUGH - 特に効果は無い

if(creep.body.some(bodyPart => bodyPart.type == ATTACK)){
    // this creep has ATTACK body parts
}


ボディパーツ1つあたりでクリープの hits を 100 増やすことができます。

クリープがダメージを受けるとボディパーツもダメージを受け、ついにはパーツが壊れて機能を失います。

同じ種類のボディパーツが多いほど、その効果は強くなります。


この章では、ATTACK、RANGED_ATTACK、HEAL のボディパーツを持った3種類のクリープが与えられます。
そして、これら3つの効果を合わせて使ってはじめて敵を倒せるようになっています。

目標: 敵のクリープを倒せ。


解答

import { getObjectsByPrototype } from 'game/utils';
import { Creep } from 'game/prototypes';
import { ERR_NOT_IN_RANGE, ATTACK, RANGED_ATTACK, HEAL } from 'game/constants';

export function loop(){
    var creeps = getObjectsByPrototype(Creep);
    var myCreeps = creeps.filter(creep => creep.my);
    var enemyCreep = creeps.find(creep => !creep.my);
    
    for(var creep of myCreeps){
        if(creep.body.some(bodyPart => bodyPart.type == ATTACK)){ // ATTACKのボディパーツを持つなら
            if(creep.attack(enemyCreep) == ERR_NOT_IN_RANGE){ // 範囲内まで移動し
                creep.moveTo(enemyCreep);
            }
            else{ // 攻撃する
                creep.attack(enemyCreep);
            }
        }
        if(creep.body.some(bodyPart => bodyPart.type == RANGED_ATTACK)){ // ATTACKと同様
            if(creep.rangedAttack(enemyCreep) == ERR_NOT_IN_RANGE){
                creep.moveTo(enemyCreep);
            }
            else{
                creep.rangedAttack(enemyCreep);
            }
        }
        if(creep.body.some(bodyPart => bodyPart.type == HEAL)){ // HEALのボディパーツを持つなら
            var myDamagedCreeps = myCreeps.filter(c => c.hits < c.hitsMax); // 体力が減っているクリープを探し
            if(myDamagedCreeps.length > 0) { // 体力が減っているクリープがいれば
                if(creep.heal(myDamagedCreeps[0]) == ERR_NOT_IN_RANGE){ // 範囲内まで移動して
                    creep.moveTo(myDamagedCreeps[0]);
                }
                else{ // 回復する
                    creep.heal(myDamagedCreeps[0]);
                }
            }
        }
    }
}


これまでは find() を使ってクリープを選んでいましたが、条件を満たす要素を1つしか返してくれません。
そこで、複数欲しい今回は filter() を使い、条件を満たすクリープ全てを配列として得ています。

また、ATTACK と RANGED_ATTACK は前の章と同様の処理で良いですが、HEAL に関しては負傷者を探す処理が入ります。


Store and transfer

RESOURCE_ENERGY という資源が存在します。
これは、建物を建てたり、クリープを生み出したり…と様々な用途で使われます。

この章では、タワーを充電して遠くから敵を倒すために使うことになります。

tower.attack(target);

タワーでは1発ごとに自身のエネルギーを 10 消費します。


タワーを動かすエネルギーはクリープに運ばせる必要があります。

creep.transfer(tower, RESOURCE_ENERGY);

エネルギー自体は、いくらかエネルギーが入った コンテナが近くにあるので、そこから持ち出します。

creep.withdraw(container, RESOURCE_ENERGY);

タワーにエネルギーがあれば、攻撃を行って敵を倒してくれます。


サンプルコードを見ると、これまでと違った方法でインポートが行われていることに気がつくでしょう。
わざわざ getObjectsByPrototypeCreepERR_NOT_IN_RANGE などを別個にインポートしなくても、「game」モジュールから全体の名前空間をインポートするだけで問題ありません。

すなわち、今までは

import { getObjectsByPrototype } from 'game/utils';
import { Creep } from 'game/prototypes';
import { ERR_NOT_IN_RANGE } from 'game/constants';

というようにインポートしていましたが、

import { utils, prototypes, constants } from 'game';

のようにすれば良い、ということです。

ただし、実際に呼び出すときは utils.getObjectsByprototypesprototypes.Creepconstants.ERR_NOT_IN_RANGE というように書く必要があります。

python でいうところの、from numpy import sin とするか、import numpy として numpy.sin と呼び出すか、という違いでしょうか。)


目標: 敵のクリープを倒せ。


解答

素直に書けば、

import { getObjectsByPrototype } from 'game/utils';
import { Creep, StructureContainer, StructureTower } from 'game/prototypes';
import { RESOURCE_ENERGY } from 'game/constants';

export function loop(){
    var creeps = getObjectsByPrototype(Creep);
    var myCreep = creeps.find(creep => creep.my);
    var enemyCreep = creeps.find(creep => !creep.my);

    var container = getObjectsByPrototype(StructureContainer)[0];
    var tower = getObjectsByPrototype(StructureTower)[0];
    
    // エネルギーをコンテナから取り出す
    myCreep.withdraw(container, RESOURCE_ENERGY);
    
    // エネルギーをタワーに移す
    myCreep.transfer(tower, RESOURCE_ENERGY);
    
    // 敵を攻撃する
    tower.attack(enemyCreep);
}

(せっかく教えてもらいましたが、コード本文がやたら長くなるので今まで通りのインポート方法で書きました。)


タワーにエネルギーが無いときだけエネルギーの移動を行うようにしてみると、

import { getObjectsByPrototype } from 'game/utils';
import { Creep, StructureContainer, StructureTower } from 'game/prototypes';
import { RESOURCE_ENERGY } from 'game/constants';

export function loop(){
    var creeps = getObjectsByPrototype(Creep);
    var myCreep = creeps.find(creep => creep.my);
    var enemyCreep = creeps.find(creep => !creep.my);

    var container = getObjectsByPrototype(StructureContainer)[0];
    var tower = getObjectsByPrototype(StructureTower)[0];
    
    if(tower.store[RESOURCE_ENERGY] < 10){ // タワーに1発分のエネルギーも無ければ
        if(myCreep.store[RESOURCE_ENERGY] == 0){ // クリープがエネルギーを持っていなければ
            // エネルギーをコンテナから取り出す
            myCreep.withdraw(container, RESOURCE_ENERGY);
        }
        // エネルギーをタワーに移す
        myCreep.transfer(tower, RESOURCE_ENERGY);
    }
    else{
        // 敵を攻撃する
        tower.attack(enemyCreep);
    }
}


Terrain

このゲームのマップには5種類の地形があります。

  • 普通の地面

  • 元からある破壊できない壁

  • 建設した破壊できる壁

  • 沼地

  • 道路


沼地と道路はクリープの重さに関係しています。

MOVE 以外のボディパーツが重さを増やします。
CARRY は空の状態では重さに加算されませんが、資源を持っていると加算されます。
そして、MOVE はクリープの移動速度を増加させます。


普通の地面では、MOVE と同じ数だけ他のボディパーツを持っていればそのクリープは毎ティック移動することができますが、MOVE が少なければ疲労を感じることになり移動しないティックが出てくる、ということになります。

沼地ではさらに疲労を感じ、5 ティックに1回しか移動できなくなります。
沼地で毎ティック移動するためには、5倍の MOVE が必要になります。

一方で道路では、MOVE の必要数を半分にすることができます。
例えば、あるクリープが 10 の重さ分のボディパーツを持っていたとしたとき、毎ティック移動するには5個の MOVE で十分です。


findClosestByPath() を使えば与えた配列の要素の内で最も近くにあるものを探してくれます。

var closestFlag = creep.findClosestByPath(flags);


目標: 全てのクリープを旗まで移動させよ。


解答

import { getObjectsByPrototype, findClosestByPath } from 'game/utils';
import { Creep, Flag } from 'game/prototypes';

export function loop(){
    var creeps = getObjectsByPrototype(Creep);
    var flags = getObjectsByPrototype(Flag);
    
    for(var creep of creeps){
        // 最も近い旗を探す
        var closestFlag = creep.findClosestByPath(flags);
        
        // 見つけた旗まで移動する
        creep.moveTo(closestFlag);
    }
}


Spawn creeps

スポーンがあれば、spawnCreep() でクリープを増やすことができます。
その際、クリープのボディパーツを配列の形で指定します。

var creep = mySpawn.spawnCreep([MOVE, ATTACK]).object;


生み出すクリープが大きいほど必要なエネルギーも大きくなります。
各ボディパーツのコストは、

  • MOVE - 50 energy

  • WORK - 100 energy

  • CARRY - 50 energy

  • ATTACK - 80 energy

  • RANGED_ATTACK - 150 energy

  • HEAL - 250 energy

  • TOUGH - 10 energy


ちなみに、loop() の外にも変数を定義することができます。

var creep;
exprot function loop(){
    if(!creep){
        creep = mySpawn.spawnCreep([MOve, ATTACK]).object;
    }
}


また、クリープに任意のプロパティを割り当てることもできます。

creep.target = flag;

例えば、

import { getObjectsByPrototype } from 'game/utils';
import { Flag } from 'game/prototypes';

export function loop(){
    var flags = getObjectsByPrototype(Flag);
    console.log(flags[0]);
    
    flags[0].test = "ABC";
    console.log(flags[0]);
}

を実行してみると、

という出力になります。
これを見ると、test という項目が追加され、その中身が 'ABC' となっていることがわかります。
(便利そうですね。具体的な使い道をすぐには思いつきませんが。)


この章では、1つのスポーンと2つの旗が与えられます。

目標: クリープを2つ生み出し、各々を別の旗に移動させよ。


解答

import { getObjectsByPrototype } from 'game/utils';
import { Creep, StructureSpawn, Flag } from 'game/prototypes';
import { MOVE } from 'game/constants';

var creep1, creep2;
export function loop(){
    var spawn = getObjectsByPrototype(StructureSpawn)[0];
    var flags = getObjectsByPrototype(Flag);

    // creep1をつくり、flags[0]へ
    if(!creep1){
        creep1 = spawn.spawnCreep([MOVE]).object;
    }
    else{
        creep1.moveTo(flags[0]);
        
        // creep2をつくり、flags[1]へ
        if(!creep2){
            creep2 = spawn.spawnCreep([MOVE]).object;
        }
        else{
            creep2.moveTo(flags[1]);
        }
    }
}


Harvest energy

エネルギー資源は様々な形で利用できます。
コンテナに貯めることもできますが、その"源"から回収することもできます。

クリープが WORK のボディパーツを持っていれば、"源"の近くで harvest() を使うことでエネルギーを回収できます。

creep.harvest(source);

WORK が多いほどティックごとに回収できるエネルギーの量が増えます。

"源"にあるエネルギーは無限で、クリープが持てるだけ回収することができます。


目標: エネルギーを回収し、スポーンに 1000 貯めよ。


解答

import { getObjectsByPrototype } from 'game/utils';
import { Creep, StructureSpawn, Source } from 'game/prototypes';
import { RESOURCE_ENERGY, ERR_NOT_IN_RANGE } from 'game/constants';

export function loop(){
    var creep = getObjectsByPrototype(Creep)[0];
    var spawn = getObjectsByPrototype(StructureSpawn)[0];
    var source = getObjectsByPrototype(Source)[0];

    if(creep.store[RESOURCE_ENERGY] < creep.store.getCapacity()){ // 所持エネルギーが上限より少なければ
        // 回収可能範囲まで移動して回収
        if(creep.harvest(source) == ERR_NOT_IN_RANGE){
            creep.moveTo(source);
        }
        else{
            creep.harvest(source);
        }
    }
    else{ // 所持エネルギーが上限になったら
        // 範囲内まで移動してスポーンに移す
        if(creep.transfer(spawn, RESOURCE_ENERGY) == ERR_NOT_IN_RANGE){
            creep.moveTo(spawn);
        }
        else{
            creep.transfer(spawn, RESOURCE_ENERGY);
        }
    }
}


クリープの所持エネルギー量の確認は getUsedCapacity(RESOURCE_ENERGY) でも可能ですし、空き容量は getFreeCapacity(RESOURCE_ENERGY) で見られます。


Construction

クリープを生み出すだけでなく、建物を建てることもできます。

初めに、createConstructionSite() で建設予定地を設定します。
予定地の座標と建てるものの種類を引数に与える必要があります。

var constructionSite = createConstructionSite({x: 50, y: 50}, StructureTower).object;


そして、エネルギーを持たせたクリープに向かわせ、build() を実行します。

creep.build(constructionSite);

クリープから十分なエネルギーが与えられれば建設が完了します。


目標: タワーを建てよ。


解答

import { getObjectsByPrototype, createConstructionSite } from 'game/utils';
import { Creep, StructureContainer, ConstructionSite, StructureTower } from 'game/prototypes';
import { RESOURCE_ENERGY, ERR_NOT_IN_RANGE } from 'game/constants';

export function loop(){
    var creep = getObjectsByPrototype(Creep)[0];
    var container = getObjectsByPrototype(StructureContainer)[0];
    var constructionSite = getObjectsByPrototype(ConstructionSite)[0];
    
    if(!constructionSite){ // 建設予定地が無ければ
        createConstructionSite({x: 50, y: 50}, StructureTower); // つくる
    }
    else{ // 建設予定地があれば
        if(creep.store[RESOURCE_ENERGY] < creep.store.getCapacity()){ // 所持エネルギーが上限より少なければ
            // 範囲内まで移動して引き出す
            if(creep.withdraw(container, RESOURCE_ENERGY) == ERR_NOT_IN_RANGE){
                creep.moveTo(container);
            }
            else{
                creep.withdraw(container, RESOURCE_ENERGY);
            }
        }
        else{ // 所持エネルギーが上限になったら
            // 範囲内まで移動して建設予定地に移す
            if(creep.build(constructionSite) == ERR_NOT_IN_RANGE){
                creep.moveTo(constructionSite);
            }
            else{
                creep.build(constructionSite)
            }
        }
    }
}


Final test

ここまでで、クリープの操作、敵の攻撃、エネルギーの利用、クリープの生成、そして構造物の建築を学んできました。


この章ではスポーンとエネルギーの"源"が与えられます。

敵のクリープの一団が攻めてくるので、クリープを生み出し、エネルギーを回収し、さらにクリープを生み出し、そして敵を倒す必要があります。

ここではサンプルコードは与えません!


チュートリアルを終えればいよいよPvPに進め、他の人との対戦ができるようになります。

Good luck and have fun with Screeps Arena!


目標: 敵を倒せ。


解答

一応クリアできたコードになりますが、他に何通りものやり方があると思います。

import { getObjectsByPrototype } from 'game/utils';
import { Creep, StructureSpawn, Source } from 'game/prototypes';
import { MOVE, WORK, CARRY, ATTACK, RESOURCE_ENERGY, ERR_NOT_IN_RANGE } from 'game/constants';

var creep1, creep2;
export function loop(){
    var spawn = getObjectsByPrototype(StructureSpawn).find(s => s.my);
    var source = getObjectsByPrototype(Source)[0];
    var enemyCreeps = getObjectsByPrototype(Creep).filter(c => !c.my);
    
    if(!creep1){
        creep1 = spawn.spawnCreep([MOVE, WORK, WORK, WORK, WORK, WORK, CARRY]).object;
    }
    else{
        if(creep1.store[RESOURCE_ENERGY] < creep1.store.getCapacity()){
            if(creep1.harvest(source) == ERR_NOT_IN_RANGE){
                creep1.moveTo(source);
            }
            else{
                creep1.harvest(source);
            }
        }
        else{
            if(creep1.transfer(spawn, RESOURCE_ENERGY) == ERR_NOT_IN_RANGE){
                creep1.moveTo(spawn);
            }
            else{
                creep1.transfer(spawn, RESOURCE_ENERGY);
            }
        }
        
        if(!creep2){
            creep2 = spawn.spawnCreep([MOVE, MOVE, MOVE, MOVE, MOVE, ATTACK, ATTACK, ATTACK, ATTACK, ATTACK]).object;
        }
        else{
            var target = enemyCreeps[0];
            if(creep2.attack(target) == ERR_NOT_IN_RANGE){
                creep2.moveTo(target);
            }
            else{
                creep2.attack(target);
            }
        }
    }
}


まとめ

以上、Screeps: Arena のチュートリアルをやってみました。
以降は普通のプログラミング学習と同じように、レファレンスを読んで機能・関数を知る、ということが必要になります。

JavaScript の勉強にもなるかなと思っていましたが、チュートリアルをやっただけですが勉強にはならなさそうな感じでした。
(勉強しよう的なコンセプトでも無いでしょうけど。)


書いていて思いましたが、やはりゲーム内にコードエディタが欲しいですね。

自分はノートパソコン1つでやっていたので、ゲームとレファレンス見てコード編集してブログ書いて、とタスクバーを反復横跳びしてました。

まだアーリーアクセスなので今後に期待です。


最後の「Final test」をやっていて、自分なりにどう攻略するかを考えてコードを書く、ということが楽しかったのでもしかしたら本編を購入するかもしれません。
そのときはまた記事にします。


興味があれば皆さんもぜひ始めてみてください。
それでは、また次回。