私はこの質問を最初にコードレビューで投稿しましたが、ここでデザインについてのフィードバックをもっと得ることができると思いました。
今回は実際のデザインを持ち、SOLID OOPの原則に準拠することを目標として、C++で簡単なスネーククローンを書き終えたところです。そのため、ゲームのクラス図を作成し、すべてのコード、そしてそれは正常に動作しますが、私がもっと上手くできたと感じるいくつかの領域があります。たとえば、エレガントに削除できなかったいくつかのハードコードされた要素があり、使用する場所が1つあります継承ですが、次に、メッセージがどの子から来ているのかを把握するための列挙型もあり、子のタイプをまったく気にしたくない場合に使用します。
とにかく、ここにクラス図があります:
Engineは、2Dグラフィックスを簡単に表示するために書いたシンプルなエンジンです。レンダリングと、位置情報を格納するための2D整数ベクトルであるv2diのようないくつかのヘルパークラスにのみ使用します
以下は、ゲームの開始と実行を担当するゲームクラスです。
OnCreate()
は起動時に1回呼び出されます。
OnUpdate()
およびOnRender()
はフレームごとに1回呼び出され、ゲームループを含む必要があります。
OnDestroy()
は、ゲームの内部ループが終了し、プログラムが終了しようとしているときに呼び出されます。
///////////////// .h
class SnakeGame : public rge::DXGraphicsEngine {
public:
SnakeGame();
bool OnCreate();
bool OnUpdate();
bool OnRender();
void OnDestroy();
protected:
Snake snake;
FieldGrid field;
int score;
bool gameOver;
int updateFreq;
int updateCounter;
};
//////////////////////// .cpp
SnakeGame::SnakeGame() : DXGraphicsEngine(), field(10, 10), snake(3, rge::v2di(3, 1), Direction::RIGHT), score(0), gameOver(false), updateFreq(10), updateCounter(0){
}
bool SnakeGame::OnCreate() {
field.AddSnake(snake.GetBody());
field.GenerateFood();
return true;
}
bool SnakeGame::OnUpdate() {
//check user input
if(GetKey(rge::W).pressed) {
snake.SetDirection(Direction::UP);
}
if(GetKey(rge::S).pressed) {
snake.SetDirection(Direction::DOWN);
}
if(GetKey(rge::A).pressed) {
snake.SetDirection(Direction::LEFT);
}
if(GetKey(rge::D).pressed) {
snake.SetDirection(Direction::RIGHT);
}
updateCounter++;
if(!gameOver && updateCounter >= updateFreq) {
updateCounter = 0;
//clear snake body from field
field.ClearSnake(snake.GetBody());
//move
snake.MoveSnake();
//add snake body to field
field.AddSnake(snake.GetBody());
//testcollision
CollisionMessage cm = field.CheckCollision(snake.GetHead());
gameOver = cm.gameOver;
score += cm.scoreChange ? snake.GetLength() * 10 : 0;
if(cm.tileType == TileType::Food) {
field.GenerateFood();
snake.ExtendSnake();
}
}
return true;
}
bool SnakeGame::OnRender() {
std::cout << score << std::endl;
field.Draw(&m_colorBuffer, 100, 20, 10);
snake.DrawHead(&m_colorBuffer, 100, 20, 10);
return true;
}
次は、ヘビを移動して拡張するSnake
クラスです。ヘビが移動できるDirection
の列挙型もあります。
///////////// .h
enum class Direction {
UP, DOWN, LEFT, RIGHT
};
class Snake {
public:
Snake();
Snake(int length, rge::v2di position, Direction direction);
rge::v2di GetHead() { return head; }
std::vector<rge::v2di> GetBody() { return body; }
void MoveSnake();
void ExtendSnake();
Direction GetDirection() { return direction; }
void SetDirection(Direction direction);
int GetLength() { return body.size() + 1; }
void DrawHead(rge::Buffer* buffer, int x, int y, int size);
protected:
std::vector<rge::v2di> body;
rge::v2di head;
Direction direction;
Direction oldDirection;
};
////////////// .cpp
Snake::Snake(): head(rge::v2di(0, 0)), direction(Direction::UP), oldDirection(Direction::UP), body(std::vector<rge::v2di>()){
body.Push_back(rge::v2di(head.x, head.y + 1));
}
Snake::Snake(int length, rge::v2di position, Direction direction) : head(position), direction(direction), oldDirection(direction), body(std::vector<rge::v2di>()) {
for(int i = 0; i < length-1; ++i) {
rge::v2di bodyTile;
switch(direction) {
case Direction::UP:{
bodyTile.x = head.x;
bodyTile.y = head.y + (i + 1);
break;
}
case Direction::DOWN:{
bodyTile.x = head.x;
bodyTile.y = head.y - (i + 1);
break;
}
case Direction::LEFT: {
bodyTile.y = head.y;
bodyTile.x = head.x + (i + 1);
break;
}
case Direction::RIGHT: {
bodyTile.y = head.y;
bodyTile.x = head.x - (i + 1);
break;
}
}
body.Push_back(bodyTile);
}
}
void Snake::MoveSnake() {
oldDirection = direction;
for(int i = body.size()-1; i > 0; --i) {
body[i] = body[i - 1];
}
body[0] = head;
switch(direction) {
case Direction::UP: {
head.y--;
break;
}
case Direction::DOWN: {
head.y++;
break;
}
case Direction::LEFT: {
head.x--;
break;
}
case Direction::RIGHT: {
head.x++;
break;
}
}
}
void Snake::ExtendSnake() {
body.Push_back(body[body.size() - 1]);
}
void Snake::SetDirection(Direction direction) {
switch(this->oldDirection) {
case Direction::UP:
case Direction::DOWN: {
if(direction != Direction::UP && direction != Direction::DOWN) {
this->direction = direction;
}
break;
}
case Direction::LEFT:
case Direction::RIGHT: {
if(direction != Direction::LEFT && direction != Direction::RIGHT) {
this->direction = direction;
}
break;
}
}
}
void Snake::DrawHead(rge::Buffer* buffer, int x, int y, int size) {
rge::Color c(100, 100, 200);
buffer->DrawRegion(x + head.x * size, y + head.y * size, x + head.x * size + size, y + head.y * size + size, c.GetHex());
}
次に、衝突検出、フードの生成、マップの状態の格納を担当するFieldGrid
クラスがあります。
//////////// .h
class FieldGrid {
public:
FieldGrid();
FieldGrid(int width, int height);
~FieldGrid();
void GenerateFood();
CollisionMessage CheckCollision(rge::v2di head);
void ClearSnake(std::vector<rge::v2di> body);
void AddSnake(std::vector<rge::v2di> body);
void Draw(rge::Buffer* buffer, int x, int y, int size);
protected:
std::vector<std::vector<Tile*>> field;
int width;
int height;
};
//////////// .cpp
FieldGrid::FieldGrid() : width(10), height(10), field(std::vector<std::vector<Tile*>>()) {
for(int i = 0; i < width; ++i) {
field.Push_back(std::vector<Tile*>());
for(int j = 0; j < height; ++j) {
field[i].Push_back(new EmptyTile());
}
}
}
FieldGrid::FieldGrid(int width, int height): width(width), height(height), field(std::vector<std::vector<Tile*>>()) {
for(int i = 0; i < width; ++i) {
field.Push_back(std::vector<Tile*>());
for(int j = 0; j < height; ++j) {
field[i].Push_back(new EmptyTile());
}
}
}
FieldGrid::~FieldGrid() {
for(int i = 0; i < field.size(); ++i) {
for(int j = 0; j < field[i].size(); ++j) {
delete field[i][j];
}
field[i].clear();
}
field.clear();
}
void FieldGrid::GenerateFood() {
int x = Rand() % width;
int y = Rand() % height;
while(!field[x][y]->IsFree()) {
x = Rand() % width;
y = Rand() % height;
}
delete field[x][y];
field[x][y] = new FoodTile();
}
CollisionMessage FieldGrid::CheckCollision(rge::v2di head) {
if(head.x < 0 || head.x >= width || head.y < 0 || head.y >= height) {
CollisionMessage cm;
cm.scoreChange = false;
cm.gameOver = true;
return cm;
}
return field[head.x][head.y]->OnCollide();
}
void FieldGrid::ClearSnake(std::vector<rge::v2di> body) {
for(int i = 0; i < body.size(); ++i) {
delete field[body[i].x][body[i].y];
field[body[i].x][body[i].y] = new EmptyTile();
}
}
void FieldGrid::AddSnake(std::vector<rge::v2di> body) {
for(int i = 0; i < body.size(); ++i) {
delete field[body[i].x][body[i].y];
field[body[i].x][body[i].y] = new SnakeTile();
}
}
void FieldGrid::Draw(rge::Buffer* buffer, int x, int y, int size) {
for(int xi = 0; xi < width; ++xi) {
for(int yi = 0; yi < height; ++yi) {
int xp = x + xi * size;
int yp = y + yi * size;
field[xi][yi]->Draw(buffer, xp, yp, size);
}
}
}
Tile
で使用されるFieldGrid
クラス:
class Tile {
public:
virtual CollisionMessage OnCollide() = 0;
virtual bool IsFree() = 0;
void Draw(rge::Buffer* buffer, int x, int y, int size) {
buffer->DrawRegion(x, y, x + size, y + size, color.GetHex());
}
protected:
rge::Color color;
};
class EmptyTile : public Tile {
public:
EmptyTile() {
this->color = rge::Color(50, 50, 50);
}
CollisionMessage OnCollide() {
CollisionMessage cm;
cm.scoreChange = false;
cm.gameOver = false;
cm.tileType = TileType::Empty;
return cm;
}
bool IsFree() { return true; }
};
class FoodTile : public Tile {
public:
FoodTile() {
this->color = rge::Color(50, 200, 70);
}
CollisionMessage OnCollide() {
CollisionMessage cm;
cm.scoreChange = true;
cm.gameOver = false;
cm.tileType = TileType::Food;
return cm;
}
bool IsFree() { return false; }
};
class SnakeTile : public Tile {
public:
SnakeTile() {
this->color = rge::Color(120, 130, 250);
}
CollisionMessage OnCollide() {
CollisionMessage cm;
cm.scoreChange = false;
cm.gameOver = true;
cm.tileType = TileType::Snake;
return cm;
}
bool IsFree() { return false; }
};
最後に、ヘビの頭がCollisionMessage
と衝突したときにゲームにメッセージを送信するために使用されるTile
クラスを次に示します。
enum class TileType {
Empty,
Snake,
Food
};
class CollisionMessage {
public:
bool scoreChange;
bool gameOver;
TileType tileType;
};
すべてのインクルードとメインメソッドは省略しました。これらはデザインに関連しておらず、余分なスペースしかとらないためです。
私のすべてのコードを読んで(またはクラス図を見て)時間をかけていただき、私が選択した全体的なデザインについてのご意見をお待ちしております。
あなたはあなたのデザインのレビューのための適切な場所にいます。しかし、これはコードレビューサイトではないので、ここで詳細な検査を期待しないでください。
私が最初にクラスを見て驚いたのは、
Snake
とFieldGrid
またはTile
の間に関係はありません。特に、いくつかのTile
がSnake
の一部を表していることを考えると、この図が示すように両方が独立しているとは思えません。CollisionMessage
はTile
に関連付けられており、エンティティ間のコラボレーションを調整するSnakeGame
ではありませんTileType
はメッセージに関連付けられていますが、タイル自体には関連付けられていませんTileType
をTile
、EmptyTile
、FoodTile
に特殊化すると、SnakeTile
は冗長になります。UMLモデル:
コードでは、CollisionMessage
(実際にはメッセージよりもイベント)に関連付けられているタイルはありません。タイルはメッセージを作成し、それを一時的に所有するGame
に返し、処理されると破棄します。
そのため、クラス図には、Tile
とCollisionMessage
の間の関連を示すべきではありません。
代わりに、CollisionMessage
とGame
間の関連付けを示す必要があり、Tile
とCollisionMessage
間に creation dependency を示すことができます。
他の弱点は、分離の懸念がないことです:
calssはtileType
からGame
を明らかにし、ゲームはタイルの(漏洩した)知識に基づいて何をするかを決定します。ゲームループは調整すべきではなく、他のクラスに詳細を決定させるべきではありませんか?
ここで、いくつかの種類のタイルを追加するとします。MountainTile
(ランダムに致命的かどうかは時々岩が落下してヘビを殺すため)、SeeTile
(一部のヘビは泳ぐ)、MongooseTile
(常に致命的)それぞれの場合に行うには?
幸い、懸念事項の分離を追加し、オープン/クローズの原則を強化することにより、この設計を改善するソリューションがあります。
FieldGrid::CheckCollision()
をサポートするTile::OnColide()
)がイベントを決定するようにします。たとえば、衝突がFoodTile
との場合はFeedingEvent
を作成しますが、SnakeTile
またはMongooseTile
はFatalEvent
を作成します。これらすべては、抽象CollisionMessage
から派生したものでもありますCollisionMessage
ではなくCollisionType
を参照するようにTileType
を変更することです。少なくとも、ゲームループのアクションは読み取るロジックが多くなります(たとえば、タイプがFeedignEvent ...の場合、タイプがFatalEvent ...の場合など)。shared_ptr
_を使用する必要があります。アドバテージは、ゲームループが単独ですべてのケースを調べるのではなく、CollisionMessageのいくつかの仮想関数を呼び出す可能性があることです。最後に、私は厄介なバグを見つけました:
CollisionMessage
を作成するFieldGrid::CheckCollision()
もあります。tileType
を設定しないでください。ちなみに、それはヘビでも、空でも、食べ物でもありません。UMLについて:
Snake
と_v2di
_とDirection
の間の構成を使用する必要があります。これらのクラスは、いくつかのSnake
メンバーとして、またはその中で使用されますが、値によって使用されます。したがって、これは実際には排他的な所有権とライフサイクル制御です。
私の最初の質問にもかかわらず、モデルは大丈夫です。コードでは、実際にはヘビをグリッドから切り離しています。移動するたびに、図のヘビタイルをクリアし、ヘビを追加して新しい位置のタイルを変更します。グリッドでヘビの 使用依存関係 を表示して、図をより表現力豊かにすることができます。
もちろん、グリッドではタイルがどのヘビに属しているかがわからないため、このデザインではゲームが1つのヘビに制限されます。
次に、コードの推奨事項:生のポインタを忘れてください:メモリ管理でエラーが発生するのは非常に簡単です。ライブラリにメモリ管理を任せましょう:
そう
_std::vector<std::vector<Tile*>> field;
_
と置き換えることができます
_std::vector<std::vector<shared_ptr<Tile>>> field;
_
そしてすべて
_ delete field[body[i].x][body[i].y];
field[body[i].x][body[i].y] = new EmptyTile(); // or SnakeTile or...
_
次に置き換えられます
_ field[body[i].x][body[i].y] = make_shared<EmptyTile>(); // or SnakeTile or...
_
共有ポインターは、使用されなくなったタイルの割り当てを解除します。それははるかに安全です。
つまり、これですべてです。長い質問に対する長い回答でごめんなさい。しかし、それが役に立てば幸いです。
ここでの問題は、タイルの配列としてのFieldGridの概念だと思います。
UI、ASCIIディスプレイがゲーム自体に密接に結合されている、ゲームのクラシックな実装に制限されます
それでいいのであれば問題ありませんが、Snakeクラスは実際には必要ありません。ヘビはSnakeTilesのコレクションです。
よりモダンなデザインでは、ヘビと食べ物の位置を3Dフロートベクトルでモデル化し、モデルからUIの関連付けを解除して、タイル、特にEmptyTileの概念を無視できるようにします。
これにより、機能のリクエストで柔軟性を高め、蛇を斜めに動かすことができます! Zインデックスでヘビを作ろう!プレイエリアを無限大にするなど