4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ぷよぷよプログラミングをBlazorで実装 その1 ステージ作成

4
Last updated at Posted at 2025-11-16

はじめに

最近、「すぐわかる! ぷよぷよプログラミング SEGA公式ガイドブック」の本を購入しました。これ自体は、JavaScript で書かれているので、勉強がてら C# に移植していきます。素材が本物なので作成するのにテンションが上がります。

似た記事として Godot版があるので、これも参考にしていきます。

Blazor Advent Calendar 2025 の2日目の記事になります。本当は開催の1ヶ月前から始めて、まとめ記事のみを投稿するつもりでいたのですが、ダメでした。

もう一度プログラミングの楽しみを取り戻すつもりで、リハビリも兼ねてこの題材にしています。ペースは遅いですが、少なくても数記事は投稿できるはずです。

利用教材

当該記事の内容は、ぷよぷよプログラミングの利用条件を理解したうえで学習にご利用ください。

画像は教材から取得しています。

ファイル構成

ぷよぷよプログラミングのファイル構成は下表になっています。
Blazor版は参考にしますが、この通りにするかはまだ決めていません。

ファイル名 役割
config.js ゲームの設定値を保持するファイル
game.js ゲーム内の処理のループを管理するファイル
player.js プレイヤーの操作とぷよの状態管理を担当するファイル
puyoimage.js ぷよの画像やゲームオーバー時のアニメーションを管理するファイル
score.js ゲームのスコア管理を担当するファイル
stage.js ステージ内のぷよの配置と管理を行うファイル

環境

  • Windows 11 Pro
  • Visual Studio 2026 Insiders
  • .NET 10.0

Blazor WebAssemly スタンドアロン アプリ でプロジェクトを BlazorPuyoPuyo で作成しました。

NuGetインストール

Blazor版では、速度重視で SkiaSharp を使用していきます。

SkiaSharp自体はWebGLに直接対応していません。
SkiaSharp → メモリ上のbitmapをCPUで描画 → Blazor経由で <canvas> に転送

NuGetで「SkiaSharp.Views.Blazor」をインストールします。
image.png

ステージ作成

最初から対戦できることを考慮して、2画面を作成することにします。

image.png

Blazorプロジェクトのテンプレートから徐々に改変する形式にしていきます。
あと、コードビハインドでロジックとビューを分離して記述しています。

ソースコード

現段階で載せられる長さの状態まではここに記載していきます。
そのうち、GitHubに移行する予定です。

Layout/MainLayout.razor

Aboutヘッダーとか余分な余白を削っています。

MainLayout.razor
@inherits LayoutComponentBase
<div class="page">
    <div class="sidebar">
        <NavMenu />
    </div>

    <main>
        <article style="height:100%">
            @Body
        </article>
    </main>
</div>

Pages/Home.razor

Gameコンポーネントを作成して表示します。

Home.razor
@page "/"
@using BlazorPuyoPuyo.Content

<div class="graphpaper">
    <PageTitle>ぷよぷよ</PageTitle>

    <Game />
</div>

Pages/Home.razor.css

背景を方眼紙模様にしています。

