構成の見直し
PHPポケモンも38回となり、大分作り込みが出来てきました。ここ最近はコードの説明ばかりでデモページなども準備出来ていませんでしたが、それには內部側の問題点が多かったためです。今回はその辺りをキレイに解決できるよう、本格的な構成の見直しをします。
ちなみにですが、どれぐらいの見直しをしたかというと
ほぼ全入れ替えというレベルです
ではまず、簡単にファイル構成を説明しておきます。
App(処理関係)
―Controllers
―Services
―Traits
Classes(クラス関係)
―Move
―Pokemon
―StateChange
―StatusAilment
―Type
Public(公開ディレクトリ)
―Assets
―index.php
Resources(テンプレート等)
―Lang
―Pages
―Partials
Storages(保存データ)
―Sessions
こんな感じです。
ClassesもAppの中に入れたほうが良いか迷ったのですが、一旦現状のままにしています。ただ、ファイル数も多くなり分けて置くほうが可視性も管理も良くなるのであれば、状況に応じて変更します。
また、流石にオートローダーを使っている関係上公開ディレクトリに置くわけにはいかなくなったので、こちらも変更しました。もし後ほど配布するコードで遊ぶ人は、公開ディレクトリにPublicを指定するようにしてください。
トークン認証とは
PHPポケモンの最大の問題点は「Webブラウザで動く」ということです。なぜこれが問題点になっているかと言うと、本来ゲームでは「戻る(ヒストリーバック)」や「更新(ページリロード)」という機能は備えられていません。しかし、Webブラウザの機能としてそれらが存在しているため、これらは避けて通ることができません。
37回まで作り込んできたPHPポケモンでは、更新すると再度POSTが発生してしまうということがありました。
例
経験値取得 → 更新 → 再度経験値が取得できる
バトル画面 → 戻る → エラー
更新に関しては、デバック時には役立つことが多かったのですが、これをプレイを想定すれば大きな欠陥であることは否めません。そこで今回採用したのが「トークン認証」です。
ページ最上部
/**
* トークン発行
*/
$_SESSION['__token'] = bin2hex(openssl_random_pseudo_bytes(32));
フォーム内
<input type="hidden" name="__token" value="<?=$_SESSION['__token']?>">
ページ生成時にトークンを発行して、セッションに格納しておきます。後は、各フォームにhiddenで同じトークンを送信するようにしておけば、次のページへ移管した際に照合して、トークンが異なっていれば処理を中断または行わないという選択肢が生まれます。
今回はこのトークン認証を使い、一致すればそのまま処理実行、一致しなければポストデータを破棄するということで更新時の2重処理を回避しました。
サニタイズとは
次にサニタイズです。前回もこちらは簡易ながら実装済みでしたが、今回はもう少しだけしっかりとサニタイズ処理をかけていきます。
サニタイズ(Sanitize.php)
<?php
// サニタイズ
class Sanitize
{
/**
* @var array
*/
private $post = [];
/**
* @return void
*/
public function __construct()
{
$this->post = $this->sanitize($_POST);
}
/**
* @return array
*/
public function sanitize($array)
{
$post = [];
foreach($array ?? [] as $key => $data){
if(preg_match('/^__/', $key)){
// 接頭語にアンダーバーが2つついていればサニタイズ不要
continue;
}
if(is_array($data)){
// 配列ならループ
$post[htmlspecialchars($key)] = $this->sanitize($data);
}else{
$post[htmlspecialchars($key)] = htmlspecialchars($data);
}
}
return $post;
}
/**
* @return array
*/
public function getPost()
{
return $this->post;
}
}
クラスとして用意しました。サニタイズクラスを呼び出すことで、ポストデータをサニタイズしたものをpostプロパティに格納してくれます。システム内では特別なものを除いてグローバル変数のPOSTデータを使用せず、サニタイズ後のものをgetPostで受け取ってから活用します。
処理の流れ
では、処理の流れも不具合に合わせて修正していきましょう。今までは各ページに合わせたコントローラーを呼び出し、そこから分岐をさせて処理をさせるというコントローラーを主軸にした構成でしたが、今回の見直しで以下の3ステップによる処理の流れを組み上げていきます。
- ルーティング
- コントローラー
- サービス
それでは、1つずつ役割を見ていきましょう。
ルーティング
URLの存在がある以上、直接アクセスされてしまうと対処が難しくなります。なので、同一URLを用いたセッションによるページ判別を行い、それに合わせたテンプレートを返却するという方法に変更します。
ルーティング(Route.php)
<?php
// ルーティング
class Route
{
/**
* @var string
*/
private $name;
/**
*
* @return void
*/
public function __construct($name, $token)
{
$this->name = $name;
if(
!isset($_POST['__token']) ||
($_POST['__token'] !== $token)
){
$_POST = [];
}
}
/**
* テンプレートパス取得
* @return string
*/
public function template():string
{
switch ($this->name) {
// ホーム画面
case 'home':
$path = '/Resources/Pages/Home.php';
break;
// バトル画面
case 'battle':
$path = '/Resources/Pages/Battle.php';
break;
// デフォルト(初期設定)
default:
$path = '/Resources/Pages/Initial.php';
break;
}
// テンプレートパスを返却
return $path;
}
}
先程作成したトークン認証はルーティングで行なっています。ログイン機能などがある場合はコントローラーでする方が分岐を作りやすいですが、PHPポケモンでは画面の手順を判別するだけで用いられているのでこれで問題ありません。
コントローラー
次にコントローラーの役割ですが、今までは各トレイトを読み込みながら作り込んでいましたが、できるだけ個別の処理は次のサービスにひとまとめにするようにして、根幹となる処理分岐のみを行うようにしました。
バトルコントローラー(BattleController.php)
<?php
$root_path = __DIR__.'/../../..';
require_once($root_path.'/App/Controllers/Controller.php');
// サービス
require_once($root_path.'/App/Services/Battle/StartService.php');
require_once($root_path.'/App/Services/Battle/RunService.php');
require_once($root_path.'/App/Services/Battle/FightService.php');
// トレイト
require_once($root_path.'/App/Traits/Controller/BattleControllerTrait.php');
// バトル用コントローラー
class BattleController extends Controller
{
use BattleControllerTrait;
/**
* 敵ポケモン格納用
* @var object
*/
protected $enemy;
/**
* 逃走を試みた回数
* @var integer
*/
public $run = 0;
/**
* ひんし状態の格納
* @var array
*/
private $fainting = [
'friend' => false,
'enemy' => false,
];
/**
* @return void
*/
public function __construct()
{
// 親コンストラクタの呼び出し
parent::__construct();
// 引き継ぎ
$this->takeOver();
// 分岐処理
$this->branch();
// 次のターンへの分岐
$this->nextTurn();
// 親デストラクタの呼び出し
parent::__destruct();
}
/**
* 引き継ぎ処理
* @return void
*/
private function takeOver()
{
// にげるの実行回数を引き継ぎ
if(isset($_SESSION['run'])){
$this->run = $_SESSION['run'];
}
// ポケモンの引き継ぎ
$this->takeOverPokemon($_SESSION['__data']['pokemon']);
// 敵ポケモンの引き継ぎ
$this->takeOverEnemy($_SESSION['__data']['enemy'] ?? '');
}
/**
* アクションに合わせた分岐
* @return void
*/
private function branch()
{
try {
// アクション分岐
switch ($this->request('action')) {
/******************************************
* 開始
*/
case 'battle':
// サービス実行
$service = new StartService;
$service->excute();
// 実行結果
$this->enemy = $service->getResponse('enemy');
$this->setMessage($service->getMessages());
break;
/******************************************
* たたかう
*/
case 'fight':
// サービス実行
$service = new FightService(
$this->pokemon,
$this->enemy,
$this->request('param'),
);
$service->excute();
// 実行結果
$this->fainting = $service->getResponse('fainting');
$this->setMessage($service->getMessages());
break;
/******************************************
* にげる
*/
case 'run':
// 回数をプラス
$this->run++;
// サービス実行
$service = new RunService(
$this->pokemon,
$this->enemy,
$this->run
);
$service->excute();
// 実行結果
if($service->getResponse('result')){
// 成功
$this->setMessage($service->getMessages());
$_SESSION['__route'] = 'home';
// 破棄
unset(
$_SESSION['__data']['enemy'],
$_SESSION['__data']['rank'],
$_SESSION['__data']['sc'],
$_SESSION['__data']['run']
);
header("Location: ./", true, 307);
exit;
}else{
// 失敗
$this->fainting = $service->getResponse('fainting');
$this->setMessage($service->getMessages());
}
break;
/******************************************
* バトル終了
*/
case 'end':
$_SESSION['__route'] = 'home';
header("Location: ./", true, 307);
exit;
break;
/******************************************
* アクション未選択 or 実装されていないアクション
*/
default:
break;
}
} catch (\Exception $e) {
// 初期画面へ移管
$_SESSION['__route'] = 'initial';
header("Location: ./", true, 307);
exit;
}
}
/**
* 次のターンへの判定処理
*
* @return void
*/
private function nextTurn()
{
// ひんしポケモンがでた場合の処理
if($this->fainting['enemy'] || $this->fainting['friend']){
$this->judgment();
return;
}
// チャージ中なら再度アクション実行
if($this->chargeNow()){
$this->branch();
}else{
$this->setMessage('行動を選択してください');
}
}
}
コントローラーで行う内容は、あくまでアクションに対する分岐を担ってもらいます。分けることでそれぞれの処理で共通する内容をわけなければいけないということも出てきますが、処理を辿っていくという点においてはかなり改善しました。
サービス
最後に、新しく導入したサービスについてです。コントローラーの分岐を見てわかるように、それぞれの分岐に合わせてサービスクラスを呼び出しています。ダメージ計算や判定処理などのメイン処理はここで行なっています。
たたかう用サービス(FightService.php)
<?php
$root_path = __DIR__.'/../../..';
// 親クラス
require_once($root_path.'/App/Services/Service.php');
// トレイト
require_once($root_path.'/App/Traits/Service/Battle/ServiceBattleAttackTrait.php');
require_once($root_path.'/App/Traits/Service/Battle/ServiceBattleCheckTrait.php');
require_once($root_path.'/App/Traits/Service/Battle/ServiceBattleEnemyAiTrait.php');
require_once($root_path.'/App/Traits/Service/Battle/ServiceBattleOrderGenelatorTrait.php');
/**
* バトル開始
*/
class FightService extends Service
{
use ServiceBattleAttackTrait;
use ServiceBattleCheckTrait;
use ServiceBattleEnemyAiTrait;
use ServiceBattleOrderGenelatorTrait;
/**
* @var object Pokemon
*/
protected $pokemon;
/**
* @var object Pokemon
*/
protected $enemy;
/**
* @var integer
*/
protected $move_number;
/**
* ひんし状態の格納
* @var array
*/
protected $fainting = [
'friend' => false,
'enemy' => false,
];
/**
* @return void
*/
public function __construct($pokemon, $enemy, $move_number)
{
$this->pokemon = $pokemon;
$this->enemy = $enemy;
$this->move_number = $move_number;
}
/**
* @return void
*/
public function excute()
{
// 技取得
$p_move = $this->selectMove();
$e_move = $this->selectEnemyMove();
// 行動順の取得
$orders = $this->orderMove(
[$this->pokemon, $this->enemy, $p_move],
[$this->enemy, $this->pokemon, $e_move],
);
// 攻撃処理
if(!$this->actionAttack($orders)){
// どちらかがひんし状態になった
$this->exportProperty('fainting');
return;
}
// 行動後の状態異常・変化をチェック
$this->afterCheck();
// 指定したプロパティを返却
$this->exportProperty('fainting');
}
/**
* 選択された技を取得
*
* @return object Move
*/
private function selectMove()
{
// 自ポケモンの技をインスタンス化
if($this->move_number === ''){
// 技が未選択の場合は「わるあがき」をセット
return new Struggle;
}else{
return $this->pokemon
->getMove($this->move_number);
}
}
/**
* 相手ポケモンの技選択
*
* @return object Move
*/
private function selectEnemyMove()
{
// AIで技選択
$ai = $this->aiSelectMove();
// 敵の技をインスタンス化
return new $ai['class']($ai['remaining'], $ai['correction']);
}
/**
* 行動順に攻撃処理
*
* @return boolean (false: ひんしポケモン有り)
*/
private function actionAttack($orders)
{
foreach($orders as list($atk, $def, $move)){
// 攻撃
$this->attack($atk, $def, $move);
// ひんしチェック
$this->fainting = [
$atk->getPosition() => $this->checkFainting($atk),
$def->getPosition() => $this->checkFainting($def),
];
// どちらかがひんし状態なら処理終了
if($this->fainting['friend'] || $this->fainting['enemy']){
$result = false;
break;
}
} # endforeach
// 結果返却
return $result ?? true;
}
/**
* 行動後のチェック処理
*
* @return void
*/
private function afterCheck()
{
// 素早さで行動順を算出
$order = $this->orderSpeed(
[$this->pokemon, $this->enemy],
[$this->enemy, $this->pokemon],
);
// 順番に処理
foreach($order as list($atk, $def)){
// ひんしチェック(開始時に行動側がひんし状態になっていないか確認)
if($this->fainting[$atk->getPosition()]){
// ひんし状態になった
continue;
}
// 状態異常チェック
$this->checkAfterSa($atk);
// ひんし状況の格納
$this->fainting[$atk->getPosition()] = $this->checkFainting($atk);
// ひんしチェック
if($this->fainting[$atk->getPosition()]){
// どちらかがひんし状態になった
continue;
}
// 状態変化チェック
$this->checkAfterSc($atk, $def);
// ひんし状況の格納
$this->fainting = [
$atk->getPosition() => $this->checkFainting($atk),
$def->getPosition() => $this->checkFainting($def),
];
} # endforeach
}
}
個別のメソッドなどは、ほぼそのまま採用しています。あくまで記述箇所をバラしているに過ぎません。
こうしておけば、すべての処理でほぼ同じ順序とすることができるので、エラーが出た際にも対応がしやすくなります。
データの配布
第38回時点のコードをGitHubにアップしているので、ぜひ参考にしてください。というよりも、今回の変更点については1割も説明できていないので、PHPポケモンに興味がある人や、これを学習の一環として取り組んでいる人は、コードを見てどういった構成になっているのかを読んで確かめてみてください。
デモページは近日公開予定です。プレイを楽しみにしている人は、今しばらくお待ちください。
まとめ
いかがだったでしょうか。
今回のPHPポケモンではシステムの根幹となる構成と、トークン認証やサニタイズといった制御部分について取り上げました。
学習目的であれば構成を考えて作り上げていくことは理解を深めるためにも良いですが、お客さんへ納品するものを作る場合はCMSやフレームワークを使って管理することをオススメします。特にシステム要素が強いものや規模が大きいものであれば尚更です。
プログラミング学習中の方は、是非参考にしてくださいね。