今回はシンプルなスネークゲームを作成します。
公式のデモはこちらになります。
http://haxeflixel.com/demos/FlxSnake/
ソースコードはこちらです。
https://github.com/HaxeFlixel/flixel-demos/tree/master/Arcade%20Classics/FlxSnake/source
- Main.hx : ゲームの起動設定
- PlayState.hx : ゲームState
今回はシンプルに2ファイルのみとなります。
あとサウンド再生も行うので、こちらから flixel.wavをダウンロードしておきます。
https://github.com/HaxeFlixel/flixel-demos/tree/master/Arcade%20Classics/FlxSnake/assets
ダウンロードできたら、assets
フォルダに入れておきます。
ゲーム起動設定 (Main.hx)
画面サイズを320x240
、開始StateをPlayState
、skipSplashをtrue
に変更します。
class Main extends Sprite
{
var gameWidth:Int = 320;
var gameHeight:Int = 240;
var initialState:Class<FlxState> = PlayState;
var zoom:Float = -1;
var framerate:Int = 60;
var skipSplash:Bool = true; // スプラッシュを表示しない
var startFullscreen:Bool = false;
登場するゲームオブジェクトの整理
今回の登場オブジェクトを整理しておきます。
- SnakeHead : スネークの頭(これを操作する)
- SnakeBody : スネークの身体(これが伸びる)
- Fruit : フルーツ(Getすると体が伸びる)
スネークの頭を実装する (PlayState.hx)
まずはスネークの頭を出現させて動かせるようにします。
必要な変数を定義します。
class PlayState extends FlxState {
private var _snakeHead:FlxSprite; // スネークの頭
private var _blockSize:Int = 8; // ブロックのサイズ
private var _movementInterval:Float = 8; // 移動タイマー更新間隔
スネークゲームは8x8のグリッド単位で移動を行うので、ここでサイズを定義しています。また移動用のタイマーを定義しています。このタイマーがスネークの移動速度となり、ゲームプレイを続けるほどこのタイマー更新の間隔が短くなります。
続いて頭の生成処理を実装します。
/**
* 生成
**/
override public function create():Void {
super.create();
// 画面の中心座標を取得
var screenMiddleX:Int = Math.floor(FlxG.width / 2);
var screenMiddleY:Int = Math.floor(FlxG.height / 2);
// スネークの頭を生成
_snakeHead = new FlxSprite(screenMiddleX - _blockSize * 2, screenMiddleY);
_snakeHead.makeGraphic(_blockSize - 2, _blockSize - 2, FlxColor.OLIVE);
_snakeHead.facing = FlxObject.LEFT; // 初期状態は左向き
_offsetSprite(_snakeHead); // 描画位置をオフセット
add(_snakeHead);
// タイマーを初期化する
_resetTimer();
}
グリッドサイズは8x8なのですが、見た目上はつながらないように1ピクセル広げているため、描画用のサイズは2を引いた6x6となります。
facing
というのはスプライトの向きを表す値となります。そして描画位置のオフセットを _offsetSprite() で行い、_resetTimer() で移動用のタイマーを初期化しています。
頭の生成ができたので、次は_offsetSprite()という描画位置のオフセット関数を作ります。
/**
* オフセットを修正
* @param sprite スプライト
*/
private function _offsetSprite(sprite:FlxSprite):Void {
// 描画位置を1ドットずらす
sprite.offset.set(1, 1);
// 座標を中心基準にする
sprite.centerOffsets();
}
8x8のグリッドに合わせるため、6x6の矩形を1ピクセルずらして、座標を中心にしています。
さらに移動タイマーの初期化を実装します。
/**
* タイマー初期化
**/
private function _resetTimer(?timer:FlxTimer):Void {
// タイマーを作りなおして繰り返し呼び出されるようにする
new FlxTimer(_movementInterval / FlxG.updateFramerate, _resetTimer);
// 移動処理を行う
_moveSnake();
}
FlxTimerというのは、指定した時間が経過したあと、コールバック関数を呼び出すオブジェクトです。ここでは _resetTimer() を再び呼び出すようにしています。なお時間の単位は秒で、_movementInterval / FlxG.updateFramerate
としているので、1/(8/60)=7.5
1秒間に7.5回動き、1回で8px移動するので8px*7.5回=60px
となり、1秒あたり60pxの速さで移動することとなります。
そして、タイマー実行時に移動処理 _moveSnake()を行います。
移動処理は以下のように実装します。
/**
* スネークを移動する
**/
private function _moveSnake():Void {
// 向きに対応した方向に移動する
switch(_snakeHead.facing) {
case FlxObject.LEFT: _snakeHead.x -= _blockSize;
case FlxObject.RIGHT: _snakeHead.x += _blockSize;
case FlxObject.UP: _snakeHead.y -= _blockSize;
case FlxObject.DOWN: _snakeHead.y += _blockSize;
}
// 画面端で反対方向にワープする
FlxSpriteUtil.screenWrap(_snakeHead);
}
facingに向きが入っているのでそれにあった方向に移動するようにします。FlxSpriteUtil.sceenWrap()はその名の通り、画面端に到達した時に反対方向にワープする関数です。
最後にキーの入力処理です。
override public function update():Void {
super.update();
if(FlxG.keys.justPressed.ESCAPE) {
// ESCキーで強制終了する
throw new Error("Terminate.");
}
// キーの対応する方向へ移動する
if(FlxG.keys.anyPressed(["UP"]) && _snakeHead.facing != FlxObject.DOWN) {
_snakeHead.facing = FlxObject.UP;
}
else if(FlxG.keys.anyPressed(["DOWN"]) && _snakeHead.facing != FlxObject.UP) {
_snakeHead.facing = FlxObject.DOWN;
}
else if(FlxG.keys.anyPressed(["LEFT"]) && _snakeHead.facing != FlxObject.RIGHT) {
_snakeHead.facing = FlxObject.LEFT;
}
else if(FlxG.keys.anyPressed(["RIGHT"]) && _snakeHead.facing != FlxObject.LEFT) {
_snakeHead.facing = FlxObject.RIGHT;
}
}
update()関数では、押したキーに対応する方向に向きを設定しています。移動処理は_snakeMove()で行うので、ここでは向きを指定するのみです。なお、後ろには移動できない仕様となっているので、その方向に向くことができるかどうかチェック処理を入れています。
これでひとまずは移動できるようになりました。
スネークの体を実装する (PlayState.hx)
体を実装します。フィールドに_snakeBody
と_headPositions
を追加します。
class PlayState extends FlxState {
private var _snakeHead:FlxSprite;
private var _snakeBody:FlxSpriteGroup; // スネークの体
private var _blockSize:Int = 8;
private var _movementInterval:Float = 8;
private var _headPositions:Array<FlxPoint>; // 頭の座標配列
_snakeBody
は描画用で、_headPositions
は実際の位置の配列となります。
create()に体の生成処理を追加します。
/**
* 生成
**/
override public function create():Void {
super.create();
// 画面の中心座標を取得
var screenMiddleX:Int = Math.floor(FlxG.width / 2);
var screenMiddleY:Int = Math.floor(FlxG.height / 2);
// スネークの頭を生成
_snakeHead = new FlxSprite(screenMiddleX - _blockSize * 2, screenMiddleY);
_snakeHead.makeGraphic(_blockSize - 2, _blockSize - 2, FlxColor.OLIVE);
_snakeHead.facing = FlxObject.LEFT; // 初期状態は左向き
_offsetSprite(_snakeHead); // 描画位置をオフセット
add(_snakeHead);
// ------------ここから追加--------------
// 頭の座標を登録
_headPositions = [FlxPoint.get(_snakeHead.x, _snakeHead.y)];
// 体グループを生成
_snakeBody = new FlxSpriteGroup();
add(_snakeBody);
// 初期状態で体を2つつなげる(頭で1つ使う)
for(i in 0...3) {
_addSegument();
_moveSnake();
}
// ------------ここまで追加--------------
// タイマーを初期化する
_resetTimer();
}
_resetTimer()を呼び出すタイミングをずらす必要があるので、_resetTimer()の前に生成処理を追加しました。初期状態で体を2つつなげています。ループは3回まわしていますが、座標配列は頭を含めたものなので+1した3回となっています。
_addSegment()関数の実装は体グループにFlxSpriteを追加するだけの処理となります。
/**
* 体を取り付ける
**/
private function _addSegument():Void {
// 体を生成
// ここでは取り付けるだけで位置は画面外に配置するだけ
var segment:FlxSprite = new FlxSprite(-20, -20);
segment.makeGraphic(_blockSize-2, _blockSize-2, FlxColor.GREEN);
_snakeBody.add(segment);
}
_moveSnake()の移動処理関数を修正します。
/**
* スネークを移動する
**/
private function _moveSnake():Void {
// ------------ここから追加--------------
// 先頭に頭の座標を追加 (※)
_headPositions.unshift(FlxPoint.get(_snakeHead.x, _snakeHead.y));
if(_headPositions.length >= _snakeBody.members.length) {
// 体の数より大きければ末尾を削除
_headPositions.pop();
}
// ------------ここまで追加--------------
// 向きに対応した方向に移動する
switch(_snakeHead.facing) {
case FlxObject.LEFT: _snakeHead.x -= _blockSize;
case FlxObject.RIGHT: _snakeHead.x += _blockSize;
case FlxObject.UP: _snakeHead.y -= _blockSize;
case FlxObject.DOWN: _snakeHead.y += _blockSize;
}
// 画面端で反対方向にワープする
FlxSpriteUtil.screenWrap(_snakeHead);
// ------------ここから追加--------------
// 体の座標を設定
for(i in 0..._headPositions.length) {
var segment:FlxObject = _snakeBody.members[i];
if(segment != null) {
segment.setPosition(_headPositions[i].x, _headPositions[i].y);
}
}
// ------------ここまで追加--------------
}
移動処理は少しややこしいので説明をします。最初の追加する処理ですが、配列の先頭に頭の位置を追加しています。
そして身体全体の長さが本当の長さを超えたら末尾を削除するようにしています。
普通に考えると、末尾に追加したほうが分かりやすいように思えます。ですがその方法を取ると身体全体の位置を再計算する必要があります。それを避けるため、あえて先頭に追加していることとなります。
身体が長くなるほど再計算のコストが上がるので、これにより最適化できることとなります。
後半の追加部分は、作成した座標に従ってFlxSpriteの座標を更新しているだけとなります。
フルーツを実装する (PlayState.hx)
アイテムであるフルーツを実装します。フィールドに_fruit
を追加します。
class PlayState extends FlxState {
private var _snakeHead:FlxSprite;
private var _snakeBody:FlxSpriteGroup;
private var _fruit:FlxSprite; // フルーツ
private var _blockSize:Int = 8;
private var _movementInterval:Float = 8;
private var _headPositions:Array<FlxPoint>;
生成関数create()にフルーツの生成を追加します。
/**
* 生成
**/
override public function create():Void {
super.create();
// 画面の中心座標を取得
// ... (省略)
// タイマーを初期化する
_resetTimer();
// フルーツの配置
_fruit = new FlxSprite();
_fruit.makeGraphic(_blockSize - 2, _blockSize - 2, FlxColor.RED);
_offsetSprite(_fruit); // 描画位置をオフセット
// ランダムな位置に移動
_randomizeFruitPosition();
add(_fruit);
}
フルーツをランダムな位置に配置するため、_randomizeFruitPosition()を実装します。
/**
* フルーツをランダム配置
*/
private function _randomizeFruitPosition(?obj:FlxObject, ?obj2:FlxObject):Void {
var size:Int = _blockSize;
var x = FlxRandom.intRanged(0, Math.floor(FlxG.width/size) - 1);
var y = FlxRandom.intRanged(0, Math.floor(FlxG.height/size) - 1);
_fruit.x = x * size;
_fruit.y = y * size;
// 身体と衝突しなくなるまで再帰処理をする
FlxG.overlap(_fruit, _snakeBody, _randomizeFroutPosition);
}
配置座標の決定は、一度画面サイズをブロックサイズで割ってグリッド上の座標に変換します。そして配置場所を決定した後、スクリーン座標に戻します。
最後の衝突判定ですが、フルーツが身体の位置に重なって出現するとおかしくなるので、身体がない場所になるまで再帰処理を繰り返します。
フルーツを取れるようにする (PlayState.hx)
配置したフルーツを取れるようにします。ついでにスコアの計算と表示を実装します。
class PlayState extends FlxState {
private static inline var MIN_INTERVAL:Float = 2; // 移動タイマーの間隔の最小値
private var _snakeHead:FlxSprite;
private var _snakeBody:FlxSpriteGroup;
private var _fruit:FlxSprite;
private var _scoreText:FlxText; // スコアテキスト
private var _blockSize:Int = 8;
private var _movementInterval:Float = 8;
private var _headPositions:Array<FlxPoint>;
private var _score:Int = 0; // スコア
フィールドに定数MIN_INTERVAL
とスコア変数_score
を追加しました。
次にcreate()関数でスコア用のテキストを生成します。
/**
* 生成
**/
override public function create():Void {
super.create();
// 画面の中心座標を取得
// ...(省略)
// フルーツの配置
_fruit = new FlxSprite();
_fruit.makeGraphic(_blockSize - 2, _blockSize - 2, FlxColor.RED);
_offsetSprite(_fruit); // 描画位置をオフセット
// ランダムな位置に移動
_randomizeFruitPosition();
add(_fruit);
// スコアテキスト作成
_scoreText = new FlxText(0, 0, 200, "Score: " + _score);
add(_scoreText);
}
更新関数update()にフルーツ獲得判定を入れます。
override public function update():Void {
super.update();
// ...(省略)
// フルーツ獲得判定
FlxG.overlap(_snakeHead, _fruit, _cbCollectFruit);
}
_cbCollectFroutは、衝突時のコールバック関数ですね。
獲得時の関数は以下のように実装します。
/**
* フルーツ獲得処理
*/
private function _cbCollectFruit(obj1:FlxObject, obj2:FlxObject):Void {
// スコアを加算
_score += 10;
// スコアテキストを更新
_scoreText.text = "Score: " + _score;
// ランダムな位置にフルーツを移動
_randomizeFruitPosition();
// 身体が伸びる
_addSegument();
// アイテム獲得SEを再生
FlxG.sound.load(FlxAssets.getSound("assets/sounds/beep").play());
// スピードアップ
if(_movementInterval >= MIN_INTERVAL) {
_movementInterval -= 0.25;
}
}
フルーツを取るたびに_movementInterval
が0.25減ります。インターバルの計算式は、$y = 1/x$なので、最初はなだらかに加速しますが、後半かなり急な速度上昇をします。サウンド再生ですが、"assets/sounds/beep"はHaxeFlixelに標準で組み込まれているSEなのでそのまま使うことができます。
では実行して確認します。フルーツを取ると身体が伸びます。
やられ判定がないので、どこまでも伸ばすことができます。
ゲームオーバー判定を実装する(PlayState.hx)
最後にゲームオーバー判定を実装します。
update()関数に終了判定と、頭と体の衝突判定を追加します。ゲームオーバー後は、SPACEキーでリスタートできます。少し横着な作りですが、ゲームオーバー時はキー入力と衝突判定をしないようにします。
override public function update():Void {
super.update();
if(FlxG.keys.justPressed.ESCAPE) {
// ESCキーで強制終了する
throw new Error("Terminate.");
}
// -----------ここに追加------------
// 終了判定
if(_snakeHead.alive == false) {
// プレイヤーが死んでいたら
if(FlxG.keys.justReleased.SPACE) {
// SPACEキーでリスタート
FlxG.resetState();
}
// 死んでいるのでこれ以降の処理を行わない
return;
}
// -----------ここまで追加------------
// キーの対応する方向へ移動する
if(FlxG.keys.anyPressed(["UP"]) && _snakeHead.facing != FlxObject.DOWN) {
_snakeHead.facing = FlxObject.UP;
}
else if(FlxG.keys.anyPressed(["DOWN"]) && _snakeHead.facing != FlxObject.UP) {
_snakeHead.facing = FlxObject.DOWN;
}
else if(FlxG.keys.anyPressed(["LEFT"]) && _snakeHead.facing != FlxObject.RIGHT) {
_snakeHead.facing = FlxObject.LEFT;
}
else if(FlxG.keys.anyPressed(["RIGHT"]) && _snakeHead.facing != FlxObject.LEFT) {
_snakeHead.facing = FlxObject.RIGHT;
}
// フルーツ獲得判定
FlxG.overlap(_snakeHead, _fruit, _cbCollectFruit);
// -----------ここに追加------------
// 頭と体が衝突しているかどうか
FlxG.overlap(_snakeHead, _snakeBody, _cbGameover);
// -----------ここまで追加------------
}
頭と体が衝突したらおしまいなので、ゲームオーバー処理_cbGameover()を実装します。
/**
* ゲームオーバー処理
**/
private function _cbGameover(obj1:FlxObject, obj2:FlxObject) {
_snakeHead.alive = false;
_scoreText.text = "Game Over - Space to restart!";
FlxG.sound.play("assets/flixel.wav");
}
alive
フラグをfalseにすることで死亡したことにしています。テキストに"Game Over"という文字を表示し、ゲームオーバーSEを再生します。
最後に頭の更新タイマーを止めます。alive
フラグの判定を入れて、タイマーを無効化します。
/**
* タイマー初期化
**/
private function _resetTimer(?timer:FlxTimer):Void {
if(_snakeHead.alive == false && timer != null) {
// 死んだので更新を止める
timer.destroy();
return;
}
// タイマーを作りなおして繰り返し呼び出されるようにする
new FlxTimer(_movementInterval / FlxG.updateFramerate, _resetTimer);
// 移動処理を行う
_moveSnake();
}
これで完成です。実行してゲームオーバーになることを確認します。
うまく動かない場合は、
- update関数のゲームオーバー判定
- _resetTimerでタイマを破棄する
このあたりを確認します。