実用性はありません
何か簡単な仕組みで生物らしく見えるシミュレーターのようなものを作れないかと思い思いついたものを形にしてみました。特に実用性はないと思いますが、暇なときに実行してみたら、「今回の文明は長く生存したな、次回は餌を減らしてみるか」とか「すぐ滅んじゃったかー設定シビアすぎたかな」とか「あー長すぎるから終わり(強制終了)」とか上位存在の気分が味わえるかもしれません。
↓動作の様子と簡単な説明
##はじめに この記事では最新版のChromeで動作するGoogle関連アプリを操作可能なGoogleAppsScriptを使っています。Qiita投稿してみた
— 栗蓮゛ (@i_Grievous) February 14, 2022
【GAS】創造主視点で楽しむ生き物シミュレーター https://t.co/qM6iWLtZuc #Qiita pic.twitter.com/mP3mKGYWvz
使用方法
スプレッドシート上でシミュレートの結果を表示する仕組みなので、実行する際はスクリプトだけではなく、シートの用意が必要です。シートをスクリプトごとコピーする。もしくはシートをコピーした後、スクリプトをそのシートのスクリプトエディタに書き込むことで実行可能になると思います。
以下にコピー用のスプレッドシートがあるのでコピーして試してみてください。
https://docs.google.com/spreadsheets/d/1eXE06xO7e4T9_xDtUrNByk6AaMK6V6KqhDU1Xqv1hvo/edit?usp=sharing
注意
必ず実行前にスクリプトが下に書いてあるものと同じかどうかを確認してください
ボタンを押すとnewというシートが作成され、そのシートでシミュレートが行われます。
初めて記事を投稿するので読みにくいことが多々あると思います。改善点等コメントで(できれば柔らかい口調で)教えていただければ幸いです。
本編 ~これであなたも創造主~
何ができるスクリプトなのか
スプレッドシート状のあるエリア内で生命体に見立てた数字が動いて餌を食べ、増殖する様を眺めることができます。
主な使用されている関数や要素等
- 時間を取得する関数(new Date 等)
- スクリプトのプロパティ(プログラムが実行していなくても変数を保存できる機能。シートのどこかに記入しておくなどすれば使わなくても再現可能)
- 上記2つを組み合わせたGASの実行時間を超えた無限ループ(ある関数をfor文内の累積する値を引き継いで無限回実行可能にする書き方)
- ランダム関数
用語にいての説明
サイクルについて
このスクリプトでは、「for文を用いてfor文の処理一回ごとに生存している生命の動きを決定し、すべての生命の動きが決定した後にその動きをスプレッドシートに反映した後次の処理を行う」という処理を繰り返すという仕組みでシミュレートを行います。このfor文の処理一回を**1サイクル**としています。1サイクルにおいて、生命は基本的に移動、移動と捕食、増殖の内の1つの行動を1回行い、また餌が一定数ランダムな位置に出現します。サイクルという名前は某星間戦争から拝借しました。生命体の情報について
生命体の情報を配列にして表現、保存しています。一つの個体は7つのステータスを持っていて、それぞれ、[0:体力,1:方向決定数(初期方向は上),2:位置x,3:位置y,4:いつ子供を産むか,5:子を産むときの体力消費量,6:子の初期体力]です。この情報を集めた二次元配列が全生命体の情報であり、その行数が生命体の個体数です。###スプレッドシートの構成
スプレッドシートは主に3種類のシートから成ります。
- 創世ボタン
スクリプトを実行するボタンが配置されているシートです。 - コピー元
スクリプトを実行した際に新しく生成されるシート(新しい世界)のテンプレートです。餌の初期配置、個数を編集可能です。 - nサイクルで滅んだ世界
(nは該当スクリプトが終了するまでにかかった実行の回数)
前回以前の実行の結果が表示されるシート。
$$\style{align: center; font-family: "Helvetica Neue",Helvetica,"ヒラギノ角ゴ ProN W3","Hiragino Kaku Gothic ProN","メイリオ",Meiryo,sans-serif}{\text{画像1.「世界を作る」ボタンとシート}}$$
実行画面の説明
$$\style{align: center; font-family: "Helvetica Neue",Helvetica,"ヒラギノ角ゴ ProN W3","Hiragino Kaku Gothic ProN","メイリオ",Meiryo,sans-serif}{\text{画像2.スクリプトを実行した直後の様子}}$$
餌の初期配置は「コピー元」シートからコピーされます。
$$\style{align: center; font-family: "Helvetica Neue",Helvetica,"ヒラギノ角ゴ ProN W3","Hiragino Kaku Gothic ProN","メイリオ",Meiryo,sans-serif}{\text{画像3.スクリプトを実行してしばらくたった後の様子(説明のため着色)}}$$
赤の部分:このシミュレーションにおける全世界、もとい、生命と餌と空欄のいずれかが記入される領域。この中を生命(数字が表示される。残り体力を表す)が動き回り、餌(「餌」と表示される)が出現します。
青の部分:サイクルと今のサイクルにおける生命の統計情報、それぞれのステータスの平均値。
緑の部分:青のエリアで示した情報の履歴。
$$\style{align: center; font-family: "Helvetica Neue",Helvetica,"ヒラギノ角ゴ ProN W3","Hiragino Kaku Gothic ProN","メイリオ",Meiryo,sans-serif}{\text{画像4.スクリプトが終了した後の様子}}$$
生命体の個体数が0になるまでに(その世界が滅ぶまでに)かかった実行の回数をシート名とし、各ステータスをグラフ化したものを表示して処理を終了します。
実行イメージ
####スクリプト全体の処理
- 初期化
新しいシートを「コピー元」シートから作成、名前をnewとする。スクリプトのプロパティに初期値0で「サイクル数」「生命体の情報」「死亡数」の3項目を設定 - メイン関数を呼び出してループ処理を開始
- 1回目の処理でない場合はこのスクリプトを実行したトリガーを削除
- 3つのプロパティから情報を取得。また現在時刻☆を取得
- 生命体の情報と死亡数の初期設定。1サイクル目でない場合は直後にプロパティから得た情報で上書き
- 生命シミュレーションのメイン処理
- 現在時刻★を取得して2.2で取得していた現在時刻☆と比較し4分よりも小さい場合処理を続行。大きい場合、スクリプトプロパティに3つの情報を保存し、メイン処理を1分以内に実行するトリガーを設定、作成しスクリプトを終了。
- 生命体全体の移動範囲(以後エリア)を二次元配列に格納。以後、この配列に対して増殖、移動、餌の配置の処理を行い。処理が終わった後にスプレッドシートに反映する。
- 生命体のそれぞれに対して処理を実行
※増殖、死亡、移動は個体1つにきいずれか1つのみ処理される- 増殖
体力がその生命体の「いつ子供を産むか」の数値を上回っていた場合、新たな生命体を配置する処理を行う。親の現在地の近くからランダムに子供が生まれる場所を決定し、親の「いつ子供を産むか」、「子を産むときの体力消費量」、「子の初期体力」をランダムに増減させて生命の情報の配列に新たな行として加える。 - 死亡
体力が0になっていた場合はその配列を削除し、死亡数を1増やす。 - 移動
増殖も死亡もしなかった場合に移動の処理を行う。※詳細は後述
- 増殖
- 餌をエリア内のランダムな座標に配置。※配置する数はデフォルトでは10個
- 生命体を移動、増殖し餌を配置した二次元配列のスプレッドシートへの反映、更新。
- 特定のステータスの平均値、餌の数等を計算、スプレッドシートに記入。
- もし生命体の個体数が0ならグラフを作成、シート名を変更、スクリプトを終了。
####移動の処理
- 進行方向をランダムに決定する。進行方向を決定する係数に0~2を足す。
- 元居た座標の配列の要素をnullにする。
- 進行先がエリアの端によりすぎていた場合は内側にワープする処理を行う。
- 方向を決める数を4で割った時の余りが1~4のどれかによって生命体の現在地の情報を上書きする。
- 移動処理実行前の体力を保存
- 移動先に何があるかで分岐するループ処理
- 餌があった場合、体力を増加させてループを抜ける。
- 他の生命体、生命体の死体があった場合、体力を1消費して移動の処理をもう一度行い、再び移動先の判定を行う。
- 空き地だった場合、体力を1消費してループを抜ける。
- エリアの生命体が存在する位置にその生命体の体力を記入。
- 返り値として[体力、向いている方向、座標x、座標y]を返す。
###スクリプト
function go(){
let mysheet = SpreadsheetApp.getActiveSpreadsheet(); //spreadsheetを取得
let sheetNameC = 'コピー元'; //コピー元シートのシート名を取得
let sheetC = mysheet.getSheetByName(sheetNameC); //コピー元のsheetを取得
sheetC.copyTo(mysheet).setName("new"); //コピー元のシートから新しいシートを作成
setP(); //スクリプトプロパティの設定
main(); //メインのループさせる処理
}
function setP(){ //スクリプトのプロパティを設定
let properties = PropertiesService.getScriptProperties(); //プロパティオブジェクトを設定
properties.setProperty("cycle",0) //サイクルのカウント
properties.setProperty("Living",0) //残存生命体の情報
properties.setProperty("Dead",0) //死亡数のカウント
}
function main(){ //メイン処理する関数
let mysheet = SpreadsheetApp.getActiveSpreadsheet();
let sheetName = 'new'; //新しく作ったシートのシート名を取得
let sheet = mysheet.getSheetByName(sheetName); //シートオブジェクトを取得
delete_specific_triggers("main"); //特定関数のトリガーのみ削除
let properties = PropertiesService.getScriptProperties(); //プロパティオブジェクトを設定
// 各プロパティから値を取得、代入
let cycle = parseInt( properties.getProperty("cycle") );
let Living2 = JSON.parse( properties.getProperty("Living") );
let dead2 = parseInt( properties.getProperty("Dead") );
// 関数実行時点の時刻取得
let start_time = new Date();
let Living = []; //生命体の配列の長さ
Living[0] = [5,100,15,10,12,6,5]; //[0:体力,1:方向決定数(初期方向は上),2:初期位置x,3:初期位置y,4:いつ子供を産むか,5:体力消費量,6:子の初期体力]
Living[1] = [5,100,15,20,12,6,5];
Living[2] = [5,100,15,30,12,6,5];
let dead = 0;//死亡数
if(cycle != 0){ //propが0でない→二週目以降の場合zs,deadに前回のzs,deadを代入
Living = Living2;
dead = dead2
}
for(let i = cycle ; i < 1000000 ; i++){ //生命体シミュレーションのメイン
let current_time = new Date(); // 現在時刻を取得する
let difference = parseInt( (current_time.getTime() - start_time.getTime()) / (1000 * 60) ); // 実行時間取得
if(difference >= 4){ //4分を超えていたら中断処理
// スクリプトプロパティの更新
properties.setProperty("cycle",i); //サイクルを保存
properties.setProperty("Living",JSON.stringify(Living)); //残存生命体の情報を保存
properties.setProperty("Dead",dead); //死亡数を保存
// 関数"main"を起動する新しいトリガーを設定して作成
ScriptApp
.newTrigger("main") //実行する関数はmain
.timeBased() //イベントのソースは時間主導型
.everyMinutes(1) //起動頻度は1分毎
.create(); //作成
return ;
}else{
/* 通常処理 */
let SS = sheet.getRange(1,1,30,40).getValues(); //領域全体をSS配列に格納
for (let k = 0; k < Living.length;k++){
if(Living[k][0]>Living[k][4]){ //体力がXたまったらリセットして子供を産む ※この子供を産む閾値と子供の初期体力、子供を産むのに消費する体力で個体差を出している
let whereBornX = (Math.floor(Math.random() * 5)-2)*3; //whereBornX -6,-3,0,3,6 子供を産む場所を親から見てランダムな座標に決定
if(Living[k][2]+whereBornX > 28 ||1 >Living[k][2]+whereBornX){whereBornX = 0}
let whereBornY = (Math.floor(Math.random() * 5)-2)*3; //whereBornY -6,-3,0,3,6
if(Living[k][3]+whereBornY > 38 ||1 >Living[k][3]+whereBornY){whereBornY = 0}
let growWNG = Math.floor(Math.random() * 5)-2; //growWhenNewGen -2,-1,0,1,2 子供を産む体力の閾値をランダムに増減(12より小さくならない)
if(12 >Living[k][4]+growWNG){growWNG = 0}
let growHHL = Math.floor(Math.random() * 5)-2; //growHowHPLoose -2,-1,0,1,2 子供を産むときに消費する体力をランダムに増減(4より小さくならない)
if(4 >Living[k][5]+growHHL){growHHL = 0}
let growHHF = Math.floor(Math.random() * 5)-2; //growHowHPFirst -2,-1,0,1,2 子供の初期体力をランダムに増減(5より小さくならない)
if(5 >Living[k][6]+growHHF){growHHF = 0}
if(Living[k][6]+growHHF >= Living[k][4]+growWNG){growHHF = Living[k][4]+growWNG - Living[k][6]+growHHF} //子供の初期体力が子供を産むときに消費する体力以上にならないようにする処理
Living.push([Living[k][6],100,Living[k][2]+whereBornX,Living[k][3]+whereBornY,Living[k][4]+growWNG,Living[k][5]+growHHL,Living[k][6]+growHHF]); //新しい子を産む(zs配列に追加する)
SS[Living[k][2]+whereBornX][Living[k][3]+whereBornY] == ""; //生まれる先の座標を空にする(餌が入っていると即成長して子供を産むため)
Living[k][0] = Living[k][0]-Living[k][5]; //親の体力から消費する量を引く
}
else if(Living[k][0] < 1){ //体力が0以下になった時に殺す処理
dead++;
Living.splice(k,1);
}else{
Living[k] = move(Living[k][0],Living[k][1],Living[k][2],Living[k][3],SS).concat(Living[k][4],Living[k][5],Living[k][6]); //k番目の生命体を移動
}
}
for(let l = 0;l<10;l++){ //餌のランダムな座標への配置 今は1サイクルあたり10回
let randFeedx = Math.floor(Math.random() * 30); //x軸のランダムな座標の取得
let randFeedy = Math.floor(Math.random() * 40); //y軸のランダムな座標の取得
SS[randFeedx][randFeedy] = "餌"; //SS配列の取得した座標に餌を代入
}
console.log(Living);
sheet.getRange(1,1,30,40).setValues(SS); //SS配列のスプレッドシートへの反映
SpreadsheetApp.flush(); //スプレッドシートの更新
//いつ子供を産むか,体力消費量,子の初期体力のそのサイクルでの平均を見るための処理
let ave = [0,0,0];
for(let m = 0;m < 3;m++){
for(let n = 0;n < Living.length;n++){
ave[m] = ave[m] + Living[n][4+m];
}
ave[m] = ave[m]/Living.length;
}
//スプレッドシートに表示する内容の処理
let SSF = SS.flat();
let esaN = SSF.filter(function(element){return element == "餌";}).length; //一次元化したSS配列から餌以外のものを取り除いたものの要素数を餌の数とする
console.log("生存数:"+Living.length); //生命体の配列の長さ→生存している生命の数
console.log("死亡数:"+dead); //deadに保存されている死亡した回数→死亡数
sheet.getRange("AQ2:AQ8").setValues([[i],[Living.length],[dead],[esaN],[ave[0]],[ave[1]],[ave[2]]]); //毎回切り替わる縦に並んだ表示
sheet.getRange(i+2,46,1,7).setValues([[i,Living.length,dead,esaN,ave[0],ave[1],ave[2]]]); //グラフを作るための横に並んだ記録
if(Living.length == 0){ //生存数が0→全滅したら
console.log("創世後"+i+"サイクル経過、全滅。プログラム終了");
//結果のグラフを作る処理
let result = sheet.newChart()
.addRange(sheet.getRange(1,46,sheet.getLastRow(),7)) //グラフの要素の範囲を指定
.setChartType(Charts.ChartType.LINE) //グラフのタイプを折れ線に
.setPosition(1,1,0,0) //グラフの作成座標を指定
.setOption("title","絶滅までの軌跡") //グラフのタイトルを指定
.setOption("series",{ //右軸を使ってほしい要素を指定
3: {targetAxisIndex:1}, // 第4,5,6系列は右のY軸を使用
4: {targetAxisIndex:1}, // 第4,5,6系列は右のY軸を使用
5: {targetAxisIndex:1}, // 第4,5,6系列は右のY軸を使用
})
.setNumHeaders(1); //使用する要素の1行目を要素名に指定
sheet.insertChart(result.build()); //グラフを生成
sheet.setName(i+"サイクルで滅んだ世界"); //シート名を変更
i = 1000000; //forループを抜ける
}
}
}
return ;
}//func_main
function move(ene,j,x,y,SS){ //生命体を移動させる関数。引数として[体力、方向、今の座標X、Y、SS]を持つ
let num = Math.floor(Math.random() * 3); //0,1,2 進行方向のランダムな判定
if(num == 1){j++;} //1なら右に回転
else if(num == 2){j--;} //2なら左に回転、0なら直進
SS[x][y]=null; //元居た場所を空欄にする
//座標が端によりすぎていた場合は内側に移動する
if(x >28){x = 25}
if(x <1){x = 10}
if(y >38){y = 35}
if(y <1){y = 10}
if(j%4 == 0){x--;} //向いている方角の判定とそれによって進む先に1マス進む処理
else if(j%4 == 1){y++;}
else if(j%4 == 2){x++;}
else if(j%4 == 3){y--;}
let enemax = ene; //そのときの体力
for(let m = 0; m < enemax; m++){ //移動した先によって処理を変えるためのループ、enemaxが上限なのは移動先に先客がいてもう一度移動するという処理を無限に繰り返さないため
if(SS[x][y] == "餌"){ //餌があった場合、体力に3を足してループを抜ける
ene=ene+3;
m = enemax;
}else if(SS[x][y] != ""){ //餌ではなく、他の生命体、生命体の死体があった場合、体力を1消費して移動の処理を再度行う
let num = Math.floor(Math.random() * 3); //move関数開始直後の処理と同じ。何かもっといい書き方ありそう
if(num == 1){j++;}
else if(num == 2){j--;}
SS[x][y]=null;
if(x >28){x = 25}
if(x <1){x = 10}
if(y >38){y = 35}
if(y <1){y = 10}
if(j%4 == 0){x--;}
else if(j%4 == 1){y++;}
else if(j%4 == 2){x++;}
else if(j%4 == 3){y--;}
ene--;
}else{ //空き地だった場合、体力を1消費してループを抜ける
m = enemax;
ene--;
}
}
SS[x][y] = ene;
let ans = [ene,j,x,y];
return ans;
}
// 特定のトリガーを全て削除する関数
function delete_specific_triggers( name_function ){
let all_triggers = ScriptApp.getProjectTriggers();
for( let i = 0; i < all_triggers.length; ++i ){
if( all_triggers[i].getHandlerFunction() == name_function )
ScriptApp.deleteTrigger(all_triggers[i]);
}//
}//
実行する上での注意
注意
・実行の際は、4分強の実行の後に最大1分間何も処理されない時間が存在します。これは次の実行を行うまでのインターバルです。
・滅ぶまでのサイクル数が多くなった場合、終了後に作成されるグラフが重い可能性があります。
最後に
実行後のグラフを見ると、生存数と餌の数がきれいな曲線を描き、一定のディレイを持ちながら相互に影響しあっていることがわかります。大変興味深いです。ぜひ様々に数値をいじって実行してみてください。
$$\style{align: center; font-family: "Helvetica Neue",Helvetica,"ヒラギノ角ゴ ProN W3","Hiragino Kaku Gothic ProN","メイリオ",Meiryo,sans-serif}{\text{画像5.333サイクルで滅んだある世界。餌の曲線が大変綺麗}}$$
今後実装してみたいこと
- 色々なパラメーターをシートを実行するときに選択可能にする。
- 他の生命体を食べることができる肉食の生命体(移動量が大きい)
- 突然肉食になる突然変異
- 一度に二匹の子供を産む個体
- より高度な学習らしきもの
- 見やすいコードに書き換える
閲覧ありがとうございました。
参考文献
[GAS]実行時間6分の壁を越えよう(不死鳥関数編)
https://qiita.com/s_maeda_fukui/items/d194c6408803229fe1b9
Markdown記法 サンプル集
https://qiita.com/tbpgr/items/989c6badefff69377da7
Markdown記法 チートシート
https://qiita.com/Qiita/items/c686397e4a0f4f11683d