【初心者歓迎】C#だけでWebゲームを1時間で作る javascriptやAJAX不要!Blazorの凄さを体験する #ゲーム制作 – Qiita

はじめに

この記事では 「たった1時間」 で Webゲーム作りを体験できます。

🎯こんな方へお勧め

  • 普段はC#を書いているけど Webフロントはちょっと苦手…
  • Webアプリを作ったことはあるけど Blazorは触ったことがない
  • JavaScriptやAJAXを最小限に と思っている

HTMLやCSSは最低限だけ触れますが、深い知識は不要です。
C#が書ければOK! 👍

🔷 ちなみに Blazor は Microsoft が開発する最新の Web フレームワークです。
「サーバーサイドC#」だけでリッチなWeb UIを構築できます。

これから作るゲームのイメージ

image.png

実際に遊んでみるのが一番わかりやすいです。
是非ご自分で操作してみてください!

👉 クリックしてデモアプリをプレイ

1.Visual Studioで新しいプロジェクトを作成する

image.png

Blazor Web アプリを選択
image.png

プロジェクト名を入力します( ここでは HakoiriMusume とします )
image.png

追加情報を確認します(青枠内は同じにして下さい)
image.png

重要な設定 設定値 備考
認証の種類 なし 今回はログイン不要
インタラクティビティ型 サーバ ★ めちゃくちゃ重要
インタラクティビティ場所 グローバル

プロジェクトが生成されました
image.png

デバッグを開始して動作確認します
image.png

デフォルトのページが表示さることを確認
image.png

2.プロジェクトの構成を知る

image.png

標準的なプロジェクト構成

ディレクトリ 内容
wwwroot cssなど
Components/Pages 各Webページ

沢山のフォルダやファイルがありますが、これから触るのは
「Components/Pages/Home.razor」のみです。

Home.razor

最初に実行したときに表示されたページのソースです

@page "https://qiita.com/"
PageTitle>HomePageTitle>
h1>Hello, world!h1>
Welcome to your new app.

3.HTMLの中にC#が書けるBlazor

Home.razorを修正します

@page "https://qiita.com/"
@for (int i = 1; i  4; i++)
{
    p>ループの @i 回目p>
}

htmlの中に

@for (int i = 1; i  4; i++)

と、突然C#のコードが出てきました。
※ HTMLの中に「@」でC#を自由に記述することができます。
(GO言語やJSP、PHPなどと同じですね)

🔷 htmlのタグの中の @i でC#の変数を利用しているのも注目です。

ループの @i 回目

実行します
image.png

4.ゲームの盤面を作る

まずは駒の背景の盤面を作ります

でゲーム盤の見た目を定義してます。
(一般的にはCSSファイルへ分離しますがここでは見やすさを優先します。)
HTMLは「何を表示するか」、が「装飾」を担当するイメージです。

CSS()の詳しい知識はなくても大丈夫!
見た目を装飾しているんだと思ってください。

@page "https://qiita.com/"

style>
    .board {
        display: grid;
        grid-template-rows: repeat(7, 50px);
        grid-template-columns: repeat(8, 50px);
        position: relative;
    }
style>

div class="board">
    @for (int row = 0; row  7; row++)
    {
        for (int col = 0; col  8; col++)
        {
            // CSSのグリッドは1オリジン
            var cssRow = row + 1;
            var cssCol = col + 1;

            div class="cell" style="grid-area:@(cssRow) / @(cssCol);">
                
            div>
        }
    }
div>

まずは7行×8列の簡素な盤面ができました
image.png

簡素過ぎるので、各セルを四角形で描画するように cellのstyleを設定します。

    .cell {
        border: 1px solid #444;
        display: flex;
        justify-content: center;
        align-items: center;
    }

枠線が描画されるようになりました
image.png

7×8を変数で宣言するように変更します。

div class="board">
    @for (int row = 0; row  MaxRows; row++)
    {
        for (int col = 0; col  MaxCols; col++)
        {
            // CSSのグリッドは1オリジン
            var cssRow = row + 1;
            var cssCol = col + 1;

            div class="cell" style="grid-area:@(cssRow) / @(cssCol);">
                
            div>
        }
    }
div>

@code {
    private const int MaxRows = 7;
    private const int MaxCols = 8;
}
  • @codeでC#のコードをまとめて書くことができます
  • @codeで宣言した変数 MaxRows を htmlの中の for 文で利用してます

5.ゲームの盤面を作る(その2)

単純な7×8から、壁や出口を定義できるようにします。

@code {
    // 盤面の定義
    private const int MaxRows = 7;
    private const int MaxCols = 8;

    private int[,] Board = new int[MaxRows, MaxCols]
    {
        // 盤面定義 (0=空, 1=壁, 9=出口)
        {1,1,1,1,1,1,1,1},
        {1,0,0,0,0,0,0,1},
        {1,0,0,0,0,0,0,1},
        {1,0,0,0,0,0,0,1},
        {1,0,0,0,0,0,0,1},
        {1,0,0,0,0,0,0,1},
        {1,1,1,9,9,1,1,1},
    };
}

