2011年6月17日金曜日

ブラウザ三国志でダイクストラを行ってみる。



「ブラウザ三国志」というゲームがあります。
そのゲームでは領地の奪い合い、砦攻略を目指して軍を進めていきます。
基本ゲームはしませんが、「ソーシャルゲーム」という分野においての勉強としてずっと続けています。いやバランス良くて本当に面白い。。。ってそんな事やってる場合じゃない><


ChromeExtentionの仕組みみたいな部分での調査が大きいので「処理速度」や汚さは勘弁してください。あくまでそんな事できるんだ。程度でお読みください。

思い立ち

さてブラウザ三国志で砦攻略で重要になってくるのが、座標計算です。
次の砦に向かうという時に領地レベル☆1をめがけて経路を作ってつなげていきます。
自分1人でやっていくのはいいのですが、「同盟」を組んで同盟員とつなげていくと

「今度ここー」
「次私ここー」

みたいな事が起こってきます。
それを掲示板等で共有して進んでいくわけです。ソーシャルですねぇー。

で「ここ」って決めてく経路があるのです。
で、既にツール等もあるのですが、せっかくだから自分で作ってみよー!って思ったわけです。
ひとまずChromeExtensionで行きます。

今回は簡単に「51x51マップ」内で指定した座標間を結ぶ。をやってみましょう。

動きとしては
・51x51マップ時のみに動作する
 ・座標を取得してくる
 ・ダイクストラで経路を算出する
 ・算出した経路をHTML表示する

って流れですかね。

Chrome Extentionを作成してみよう!

さて、まずはボタンを表示してみましょう。
ここを元に構成を勉強して、、、とひとまずHTMLを書けました。

こんな感じ。





ブラ三おなじみのアイコンで、
アイコンをクリックするとHTMLを出力してくれるようになりました。

座標データを収集

さて、このツールは何につけても座標データが存在しないといけません。

って事で
・HTMLのデータを取得
・そこから座標データを抜き出す!
という流れを説明します。

まず51x51マップか、、、という判定。
既存のページ(対象のHTML)の要素を取得するには先ほどのページで使った
「browser_action」とは別に「content_scripts」という仕組みを使用する方法があります。

以下をmanifest.jsonに追記します。

"content_scripts": [
{
"matches": [
"http://m21.3gokushi.jp/big_map.php*"
],

"js" : [
"script.js"
],

"run_at": "document_end",
"all_frames": true
]

※要素のつなぎは「,」を忘れずに。

という風に記述します。
matchesはそのページに来たら。jsは使用するJavaScript、run_atはどのタイミングで動作するか?です。
all_frameはすべてのフレームに対して処理を行うか?を記述します。

manifest.jsonは更新したら再読込の必要あります。
これらを思い通りに動かせる(読み込み失敗とかがない)ようになるまでは
Googleのトップページで練習した方が良いかもです。

ブラウザ三国志(mixi)ですが難しいのはページの構成ですね。
URLはもちろんmixiを指しますが、アプリ自体は
「"http://m21.3gokushi.jp/big_map.php*"」にアクセスしています。
なので、それをマッチに入れて、Frameをtrueにしているわけです。

ここまでで、ひとまず「全体地図」のクリックで動作するか確認しておくべきでしょう。

そこまでできたらアプリケーションが使用しているHTMLをハックします。
そうすれば



というコードが存在するので、このデータを


var map51 = document.getElementById( "map51-wrapper" );
if ( map51 == null ) {
return;
}


という感じでデータを取得してきます。
その中に

<a
href=​"/​land.php?x=-22&y=-52#ptop"
onmouseover=​"return gloss('

<​dl class="​bigmap"​>
​<​dt class="​bigmap-caption"​>​空き地<​/​dt>
​<​dd class="​bigmap-subcap"​>​&​nbsp;​<​/​dd>
​<​dt>​座標&​nbsp;​/​&​nbsp;​距離<​/​dt>​
<​dd>​(-22,-52)​&​nbsp;​/​&​nbsp;​[35.36]​<​/​dd>​
<​dt>​戦力<​/​dt>
​<​dd>​★<​/​dd>​
<​dt class="​bottom-popup-l"​>​資源<​/​dt>
​<​dd class="​bottom-popup-r"​>​木1&​nbsp;​岩0&​nbsp;​鉄0&​nbsp;​糧0<​/​dd>​<​/​dl>​'

)​;​" onmouseout=​"nd()​;​">​
1​
</a>


というデータがありますので取得します。

