マップエディタの開発後記です。
しばらくはこの関連記事が続きます。
C#によるWindowsフォームアプリケーションの開発プロジェクトです。

このアプリケーションの正体はゲームを開発するためのビジュアルエディタです。
本当は元々Visual Basic.Netで作られていた同一物をC#.NETで作り直す企画なのですが、もうかなりの部分を書き直しており、その趣旨は薄れてきてしまいました。
分かる人には分かるかもしれません。ロックマンというファミリーコンピュータのゲームを創作するためのものです。

このゲーム開発自体はC++で、これとは別のプロジェクトで進行しています(現在はこのC#のプロジェクトが進行中のため中断してますが)
今回の記事もリファクタリング関連の話題となります。
保守性
保守性とはいわゆるコードの読みやすさ(可読性)、コードの変えやすさ(拡張性)、コードのまとまり(一貫性)などの品質の事で、ソフトウェア業界ではソースコード品質などとも呼ばれます。
これらの要素は保守を目的とされた世間的に好まれる様態であり、多くの開発者の指標になっています。
事実この要素を兼ね備えたシステムが世の中で使用されるているからです。
10月頭に公開した記事でリファクタリングに関してかなり書きましたが、実はそれからも一ヶ月ほどを掛けて更なるリファクタリングをしておりました。
それは元の構造を全体から変えるレベルで行われました。
上記の記事の中で紹介しておりましたが、マップタイル(アプリケーションの左側の大枠)の選択範囲指定をモードの切り替えで行う方式にしたのと、あとはオブジェクト指向設計になっていなかった部分があったので、それを根本から見直そうと思ったからです。
どうしても私はそれが納得のいくコードではないと感じ、理想の設計ができていませんでした。
具体的なお話をしましょう。
まずは2017年のVB.Netのコードです。
Public Class Form1
Private Sub MapField_Add_NewTable()
Const MAP_SIZE As Integer = 32
Const MAP_COL As Integer = 16
Const MAP_ROW As Integer = 15
TableLayoutPanel2.ColumnCount = MAP_COL
TableLayoutPanel2.ColumnStyles.Clear()
For i As Integer = 1 To MAP_COL
TableLayoutPanel2.ColumnStyles.Add(New ColumnStyle(SizeType.Absolute, MAP_SIZE))
Next
TableLayoutPanel2.RowCount = MAP_ROW
TableLayoutPanel2.RowStyles.Clear()
For i As Integer = 1 To MAP_ROW
TableLayoutPanel2.RowStyles.Add(New RowStyle(SizeType.Absolute, MAP_SIZE))
Next
TableLayoutPanel2.Size = New Size((MAP_COL * MAP_SIZE) + MAP_COL + 1, (MAP_ROW * MAP_SIZE) + MAP_ROW + 1)
For r As Integer = 0 To MAP_ROW - 1
For c As Integer = 0 To MAP_COL - 1
Dim iPx = New PictureBox() With {
.Name = "PictureBox_X" & c & "Y" & r,
.Size = New Size(MAP_SIZE, MAP_SIZE),
.Margin = New Padding(0),
.BackgroundImage = PictureBox1.BackgroundImage
}
AddHandler iPx.MouseDown, AddressOf MouseDown_MapTable
AddHandler iPx.MouseMove, AddressOf MouseMove_MapTable
AddHandler iPx.MouseUp, AddressOf MouseUp_MapTable
TableLayoutPanel2.Controls.Add(iPx, c, r)
Next
Next
End Sub
Private Sub MouseDown_MapTable(sender As Object, e As MouseEventArgs) Handles TableLayoutPanel2.MouseDown
' 選択範囲の開始点を決定する処理 ...
End Sub
Private Sub MouseMove_MapTable(sender As Object, e As MouseEventArgs) Handles TableLayoutPanel2.MouseMove
' 選択範囲の終点を決定する処理 ...
End Sub
Private Sub MouseUp_MapTable(sender As Object, e As MouseEventArgs) Handles TableLayoutPanel2.MouseUp
' TableLayoutPanel2に選択範囲にタイルを敷き詰める処理 ...
End Sub
End Class
次に先月作成したC#.NETのコードです。
namespace MapEditor.src.app.models
{
internal partial class MapStructs
{
internal bool Unzip(string path, List<Image> imagelist, ref Panel objects)
{
if (null != _binMapFile && _binMapFile.FileOpen(path))
{
_mapTable = CreateMapFieldTable(MAPFIELDNAME, MAPCOLUMNS, MAPROWS, new Size(MAPSIZES_X, MAPSIZES_Y), new Point(MAPLOCATIONS_X, MAPLOCATIONS_Y), MAPCELLSIZES);
int row_number = _mapTable.RowCount;
int col_number = _mapTable.ColumnCount;
int cellheight = _mapTable.Height / row_number;
int cellwidth = _mapTable.Width / col_number;
Size boxsize = new(cellwidth, cellheight);
int index = 0x00, chipindex = MAP_HEADERSIZE;
// An iterative process that sequentially accesses each split panel in a TableLayoutPanel.
for (int i = 0; i < row_number; i++)
{
for (int j = 0; j < col_number; j++)
{
// Place the image container for map data editing in MapFieldTable.
byte chipimage = _binMapFile.GetDataByte(chipindex) ?? 0xFF;
var chipno = imagelist.Count < chipimage ? imagelist.Count : chipimage;
PictureBox picturebox = BinMapFile.CreateTextureBox(index, imagelist[chipno], boxsize);
picturebox.Text = chipno.ToString();
picturebox.MouseDown += MapFieldChip_MouseDown;
picturebox.MouseUp += MapFieldChip_MouseUp;
picturebox.MouseMove += MapFieldChip_MouseMove;
picturebox.Click += MapFieldChip_Click;
_mapTable.Controls.Add(picturebox, j, i);
index++; chipindex++;
}
}
objects.Controls.Add(_mapTable);
MapPages = 0;
SetupMapStructure();
return true;
}
else
{
return false;
}
}
private void MapField_MouseDown(object? sender, MouseEventArgs e)
{
// 選択範囲の開始点を決定する処理 ...
}
private void MapField_MouseMove(object? sender, MouseEventArgs e)
{
// 選択範囲の終点を決定する処理 ...
}
private void MapField_MouseUp(object? sender, MouseEventArgs e)
{
// 選択範囲のアニメーションを開始する処理 ...
}
private void MapField_Paint(object? sender, PaintEventArgs e)
{
// 選択範囲の矩形を描画する処理(新設機能) ...
}
}
}
それを更に以下変更しました。
namespace ClientForm.src.CustomControls.Map
{
public partial class TilingPanel : Panel
{
private readonly byte[,] _mapTile = new byte[MAPFIELD_LINES, MAPFIELD_COLUMNS];
private void TileDrawer(PaintEventArgs e)
{
_ = ExceptionHandler.TryCatchWithLogging(() =>
{
// Exit if the graphic list is empty.
if (_chipManager == null || _chipManager.Count <= 0 || string.IsNullOrEmpty(Navigator.FieldName)) return;
int tileWidth = TILE_SIZE;
int tileHeight = TILE_SIZE;
for (int y = 0; y < _mapTile.GetLength(0); y++)
{
for (int x = 0; x < _mapTile.GetLength(1); x++)
{
byte imageIndex = _mapTile[y, x];
Image? image = _chipManager.GetImageByIndex(imageIndex);
if (image != null)
{
e.Graphics.DrawImage(image, x * tileWidth, y * tileHeight);
}
}
}
});
}
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
TileDrawer(e);
DrawRangeTool(e);
}
protected override void OnMouseDown(MouseEventArgs e)
{
base.OnMouseDown(e);
_rangeTool?.SetDraggingBeginPoint(e); // 選択範囲の開始点を決定する処理 ...
}
protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);
_rangeTool?.SetDraggingEndPoint(e); // 選択範囲の終点を決定する処理 ...
}
protected override void OnMouseUp(MouseEventArgs e)
{
base.OnMouseUp(e);
ProcessMouseClickOrDrag(e); // 選択範囲モードとタイルを配置するを挙動で判断 ...
}
private void DrawRangeTool(PaintEventArgs e)
{
// 選択範囲の矩形を描画する処理(新設機能) ...
}
private class MapSelectionTool : IDisposable
{
private Point _selectionStart; // Starting point of selection rectangle
private Point _selectionEnd; // End point of rectangle
private bool _isDragging; // State indicating that dragging is in progress
private System.Windows.Forms.Timer? _selectedRangeAnimation; // Selection rectangle animation counter
private float _dashOffset; // Dotted selection animation director
internal void SetDraggingBeginPoint(MouseEventArgs e) { ... }
internal void SetDraggingEndPoint(MouseEventArgs e) { ... }
internal Rectangle GetSelectionRectangle(int fromOffset, int toOffset, int inflates) { ... }
public void Dispose() { ... }
}
}
}
VB.Netからで言うともうほとんど原型がなくなってます😂
元のコードはかなり端折っていますので、意味は分からないかもしれないですがいわゆる手続き型のパラダイムを使っていて、メソッドはUIのボタンと密接な関係性を持ちます。
ボタンに対するメソッドは基本一つです。
一方のC#.NETはそれを再利用性に特化した形に書き換えようとしており、最終的にPanelコントロール(を継承したオブジェクト)に全てのロジックを含めています。
それを行う事でクラスやメソッドの一貫性が保たれ、SOLID原則やポリモルフィズムなど本来オブジェクト指向言語が持つ特徴を活かした構造になります。
ただこの構造を考えるのは一日掛かりました。構造というか設計ですね。
(構造を考えるだけで一日ですよ)
それだけ難しいんです。
私がロジカルシンキングできない生命体なだけかもしれませんが。
TilingPanelとなっているクラスが、