htmlの表示を定義に対応させます

div class="board">
    @for (int row = 0; row  MaxRows; row++)
    {
        for (int col = 0; col  MaxCols; col++)
        {
            // CSSのグリッドは1オリジン
            var cssRow = row + 1;
            var cssCol = col + 1;

            int cell = Board[row, col];
            switch (cell)
            {
                case 0: // 空
                    div class="cell" style="grid-area:@(cssRow) / @(cssCol);">
                        
                    div>
                    break;
                case 1: // 壁
                    div class="cell" style="grid-area:@(cssRow) / @(cssCol);">
                        
                    div>
                    break;
                case 9: // 玄関
                    div class="cell" style="grid-area:@(cssRow) / @(cssCol);">
                        玄関
                    div>
                    break;
            }
        }
    }
div>

実行して確認します
image.png

(まあまあ盤面ぽく成ってきました)

styleの定義を強化してもっと見た目を良くします
セルの背景を塗りつぶして盤面を表現します

style>
    /* ゲーム盤全体 */
    .board {
        display: grid;
        grid-template-rows: repeat(7, 50px);
        grid-template-columns: repeat(8, 50px);
        position: relative;
    }

    /* 1つ1つのセルの書式 */
    .cell {
        border: 1px solid #444;
        display: flex;
        justify-content: center;
        align-items: center;
    }

    /* 壁 */
    .wall {
        background: #444;
    }
    /* 盤面 */
    .empty {
        background: #aaa;
    }
    /* 玄関 */
    .exit {
        background: #eee;
        color: #333;
        display: flex;
        align-items: center;
        justify-content: center;
    }
style>
switch (cell)
{
    case 0: // 空
            div class="cell empty" style="grid-area:@(cssRow) / @(cssCol);">
            div>
        break;
    case 1: // 壁
            div class="cell wall" style="grid-area:@(cssRow) / @(cssCol);">
            div>
        break;
    case 9: // 玄関
            div class="cell exit" style="grid-area:@(cssRow) / @(cssCol);">
                玄関
            div>
        break;
}

image.png

これで盤面が完成しました!
次はいよいよ駒を置いていきます

6.駒を置く – C#クラスをHTMLに描画してみよう

盤面はhtmlの中に固定で定義しました。
ページを表示するたびに必ず描画されます。

駒は固定では困るので盤面とは別に描画します。

イベントで駒を置く

まずはページ表示時の「イベント OnInitializedで駒を置きます。

@code {
    // 駒の定義
    private class Piece
    {
        public int Row { get; set; }
        public int Col { get; set; }
        public string? Label { get; set; }
    }

    // 駒のリスト(行・列・表示文字)
    private ListPiece> Pieces
        = new ListPiece>();

    protected override void OnInitialized()
    {
        // 初期配置
        Pieces.Add(new Piece { Row = 2, Col = 3, Label = "娘" });
    }
}

C#で駒のクラスを定義したので、HTMLで描画します。

    @foreach (Piece p in Pieces)
    {
        var cssPieceRow = p.Row + 1;
        var cssPieceCol = p.Col + 1;

        div class="piece"
             style="
                grid-row:@cssPieceRow;
                grid-column:@cssPieceCol;">
            @p.Label
        div>
    }

スタイルで駒の見た目を装飾します

    /* 駒 */
    .piece {
        background: red;
        color: white;
        font-weight: bold;
        display: flex;
        align-items: center;
        justify-content: center;
        z-index: 1;
    }

実行して確認します
image.png

行2,列3(0オリジン)に駒「娘」が配置されました。

少しソースが複雑になってきたので、ここで全体像を一度確認します。
今、ソース全体はこうなってます。

============================
   ソースコード全体を表示(折りたたみ)
 =============================
@page "https://qiita.com/"

style>
    /* ゲーム盤全体 */
    .board {
        display: grid;
        grid-template-rows: repeat(7, 50px);
        grid-template-columns: repeat(8, 50px);
        position: relative;
    }

    /* 1つ1つのセルの書式 */
    .cell {
        border: 1px solid #444;
        display: flex;
        justify-content: center;
        align-items: center;
    }

    /* 壁 */
    .wall {
        background: #444;
    }
    /* 盤面 */
    .empty {
        background: #aaa;
    }
    /* 玄関 */
    .exit {
        background: #eee;
        color: #333;
        display: flex;
        align-items: center;
        justify-content: center;
    }

    /* 駒 */
    .piece {
        background: red;
        color: white;
        font-weight: bold;
        display: flex;
        align-items: center;
        justify-content: center;
        z-index: 1;
    }

style>