Home.razor.css
.graphpaper {
    /* 方眼紙模様に必須のスタイル */
    background-image: linear-gradient(0deg, transparent calc(100% - 1px), #f0f0f0 calc(100% - 1px)), linear-gradient(90deg, transparent calc(100% - 1px), #f0f0f0 calc(100% - 1px));
    background-size: 12px 12px;
    background-repeat: repeat;
    background-position: center center;
    /* 以下任意のスタイル */
    padding: 20px;
    height: 100%;
}

Content/Config.cs

現段階の設定です。徐々に設定は増えていきます。

Config.cs
namespace BlazorPuyoPuyo.Content
{
    public static class Config
    {
        public const int PuyoImageWidth = 40;   // ぷよぷよ画像の幅
        public const int PuyoImageHeight = 40;  // ぷよぷよ画像の高さ

        public const int StageCols = 6;         // ステージの横の個数
        public const int StageRows = 12;        // ステージの縦の個数

        public const int FontWidth = 26;        // スコア画像の幅
        public const int FontHeight = 33;       // スコア画像の高さ
        public const int ScorePadding = 3;      // 内側余白

        // ステージの背景色
        public const string StageBackgroundColor = "#11213b";

        // ステージの背景画像
        public static readonly string[] StageBackgroundImage = { "Images/puyo_2bg.png", "Images/puyo_4bg.png" };

        // スコアの背景色
        public static readonly string[] ScoreBackgroundColor = { "#006EAB", "#B12332" };

        // ネクストの背景色
        public static readonly string[] NextBackgroundColor = { "#00C5E1", "#C9688B" };
    }
}

Content/Game.razor

1行3列のグリッドに分けて、左側にプレイヤー1画面、右側にプレイヤー2画面とします。

カスケーディングCascadingValue Value="this"を使用し下位コンポーネントにパラメーターを渡す方法にしています。

【2026/01/4追記】
「子コンポーネントのメソッドを直接呼びたい」場合は@ref以外に正式な手段はありませんとのことで、@refを付けます。

Game.razor
<CascadingValue Value="this">
    <div class="container ps-4 pt-4">
        <div class="row">
            <div class="col-4">
                <Next Index=1 @ref="_nextRef1" />
                <Stage Index=1 @ref="_stageRef1" />
                <Score Index=1 @ref="_scoreRef1" />
            </div>
            <div class="col-1">
            </div>
            <div class="col-4">
                <Next Index=2 @ref="_nextRef1" />
                <Stage Index=2 @ref="_stageRef2" />
                <Score Index=2 @ref="_scoreRef2" />
            </div>
        </div>
    </div>
</CascadingValue>

Content/Game.razor.cs

キャラクターやスコアの画像を取得しておきます。

DevicePixelRatioには、現在のディスプレイ機器における CSS ピクセルの解像度と物理ピクセルの解像度の比をセットしています。

await Task.WhenAllを使用して複数画像を読む込みようにしています。
そうしないと複数画像を読み込まれる前にOnPaintSurfaceメソッドが動作して例外エラーが発生しました。

【2026/01/4追記】
子コンポーネントのメソッドを直接呼べるようにするため、@ref用の変数をセット

Game.razor.cs
namespace BlazorPuyoPuyo.Content
{
    [SupportedOSPlatform("browser")]
    public partial class Game
    {
        [Inject]
        private HttpClient Http { get; set; } = default!;
        [Inject]
        private IJSRuntime JS { get; set; } = default!;

        public List<SKBitmap> PuyoImageList = [];
        public List<SKBitmap> ScoreImageList = [];

        public double DevicePixelRatio = 0;
        public bool IsLoaded = false;

        private Next? _nextRef1;
        private Next? _nextRef2;
        private Stage? _stageRef1;
        private Stage? _stageRef2;
        private Score? _scoreRef1;
        private Score? _scoreRef2;
        
        protected override async Task OnInitializedAsync()
        {
            const int puyoCount = 5;
            const int fontCount = 10;

            // ぷよ画像取得
            var paths = Enumerable.Range(1, puyoCount)
                                  .Select(i => $"images/puyo_{i}.png")
                                  .ToList();

            // スコアフォント取得
            var paths2 = Enumerable.Range(0, fontCount)
                                  .Select(i => $"images/{i}.png")
                                  .ToList();

            paths.AddRange(paths2);

            // 複数画像を同時に読み込む
            var tasks = paths.Select(LoadPngFromWwwroot).ToArray();
            var results = await Task.WhenAll(tasks);

            ImageList.AddRange(results);

            // ぷよ画像格納
            PuyoImageList.AddRange(ImageList.Take(puyoCount).Where(img => img != null)!);
            // スコアフォント格納
            ScoreImageList.AddRange(ImageList.Skip(puyoCount).Take(fontCount).Where(img => img != null)!);
        }

        protected override async Task OnAfterRenderAsync(bool firstRender)
        {
            if (firstRender)
            {
                DevicePixelRatio = await JS.InvokeAsync<double>("eval", "window.devicePixelRatio");
                isLoaded = true;
            }
        }

        private async Task<SKBitmap?> LoadPngFromWwwroot(string path)
        {
            // 画像データをメモリ上にロード、リトライ対応
            const int maxRetry = 3;
            int delay = 100;

            for (int retry = 1; retry <= maxRetry; retry++)
            {
                try
                {
                    var bytes = await Http.GetByteArrayAsync(path);
                    return SKBitmap.Decode(bytes);
                }
                catch (Exception ex)
                {
                    Console.WriteLine($"[{retry}/{maxRetry}] 画像読み込み失敗: {path}, {ex.Message}");

                    if (retry == maxRetry)
                        return null;

                    // 100 → 200 → 400 と伸びる
                    await Task.Delay(delay);
                    delay *= 2;
                }
            }

            return null;
        }
    }
}

Content/Stage.razor

ステージを作成しています。プレイヤー1が青系統、プレイヤー2が赤系統としています。

【2026/01/4追記】
SKCanvasViewを再描画するため、@ref="_canvasView"を付けます。

Stage.razor
@using SkiaSharp.Views.Blazor

<div id="score" 
     style="width:@(Config.PuyoImageWidth * Config.StageCols)px;
     height:@(Config.FontHeight)px;
     background-color:@(Config.ScoreBackgroundColor[Index - 1]);
     padding:0 @(Config.ScorePadding)px  0">
    <SKCanvasView OnPaintSurface="@OnPaintSurface" style="width:100%; height:100%" @ref="_canvasView" />
</div>

Content/Stage.razor.cs

読み込んだ画像の確認として、ぷよぷよキャラクターを5つ並べています。

【2026/01/4追記】
SKCanvasViewを再描画するため、Redraw()メソッドを追加します。
「CA1416:プラットフォームの互換性を検証する」の警告のため、[SupportedOSPlatform("browser")]を追加します。

Stage.razor.cs
using Microsoft.AspNetCore.Components;
using SkiaSharp;
using SkiaSharp.Views.Blazor;
using System.Reflection.Metadata;

namespace BlazorPuyoPuyo.Content
{
    [SupportedOSPlatform("browser")]
    public partial class Stage
    {
        [CascadingParameter]
        public Game Game { get; set; } = default!;

        [Parameter]
        public int Index { get; set; }

        private SKCanvasView? _canvasView;

        protected override async Task OnInitializedAsync()
        {
        }

        // 再描画
        public void Redraw()
        {
            if (_canvasView != null)
                _canvasView?.Invalidate();
        }

        private void OnPaintSurface(SKPaintSurfaceEventArgs e)
        {
            if (!Game.IsLoaded)
                return;

            var canvas = e.Surface.Canvas;
            float width = (float)(Config.PuyoImageWidth * Game.DevicePixelRatio);
            float height = (float)(Config.PuyoImageHeight * Game.DevicePixelRatio);
            foreach (var i in Enumerable.Range(0, 5))
            {
                var destRect = SKRect.Create(i * width, 0, width, height);
                canvas.DrawBitmap(Game.PuyoImageList[i], destRect);
            }
        }
    }
}

Content/Score.razor

スコアを作成しています。プレイヤー1が青系統、プレイヤー2が赤系統としています。

【2026/01/4追記】
SKCanvasViewを再描画するため、@ref="_canvasView"を付けます。

Score.razor
@using SkiaSharp.Views.Blazor

<div id="score" 
     style="width:@(Config.PuyoImageWidth * Config.StageCols)px;
     height:@(Config.FontHeight)px;
     background-color:@(Config.ScoreBackgroundColor[Index - 1]);
     padding:0 @(Config.ScorePadding)px  0">
    <SKCanvasView OnPaintSurface="@OnPaintSurface" style="width:100%; height:100%" @ref="_canvasView" />
</div>

Content/Score.razor.cs

読み込んだ画像の確認として、スコアフォントを0〜8まで並べています。

【2026/01/4追記】
SKCanvasViewを再描画するため、Redraw()メソッドを追加します。
「CA1416:プラットフォームの互換性を検証する」の警告のため、[SupportedOSPlatform("browser")]を追加します。

Score.razor.cs
using Microsoft.AspNetCore.Components;
using SkiaSharp;
using SkiaSharp.Views.Blazor;

namespace BlazorPuyoPuyo.Content
{
    [SupportedOSPlatform("browser")]
    public partial class Score
    {
        [CascadingParameter]
        public Game Game { get; set; } = default!;

        [Parameter]
        public int Index { get; set; }

        private SKCanvasView? _canvasView;
        
        protected override async Task OnInitializedAsync()
        {
        }

        // 再描画
        public void Redraw()
        {
            if (_canvasView != null)
                _canvasView?.Invalidate();
        }
        
        private void OnPaintSurface(SKPaintSurfaceEventArgs e)
        {
            if (!Game.IsLoaded)
                return;

            var canvas = e.Surface.Canvas;
            float width = (float)(Config.FontWidth * Game.DevicePixelRatio);
            float height = (float)(Config.FontHeight * Game.DevicePixelRatio);
            foreach (var i in Enumerable.Range(0, 9))
            {
                var destRect = SKRect.Create(i * width, 0, width, height);
                canvas.DrawBitmap(Game.ScoreImageList[i], destRect);
            }
        }
    }
}

Content/Next.razor

ネクストを作成しています。プレイヤー1が青系統、プレイヤー2が赤系統としています。

【2026/01/4追記】
SKCanvasViewを再描画するため、@ref="_canvasView"を付けます。

Score.razor
@using SkiaSharp.Views.Blazor

<div id="next" 
     style="width:@(Config.PuyoImageWidth * Config.StageCols)px;
     height:@(Config.PuyoImageHeight * 2.2)px;
     background-color:@(Config.NextBackgroundColor[Index - 1]);">
    <SKCanvasView OnPaintSurface="@OnPaintSurface" style="width:100%; height:100%" @ref="_canvasView" />
</div>

Content/Next.razor.cs

まだ何も表示していません。

【2026/01/4追記】
SKCanvasViewを再描画するため、Redraw()メソッドを追加します。
「CA1416:プラットフォームの互換性を検証する」の警告のため、[SupportedOSPlatform("browser")]を追加します。

Next.razor.cs
using Microsoft.AspNetCore.Components;
using SkiaSharp;
using SkiaSharp.Views.Blazor;

namespace BlazorPuyoPuyo.Content
{
    [SupportedOSPlatform("browser")]
    public partial class Next
    {
        [CascadingParameter]
        public Game Game { get; set; } = default!;

        [Parameter]
        public int Index { get; set; }

        private SKCanvasView? _canvasView;
        
        protected override async Task OnInitializedAsync()
        {
        }

        // 再描画
        public void Redraw()
        {
            if (_canvasView != null)
                _canvasView?.Invalidate();
        }
        
        private void OnPaintSurface(SKPaintSurfaceEventArgs e)
        {
        }
    }
}

最後に

次はぷよぷよのキャラクターを消すようにしていきます。

4
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?