おことわり
初心者すぎる人のただの日記です.
話
この質問 を見ていて,
テトリス
みたいな物であれば,「コンソールアプリで勉強する際の題材」としてはかなり適しているのでは? とか思った.
(特に「ゲームを作りたい人」であれば,ゲームっぽい題材の方が良いだろうし)
……というわけで,少しばかりやってみた.
ただし,興味対象は
「C#でコンソールアプリで ゲームのような物 を作ろとしたら,どうなるのか?」というところ(:そもそも自分自身が「 C# でコンソールアプリ 」というのをほとんどやったことがないので,「ちょっとしたのを書こうとしたらどれだけ簡単なのかな?」みたいな)
なので,本稿は「テトリス」を完成させるわけではない.
テトリスがテトリスたる部分(回転したり何だりいうところの細かいルールとか?)を扱おうとすればいろいろと面倒事があるのだろうけど(←正直よく知らない),そういう部分をやりたいわけではないので,呆れるほどにシンプルにした内容のもの:
- 上から落ちてくるやつのサイズは 1x1 マス(つまり回転とかそれに関する面倒なルール関係は全く存在しない)
- 1ラインが埋まったら消す
- 上まで積もったらゲームオーバー
っていうだけの物を相手にする.
使えそうなやつは何か
「C#でコンソールアプリで表示更新とか入力受け付けあたりはどうやればいいの?」みたいな部分について.
ちょっとググってみたところ
- Console.SetCursorPosition()
- Console.CursorVisible
- Console.KeyAvailable
- Console.Clear()
- Console.ReadKey()
とか,便利そうなのがいろいろと揃っているみたい.すげぇ! 楽そう!
書いたコード
……というわけで,↑の使えそうなやつらを試してみた.
何も考えずに全てを Main()
の中に書き殴った.
(Console.XXX
を使った箇所は注釈に ★
を付与してある)
初心者丸出しコード(130行くらいある)
//(テトリス用語がわからんので,「フィールド」「ブロック」「落ちてくるやつ」等々,言葉はてきとー)
static void Main(string[] args)
{
//-------------------------
//定数群
//(壁を含む)ゲームフィールド(Field[,])の大きさ
const int W = 6;
const int H = 10;
//左右の壁を除いたx方向の範囲
const int XMin = 1;
const int XMax = W-2;
//Field[,]の要素値
const int Space = 0; //何もない場所
const int Block = 1; //ブロック
const int Wall = 2; //壁
//表示に使う文字群
string Chars = "_■壁";
//-------------------------
//初期処理
//Field[,]の準備
var Field = new int[H,W];
for( int y=0; y<H; ++y )
{
for( int x=0; x<W; ++x )
{
Field[y,x] = ( (x<XMin || x>XMax || y==H-1) ? Wall : Space );
}
}
//★カーソルを非表示に
Console.CursorVisible = false;
//(PX,PY) : 1x1なサイズの落ちてくるやつの座標
int PX = W/2;
int PY = 0;
//-------------------------
//ゲームループ
bool Loop = true;
while( Loop )
{
//★現在の状況を表示
// 最初はここで Console.Clear() を使ってみたのだが,ちらつきが想像以上にすごかったので却下.
// Console.SetCursorPosition(0,0); に置き換えた.
Console.SetCursorPosition(0,0); //★出力開始箇所を左上にする
for( int y=0; y<H; ++y )
{
for( int x=0; x<W; ++x )
{
if( x==PX && y==PY )
{ Console.Write( '◇' ); } //落ちてくるやつ
else
{ Console.Write( Chars[ Field[y,x] ] ); }
}
Console.WriteLine();
}
//スリープでウェイトを入れる
System.Threading.Thread.Sleep( 160 ); //値はてきとー
//キー入力処理
if( Console.KeyAvailable ) //★何かキー入力が成されたかを判定
{
var Input = Console.ReadKey(true); //★入力情報を得る
switch( Input.KeyChar )
{
case 'q': //やめるとき用
Loop=false;
break;
case 'z': //左に移動
if( Field[PY,PX-1]==Space )
{ --PX; }
break;
case 'x': //右に移動
if( Field[PY,PX+1]==Space )
{ ++PX; }
break;
}
}
//下に落ちていく処理
if( Field[PY+1,PX] == Space )
{ ++PY; } //1個下に動かす
else
{//着地時
if( PY==0 )
{//一番上の位置だったらゲームオーバーということで
Loop = false;
Console.WriteLine( "Game Over" );
Console.WriteLine( "(Enter Key to Quit)" );
while( Console.ReadKey().Key != ConsoleKey.Enter ); //★Enterが押されるまで待つ
}
else
{
//着地箇所をブロック化
Field[PY,PX] = Block;
{//ラインを消す処理
//消すべきかどうか判定
bool ShouldEraseLine = true;
for( int x=XMin; x<=XMax; ++x )
{
if( Field[PY,x]!=Block )
{
ShouldEraseLine = false;
break;
}
}
if( ShouldEraseLine )
{//消す
for( int x=XMin; x<=XMax; ++x )
{
for( int y=PY; y>0; --y )
{
Field[y,x] = Field[y-1,x];
if( Field[y,x]==Space )break;
}
}
}
}
//落ちてくるやつの座標を初期位置に戻す
PX = W/2;
PY = 0;
}
}
}
}
このコードの表示具合はこんな感じ↓
これはゲームオーバーになったところ.
◇
が上から降ってくるやつで,降り積もった箇所が ■
.
結論
なんとなく たのしい.
- 「何かキー押されましたかね?」っていうのが
Console.KeyAvailable
で知れる.便利. -
Console.Clear()
は予想よりも激しいちらつきが起きる.きつい.
今回は固定サイズの表示なのでConsole.Clear()
は用いずにConsole.SetCursorPosition()
で出力位置だけ戻して上書することで対処した.
追記(迷路)
イェーイ!
ちょっとだけ改造して,なつかしい感じの迷路ゲームに変更ー!
- こういう「操作するまで止まってる」やつなら
Console.KeyAvailable
は不要.
Console.ReadKey()
で入力待ちにしちゃえばOK.
初心者丸出しコードその2(115行くらいある)
static void Main(string[] args)
{
//-------------------------
//定数群
//ゲームフィールド(Field[,])の大きさ
const int W = 16;
const int H = 12;
//視界の広さ
const int ViewR = 3; //半径的な
const int ViewSize = ViewR*2 + 1;
//Field[,]の要素値
const int Wall = 1; //壁
const int Goal = 2; //ゴール
//表示に使う文字列群
var SquareStrs = new string[]{ " ", "■", "G " }; //マス用
var HBorder = '+' + new string( '-', ViewSize*2 ) + '+'; //枠描画用
//-------------------------
//初期処理
//Field[,]の準備
var Field = new int[H,W]{
{ 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1 },
{ 1,0,0,0,1,1,0,0,0,1,1,0,0,0,0,1 },
{ 1,0,1,0,1,1,0,0,0,0,0,0,1,1,0,1 },
{ 1,1,1,0,1,1,0,0,0,1,1,0,1,1,0,1 },
{ 1,0,0,0,1,0,0,0,0,1,0,0,1,0,0,1 },
{ 1,0,1,1,1,0,1,1,1,1,1,0,0,0,1,1 },
{ 1,0,0,0,0,0,1,0,1,0,0,0,1,0,0,1 },
{ 1,1,0,1,0,1,1,0,1,1,1,1,1,1,0,1 },
{ 1,0,0,1,0,0,0,0,1,0,1,0,0,1,0,1 },
{ 1,0,1,1,1,0,1,1,1,0,1,2,0,0,0,1 },
{ 1,0,0,0,0,0,0,0,0,0,1,0,0,1,1,1 },
{ 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1 }
};
//カーソルを非表示に
Console.CursorVisible = false;
//(PX,PY) : 現在の位置
int PX = 1;
int PY = 1;
//-------------------------
//ゲームループ
bool Loop = true;
while( Loop )
{
{//現在の状況を表示
Console.SetCursorPosition(0,0);
//x方向描画範囲 [sx, ex)
int sx = Math.Max( 0, PX-ViewR );
int ex = sx+ViewSize;
if( ex > W ){ ex=W; sx=ex-ViewSize; }
//y方向描画範囲 [sy, ey)
int sy = Math.Max( 0, PY-ViewR );
int ey = sy+ViewSize;
if( ey > H ){ ey=H; sy=ey-ViewSize; }
Console.WriteLine( HBorder ); //枠
for( int y=sy; y<ey; ++y )
{
Console.Write( '|' ); //枠
for( int x=sx; x<ex; ++x )
{
if( x==PX && y==PY )
{ Console.Write( '◇' ); }
else
{ Console.Write( SquareStrs[ Field[y,x] ] ); }
}
Console.WriteLine('|'); //枠
}
Console.WriteLine( HBorder ); //枠
}
//ゴール判定
if( Field[PY,PX] == Goal )
{
Console.WriteLine( "ゲームクリア" );
while( Console.ReadKey().Key != ConsoleKey.Enter ); //★Enterが押されるまで待つ
break;
}
//スリープでウェイトを入れる
System.Threading.Thread.Sleep( 160 );
//キー入力処理
while( true ) //※現在値(PX,PY)が変化しないなら表示更新する必要もないのでループにしとく
{
var Input = Console.ReadKey(true); //★入力待ち
if( Input.KeyChar == 'q' ) //やめるとき用
{ Loop=false; break; }
//移動
int dx=0, dy=0;
switch( Input.Key )
{
case ConsoleKey.LeftArrow: dx=-1; break;
case ConsoleKey.RightArrow: dx=1; break;
case ConsoleKey.UpArrow: dy=-1; break;
case ConsoleKey.DownArrow: dy=1; break;
default: break;
}
if( (dx!=0 || dy!=0) && (Field[PY+dy, PX+dx] != Wall) )
{
PX += dx;
PY += dy;
break;
}
}
}
}
矢印キーで ◇
が動くよ!
やべぇ,なんか楽しい.
Console.ForegroundColor
とかを使えばグラフィックが向上(?)するけども,個人的には白黒の方が好みかなぁ.
追記2(ペイント)
やったー! 無意味にペイントソフトのようなもの できたよー!
初心者丸出しコードその3(115行くらいある)
static void Main(string[] args)
{
//画像データ
const int W = 16;
const int H = 16;
var ImgData = new int[H,W];
//
int PX=0, PY=0; //現在の描画対象画素位置
int iColor = 0; //現在選択中の色
//
var HBorder = '+' + new string( '-', W*2 ) + '+'; //枠描画用
//
bool Loop = true;
while(Loop)
{
{//表示
Console.SetCursorPosition(0,0);
//画像エリア
Console.ForegroundColor = ConsoleColor.White;
Console.WriteLine( HBorder ); //枠
for( int y=0; y<H; ++y )
{
Console.Write('|'); //枠
for (int x = 0; x<W; ++x)
{
Console.BackgroundColor = (ConsoleColor)ImgData[y,x];
Console.Write( "□" );
}
Console.WriteLine('|'); //枠
}
Console.WriteLine(HBorder); //枠
//パレット
Console.BackgroundColor = ConsoleColor.Black;
Console.Write( '<' );
Console.ForegroundColor = (ConsoleColor)( (~iColor) & 0x0F );
for( int i=0; i<16; ++i )
{
Console.BackgroundColor = (ConsoleColor)i;
Console.Write( i==iColor ? "〇" : " " ); //現在選択中の色を〇で示す
}
Console.ForegroundColor = ConsoleColor.White;
Console.BackgroundColor = ConsoleColor.Black;
Console.Write( '>' );
//カーソル位置を (PX,PY) 相当の位置にする
Console.SetCursorPosition(1+PX*2, 1+PY);
}
System.Threading.Thread.Sleep( 100 );
//キー入力処理
while (true)
{
var Input = Console.ReadKey(true);
if (Input.KeyChar == 'q') //やめるとき用
{ Loop = false; break; }
{//カーソル (PX,PY) 移動
int dx=0, dy=0;
switch (Input.Key)
{
case ConsoleKey.LeftArrow: dx = -1; break;
case ConsoleKey.RightArrow: dx = 1; break;
case ConsoleKey.UpArrow: dy = -1; break;
case ConsoleKey.DownArrow: dy = 1; break;
default: break;
}
if( dx != 0 || dy != 0 )
{
PX += dx;
if( PX<0 )PX = W-1;
if( PX>=W )PX = 0;
PY += dy;
if( PY<0 )PY = H-1;
if( PY>=H )PY = 0;
Console.SetCursorPosition(1+PX*2, 1+PY);
System.Threading.Thread.Sleep( 100 );
}
}
//カーソル位置(PX,PY)の画素に色を塗る
if( (Input.Key == ConsoleKey.Spacebar ) && ( ImgData[PY,PX] != iColor ) )
{
ImgData[PY,PX] = iColor;
break;
}
{//パレットの色選択変更
int dx = 0;
switch (Input.KeyChar)
{
case 'z': dx = -1; break;
case 'x': dx = 1; break;
default: break;
}
if( dx != 0 )
{
iColor += dx;
if( iColor<0 )iColor = 15;
if( iColor>15 )iColor = 0;
break;
}
}
}
}
}
期待と異なり,色が原色じゃない!
なんか若干スタイリッシュ(?)というか淡い感じの色になっている! 気に入らない!
追記3(キー押しっぱなしへの対策方法は?)
上記のコード群ではキーを長く押されてしまうと困る.
入力バッファに溜まってしまって,すっごい先行入力というかそういう状態になっちゃう.
気にするならば,入力バッファをクリアする処理をどこかに適当に入れ込むような対策が必要だと思う.
で,そのバッファクリア方法に関してググって出てきた方法がコレ↓
while(Console.KeyAvailable)
Console.ReadKey(true);
「全部消費すればいいだろ」と.なるほどなぁ.
追記4(ギリSTGと言えるか?)
滅茶苦茶簡素なSTGみたいな.
- バッファ(
char
の配列)に表示用イメージを作って,それをConsole.WriteLine()
で表示している. -
Main()
の外側にもコードがちょっとはみ出した.
内容としては,1vs1 のシンプルなHPの削り合い.
こっちは左右にしか動けないし,敵にいたっては能動的に動かない(ただし弾が当たる毎にX座標が別の場所にワープする).
敵の当たり判定は中央部にしかないからX座標を合わせてから撃つ必要があるのだが,劣悪な操作性で割と無理ゲーに思える.
(少なくとも現状のやり方だと)キーを長く押すとか同時に押すとかが操作として必要になるようなものはちょっといい感じには作れない感.
でもまぁそもそも目的は「コンソールアプリで抜群の操作性を実現すること」とかではないハズだから,そういうところの不満については「まぁ仕方ないね」で済ませておけば良いと思う.
(ある程度動くものができたならば勉強用のコンソール世界の役目は終了ってことで別の世界に行けばいい.)
初心者丸出しコードその4(270行くらいある)
namespace ConsoleTest
{
//1行分のバッファ
class LineBuffer
{
public LineBuffer( int W ){ m_Buff = new char[W+1]; m_Buff[W] = '|'; }
public void Clear(){ for( int i=0; i<m_Buff.Length-1; ++i ){ m_Buff[i]=' '; } }
public void Write( int left, string s ){ for( int i=0; i<s.Length; ++i ){ m_Buff[left+i] = s[i]; } }
public void Write( int left, char c ){ m_Buff[left] = c; }
public void Output(){ Console.WriteLine( m_Buff ); }
private char[] m_Buff;
}
//座標
class Pos
{
public Pos( int X=0, int Y=0 ){ x=X; y=Y; }
public int x{ get; set; }
public int y{ get; set; }
}
class Program
{
//自機
static string[] MyFig = {
@" |",
@"!=H=!"
};
const int MyFigCX = 2; //MyFig[]内での機体中心位置座標
const int MyFigCY = 1;
//敵の弾と自機との当たり判定
static bool MyFig_CollisionCheck( Pos Bullet, Pos FigC )
{ return ( Math.Abs(Bullet.x-FigC.x)<=1 && (Bullet.y==FigC.y || Bullet.y==FigC.y-1) ); }
//敵
static string[] EnemyShip = {
@" Z _ _ Z",
@" E]._[:=:]_.E]",
@"< }^=MOM=^{ >",
@" [|' Y^Y `|]",
@" {| |}",
@" ! !"
};
const int EnemyShipCX = 7; //EnemyShip[]内での機体中心位置座標
const int EnemyShipCY = 2;
//こっちの弾と敵との当たり判定
static bool EnemySip_CollisionCheck( Pos Bullet, Pos EnemyC )
{ return ( Math.Abs(Bullet.x-EnemyC.x)<=2 && Math.Abs(Bullet.y-EnemyC.y)<=1 ); }
//敵の弾
class EnemyBullet
{
//発射時のデータ初期化
public void Setup( int x, int y, Random rnd )
{
m_Count = 0;
m_x = x;
m_y = y;
//Update() が m_MoveXFreq 回実施される毎に,X座標が1だけ動く,という感じ
m_MoveToLeft = ( (rnd.Next() & 0x01) == 0 ); //左右どっちに動くか
m_MoveXFreq = 1 + rnd.Next( 8 ); //動く頻度
//表示用の文字をランダムに決める
img = Chars[ rnd.Next( Chars.Length ) ];
}
//位置更新
public void Update()
{
++m_y;
++m_Count;
if( m_Count >= m_MoveXFreq )
{
m_Count = 0;
m_x += ( m_MoveToLeft ? -1 : 1 );
}
}
//位置取得
public Pos pos{ get{ return new Pos(m_x,m_y); } }
//この弾の表示用の文字を取得
public char img{ get; private set; }
private int m_x;
private int m_y;
private int m_Count;
private bool m_MoveToLeft;
private int m_MoveXFreq;
private static char[] Chars = new char[3]{ '*', '%', '+' };
}
//自機,敵 の絵をバッファの指定位置にコピーする処理
static void DrawChar( LineBuffer[] ImgBuff, int Left, int Top, string[] Lines)
{
foreach (string s in Lines)
{
ImgBuff[Top].Write( Left, s );
++Top;
}
}
//Main
static void Main(string[] args)
{
//ゲーム世界の広さ
const int W = 60;
const int H = 24;
//表示用のバッファ
var ImgBuff = new LineBuffer[H + 2]; //※ +2 は,こっちと敵の HP 表示行の分
for( int y=0; y<ImgBuff.Length; ++y ){ ImgBuff[y] = new LineBuffer(W); }
//自機側のデータ
var MyPos = new Pos( W/2, H - 2 ); //位置
int MyHP = 6; //HP
var MyBullets = new Pos[ 3 ];
for( int i=0; i<MyBullets.Length; ++i ){ MyBullets[i] = new Pos(); }
int nMyBullets = 0;
//敵側データ
var EnemyPos = new Pos( W/2, EnemyShipCY+1 ); //位置
int EnemyHP = 16; //HP
var EnemyBullets = new EnemyBullet[10];
for( int i=0; i<EnemyBullets.Length; ++i ){ EnemyBullets[i] = new EnemyBullet(); }
int nEnemyBullets = 0;
//---
var Rnd = new Random();
Console.CursorVisible = false;
bool Loop = true;
while(Loop)
{
{//表示
//バッファクリア
foreach( var LB in ImgBuff ){ LB.Clear(); }
if( EnemyHP > 0 ) //バッファに敵の描画
{ DrawChar( ImgBuff, EnemyPos.x-EnemyShipCX, EnemyPos.y-EnemyShipCY, EnemyShip ); }
if( MyHP > 0 ) //バッファに自機の描画
{ DrawChar( ImgBuff, MyPos.x-MyFigCX, MyPos.y-MyFigCY, MyFig ); }
//バッファにこっちの弾を描画
for( int i=0; i<nMyBullets; ++i )
{ ImgBuff[ MyBullets[i].y ].Write( MyBullets[i].x, ':' ); }
//バッファに敵の弾を描画
for( int i=0; i<nEnemyBullets; ++i )
{
var P = EnemyBullets[i].pos;
ImgBuff[ P.y ].Write( P.x, EnemyBullets[i].img );
}
//バッファにHPを描画
ImgBuff[H].Write( 0, "M:" + new string( '@', MyHP ) );
ImgBuff[H+1].Write( 0, "E:" + new string( '#', EnemyHP ) );
//バッファ内容を表示
Console.SetCursorPosition( 0,0 );
foreach( var LB in ImgBuff ){ LB.Output(); }
}
//終了判定
if( EnemyHP<=0 || MyHP<=0 )
{
Console.WriteLine( EnemyHP<=0 ? "Game Clear" : "Game Over" );
Console.WriteLine( "(Press Enter Key to Quit)" );
while( Console.ReadKey().Key != ConsoleKey.Enter );
break;
}
//ウェイト
System.Threading.Thread.Sleep( 100 );
//キー入力処理
if( Console.KeyAvailable )
{
var Input = Console.ReadKey(true);
switch( Input.KeyChar )
{
case 'q': //やめるとき用
Loop=false;
break;
case 'z': //左に移動
if( MyPos.x-MyFigCX > 0 )
{ --MyPos.x; }
break;
case 'x': //右に移動
if( MyPos.x+MyFigCX < W-1 )
{ ++MyPos.x; }
break;
case ' ': //弾の発射
if( nMyBullets < MyBullets.Length )
{
MyBullets[nMyBullets].x = MyPos.x;
MyBullets[nMyBullets].y = MyPos.y - MyFigCY;
++nMyBullets;
}
break;
}
//キー入力バッファのクリア
while( Console.KeyAvailable )Console.ReadKey(true);
}
{//こっちの弾の移動,敵との当たり関係の処理
bool Hit = false; //いずれかの弾が敵に当たったか?
int i = 0;
while( i < nMyBullets )
{
--MyBullets[i].y;
bool ShouldRemove = false;
if( MyBullets[i].y < 0 ) //場外判定
{ ShouldRemove = true; }
else if( !Hit && EnemySip_CollisionCheck( MyBullets[i], EnemyPos ) )
{//敵に当たったとき
--EnemyHP;
Hit = true;
ShouldRemove = true;
}
if( ShouldRemove )
{
MyBullets[i].x = MyBullets[nMyBullets-1].x;
MyBullets[i].y = MyBullets[nMyBullets-1].y;
--nMyBullets;
}
else
{ ++i; }
}
//弾が当たった場合,敵がどこかにワープする
if( Hit )
{ EnemyPos.x = EnemyShipCX + Rnd.Next( W - EnemyShipCX*2 ); }
}
//敵の弾発射処理
if( (EnemyHP>0) && ( nEnemyBullets <= Rnd.Next(EnemyBullets.Length) ) )
{
EnemyBullets[nEnemyBullets].Setup( EnemyPos.x, EnemyPos.y, Rnd );
++nEnemyBullets;
}
{//敵の弾の移動,自機との当たり関係の処理
int i = 0;
while( i < nEnemyBullets )
{
EnemyBullets[i].Update();
bool ShouldRemove = false;
var P = EnemyBullets[i].pos;
if( P.x<0 || P.x>=W || P.y>=H ) //場外判定
{ ShouldRemove = true; }
else if( MyFig_CollisionCheck( P, MyPos ) )
{//当たったとき
--MyHP;
ShouldRemove = true;
}
if( ShouldRemove )
{
var Tmp = EnemyBullets[i];
EnemyBullets[i] = EnemyBullets[nEnemyBullets-1];
EnemyBullets[nEnemyBullets-1] = Tmp;
--nEnemyBullets;
}
else
{ ++i; }
}
}
}
}
}
}
下側の文字の塊が自機で,上側のでかい文字の塊が敵(割と渾身のAAですぞ!).
最下段の2行は両者のHPバー.どっちかが無くなった時点で終了.