div class="board">
    @for (int row = 0; row  MaxRows; row++)
    {
        for (int col = 0; col  MaxCols; col++)
        {
            // CSSのグリッドは1オリジン
            var cssRow = row + 1;
            var cssCol = col + 1;

            int cell = Board[row, col];
            switch (cell)
            {
                case 0: // 空
                        div class="cell empty" style="grid-area:@(cssRow) / @(cssCol);">
                        div>
                    break;
                case 1: // 壁
                        div class="cell wall" style="grid-area:@(cssRow) / @(cssCol);">
                        div>
                    break;
                case 9: // 玄関
                        div class="cell exit" style="grid-area:@(cssRow) / @(cssCol);">
                            玄関
                        div>
                    break;
            }
        }
    }

    @foreach (Piece p in Pieces)
    {
        var cssPieceRow = p.Row + 1;
        var cssPieceCol = p.Col + 1;

        div class="piece"
             style="
                grid-row:@cssPieceRow;
                grid-column:@cssPieceCol;">
            @p.Label
        div>
    }

div>

@code {
    // 盤面の定義
    private const int MaxRows = 7;
    private const int MaxCols = 8;

    private int[,] Board = new int[MaxRows, MaxCols]
    {
        // 盤面定義 (0=空, 1=壁, 9=出口)
        {1,1,1,1,1,1,1,1},
        {1,0,0,0,0,0,0,1},
        {1,0,0,0,0,0,0,1},
        {1,0,0,0,0,0,0,1},
        {1,0,0,0,0,0,0,1},
        {1,0,0,0,0,0,0,1},
        {1,1,1,9,9,1,1,1},
    };


    // 駒の定義
    private class Piece
    {
        public int Row { get; set; }
        public int Col { get; set; }
        public string? Label { get; set; }
    }

    // 駒のリスト(行・列・表示文字)
    private ListPiece> Pieces
        = new ListPiece>();

    protected override void OnInitialized()
    {
        // 初期配置
        Pieces.Add(new Piece { Row = 2, Col = 3, Label = "娘" });
    }
}

7.駒を選択する – Blazorでイベント処理を体験

いよいよここからがBlazorの本領発揮です!

駒を動かすためには、まずは駒を選択する必要があります。
また盤面の中で選択中の駒は1つだけじゃないと、どの駒を動かすのか困ります。

@code の中に 下の2つを追加します。

  • 選択中の駒を示す Selected 変数
  • 選択された時のイベント SelectPiece(p)
    // 選択中の駒
    private Piece? Selected;
    // 駒の選択
    private void SelectPiece(Piece p) => Selected = p;

駒のエレメントにクリックされた時のアクション @onclick と、自身が選択された駒の場合だけ”selected”クラスを指定します

div class="piece @(p == Selected ? "selected" : "")"
     style="
        grid-row:@cssPieceRow;
        grid-column:@cssPieceCol;"
        @onclick="() => SelectPiece(p)">
    @p.Label
div>

最後に へ選択された時の見た目の書式を追加します。

.piece.selected {
    outline: 5px solid orange;
}

実行して「娘」をクリックします
image.png

「父」をクリックします
image.png
( 「娘」の選択が自動で解除されました )

C#を書きなれている方には自然に見えたと思います。
でもこれは、通常のWeb開発では絶対にありえないことなんです。

本来なら、セルの見た目を変えるだけでも

  • JavaScriptでページを直接書き換える
  • もしくはAJAXでサーバーと通信し、ページの一部分を書き換える
    といった手間が必要でした。

それがBlazorでは @onclick を1行追加するだけ。
JavaScriptもAJAXも不要で、C#のコードだけでページが動的に変化します。

これこそがBlazorのすごさであり、最大の魅力です!

8.Blazor Serverの通信(技術的な背景)

かなり通信技術に踏み込んだ話なので、興味のある方だけどうぞ

=======================
   技術的な背景を表示(折りたたみ)
 ========================

HTTPリクエストもjavascriptも無しに、いったいどうやってページの内容が書き換わったんでしょう?
実は、Blazor Serverでは WebSocket という仕組みで通信しています。

❓このWebSocketとはなんでしょう?信頼できる技術なんでしょうか?

WebSockets(ウェブソケット)は、WebブラウザとWebサーバー間でリアルタイムに双方向通信を可能にする通信プロトコルです。一度接続が確立されると、HTTPのような都度のリクエスト・レスポンスを必要とせず、データのやり取りが継続的に行われるため、チャットオンラインゲームデータストリーミングなどのリアルタイム性が求められるアプリケーションで効率的に利用されます。

https://ja.wikipedia.org/wiki/WebSocket

📶 通信内容を確認しよう

実行してブラウザの開発者ツールを表示します。(Chromeなら「F12」です)
image.png

ネットワークタブを選択して「F5」でページをGETし直します。
image.png

Name=_blazor?id=…. Type=websocketの通信があります
image.png

メッセージタブを見ると…
image.png

「娘」をクリックします
image.png

「娘」をクリックした瞬間(時刻 06:03:02.017)にサーバーへ送信されたメッセージが確認できます。
バイナリですが BeginInvokeDotNetFromJS… という文字列が含まれており、
Blazorの @onclick イベントがサーバーへ送られていることがわかります。