左のグレーのパネルです。
実際にマップデータ(バイナリデータ)を取り込むと以下のようになります。

このパネルに用意された機能としては、
- マップデータを取り込む
- マップタイルを描画する
- マップタイルの画像を差し替える(マップエディット機能)
- マップを選択する
などがあり、これらの機能はパネルオブジェクトに対して実装されるべきでしょう。
あくまでパネルというオブジェクト(クラス)に対して、必要な機能(メソッド)が実装してある事が望ましいのです。
それがオブジェクト指向設計の考え方です。
なぜそうなっていないといけないのか。
それは欲しいものが直感的に内包されている場所から見つかるその瞬間、きっと実感できる事でしょう。
命名について
プログラミングをしていると嫌と言うほど問題になってくる「命名」について少しお話を。
お話というか感想?対するコメント?
世の中、色んな景色の中にあるありとあらゆる物には全て名前が付いてます。
テレビ、テーブル、ソファー、冷蔵庫・・・。
外に出かければ車が行き交う道路があり、歩道には多くの人が歩いています。

街はたくさんの建造物や植栽物があり、お店には必ず何かの名前が付いています。
もちろん山や川、街自体にも名前があります。
それらには必ず誰にでも通じる共通した呼称がある事は誰にでも分かるでしょう。
それは裏を返せば、全ての物には名前が付いていないといけないという事です。
プログラミングにおいては、システムや機械を作る立場上、ものづくりです。
コーディングはクリエイティブ業です。
クリエイターにとって命名する事は因縁とも言え、切っても切り離せない義務なのです。
Qiitaに同じような事を言っている記事があります。
(というかこの記事がそれをパクったものなんですが🤣)

