C#でゲームを作ると言えば?そうUnityですね。
しかし私はUnityを勉強しようとして無理だと諦めました。
そこから編み出した2Dゲームの作り方を書いていこうと思います。
対象はC#でドローアプリなどを作ったことある人を想定しています。
最終的にはこういうゲームが作れると思います。
サンプル提示
今回は画面に画像を表示する編です。
その前に
C#だけで作る意味はあるのか?
ないです。やりやすいってのはありますがC++で作ったほうがいいと思います。そして最終的にUnityの劣化コピーに落ち着くので作る楽しみを味わいたい場合のみ有用だと思います。
そしてこの記事は本を参考に描いたとかそういうのもなくネットサーフィンと試行錯誤の末いい感じになったものなのであまり信用しないでください。
準備
・Visualstudio2019(VSのバージョンは多分あまり関係ない)
・Vortice一式をプロジェクトにインストール しかし依存関係が複雑(SharpGenの特殊なバージョンが必要)なのでパッケージマネージャコンソールで依存関係を満たすようにバージョンを指定してインストールしてください。
これだけあればいいと思います。今後使うものも含まれてます。
・WindowsFormで新規アプリケーションを立ち上げる
今後このプロジェクトにいろいろしていきます。WinForm以外でもできる方法があるかもしれませんが、私は知らないので、これでやった方がいいと思います。
しかし何故Vorticeとかいうよくわからないやつを使うの…?
C#には標準でgraphicsという画像とか図形を描く奴があります。しかしこれはCPUを使って描画するので毎フレーム画面が変わるゲームには向きません。めちゃくちゃ重くてプレイできないでしょうね。
なのでGPUを活用するためにDirectXを使う必要があります。そのDirectXをC#で扱えるようにするのがVorticeなのです!あと音とかゲームパッドの入力とかもできるらしいです。詳しくは
Vorticeのgithub
キャンパスの用意!
絵を描くにはキャンパスが必要です。それもOSで定められてるモニターのキャンパスを取得する必要があります。Microsoft神がいろいろ定めてくれているので、WindowsFormからなら割と簡単にできます。こんな感じの関数を好きなところに作りましょう。私はファイル操作とか全体的に含めたstaticクラスにまとめておいてます。
using Vortice.Direct2D1;
namespace wow
{
static class nanka
{
static ID2D1Factory fac;
static ID2D1HwndRenderTarget rendertarget;
//一応この二つは保存しておこう。何個も作るもんでもないのでstaticでいいと思う
static public void resizen(Form f)
{
D2D1.D2D1CreateFactory<ID2D1Factory>(FactoryType.SingleThreaded, out fac);
var renpro = new RenderTargetProperties();
var hrenpro = new HwndRenderTargetProperties();
hrenpro.Hwnd = f.Handle;//フォームの描画画面の情報を渡してるようなもん
hrenpro.PixelSize = new System.Drawing.Size(f.ClientSize.Width, f.ClientSize.Height);//フォームの描画領域と同じサイズに
rendertarget = fac.CreateHwndRenderTarget(renpro, hrenpro);
}
}
}
キャンパスの情報はForm型が持ってくれているのでそのポインターを渡せばいいだけです。簡単ですね。
図形を描くぞ!
絵を描く方法はgraphicsとかとほぼ変わりません。ブラシを用意して色々するっていうのは同じですね。だいたいこんな感じになります。
static public void kakuze(Vortice.Direct2D1.ID2D1RenderTarget render)
{
render.BeginDraw();//書き始める
render.Clear(Color.Aqua);//カラーはSystem.Drawingのも使える。画面を一色に染める
var dio = render.CreateSolidColorBrush(new Vortice.Mathematics.Color4(1, 0, 0, 0));//RGBAでも指定できる
//こんな風にrenderから作らないといけないものが結構ある。
render.DrawLine(new PointF(50, 50), new PointF(10 + 50, 10 + 50), dio);
dio.Dispose();//Disposeは忘れない。使いまわすつもりならstaticで置いとくのとかもあり
var fa = Vortice.DirectWrite.DWrite.DWriteCreateFactory<Vortice.DirectWrite.IDWriteFactory>();//テキストフォーマットを作るためのやーつ
var fom = fa.CreateTextFormat("MS UI Gothic", Vortice.DirectWrite.FontWeight.Light, Vortice.DirectWrite.FontStyle.Italic, 16);//フォントの感じを指定する
render.DrawText("danpasiteru", fom, new RawRectF(0, 0, 100, 100), dio);//ほんで書く!!
render.EndDraw();//endDrawの瞬間に描画結果は画面に反映されるよ
}
他にもrender.DrawRectangle(),render.Drawbitmap()等あります。詳しくはMicrosoftDocsのDirectXを読めばわかると思います。
bmp画像を読み込みたいぞ!
bmp画像を表示するにはファイルをbitmapクラスに変換する必要があります。その変換はこんな感じでできます。(ネットの海で拾ったものの寄せ集めなので私自身よくわかってません)
こちらも好きなところに置いときましょう
static Dictionary<string, ID2D1Bitmap> texs = new Dictionary<string, ID2D1Bitmap>();
static public ID2D1Bitmap ldtex(string file, bool reset = false)
{
if (!texs.ContainsKey(file) || reset)
{
Console.WriteLine(file + "load sitao!");//ロードするファイルの告知を出しとく
string fi = @".\tex\" + file + ".bmp";
if (System.IO.File.Exists(fi))
{
//System.drawingの機能を使うといいらしい
using (var bitmap = (System.Drawing.Bitmap)System.Drawing.Image.FromFile(fi))
{
// BGRA から RGBA 形式へ変換する
// 1行のデータサイズを算出
int stride = bitmap.Width * sizeof(int);
using (var tempStream = new DataStream(bitmap.Height * stride, true, true))
{
// Bitmapをロックする?
var sourceArea = new System.Drawing.Rectangle(0, 0, bitmap.Width, bitmap.Height);
var bitmapData = bitmap.LockBits(sourceArea, System.Drawing.Imaging.ImageLockMode.ReadOnly, System.Drawing.Imaging.PixelFormat.Format32bppPArgb);
// 変換処理
for (int y = 0; y < bitmap.Height; y++)
{
int offset = bitmapData.Stride * y;
for (int x = 0; x < bitmap.Width; x++)
{
// 1byteずつデータを読み込む
byte B = Marshal.ReadByte(bitmapData.Scan0, offset++);
byte G = Marshal.ReadByte(bitmapData.Scan0, offset++);
byte R = Marshal.ReadByte(bitmapData.Scan0, offset++);
byte A = Marshal.ReadByte(bitmapData.Scan0, offset++);
//Console.WriteLine(B + " " + G + " " + R + " " + A);
byte a = 0;
int gaba = a | (a << 8) | (a << 16) | (a << 24);
//gabeが透明色でrgbaが読み込んだ色
int rgba = R | (G << 8) | (B << 16) | (A << 24);
//透過色の場合は透明にするよ
if (B == 254 && R == 0 && G == 254) tempStream.Write(gaba);
else
tempStream.Write(rgba);
}
}
// 読み込み元のBitmapのロックを解除する
bitmap.UnlockBits(bitmapData);
tempStream.Position = 0;
// 変換したデータからBitmapを生成して返す
var size = new System.Drawing.Size(bitmap.Width, bitmap.Height);
var bitmapProperties = new Vortice.DCommon.BitmapProperties(new PixelFormat(Vortice.DXGI.Format.R8G8B8A8_UNorm, Vortice.DCommon.AlphaMode.Premultiplied));
//pixcelformatの設定はよくわからない
var result = rendertarget.CreateBitmap(size, tempStream.BasePointer, stride, bitmapProperties);
//さっき書き込んだtempstreamから作る
if (texs.ContainsKey(file))
{
texs[file].Dispose();
texs[file] = result;
//ファイルのパスと作成したビットマップをDictionaryにしておいとくのが再利用するとき便利
}
else texs.Add(file, result);
}
}
}
else
{
return null;
}
}
return texs[file];
}
これでbitmapを読み込んで貼り付けることができますが…サイズを変更したり回転させたりしたいですよね?
画像を回転したり拡大させたりするにはrender.Transformをいじる必要があります。
var bitmap = ldtex(@"oblivion");
if (bitmap != null)
{
var a = Matrix3x2.CreateRotation((float)Math.PI/2, new Vector2(0,0 ));
//(0,0)を中心に90度回転
a = Matrix3x2.Multiply(Matrix3x2.CreateTranslation(10,10),a);
//さらに平行移動をかける。どっちが先に適用されるか見ものだぞ!!
render.Transform = a;
var btmpos = new RawRectF(100,100,200,200);//(100,100)->(200,200)の四角形の範囲に描く。つまりここでサイズを変えれる。
var bitmon = new RawRectF(0, 0, bitmap.Size.Width, bitmap.Size.Height);//ビットマップのどの部分から取り出すか。
//いろんな画像を一枚にまとめたあのシートがあるときは使うと思うよ
render.DrawBitmap(bitmap, btmpos, 1/*これ不透明度ね*/, BitmapInterpolationMode.Linear, bitmon);
//これでビットマップを張り付ける
}
これで画像が書けるようになりましたね。
これをゲームに落とし込む!
単純な図形の組み合わせだけで作るゲームならともかく、画像を読み込んでキャラクターを表現するようなゲームだと多くの画像を拡大縮小移動回転、どっちを前に表示するかとか画面をスクロールさせたり、背景はスクロールを落としたり。とても面倒になります。
そこで一枚の画像をクラスとしてあらわすのがおススメです。いわゆるスプライトってやつですかね?紹介しきれないコード量なのでかいつまんで紹介します。
hyojimanというクラスにdrawingsのリストがありhyojiman.hyoji();で全部を描画する仕組みです。
※hyojimanには画面の左上を指すcamx,camyって変数があります。あと画面の拡大率を示すbairituというものもあります。
abstract public class drawings //画像だけでなく文字とかも一緒くたに扱うための基底クラス
{
public float x,y,z;//Zは画像の表示の順番を決めるよ
public drawings(float xx,float yy,float zz) { x = xx;y = yy;z = zz; }
public drawings(drawings d) { x = d.x; y = d.y; z = d.z; }
public drawings() { }
abstract public bool draw(hyojiman hyo);
}
public class picture:drawings
{
public float w, h;//幅と高さ
public float tx, ty;//回転中心
public float TX { get { if (mir) return w - tx; else return tx; } }
public float TY { get { return ty; } }
protected double rad;//回転角度
public bool mir;//反転してるか
protected float opa;//不透明度
public string pretex;//テクスチャが変化したときの検出用
public float OPA { get { return opa; } set { if (value > 1) opa = 1; else if (value < 0) opa = 0; else opa = value; } }
public string texname;//どのテクスチャを選んでるか
public double RAD { get { return rad; } set { rad = Math.Atan2(Math.Sin(rad), Math.Cos(rad)); float x = gettx(), y = getty(); rad = value; settxy(x, y); rad = Math.Atan2(Math.Sin(rad), Math.Cos(rad)); } }
//角度を変更したら回転中心が正しくなるようにする。
public Dictionary<string, string> textures = new Dictionary<string, string>();//テクスチャ一覧
//{"default",@"trees\akasia"},{"kareta",@"trees\sinasina"}みたいな感じで登録する
//コンストラクタは省略
public override bool draw(hyojiman hyo)
{
if (hyo == null) return false ;
if ((Math.Pow(hyo.camx + hyo.ww / 2 - this.getcx(this.w / 2, this.h / 2), 2) + Math.Pow(hyo.camy + hyo.wh / 2 - this.getcy(this.w / 2, this.h / 2), 2)) <= (hyo.ww * hyo.ww + hyo.wh * hyo.wh + this.w * this.w + this.h * this.h) / 2
&& this.OPA > 0)//透明だったり画面外の場合は描画しない
{
var p = this;
if (p.textures.ContainsKey(p.texname) && p.textures[p.texname] != "nothing")//選択してるテクスチャが登録されてるもしくはnothing出ない場合は描画
{
var bitmap = fileman.ldtex(p.textures[p.texname]);//bmpファイルををロードする
if (bitmap != null)//ファイイルがあった場合
{
var a = Matrix3x2.CreateRotation((float)p.RAD, new Vector2((p.x * hyo.bairitu - hyo.camx * hyo.bairitu) + (p.tx * 0) * hyo.bairitu, (p.y * hyo.bairitu - hyo.camy * hyo.bairitu) + (p.ty * 0) * hyo.bairitu));
//回転中心で回す
if (p.mir)//反転処理
{
a = Matrix3x2.Multiply(new Matrix3x2(-1, 0, 0, 1, 0, 0), a);
a = Matrix3x2.Multiply(Matrix3x2.CreateTranslation(-(p.x + p.w / 2 - hyo.camx) * 2 * hyo.bairitu, 0), a);
}
if (p.w < 0)//幅がマイナスの時は反転処理を行う。これがない場合なんだかおかしくなる。どうおかしくなるんだっけな?ぜひ実験してみてね!
{
a = Matrix3x2.Multiply(new Matrix3x2(-1, 0, 0, 1, 0, 0), a);
a = Matrix3x2.Multiply(Matrix3x2.CreateTranslation(-(p.x + p.w / 2 - hyo.camx) * 2 * hyo.bairitu, 0), a);
}
if (p.h < 0)//高さがマイナスの時は反転処理を行う。これがない場合なんだかおかしくなる。どうおかしくなるんだっけな?ぜひ実験してみてね!
{
a = Matrix3x2.Multiply(Matrix3x2.CreateScale(1, -1), a);
a = Matrix3x2.Multiply(Matrix3x2.CreateTranslation(0, -(p.y + p.h / 2 - hyo.camy) * 2 * hyo.bairitu), a);
}
hyo.render.Transform = a;
var btmpos = new RawRectF((p.x * hyo.bairitu - hyo.camx * hyo.bairitu), (p.y * hyo.bairitu - hyo.camy * hyo.bairitu), (p.x * hyo.bairitu + p.w * hyo.bairitu - hyo.camx * hyo.bairitu), (p.y * hyo.bairitu + p.h * hyo.bairitu - hyo.camy * hyo.bairitu));
var bitmon = new RawRectF(0, 0, bitmap.Size.Width, bitmap.Size.Height);
hyo.render.DrawBitmap(bitmap, btmpos, this.OPA, BitmapInterpolationMode.Linear, bitmon);
//さっきの通り描画
}
return true;
}
}
return false;
}
}
さらにhyojiman側の処理は
public class hyojiman
{
public float bairitu = 1;//拡大率
public float camx = 0;//カメラ
public float camy = 0;
public bool resetpicsman=false;
public float HR = 0, HG = 0.8f, HB = 0.9f;//背景色
public List<drawings> pics = new List<drawings>();//画像とかのリスト
public hyojiman(ID2D1RenderTarget ren)
{
render = ren;
}
public ID2D1RenderTarget render;
public float ww { get { return render.PixelSize.Width / bairitu; } }
public float wh { get { return render.PixelSize.Height / bairitu; } }
public void hyoji(float cl = 1)
{
if (resetpicsman)//新しくdrawingが追加された場合このフラグをオンにしてはソートする
{
resetpicsman = false;
// pics.Sort((a, b) => (int)(-a.z + b.z));
//↑みたいな標準のソートにするとクイックソートになる。不安定なソートだからzが同じ時にちらついてしまうので安定ソートを使うのが吉。timsortがおススメ
timomaedatanoka();//これがソートする関数ね。
}
render.BeginDraw();
render.Clear(new Color4(HR, HG, HB, 1));
/*ここにもっと複雑な背景の描画の部分を入れてもいいし*/
int cou = 0;
for (int i = pics.Count() - 1; i >= 0; i--)
{
if (cou > 900) break;//最大の負荷を設定しておくと便利?
if (pics[i].draw(this)) cou++;
}
render.Transform = Matrix3x2.CreateTranslation(0, 0);
//Transformをリセットしとくと不具合が起こりにくい
render.EndDraw();
}
}
本当はもっと複雑ですが、このようにすれば画像表示はかなり楽になると思います。pictureを集めれば人形のようにキャラクターを動かすことだってできるようになるので楽しいです。
(この機能についてはまた後程)
良きC#ライフを送りましょう!!!