👉 つまり Blazor Server では、ブラウザでのクリックが オンラインゲームと同じ技術 WebSocketでサーバーに即時送信され、サーバーで処理された結果が再びWebSocket経由で返ってきて、画面が更新される仕組みになっています。

これこそが「AJAXの記述なしで動的にページが変化する」秘密です。

9.選択した駒を動かす

駒のアクション @onclick をドラッグ用に @onmousedown へ変更します。
ドラッグの移動検出用に座標も同時に取得します。

div class="piece @(p == Selected ? "selected" : "")"
     style="
        grid-row:@cssPieceRow;
        grid-column:@cssPieceCol;"
        @onmousedown="(e) => StartDrag(p, e.ClientX, e.ClientY)">
    @p.Label
div>

ドラッグ開始イベントを記述します。

    // ドラッグ開始 
    private void StartDrag(Piece p, double clientX, double clientY)
    {
        Selected = p;
        startX = (int)clientX;
        startY = (int)clientY;
    }

ドラッグ移動イベントを記述します。

    // ドラッグ移動
    private void DragMove(MouseEventArgs e)
    {
        if (Selected == null) return;

        // マウス移動量(px単位)
        int currentX = (int)e.ClientX;
        int currentY = (int)e.ClientY;

        const int cellSize = 50; // 1駒のサイズ
        int threshold = cellSize / 2; // 50pxの半分

        // 1駒の半分以上ドラッグさせたら、移動させる
        int rowStep = 0, colStep = 0;
        if (Math.Abs(currentX - startX) > threshold)
        {
            colStep = (currentX > startX) ? 1 : -1;
            startX += colStep * cellSize;
        }
        if (Math.Abs(currentY - startY) > threshold)
        {
            rowStep = (currentY > startY) ? 1 : -1;
            startY += rowStep * cellSize;
        }

        // 次の位置
        int newRow = Selected.Row + rowStep;
        int newCol = Selected.Col + colStep;

        // 駒位置の移動
        Selected.Row = newRow;
        Selected.Col = newCol;
    }

ドラッグの終了イベントを記述します。

    // ドラッグ終了
    private void EndDrag()
    {
        Selected = null;
    }

駒をドラッグしやすいようにスタイルを追加します

    .piece {
        background: red;
        color: white;
        font-weight: bold;
        display: flex;
        align-items: center;
        justify-content: center;
        z-index: 1;
        
+       cursor: pointer;
+       user-select: none;
+       touch-action: none;
    }

ドラッグ移動イベントは、ドラッグ開始と同様に「駒」に書きたいところですがそれだとうまくいきません。
「駒」の範囲を超えてマウスを移動させたときに「駒」を移動させるので、「駒」の範囲外でもイベントを取れる必要があります。
そこで、盤面

エレメントへ、ドラッグ移動イベントの呼び出しを記述します。
(ドラッグの終了も同様です)

div class="board"
     @onmousemove="(e) => DragMove(e)"
     @onmouseup="EndDrag">

動作確認します。

=======================
   動画を表示(折りたたみ)
 ========================

レコーディング 2025-09-30 053541.gif

無事に駒が動きました!

10.仕上げる

駒は動くようになりましたがまだまだ不完全です。

❌ 駒が壁に突入できてしまう
❌ 駒同士が重なる移動もできてしまう
❌ 駒のサイズが 1×1 固定
❌ 駒の背景色が全部同じ

Blazorの体験からは少し離れるので一気に仕上げます。

駒Classを拡張します

  • 幅と高さ、背景色のクラス名を追加します
  • コンストラクタも追加しておきます
// 駒の定義
private class Piece
{
    public int Row { get; set; }
    public int Col { get; set; }
    public int Width { get; set; }
    public int Height { get; set; }
    public string Label { get; set; } = string.Empty;
    public string ColorClass { get; set; } = string.Empty;

    public Piece(string label, int col, int row, int w, int h, string colorClass = "piece-default")
    {
        Label = label;
        Row = row;
        Col = col;
        Width = w;
        Height = h;
        ColorClass = colorClass;
    }
}

全ての駒を実装します

private void InitPieces()
{
    Pieces.Clear();
    // 駒名、駒の左上の 列、行、幅、高さ、色のクラス名
    Pieces.Add(new Piece("父", 2, 1, 1, 2, "Color_父"));
    Pieces.Add(new Piece("娘", 3, 1, 2, 2, "Color_娘"));
    Pieces.Add(new Piece("母", 5, 1, 1, 2, "Color_母"));
    Pieces.Add(new Piece("手代", 1, 3, 1, 1, "Color_手代"));
    Pieces.Add(new Piece("大番頭", 2, 3, 4, 1, "Color_大番頭"));
    Pieces.Add(new Piece("兄嫁", 6, 3, 1, 1, "Color_兄嫁"));
    Pieces.Add(new Piece("丁稚", 1, 4, 1, 1, "Color_丁稚"));
    Pieces.Add(new Piece("女中", 2, 4, 2, 1, "Color_女中"));
    Pieces.Add(new Piece("番頭", 4, 4, 2, 1, "Color_番頭"));
    Pieces.Add(new Piece("丁稚", 6, 4, 1, 1, "Color_丁稚"));
    Pieces.Add(new Piece("番犬", 1, 5, 1, 1, "Color_番犬"));
    Pieces.Add(new Piece("祖父", 2, 5, 2, 1, "Color_祖父"));
    Pieces.Add(new Piece("祖母", 4, 5, 2, 1, "Color_祖母"));
    Pieces.Add(new Piece("丁稚", 6, 5, 1, 1, "Color_丁稚"));
}