ぶっちゃけオブジェクト指向(というかプログラミング全体)で重要なのは、「名づけ」であるとされます。
それは合ってます。
プログラミングで一番時間が掛かっているのはファイルやそれを格納するフォルダ(場所)、クラスや関数、変数の名前を付ける事なのは間違いないです。
先日某IT会社のシステム開発Webセミナーに参加したのですがそこで気づいた事があり、
もの巧みなアプリはしっかり画面デザインやリソースの名前が分かりやすいものになって作りこまれてますし、それをJIT1方式で利用する適材適所を実現していました。
それはつまり分かりやすいものを作っている事を意味するのですが、世の中に一般的に普及しているものは全てその概念を汲んでいます。
それを実現するために「名づけ」は重要なキーパーソンに該当するのです。
私もこのプロジェクトでその命名についてかなりもがき苦しんでいます。
これは事実です。
そしてクラスやメソッドに適した名前が付いた時に気づくのが、無駄や斑がある事です。
そしてまたリファクタリングに繋がっていくのです。
結果的に良い結果に結びついていく事に繋がり、プログラミングとしても楽しくなってきます。
それが命名の重要性になります。
- 自動車業界用語であるJIT(Just In Time)は、無駄を省き、斑の無いように、無理をせずに働く思想の事。トヨタ自動車創業者の豊田喜一郎氏が考案したとされる。 ↩︎
スタックアルゴリズムを改造してみた
以前以下の記事でアンドゥ・リドゥの処理を実装した事を書きましたが、
この中で使用しているスタッククラスに少し課題が残っていたので、修正しました。
以前までスタックを積んだクラスの中に直接アンドゥロジックを書いていましたが、それをやめてメソッドの切り出しをしました。
(単一責任の原則、インターフェース分離の原則に則り)
※元コードについては上記の記事を参照ください
まずはUndoとRedoの構造を確認します。

