はじめに
Flutterでテトリスを実装しました。
なんだかすごそうですが、最初に言っておきます。このテトリス、様々な点が未完成の為所々動きません。
スコア計算、ゴースト、ロックダウンあたりは実装しきれていなく、
SRS、ネクスト管理、ホールドが正常に動かないです。
(ゴースト = テトリミノの落下予想地点に出てくるやつ)
(ロックダウン = テトリミノが0.5秒以上地面に触れていると自動的に設置される仕様)
また、この記事は限界開発鯖AdventCalendar2020 5日目の記事です。
ちなみに執筆中の現在時刻は公開日の6:14です。
おはようございます。
プレビュー
皆さんの心が離れて行ってしまう前に現時点でのプレビュー動画を。
一番最初にTミノを置いたのに、次のIミノがTミノに変化して落ちてきていて、ガバが確認できます。
なぜFlutterなのか
僕がFlutterしか書けないから…です…
テトリスの実装
このアプリケーションの中核の部分である、テトリスの実装について触れていきます。
テトリミノの種類
テトリミノは七種類あり、 T
O
S
Z
L
J
I
の七つのアルファベットで表されます。
enum TetroMino
では、ミノの種類を、
その下のextensionは デフォルトでのブロックの配置
+プレビュー表示ブロック配置
+色
を定義しています。
import 'package:flutter/material.dart';
enum TetroMino { T, O, S, Z, L, J, I }
extension MinoPlacements on TetroMino {
List<List<int>> get defaultPlacement {
switch (this) {
case TetroMino.T:
return [
[0, 1, 0],
[1, 1, 1],
[0, 0, 0],
];
/* 省略 */
default:
return [];
}
}
List<List<int>> get previewPlacement {
switch (this) {
case TetroMino.T:
/* 省略 */
default:
return [];
}
}
Color get color {
switch (this) {
case TetroMino.T:
return Colors.purple;
case TetroMino.O:
return Colors.yellow;
case TetroMino.S:
return Colors.lightGreen;
case TetroMino.Z:
return Colors.red;
case TetroMino.L:
return Colors.orange;
case TetroMino.J:
return Colors.blue;
case TetroMino.I:
return Colors.cyan;
default:
return Colors.grey;
}
}
}
ブロック
class Block {
final Color color;
Cordinate cordinate;
Block({
@required this.cordinate,
@required this.color,
});
}
位置管理
大体は二次元配列を用い、そこに当てはめていくのが一般的だと思うのですが、
実装中の私はそれを知らなかったので、座標をもつクラスを実装、それをフロントでいい感じに表示する方式にしています。
座標
class Cordinate {
int x;
int y;
Cordinate(this.x, this.y);
String toString() => "($x, $y)";
Cordinate operator +(Cordinate other) => Cordinate(x + other.x, y + other.y);
Cordinate operator -(Cordinate other) => Cordinate(x + other.x, y - other.y);
bool operator ==(dynamic cordinate) => cordinate?.x == x && cordinate?.y == y;
@override
int get hashCode => super.hashCode;
bool get isExcess => x < 0 || x > 9 || y < 0;
bool isStucking(Cordinate other) => other.x == x && other.y == y;
void down() => y -= 1;
void toRight() => x += 1;
void toLeft() => x -= 1;
}
このクラスをなんやかんやして移動を反映し、表示の際にフロントで調整する感じです。
ゲームフィールドの左下の点を(0, 0)
としています。
右に向かうほどx座標が、上に向かうほどy座標が大きくなります。
設置されたブロックたち
class PlacedBlocks {
static final List<Block> placedBlocks = [];
}
設置されたブロックをstatic変数で保存し、フロントに渡しています。
テトリミノを移動させる
移動するために、以下のようなステップを踏んでいます。
- まず試しに移動する
- すでに置いてあるブロックと重複しないか判定
- 重複していないなら現在の座標に反映
ハードドロップも同様で、重なるまで試しに移動した後、重なるひとつ前の座標を反映します。
テトリミノを回転させる
先ほど「座標を用いて位置を判定する」方式を提示しましたが、それだと回転するのが少し面倒です。
そこで、ここでは一時的に二次元配列を使用し、
- 現在の向きを持っている二次元配列に回転を適用
- 座標に変換
という流れを取っています。
回転
beforePlacement.asMap().forEach((horizonIndex, row) {
row.asMap().forEach((blockIndex, _) => rotatedPlacement[horizonIndex]
.add(beforePlacement[blockIndex][horizonIndex]));
});
// Tミノの右回転
// [0, 1, 0] [0, 1, 0]
// [1, 1, 1] → [0, 1, 1]
// [0, 0, 0] [0, 1, 0] これを座標に変換して反映する
上記は右回転のときの処理ですが、左回転の場合は配列を反転させたりして対応しています。
また、回転が必要ないO
ミノや、サイズが他のものと異なるI
ミノはそれでまた別の処理をしていたりします。
二次元配列
→ 座標
への変換
もとの位置を保持しておく必要があるので、
ミノが占有する 3×3(Iミノは4×4)
の空間の左上の角の座標を
向きに依存しないものとして保持し、それを利用して座標への変換をしています。
(下のprimeCordinate
)
placement.toList().asMap().forEach((y, row) => row.asMap().forEach(
(x, block) {
if (block != 0) parsed.add(primeCordinate + Cordinate(x, y));
},
));
回転を最適化する (SRS)
テトリスには、SRSと言うシステムが存在し、これがあることによって、
プレイヤーはより直感的にテトリミノを回転させることができます。
具体的には、壁に触れたままの状態などで回転を試みた時、壁に埋まったりしないように
自然な動作でテトリミノをシフトさせます。
case RotatePattern.NtoE:
return [
Cordinate(0, 0),
Cordinate(-1, 0),
Cordinate(-1, 1),
Cordinate(0, -2),
Cordinate(-1, -2),
];
例えば↑では、「北向きから東向きへ回転したとき」に、
「これら複数のシフトを試し」、最初にブロック同士のスタックを解消したパターンを実際の座標に反映します。
もし最終的にスタックを解消できなかった場合、回転しません。
final Cordinate shift = rotatePattern.shiftedCordinates.firstWhere(
(_shift) {
tmp = rotatedCordinates.map((c) => c += _shift).toList();
final bool anyExcesses = tmp.any((c) => c.isExcess);
return !PlacedBlocks.doseOverlapWith(tmp) && !anyExcesses;
},
orElse: () => null,
);
SRSについては、tetrisちゃんねるさんの記事が詳しいです。
何かをミスってしまったらしく、この機能は動きませんでした。
一部ちゃんと回転が補助されるのですが、たまに壁に埋まったりします。
ネクストの決定
次に出てくるテトリミノたちの配列を「ネクスト」と言います。
enum
の要素をshuffle
するだけです。
List<TetroMino> nexts = List.from(TetroMino.values)..shuffle();
ミノを設置するたびに更新します。
if (nexts.length > 7) return;
nexts.addAll(List.from(TetroMino.values)..shuffle());
ホールド
現在操作しているテトリミノを保留にし、それまでに保留にしていたテトリミノと交換することをホールドと言います。
ゲーム開始時には何も入っていないので、null
が入っているものとします。
ライン消去
テトリスのフィールドは横10マスのため、同じy座標にブロックが10個揃ったら消去するようにしています。
消去した後では空白を埋めるために、消えたぶんだけPlacedBlocks
のy座標を下げています。
時間経過でテトリミノを落下させ続ける
timer = Timer.periodic(
Duration(milliseconds: 750),
(_) {
operationMino.move(MoveDirection.Down);
notifyListeners();
},
);
ロックダウンを実装していないので、いくら下に下がっても設置されることはありません。
テトリミノを設置する
旧操作中のテトリミノをPlacedBlocks
に代入し、新しく操作中テトリミノのインスタンスを作りそれを操作します。
その他enum
移動の方向、回転の方向
enum MoveDirection { Up, Right, Down, Left }
enum RotateDirection { Right, Left }
#フロントエンドの実装
UIの部分です。
既に記事が長いので、特筆すべき場所だけ書いていきます。
Providerを使った
Provider
ウィジェットを活用した、ProviderパターンはFlutterでの状態管理方法のひとつで、
公式が推奨もしています。
Provider
を使用することで子ウィジェットから直接先祖ウィジェットの値が参照できたり、
変更の反映をsetState
を使用せずに動的に反映することもできます。(StatelessWidgetだけで実装していける)
また、従来では値を中継していたウィジェットから無駄な参照を取り除くことができます。
変更の反映なのですが、先ほど載せたコードに少し出てきていたnotifyListeners
関数が使用されます。
「Provide」された値の変更を購読しているウィジェットなどで変更を検知するには
notifyListeners
関数を実行します。(setState
と同じような役割)
notifyListeners
関数は、状態を管理するクラスに Notifier
クラスを継承/mixinさせれば使用できます。
有用なProviderパターンですが、今回においては、
- 一つのボタンの操作時に、ほぼすべての状態を触らないといけない場合が多発した
- フロントエンドとロジックの分割がうまくいかなかった
などあって、はじめてProviderを使用したこともありあまり綺麗なコードになりませんでした。
ブロックの描画
もともとブロックは Stack
+ Positioned
で描画しようとしていたのですが、
-
Stack
の要素がオーバーフローしてしまうと表示できない(親ウィジェットのサイズで制約を課せなかった)
→ 始めは背景とブロックが別々だったので、レイアウトが地獄みたいに崩れてた
GridView
格子模様を描くならこっちでもいいんじゃないか?と思い、もともと別々で描画していた背景
と存在するブロック
を
同時に描画するようにし、GridView
を使って描画しました。
Stack
+Positioned
に対してGridView
は、親要素のサイズを変えたらちゃんと自分や子孫にも反映してくれます。
child: GridView.count(
physics: NeverScrollableScrollPhysics(),
semanticChildCount: height * width,
childAspectRatio: 1,
crossAxisCount: 10,
children: fillBlank(operatingMino.blocks)
.map((b) => Container(
height: gridSize,
width: gridSize,
decoration: BoxDecoration(
color: b.color,
border: Border.all(color: Colors.white),
),
))
.toList(),
),
fillBlank
関数では操作中のミノ(を構成するブロック達)
+既に設置されたブロック達
のふたつのリストをくっつけ、座標とインデックスを調べ、
ブロックが割り当てられていない場所には背景用のブロックを割り当てています。
List<Block> fillBlank(List<Block> operationBlocks) {
final List<Block> alreadyAssigned = operationBlocks + placedBlocks;
final List<Block> whole = [];
for (int y = 19; y >= 0; y--) {
for (int x = 0; x < 10; x++) {
final Cordinate current = Cordinate(x, y);
final Block pointedBlock = alreadyAssigned.firstWhere(
(b) => b.cordinate == current,
orElse: () => null,
);
whole.add(
pointedBlock == null
? Block(cordinate: current, color: Colors.grey[400]) // ブロックが割り当てられていないので背景とする
: pointedBlock
);
}
}
return whole;
}
フロントの反省点
「フロントのコードにはロジックを書かない」ということは古事記にも書いてあることなのですが、
私は今回それを犯しています。
onPressed: () {
final OperationModel operationModel = context.read<OperationModel>(); // 先祖(Provider)から、状態を管理するクラスを取得しています
final NextsModel nextsModel = context.read<NextsModel>();
final HoldModel holdModel = context.read<HoldModel>();
final TetroMino holdedMino = holdModel.holdedMino;
final TetroMino operationMino = operationModel.operationMino.minoType;
operationModel.currentOperationMino =
holdedMino ?? nextsModel.nextMino; // 何もホールドされていなかったらネクストの最初を持ってきます
holdModel.hold(operationMino);
if (holdedMino == null) nextsModel.pushOneOut();
print(operationModel.operationMino.location.currentLocation);
},
これが何箇所かにおいて発生しているので、設計をミスったとしか言いようがありません。
ゲームを実装するには適切ではないほど一画面の状態を細分化してしまったのでしょうか…?
状態を分けたせいでViewでまとめるしかなくなっていて、書く場所が適切ではないのかもしれません。
それともこれが正常だったり…?
おわりに
「テトリスを実装する」という目的を考えると、今回出来上がったものは果たして目的を達成できているのか。
スコア計算やゴーストは百歩譲るとして、回転が正常に動かなかったりネクストが変化したりするって言うのはどうなの。
しかし、一応は動くものを作ることができて本当に良かった。
これからは少しずつ改良していけたらいいなと思います。
皆さんもテトリスを実装して、よきテトリスライフを送ってみませんか。
参考記事
SRS - tetrisちゃんねる
テトリス(tetris)のガイドラインを理解する ← タイトルが575
↓今回のリポジトリのリンク