駒色のスタイルを追加します

    /* 駒色 */
    .Color_父 {
        background: #b9bbdd;
    }

    .Color_娘 {
        background: #e4bdc3;
    }

    .Color_母 {
        background: #e9cccc;
    }

    .Color_手代 {
        background: #f7dcb4;
    }

    .Color_大番頭 {
        background: #dfbfaa;
    }

    .Color_兄嫁 {
        background: #d2e7d4;
    }

    .Color_丁稚 {
        background: #e9e3cc;
    }

    .Color_女中 {
        background: #f3e0e3;
    }

    .Color_番頭 {
        background: #ddcdc1;
    }

    .Color_番犬 {
        background: #f7f0f1;
    }

    .Color_祖父 {
        background: #adafdf;
    }

    .Color_祖母 {
        background: #daadad;
    }

駒のエレメントで背景色、幅、高さを描画できるようにします

    @foreach (Piece p in Pieces)
    {
        var cssPieceRow = p.Row + 1;
        var cssPieceCol = p.Col + 1;

        div class="piece @(p.ColorClass) @(p == Selected ? "selected" : "")"
             style="
                grid-row:@cssPieceRow;
                grid-column:@cssPieceCol;
                width:@(p.Width * 50)px;
                height:@(p.Height * 50)px;"
             @onmousedown="(e) => StartDrag(p, e.ClientX, e.ClientY)">
            @p.Label
        div>
    }

駒の移動判定を追加します

    //------------------------------------------------
    // 移動して良いか判定する
    //------------------------------------------------
    private bool CanMove(Piece p, int newRow, int newCol)
    {
        // ----- 盤外は禁止 -----
        if (newRow  0 || newRow + p.Height > MaxRows) return false;
        if (newCol  0 || newCol + p.Width > MaxCols) return false;

        // ----- 壁セルとの衝突 -----
        bool blocked = Enumerable.Range(newRow, p.Height).Any(r =>
            Enumerable.Range(newCol, p.Width).Any(c =>
                Board[r, c] == 1));
        if (blocked) return false;

        // 出口判定(娘だけOK)
        bool inExit = Enumerable.Range(newRow, p.Height).Any(r =>
            Enumerable.Range(newCol, p.Width).Any(c =>
                Board[r, c] == 9));
        if (inExit)
        {
            if (p.Label == "娘")
            {
                GameClear();
                return true;
            }
            else
            {
                ShowWarning();
                return false;
            }
        }

        // 他駒との衝突
        foreach (var o in Pieces.Where(o => o != p))
        {
            bool overlapX = newCol  o.Col + o.Width && newCol + p.Width > o.Col;
            bool overlapY = newRow  o.Row + o.Height && newRow + p.Height > o.Row;
            if (overlapX && overlapY) return false;
        }

        return true;
    }

駒が出口に来た時の処理を追加します

  • 娘が出口に来たらゲームクリアー
  • 娘以外が出口に来たら警告を表示
    private async void GameClear()
    {
        isCleared = true;
        StateHasChanged();
        await Task.Delay(5000);
        isCleared = false;
        StateHasChanged();
    }

    private async void ShowWarning()
    {
        isWarning = true;
        StateHasChanged();
        await Task.Delay(3000);
        isWarning = false;
        StateHasChanged();
    }

htmlへゲームクリアなどのメッセージ表示を追加します

@if (isCleared)
{
    div class="game-message">🎉クリア🎉div>
}

@if (isWarning)
{
    div class="game-message">玄関を出てよいのは娘だけdiv>
}

メッセージをあらかじめ準備しておき、C#のフラグ制御で表示をON/OFFするのも簡単制御で良いです😄

メッセージの書式を設定します。

(ちょっと凝ってアニメーションさせてます)

    /*メッセージ */
    .game-message {
        position: fixed;
        top: 40%;
        left: 50%;
        transform: translate(-50%, -50%);
        font-size: 72px;
        font-weight: bold;
        color: #ff4081;
        text-shadow: 0 0 10px #fff, 0 0 20px #ff80ab, 0 0 30px #ff4081;
        animation: flash 1s infinite alternate;
        z-index: 9999;
        pointer-events: none;
    }

    @@keyframes flash {
        from {
            opacity: 1;
            transform: translate(-50%, -50%) scale(1.0);
        }
        to {
            opacity: 0.5;
            transform: translate(-50%, -50%) scale(1.1);
        }

    }

11.もう少しだけゲームらしく

最後に、タイマー表示とリセットボタンぐらいは欲しいですね