// ULタグを取得
var mapUlList = map51.getElementsByTagName("ul");
for ( var ulIdx = 0; ulIdx < mapUlList.length; ++ulIdx ) {
// 内部のLiタグを取得
var mapLiList = mapUlList[ulIdx].getElementsByTagName("li");
for ( var liIdx = 0; liIdx < mapUlList.length; ++liIdx ) {
// Aタグを取得
var linkTag = mapLiList[liIdx].getElementsByTagName("a")[0];
dijkstra.pointArray[ulIdx][liIdx] = createPoint(linkTag);
}
}


createPoint(linkTag)で座標データを解析しています。


var createPoint = function ( linkTag ) {

var point = {};
point.x = 0;
point.y = 0;
point.team = "";
point.lv = 0;

var lv = replaceAll(linkTag.innerHTML,"\n","");
if ( lv != "1" && lv != "2" && lv != "3" && lv != "4" && lv != "5" ) {
return point;
}
point.lv = parseFloat(lv);
if ( point.lv != 1 ) {
point.lv = parseFloat(lv) * parseFloat(lv);
}
// 文字列に変換
var text = linkTag.outerHTML;

// リンクタグの解析
var firstIdx = text.indexOf("'");
var lastIdx = text.lastIndexOf("'");
var data = text.slice(firstIdx+1,lastIdx);

data = replaceAll(data,"<","<");
data = replaceAll(data,">",">");
data = replaceAll(data,""","'");
data = replaceAll(data,"&","&");
data = replaceAll(data,"&n"," ");
data = replaceAll(data," bsp;"," ");

var dp = new DOMParser();
var doc = dp.parseFromString(data, "text/xml");

var dtTags = doc.getElementsByTagName("dt");
var ddTags = doc.getElementsByTagName("dd");

// タグ数回繰り返す
for ( var idx = 0; idx < dtTags.length; ++idx ) {

var dtTag = dtTags[idx];
var tagVal = dtTag.firstChild.data;

if ( tagVal == "君主名" ) {
point.team = ddTags[idx].firstChild.data;
} else if ( tagVal == "座標 / 距離") {
var tagData = ddTags[idx].firstChild.data;
var startP = tagData.indexOf("(");
var endP = tagData.lastIndexOf(")");
var pointData = tagData.slice(startP+1,endP);
var pointArray = pointData.split(",");
point.x = pointArray[0];
point.y = pointArray[1];
}
}

return point;
};


相手が存在する場合等もありますから、
x,y,同盟名,lv(領地の強さ)を設定したpointオブジェクトを貯めこんでおきます。
パーサを使用していますが、大きなマップ等になったら変更する予定ではあります。
※51x51ではそんなに重くなかった。

。。。さぁこれで座標データの抜き出しはOKです。
ダイクストラを行ってみましょう!

ダイクストラで最短経路を算出

さて座標データはすべて取得できたので、そのデータから最短経路を算出してみましょう。
実際のソースを貼っておきます。



//Map51x51かを確認
var map51 = document.getElementById( "map51-content" );
if ( map51 == null ) {
return;
}

var MAX_X = 51;
var MAX_Y = 51;

// ダイクストラのロジック
var dijkstra = {};

// 座標データ
dijkstra.pointArray = new Array(MAX_X);
for ( var idx = 0; idx < dijkstra.pointArray.length; ++idx ) {
dijkstra.pointArray[idx] = new Array(MAX_Y);
}

// 経路算出
dijkstra.getRoute = function(startX,startY,endX,endY){

// その座標まで行くルート
var workRouteArray = new Array(MAX_X);
// 決定している距離
var pointArray = new Array(MAX_X);
// すべての配列を初期化
for ( var xidx = 0; xidx < workRouteArray.length; ++xidx ) {
workRouteArray[xidx] = new Array(MAX_Y);
pointArray[xidx] = new Array(MAX_Y);
for ( var yidx = 0; yidx < pointArray[xidx].length; ++yidx ) {
workRouteArray[xidx][yidx] = null;
pointArray[xidx][yidx] = Number.POSITIVE_INFINITY;
}
}

// 作業する座標
var workPointArray = new Array();

// 始点オブジェクト
var nowPoint = dijkstra.pointArray[startX][startY];
var nowX = startX;
var nowY = startY;
var nowLen = 0;

// 作業用のリストを取得する
workRouteArray[nowX][nowY] = new Array();
var nowRoute;

while ( true ) {

pointArray[nowX][nowY] = nowLen;
nowRoute = workRouteArray[nowX][nowY];

// 近隣の座標を算出
for ( var addX = -1; addX <= 1; ++addX ) {

var wkX = nowX + addX;
if ( wkX < 0 ) continue;
if ( wkX >= MAX_X ) continue;

// 周りを見渡す
for ( var addY = -1; addY <= 1; ++addY ) {

var wkY = nowY + addY;
if ( wkY < 0 ) continue;
if ( wkY >= MAX_Y ) continue;

// 既に決定済の場合
if ( pointArray[wkX][wkY] != Number.POSITIVE_INFINITY ) {
continue;
}

// その座標のルートを取得
var workRoute = workRouteArray[wkX][wkY];
// そこに行っていいのか?
if ( workRoute === undefined ) {
continue;
}

// 作業中の場所を取得
var point = dijkstra.pointArray[wkX][wkY];

//既に存在した場合
if ( workRoute != null ) {
var workLen = workRoute.length;
var nowRouteLen = nowRoute.length;
//作業用のリストが短い場合
if ( (workLen - 1) <= nowRouteLen ) {
continue;
}

var workLv = 0;
//TODO レベル加算を見る必要あり
for ( var idx = 0; idx < workLen; ++idx ) {
var point = workRoute[idx];
var lv = parseFloat(point.lv);
workLv = workLv + lv;
}

if ( (nowLen + parseFloat(point.lv)) > workLv ) {
continue;
}
}

// 行けない場所だった場合
if ( point.lv == "0" ) {
// 行っちゃダメにする
workRouteArray[wkX][wkY] = undefined;
continue;
}

var newRoute = new Array();
for (var i=0, l= nowRoute.length; i<l; i++) {
newRoute[i] = nowRoute[i];
}

//その地点を設定
newRoute.push(point);
// 作業用のルートを作成
workRouteArray[wkX][wkY] = newRoute;

// 作業の配列に座標を指定
var workPoint = {};
workPoint.x = wkX;
workPoint.y = wkY;

workPointArray.push(workPoint);
}
}

//console.log(workPointArray.length);
//for ( var cnt = 0; cnt < workPointArray.length; ++cnt ) {
//console.log("作業リスト" + workPointArray[cnt].x + ":" + workPointArray[cnt].y);
//}

var minLen = Number.POSITIVE_INFINITY;
var deleteIdx = 0;

// 作業リスト数回繰り返す
for ( var cnt = 0; cnt < workPointArray.length; ++cnt ) {

var workPoint = workPointArray[cnt];
var wkX = workPoint.x;
var wkY = workPoint.y;

var workRoute = workRouteArray[wkX][wkY];
var wkLen = 0;

for ( var idx = 0; idx < workRoute.length; ++idx ) {
var point = workRoute[idx];
var lv = parseFloat(point.lv);
wkLen = wkLen + lv;
}

// 設定できた場合
if ( minLen == Number.POSITIVE_INFINITY || wkLen < minLen ) {
minLen = wkLen;

nowX = wkX;
nowY = wkY;
nowLen = wkLen;
deleteIdx = cnt;
}
}


// 行く所がない場合
if ( minLen == Number.POSITIVE_INFINITY ) {
break;
}
workPointArray.splice(deleteIdx, 1);

// 現在の点を編集
nowPoint = this.pointArray[nowX][nowY];
//console.log("[" + nowPoint.x + "," + nowPoint.y + "]" + nowPoint.lv );
// 終点と同一点だった場合
if ( nowX == endX && nowY == endY ) {
// 作業ルートから取得
return workRouteArray[endX][endY];
}
}

// 決まらなかった場合
return null;
};

var nbsp = String.fromCharCode( 160 );
// 全ての文字列 s1 を s2 に置き換える
function replaceAll(expression, org, dest){
return expression.split(org).join(dest);
}

var createPoint = function ( linkTag ) {

var point = {};
point.x = 0;
point.y = 0;
point.team = "";
point.lv = 0;

var lv = replaceAll(linkTag.innerHTML,"\n","");
if ( lv != "1" && lv != "2" && lv != "3" && lv != "4" && lv != "5" ) {
return point;
}
point.lv = parseFloat(lv);
if ( point.lv != 1 ) {
point.lv = parseFloat(lv) * parseFloat(lv);
}
// 文字列に変換
var text = linkTag.outerHTML;

// リンクタグの解析
var firstIdx = text.indexOf("'");
var lastIdx = text.lastIndexOf("'");
var data = text.slice(firstIdx+1,lastIdx);

data = replaceAll(data,"<","<");
data = replaceAll(data,">",">");
data = replaceAll(data,""","'");
data = replaceAll(data,"&","&");
data = replaceAll(data,"&n"," ");
data = replaceAll(data," bsp;"," ");

var dp = new DOMParser();
var doc = dp.parseFromString(data, "text/xml");

var dtTags = doc.getElementsByTagName("dt");
var ddTags = doc.getElementsByTagName("dd");

// タグ数回繰り返す
for ( var idx = 0; idx < dtTags.length; ++idx ) {

var dtTag = dtTags[idx];
var tagVal = dtTag.firstChild.data;

if ( tagVal == "君主名" ) {
point.team = ddTags[idx].firstChild.data;
} else if ( tagVal == "座標 / 距離") {
var tagData = ddTags[idx].firstChild.data;
var startP = tagData.indexOf("(");
var endP = tagData.lastIndexOf(")");
var pointData = tagData.slice(startP+1,endP);
var pointArray = pointData.split(",");
point.x = pointArray[0];
point.y = pointArray[1];
}
}

return point;
};