元に戻す、やり直しをそれぞれ実行した時に対のスタックにデータを移し替えます。
次にこのスタックの容量を設定します。
たとえば記憶できるデータ量を50に設定する、などです。
アンドゥ処理を好きなだけ実行できるようにするとアプリケーション自体のパフォーマンスやPCの動作に影響を及ぼすリスクがあり、それはユーザーの意図しないところです。
スタックの上限に到達したら、データの古いものから削除してデータ数が上限を超えないように制御する必要があります。

これを実現するために先頭・末尾のデータにアクセスできる「連結リスト」を採用します。
連結リストはそれぞれの要素に前後の要素のアドレスを保持している拡張されたリスト構造の事で、C#で利用する事ができます。
この構造でありながら要素の追加および削除は非常に高速なので、アンドゥ・リドゥアルゴリズムと相性が良いです😄
internal class Deque<T>
{
private readonly LinkedList<T> _list = new();
internal int Count => _list.Count;
internal void Clear() => _list.Clear();
internal void AddFront(T item)
{
_list.AddFirst(item);
}
internal void AddRear(T item)
{
_list.AddLast(item);
}
internal T RemoveFront()
{
if (!IsEmpty())
{
T value = _list.First!.Value;
_list.RemoveFirst();
return value;
}
throw new InvalidOperationException("Deque is empty.");
}
internal T RemoveRear()
{
if (!IsEmpty())
{
T value = _list.Last!.Value;
_list.RemoveLast();
return value;
}
throw new InvalidOperationException("Deque is empty.");
}
internal T PeekFront()
{
if (!IsEmpty())
{
return _list.First!.Value;
}
throw new InvalidOperationException("Deque is empty.");
}
internal T PeekRear()
{
if (!IsEmpty())
{
return _list.Last!.Value;
}
throw new InvalidOperationException("Deque is empty.");
}
private bool IsEmpty()
{
return _list.Count == 0;
}
}
このクラスはデータ(T)を管理するためのもので、要素の先頭と末尾にデータを追加及び削除する機能を有します。
双方向連結リストはデータの追加・削除を行う速度がO(1)となっており、非常に高速です。
Flexible boundary access memory 😀
そしてこの連結リストを利用してスタックを構築します。
public class MementoStack<T>
{
private uint _maxCapacity;
private readonly Deque<T> _dequeuedStack = new();
public int Count { get => _dequeuedStack.Count; }
public MementoStack(uint maxCapacity)
{
_maxCapacity = maxCapacity;
}
internal uint MaxCapacity
{
get { return _maxCapacity; }
set
{
_maxCapacity = value;
AdjustCapacity();
}
}
internal void Clear() => _dequeuedStack.Clear();
internal void Push(T item)
{
_dequeuedStack.AddRear(item);
AdjustCapacity();
}
internal T Pop()
{
return _dequeuedStack.RemoveRear();
}
private void AdjustCapacity()
{
while (_dequeuedStack.Count > _maxCapacity)
{
_ = _dequeuedStack.RemoveFront();
}
}
}
スタックへデータ(T)を追加した際にキャパシティーを調べて、_maxCapacityを超えるようであれば先頭のデータ(最も古いデータ)を削除してデータ量を調節する機能を含んでいます。
これはAdjustCapacityメソッドで実装しています。
次にデータ(T)の部分です。
これは以前まで固定のデータ(マップタイルの位置や画像データのアドレスなど)を持っていましたが、それだと処理が追加される都度Undo、Redoメソッドを修正する手間が発生して開発効率が悪くなってしまいます。
なのでこのデータも可変で自由に追加・削除できるような仕組みにしましょう。
そこでデザインパターンの登場です( `・∀・)ノ
今回はCommandパターンを使いましょう。
public abstract class Command
{
public abstract void Execute();
public abstract void Undo();
}
internal class ChipSelectCommand : Command
{
// Resource.
private readonly ChipManagedPanel _targets;
private readonly Image? _newImage;
private readonly byte _newTileIndex;
private readonly Image? _oldImage;
private readonly byte _oldTileIndex;
public ChipSelectCommand(ChipManagedPanel targets, Image? newimage, byte newtile)
{
_targets = targets;
_newImage = newimage;
_newTileIndex = newtile;
_oldImage = _targets!.ChoiceChip;
_oldTileIndex = (byte)Math.Max(byte.MinValue, Math.Min(byte.MaxValue, _targets!.ChoiceChipNumber));
}
// Redo command.
public override void Execute()
{
_targets.ChoiceChip = _newImage;
_targets.ChoiceChipNumber = _newTileIndex;
}
// Undo command.
public override void Undo()
{
_targets.ChoiceChip = _oldImage;
_targets.ChoiceChipNumber = _oldTileIndex;
}
}
internal class MapTileChangeCommand : Command
{
private const int TILE_SIZE = MAPFIELD_CELLSIZE; // Square tile length.
// Resource.
private readonly TilingPanel _targets;
private readonly Point _startCell;
private readonly Point _endCell;
private readonly byte _newTileIndex;
private readonly List<byte> _oldTileIndex = new();
public MapTileChangeCommand(TilingPanel targets, Point start, Point end, byte newTileIndex)
{
_targets = targets;
_startCell = start;
_endCell = end;
_newTileIndex = newTileIndex;
}
// Redo command.
public override void Execute() => ChangeMapTiles(true);
// Undo command.
public override void Undo() => ChangeMapTiles(false);
private void ChangeMapTiles(bool flag)
{
Point startPoint = new(
Math.Min(_startCell.X, _endCell.X),
Math.Min(_startCell.Y, _endCell.Y)
);
Point endPoint = new(
Math.Max(_startCell.X, _endCell.X),
Math.Max(_startCell.Y, _endCell.Y)
);
int index = 0;
for (int row = startPoint.Y; row <= endPoint.Y; row++)
{
for (int col = startPoint.X; col <= endPoint.X; col++)
{
if (flag)
{
// Redo (Execute command)
_oldTileIndex.Add(_targets.GetMapTile(col, row));
_targets.SetMapTile(col, row, _newTileIndex);
}
else
{
// Undo
_targets.SetMapTile(col, row, _oldTileIndex[index]);
}
index++;
}
}
RefreshTheRectInTargets(startPoint, endPoint);
}
private void RefreshTheRectInTargets(Point startPoint, Point endPoint)
{
int x = startPoint.X * TILE_SIZE;
int y = startPoint.Y * TILE_SIZE;
int width = (endPoint.X - startPoint.X + 1) * TILE_SIZE;
int height = (endPoint.Y - startPoint.Y + 1) * TILE_SIZE;
Rectangle invalidateRect = new(x, y, width, height);
_targets.Invalidate(invalidateRect);
}
}
記事の都合上、Commandクラスの説明は省略しますが、
ChipSelectCommandクラスは右のグレーのグラフィックチップ一覧を選択する時のロジックで、
MapTileChangeCommandクラスは左のグレーのタイルマップにグラフィックチップを配置する時のロジックです。
このロジックをCommandクラスに持たせておき、実際のUI操作やフォーム処理とロジックを切り離しておく事で、アンドゥ・リドゥ操作を処理として記憶する事が可能になります。
つまりデータをスタックに積み上げるのではなく、ロジック本体をスタックに積み上げる思想です。
これなら他にやり直しさせたい新たなロジックが仕様追加されたとしても、Command抽象型を継承して別のクラスを実装する事で既存の実装には何の影響を与える事なく機能を拡張する事ができます。
これが前半でお話した保守性を向上させるという事です。
それではこのクラスを使って実際にスタックマネージャーを実装していきます。
public class RecordSupervision
{
private readonly MementoStack<Command> _undoStack = new(new CommonOption().MementoListNumber);
private readonly MementoStack<Command> _redoStack = new(new CommonOption().MementoListNumber);
internal RecordSupervision() {}
internal void PushUndoStack(Command command)
{
_undoStack.Push(command);
_redoStack.Clear();
}
internal Command? PopUndoStack()
{
if (0 < _undoStack.Count)
{
Command command = _undoStack.Pop();
_redoStack.Push(command);
return command;
}
return null;
}
internal Command? PopRedoStack()
{
if (0 < _redoStack.Count)
{
Command command = _redoStack.Pop();
_undoStack.Push(command);
return command;
}
return null;
}
internal void Clear()
{
_undoStack.Clear();
_redoStack.Clear();
}
}
CommonOption().MementoListNumberは定数です。
これはスタックの上限量で、具体的な数字は50などにします。
このスタックマネージャーはMainFormのメンバとして定義しました。
それはアンドゥ・リドゥ処理はアプリケーション全体として有する機能だからです。
public partial class MainForm : Form
{
private RecordSupervision _recorder = new();
private src.CustomControls.Chip.ShowcasePanel graphicChipPanel;
graphicChipPanel.SetPrimaryInstance(ref choiceChipPanel, ref _recorder);
// 元に戻すコマンドを押した時に呼び出すイベントリスナー
private void ExecuteUndo(object sender, EventArgs e)
{
Command? command = _recorder!.PopUndoStack();
command?.Undo();
}
// やり直しコマンドを押した時に呼び出すイベントリスナー
private void ExecuteRedo(object sender, EventArgs e)
{
Command? command = _recorder!.PopRedoStack();
command?.Execute();
}
}
public partial class ShowcasePanel : Panel
{
private RecordSupervision? _memento;
private ChipManagedPanel? _chipManager;
public void SetPrimaryInstance(ref ChipManagedPanel chipmanager, ref RecordSupervision memento)
{
_chipManager = chipmanager;
_memento = memento;
}
private void Button_Click(object? sender, EventArgs e)
{
ChipButton button = (ChipButton)sender!;
Command command = new ChipSelectCommand(_chipManager!, button.Image, button.ChipIndex);
command.Execute();
_memento!.PushUndoStack(command);
}
}
ShowcasePanelというクラスが、

右側のグレーのグラフィックが定間隔で配置されているパネルです。
ChipManagedPanelクラスもPanelを継承したものですが、ChoiceChipNumberなどの独自のメンバを持ったオブジェクトです。
これはマップエディタの内部で必要なものなのでお気になさらず( ̄▽ ̄)
Button_Clickイベントはグラフィックをクリックした時に発生する処理で、クリックした情報をコマンドクラスへ渡し、取得した実行用コマンドをExecuteメソッドで実行してスタックに積み上げています。
これでRecordSupervisionクラスの_undoStackメンバへ指定のグラフィックをクリックしたよ、という情報が格納されます。
これ以降の処理は以前の記事に書いた通りのビヘイビアーと同じです。
これらの処理は全てC#の基本的なライブラリで実装する事ができます。(C#11)
あと注意事項としては、C#でアプリケーションを開発する場合でもそうでない場合でも、言語のフレームワークは特別な理由がない限り最新のバージョンを使用したほうがいいです。
以前VB.NetのソリューションをVisual Studio 2022で起動した時に、互換性の問題がうんぬんかんぬんとコンパイラからメッセージが出た事があり、困惑した事があります。
経年によるバージョン差異については仕方がありませんが、システムは常に進化しているため現行バージョンまたはサポートが最も長い仕様を選択するべきです。
前回の記事で少し触れましたが、VB.NetのポリシーでVB6のコードとの互換性を維持しているというのがありました。
これは多くの人が古いプログラムを無意識に使い続ける傾向が強い事を如実に示すものです。
人間であるが以上、変化をする事は避ける事のできない宿命ですので、色んな可能性を考慮してプログラムを作成する事も必要になってくるという事も覚えておきましょう٩(′ω′)و
【修正履歴】
2023/11/12 9:13 … 一部誤字を修正しました。
コメント