経過時間とリセットボタンの表示スタイルを追加します

    /* 経過時間とリセット */
    .status {
        margin: 15px 0;
        user-select: none;
    }

    #reset-btn {
        margin-left: 10px;
        background-color: #dc3545;
        color: white;
        border: none;
        padding: 5px 10px;
        border-radius: 4px;
        cursor: pointer;
        user-select: none;
    }

        #reset-btn:hover {
            background-color: #b52a37;
        }

htmlの先頭に経過時間とリセットボタンを表示します

div class="status">
    @if (isCleared)
    {
        span id="elapsed-time">🎉クリア🎉span>
    }
    span id="elapsed-time">経過時間: @Elapsed span>
    button id="reset-btn" @onclick="ResetGame">リセットbutton>
div>

1秒ずつカウントアップする経過時間を @Elapsed だけで表現できます!

C#でタイマーのカウントアップ処理を追加します

    protected override void OnInitialized()
    {
        // 盤面初期化
        InitPieces();

        // タイマー開始
        StartTimer();
    }

    // --------------------------------------------------
    // タイマー
    // --------------------------------------------------
    private System.Threading.Timer? timer;
    private int Elapsed = 0;

    // タイマー開始
    private void StartTimer()
    {
        timer?.Dispose();
        timer = new System.Threading.Timer(_ =>
        {
            InvokeAsync(() =>
            {
                Elapsed++;
                StateHasChanged();
            });
        }, null, 1000, 1000);
    }

リセットボタンの処理とゲームクリアー時のタイマー停止を組み込みます

// リセット
private void ResetGame()
{
    Elapsed = 0;
    isCleared = false;
    isWarning = false;
    InitPieces();
    StartTimer();
    StateHasChanged();
}
private async void GameClear()
{
    // タイマー停止
    timer?.Dispose();
    timer = null;

    isCleared = true;
    StateHasChanged();
    await Task.Delay(5000);
    isCleared = false;
    StateHasChanged();
}

修正されたコードの全体像

=======================
   コード全体を表示(折りたたみ)
 ========================
@page "https://qiita.com/"

style>
    /* 経過時間とリセット */
    .status {
        margin: 15px 0;
        user-select: none;
    }

    #reset-btn {
        margin-left: 10px;
        background-color: #dc3545;
        color: white;
        border: none;
        padding: 5px 10px;
        border-radius: 4px;
        cursor: pointer;
        user-select: none;
    }

        #reset-btn:hover {
            background-color: #b52a37;
        }


    /* ゲーム盤全体 */
    .board {
        display: grid;
        grid-template-rows: repeat(7, 50px);
        grid-template-columns: repeat(8, 50px);
        position: relative;
    }

    /* 1つ1つのセルの書式 */
    .cell {
        display: flex;
        justify-content: center;
        align-items: center;
        padding: 4px;
    }

    /* 壁 */
    .wall {
        background: #444;
    }
    /* 盤面 */
    .empty {
        background: #aaa;
    }
    /* 玄関 */
    .exit {
        background: #eee;
        color: #333;
        display: flex;
        align-items: center;
        justify-content: center;
    }

    /* 駒 */
    .piece {
        font-weight: bold;
        display: flex;
        align-items: center;
        justify-content: center;
        z-index: 1;
        cursor: pointer;
        user-select: none;
        touch-action: none;
        border: 1px solid #555;
        box-sizing: border-box;
        transform: scale(0.97);
    }
        .piece:active {
            cursor: grabbing;
        }
        .piece.selected {
            outline: 5px solid orange;
        }

    /* 駒色 */
    .Color_父 {
        background: #b9bbdd;
    }

    .Color_娘 {
        background: #e4bdc3;
    }

    .Color_母 {
        background: #e9cccc;
    }

    .Color_手代 {
        background: #f7dcb4;
    }

    .Color_大番頭 {
        background: #dfbfaa;
    }

    .Color_兄嫁 {
        background: #d2e7d4;
    }

    .Color_丁稚 {
        background: #e9e3cc;
    }

    .Color_女中 {
        background: #f3e0e3;
    }

    .Color_番頭 {
        background: #ddcdc1;
    }

    .Color_番犬 {
        background: #f7f0f1;
    }

    .Color_祖父 {
        background: #adafdf;
    }

    .Color_祖母 {
        background: #daadad;
    }

    /*メッセージ */
    .game-message {
        position: fixed;
        top: 40%;
        left: 50%;
        transform: translate(-50%, -50%);
        font-size: 72px;
        font-weight: bold;
        color: #ff4081;
        text-shadow: 0 0 10px #fff, 0 0 20px #ff80ab, 0 0 30px #ff4081;
        animation: flash 1s infinite alternate;
        z-index: 9999;
        pointer-events: none;
    }

    @@keyframes flash {
        from {
            opacity: 1;
            transform: translate(-50%, -50%) scale(1.0);
        }
        to {
            opacity: 0.5;
            transform: translate(-50%, -50%) scale(1.1);
        }

    }
style>