console.log("start");
// ULタグを取得
var mapUlList = map51.getElementsByTagName("ul");
for ( var ulIdx = 0; ulIdx < mapUlList.length; ++ulIdx ) {
// 内部のLiタグを取得
var mapLiList = mapUlList[ulIdx].getElementsByTagName("li");
for ( var liIdx = 0; liIdx < mapUlList.length; ++liIdx ) {
// Aタグを取得
var linkTag = mapLiList[liIdx].getElementsByTagName("a")[0];
dijkstra.pointArray[ulIdx][liIdx] = createPoint(linkTag);
}
}

console.log("getRoute()");
var route;
route = dijkstra.getRoute(0,0,50,50);
if ( route != null ) {
console.log("route log:" + route.length);
for ( var idx = 0; idx < route.length; ++idx ) {
var point = route[idx];
console.log("[" + point.x + "," + point.y + "]" + point.lv );
}
}
console.log("end");


このコードでは0,0 - 51,51を算出するわけですが、あくまで動作確認ように行なっているだけです。
実際には、始点座標から終点座標を入力するのが良いですね。

実際のコードのcreatePoint()はLvに対してべき乗の重みを付けています。


画面に表示してみよう

さて、座標は判定できたのでそのままHTMLに表示していきます。

route = dijkstra.getRoute(0,0,50,50);
var pointArray = new Array();