div class="status">
    @if (isCleared)
    {
        span id="elapsed-time">🎉クリア🎉span>
    }
    span id="elapsed-time">経過時間: @Elapsed span>
    button id="reset-btn" @onclick="ResetGame">リセットbutton>
div>

div class="board"
     @onmousemove="(e) => DragMove(e)"
     @onmouseup="EndDrag">

    @for (int row = 0; row  MaxRows; row++)
    {
        for (int col = 0; col  MaxCols; col++)
        {
            // CSSのグリッドは1オリジン
            var cssRow = row + 1;
            var cssCol = col + 1;

            int cell = Board[row, col];
            switch (cell)
            {
                case 0: // 空
                        div class="cell empty" style="grid-area:@(cssRow) / @(cssCol);">
                        div>
                    break;
                case 1: // 壁
                        div class="cell wall" style="grid-area:@(cssRow) / @(cssCol);">
                        div>
                    break;
                case 9: // 玄関
                        div class="cell exit" style="grid-area:@(cssRow) / @(cssCol);">
                            玄関
                        div>
                    break;
            }
        }
    }

    @foreach (Piece p in Pieces)
    {
        var cssPieceRow = p.Row + 1;
        var cssPieceCol = p.Col + 1;

        div class="piece @(p.ColorClass) @(p == Selected ? "selected" : "")"
             style="
                grid-row:@cssPieceRow;
                grid-column:@cssPieceCol;
                width:@(p.Width * 50)px;
                height:@(p.Height * 50)px;"
             @onmousedown="(e) => StartDrag(p, e.ClientX, e.ClientY)">
            @p.Label
        div>
    }

div>


@if (isCleared)
{
    div class="game-message">🎉クリア🎉div>
}

@if (isWarning)
{
    div class="game-message">玄関を出てよいのは娘だけdiv>
}



@code {
    // 盤面の定義
    private const int MaxRows = 7;
    private const int MaxCols = 8;

    private int[,] Board = new int[MaxRows, MaxCols]
    {
        // 盤面定義 (0=空, 1=壁, 9=出口)
        {1,1,1,1,1,1,1,1},
        {1,0,0,0,0,0,0,1},
        {1,0,0,0,0,0,0,1},
        {1,0,0,0,0,0,0,1},
        {1,0,0,0,0,0,0,1},
        {1,0,0,0,0,0,0,1},
        {1,1,1,9,9,1,1,1},
    };


    // 駒の定義
    private class Piece
    {
        public int Row { get; set; }
        public int Col { get; set; }
        public int Width { get; set; }
        public int Height { get; set; }
        public string Label { get; set; } = string.Empty;
        public string ColorClass { get; set; } = string.Empty;

        public Piece(string label, int col, int row, int w, int h, string colorClass = "piece-default")
        {
            Label = label;
            Row = row;
            Col = col;
            Width = w;
            Height = h;
            ColorClass = colorClass;
        }
    }

    // 駒のリスト
    private ListPiece> Pieces = new ListPiece>();
    private void InitPieces()
    {
        Pieces.Clear();
        // 駒名、駒の左上の 列、行、幅、高さ、色のクラス名
        Pieces.Add(new Piece("父", 2, 1, 1, 2, "Color_父"));
        Pieces.Add(new Piece("娘", 3, 1, 2, 2, "Color_娘"));
        Pieces.Add(new Piece("母", 5, 1, 1, 2, "Color_母"));
        Pieces.Add(new Piece("手代", 1, 3, 1, 1, "Color_手代"));
        Pieces.Add(new Piece("大番頭", 2, 3, 4, 1, "Color_大番頭"));
        Pieces.Add(new Piece("兄嫁", 6, 3, 1, 1, "Color_兄嫁"));
        Pieces.Add(new Piece("丁稚", 1, 4, 1, 1, "Color_丁稚"));
        Pieces.Add(new Piece("女中", 2, 4, 2, 1, "Color_女中"));
        Pieces.Add(new Piece("番頭", 4, 4, 2, 1, "Color_番頭"));
        Pieces.Add(new Piece("丁稚", 6, 4, 1, 1, "Color_丁稚"));
        Pieces.Add(new Piece("番犬", 1, 5, 1, 1, "Color_番犬"));
        Pieces.Add(new Piece("祖父", 2, 5, 2, 1, "Color_祖父"));
        Pieces.Add(new Piece("祖母", 4, 5, 2, 1, "Color_祖母"));
        Pieces.Add(new Piece("丁稚", 6, 5, 1, 1, "Color_丁稚"));
    }

    protected override void OnInitialized()
    {
        // 盤面初期化
        InitPieces();

        // タイマー開始
        StartTimer();
    }

    // --------------------------------------------------
    // タイマー
    // --------------------------------------------------
    private System.Threading.Timer? timer;
    private int Elapsed = 0;

    // タイマー開始
    private void StartTimer()
    {
        timer?.Dispose();
        timer = new System.Threading.Timer(_ =>
        {
            InvokeAsync(() =>
            {
                Elapsed++;
                StateHasChanged();
            });
        }, null, 1000, 1000);
    }

    // リセット
    private void ResetGame()
    {
        Elapsed = 0;
        isCleared = false;
        isWarning = false;
        InitPieces();
        StartTimer();
        StateHasChanged();
    }

    // 選択中の駒
    private Piece? Selected;
    private int startX, startY;

    // ゲームクリアと警告フラグ
    private bool isCleared = false;
    private bool isWarning = false;

    // ドラッグ開始 
    private void StartDrag(Piece p, double clientX, double clientY)
    {
        Selected = p;
        startX = (int)clientX;
        startY = (int)clientY;
    }

    // ドラッグ移動
    private void DragMove(MouseEventArgs e)
    {
        if (Selected == null) return;

        // マウス移動量(px単位)
        int currentX = (int)e.ClientX;
        int currentY = (int)e.ClientY;

        const int cellSize = 50; // 1駒のサイズ
        int threshold = cellSize / 2; // 50pxの半分

        // 1駒の半分以上ドラッグさせたら、移動させる
        int rowStep = 0, colStep = 0;
        if (Math.Abs(currentX - startX) > threshold)
        {
            colStep = (currentX > startX) ? 1 : -1;
            startX += colStep * cellSize;
        }
        if (Math.Abs(currentY - startY) > threshold)
        {
            rowStep = (currentY > startY) ? 1 : -1;
            startY += rowStep * cellSize;
        }

        // 次の位置
        int newRow = Selected.Row + rowStep;
        int newCol = Selected.Col + colStep;

        // 移動して良いか判定
        if(CanMove(Selected, newRow, newCol))
        {
            // 駒位置の移動
            Selected.Row = newRow;
            Selected.Col = newCol;
        }
    }

    //------------------------------------------------
    // 移動して良いか判定する
    //------------------------------------------------
    private bool CanMove(Piece p, int newRow, int newCol)
    {
        // ----- 盤外は禁止 -----
        if (newRow  0 || newRow + p.Height > MaxRows) return false;
        if (newCol  0 || newCol + p.Width > MaxCols) return false;

        // ----- 壁セルとの衝突 -----
        bool blocked = Enumerable.Range(newRow, p.Height).Any(r =>
            Enumerable.Range(newCol, p.Width).Any(c =>
                Board[r, c] == 1));
        if (blocked) return false;

        // 出口判定(娘だけOK)
        bool inExit = Enumerable.Range(newRow, p.Height).Any(r =>
            Enumerable.Range(newCol, p.Width).Any(c =>
                Board[r, c] == 9));
        if (inExit)
        {
            if (p.Label == "娘")
            {
                GameClear();
                return true;
            }
            else
            {
                ShowWarning();
                return false;
            }
        }

        // 他駒との衝突
        foreach (var o in Pieces.Where(o => o != p))
        {
            bool overlapX = newCol  o.Col + o.Width && newCol + p.Width > o.Col;
            bool overlapY = newRow  o.Row + o.Height && newRow + p.Height > o.Row;
            if (overlapX && overlapY) return false;
        }

        return true;
    }

    private async void GameClear()
    {
        // タイマー停止
        timer?.Dispose();
        timer = null;

        isCleared = true;
        StateHasChanged();
        await Task.Delay(5000);
        isCleared = false;
        StateHasChanged();
    }

    private async void ShowWarning()
    {
        isWarning = true;
        StateHasChanged();
        await Task.Delay(3000);
        isWarning = false;
        StateHasChanged();
    }

    // ドラッグ終了
    private void EndDrag()
    {
        Selected = null;
    }

}

修正後のイメージ

image.png

本格的な駒が実装されました。

レコーディング 2025-10-02 074705.gif

メッセージ表示も良い感じですね。

クリアメッセージは是非ご自分で確認してください😄

念のためにここまでのソリューション全体をGitHubへアップしました

12.もっと拡張したい方へ

いかがだったでしょうか?
Webゲームが簡単に作成できました。

さらにゲームを発展させたい方もいると思います。

🔼 マウスだけじゃなくてスマホでタッチ操作したい
🔼 見た目をもっと強化したい

この記事の趣旨である 「初心者向けのBlazorのご紹介から」 外れますので、こちら公開しているデモ版のソースを参考にしてみてください。

13.最後に

Blazor ServerはWebSockets(ウェブソケット)を利用したフレームワークなのでゲーム作成が得意なだけなんでしょうか?

この記事で利用してきた技術を流用すれば、相当にリッチなユーザーインターフェースを簡単に作成できることが想像できると思います。
ブラウザ上の単純なボタンクリックだけでなく、必要に応じマウスドラッグなど任意のイベントを拾うことができます。

Blazorは業務アプリ開発でも優秀!

こちらの記事ではBlazorを利用した業務アプリケーションの開発事例を紹介しています。
気になった方は是非ご覧ください。

👉 Blazor業務アプリの実例記事はこちら

【 参考画像 】
image.png

image.png




元の記事を確認する

関連記事