//経路のリストを連想配列に入れる
for ( var idx = 0; idx < route.length; ++idx ) {
var point = route[idx];
pointArray[point.arrayX + "," + point.arrayY] = point;
}

for ( var ulIdx = 0; ulIdx < mapUlList.length; ++ulIdx ) {
// 内部のLiタグを取得
var mapLiList = mapUlList[ulIdx].getElementsByTagName("li");
for ( var liIdx = 0; liIdx < mapUlList.length; ++liIdx ) {

var idx = pointArray[ulIdx + "," + liIdx];
if ( idx === undefined ) {
continue;
}
// Aタグを取得
var liTag = mapLiList[liIdx];
liTag.style.backgroundColor = "#555555";

}
}


配列のポイントがあった方が処理しやすかったので
routeのオブジェクトにarrayX,arrayYを設定しています。

あとはULとLI回回して背景を変更するって感じです。
で出来上がったマップが以下の通り。


コードにより☆5までしか判定してないので最終地点に拠点等が存在すると
経路算出でエラーになります。

ここに載っているように世界にすぐ配信できます。
権利関係微妙ですが他のツール等に比べるとおとなしいので公開しておきました
誰かアイコン作って!(128x128pngしか受け付けない><)

仕事中に呆けて遊んでいたのでやっとアウトプットできた感じ><

今回のマップのダイクストラでの醍醐味は
重さが領地のレベルである事。☆2を進んだ時に遠征だときついだとか
その辺を考慮して重さを設定するところですね。

このソースを利用してマップをDBに貯めこんで計算してとかやれば大規模な遠征の道順等も教えてくれるはずです。
これをつくりはじめた時に、AppEngineを利用して
座標を全ユーザから送信してもらって、それで地図を更新して行くという仕様を考えましたけど
本気で行くと有料レベルになると思って止めました。
※お金発生してもOKですけど、お金取る(CM)とか入れると厄介かなぁと。。。

今回はゲーム周りでしたが、Tweetボタンやいいねボタンがないようなページを
簡単に共有したり、その他他人のWebページに1つエッセンスを加えて処理をやりやすくしたり、
Webのマクロみたいなイメージで処理する事が可能になります。

ChromeExtentionはChromeOSの登場によって
ChromeOS上で動作するアプリケーションを作れる事になります。
ChromeOSがどの程度市場を席巻するかは不明ですが、1つのアプリの公開形態としては
注視しておいても良いでしょう。

さぁ同盟員に書簡して使ってもらおう!

0 件のコメント: