C#/WPFでライフゲームを作る第2弾。
前回(ライフゲームを作ってみるver1)では、
WPFで作っておきながら、全ての実装をxaml.csに詰め込むという暴挙に出たため
全く持ってWPFである意味はありませんでした。
折角なので勉強中のMVVMパターンで組みたいし拡張性を持たせたいと思うので
改めて設計しなおして書き直しました。
構成
まずは構成。View→ViewModel→Modelの依存関係を前提として、
今回はライフゲームで世代更新→画面更新したいので
- 非同期で1世代ずつ内容を更新する
- 更新した内容で画面を更新する
の2つが必要になります。ライフゲームの管理はModel側に任せるとして、
非同期で画面にアクセスすることは単純にはできないので
ライフゲーム用のコントロールであるDataGridを扱うクラスを仲介役として設けました。
InteractionRequests名前空間内にいろいろとViewModel→Viewの方向で行いたい処理(クラス)を別個に定義しています。
これについては[WPF]MVVMパターンでViewModelからViewへのリクエストを参照してください。
なお、ソースコード全部載せると長いので全容はGithubに置いておきます。
https://github.com/MtBigYashi/LifeGame
View
Viewでは、イベントを掴まえていい感じに処理したいのでNuGetでWPF.Interactivityをインストールしています。
xamlは以下の通り。
<Window x:Class="LifeGameView.MainView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:iy="http://schemas.microsoft.com/expression/2010/interactivity"
xmlns:is="http://schemas.microsoft.com/expression/2010/interactions"
xmlns:trigger="clr-namespace:LifeGameView.TriggerActions"
xmlns:vm="clr-namespace:LifeGameViewModel;assembly=WpfTestViewModel"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" ResizeMode="NoResize"
Title="MainWindow" Height="650" Width="600" >
<Window.DataContext>
<vm:MainViewModel/>
</Window.DataContext>
<iy:Interaction.Triggers>
<is:PropertyChangedTrigger Binding="{Binding UpdateGenerationRequest}">
<trigger:UpdateGeneration/>
</is:PropertyChangedTrigger>
<is:PropertyChangedTrigger Binding="{Binding ResizeCellRequest}">
<trigger:ResizeCell/>
</is:PropertyChangedTrigger>
<is:PropertyChangedTrigger Binding="{Binding ToggleCurrentCellAliveRequest}">
<trigger:ToggleCurrentCellAlive/>
</is:PropertyChangedTrigger>
</iy:Interaction.Triggers>
<Grid Name="parentGridSample">
<StackPanel>
<Grid Name="gridSample">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Button Grid.Column="0" Content="Refresh" Command="{Binding Refresh}"/>
<Button Grid.Column="1" Content="Random" Command="{Binding Random}"/>
<Button Grid.Column="2" Content="Start" Command="{Binding Start}"/>
<Button Grid.Column="3" Content="Stop" Command="{Binding Stop}"/>
</Grid>
<DataGrid Name="dgSample" ItemsSource="{Binding LifeGameView}"
HeadersVisibility="None" IsReadOnly="True"
SelectionMode="Single" SelectionUnit="Cell" >
<iy:Interaction.Triggers>
<iy:EventTrigger EventName="MouseUp">
<iy:InvokeCommandAction Command="{Binding MouseUp}" />
</iy:EventTrigger>
</iy:Interaction.Triggers>
</DataGrid>
</StackPanel>
</Grid>
</Window>
今回、ViewModel側とバインディングしているのは4つのButtonそれぞれのコマンドに加え
1つのDataGridのItemsSourceおよびMouseUpイベントをコマンドに繋げています。
名前はサンプルで作った時のままなので適当です。
ライフゲームの世代更新、DataGridのセルをクリック、Randomボタンによる初期化などされた時、
画面を更新するために以下のDataGrid操作クラスを作成しています。
namespace LifeGameView
{
using System;
using System.Diagnostics;
using System.Windows;
using System.Windows.Media;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
internal class DataGridAccessor
{
private DataGridAccessor() { }
internal static DataGridAccessor Instance { get; } = new DataGridAccessor();
internal void SetCellColor(DataGrid dataGrid, int row, int column, Brush brush)
{
DispatchAction(() =>
{
var cell = GetCell(dataGrid, row, column);
if (cell == null)
{
Trace.WriteLine($"[ERR] Failed to get cell : [Row{row},Column{column}]");
return;
}
cell.Background = brush;
});
}
internal void ToggleCellColor(DataGridCell cell)
{
if (cell == null)
{
Trace.WriteLine("[ERR] The specified cell is null");
return;
}
DispatchAction(() =>
{
SolidColorBrush brush = cell.Background as SolidColorBrush;
if (brush == Brushes.Black)
cell.Background = Brushes.White;
else
cell.Background = Brushes.Black;
});
}
private DataGridCell GetCell(DataGrid dataGrid, int i, int j)
{
DataGridRow row = dataGrid.ItemContainerGenerator.ContainerFromIndex(i) as DataGridRow;
if (row == null)
{
Trace.WriteLine("[ERR] Failed to cast to DataGridRow from ItemContainer");
return null;
}
DataGridCellsPresenter presenter = GetVisualChild<DataGridCellsPresenter>(row);
if (presenter == null)
{
Trace.WriteLine("[ERR] Failed to get DataGridCellsPresenter from row");
return null;
}
return presenter.ItemContainerGenerator.ContainerFromIndex(j) as DataGridCell;
}
private T GetVisualChild<T>(Visual parent) where T : Visual
{
T child = default;
int visCt = VisualTreeHelper.GetChildrenCount(parent);
for (int i = 0; i < visCt; i++)
{
Visual v = VisualTreeHelper.GetChild(parent, i) as Visual;
child = v as T;
if (child == null) child = GetVisualChild<T>(v);
if (child != null) break;
}
return child;
}
private void DispatchAction(Action action)
{
if (Application.Current.Dispatcher.CheckAccess())
{
action.Invoke();
}
else
{
Application.Current.Dispatcher.Invoke(action);
}
}
}
}
コードの内容はver1の頃からほぼ変わっていませんが、非同期で画面にアクセスするためのディスパッチャを定義しています。
TriggerActionsに関しては冗長になるので割愛。ViewModel側のPropertyChangedイベントを受けてから、
このDataGridAccessorを経由して画面を更新していきます。
ViewModel
MainViewModelは画面とライフゲーム管理の仲介役としての働きを持たせます。
namespace LifeGameViewModel
{
using System;
using System.Data;
using System.Diagnostics;
using System.Windows.Input;
using LifeGameModel;
using LifeGameViewModel.InteractionRequests;
public class MainViewModel : ViewModelBase
{
private DataTable _dataTable = new DataTable();
public MainViewModel()
=> LifeGameManager.Instance.LayoutUpdated += LayoutUpdate;
private void InitializeLifeGameTable()
{
_dataTable.Columns.Clear();
_dataTable.Rows.Clear();
if (_lifeGameView != null)
{
_lifeGameView.Dispose();
_lifeGameView = null;
}
LifeGameManager.Instance.InitializeGeneration();
for (int i = 0; i < LifeGameManager.Instance.Rank; i++)
{
_dataTable.Columns.Add();
_dataTable.Rows.Add();
}
LifeGameView = new DataView(_dataTable);
}
private void LayoutUpdate(object sender, EventArgs e)
{
UpdateGenerationRequest = new UpdateGenerationRequest()
{
Rank = LifeGameManager.Instance.Rank,
GenerationInformation = LifeGameManager.Instance.GetCurrentGeneration()
};
}
private DataView _lifeGameView = null;
public DataView LifeGameView
{
get => _lifeGameView;
set
{
_lifeGameView = value;
OnPropertyChanged();
}
}
private ICommand _mouseUp = null;
public ICommand MouseUp => _mouseUp ?? (_mouseUp = new RelayCommand(o =>
{
ToggleCurrentCellAliveRequest = new ToggleCurrentCellAliveRequest();
int row = ToggleCurrentCellAliveRequest.Row;
int col = ToggleCurrentCellAliveRequest.Column;
int rank = LifeGameManager.Instance.Rank;
if (row < 0 || rank <= row || col < 0 || rank <= col)
{
Trace.WriteLine($"[ERR] Invalid index is specified [Row{row},Column{col}");
return;
}
LifeGameManager.Instance.ToggleAlive(row, col);
}));
private ICommand _refresh = null;
public ICommand Refresh => _refresh ?? (_refresh = new RelayCommand(o =>
{
InitializeLifeGameTable();
ResizeCellRequest = new ResizeCellRequest()
{
Rank = LifeGameManager.Instance.Rank
};
}));
private ICommand _random = null;
public ICommand Random => _random ?? (_random = new RelayCommand(o =>
{
LifeGameManager.Instance.RandomInitializeGeneration();
LayoutUpdate(null, null);
}));
private ICommand _start = null;
public ICommand Start => _start ?? (_start = new RelayCommand(o => LifeGameManager.Instance.StartGame()));
private ICommand _stop = null;
public ICommand Stop => _stop ?? (_stop = new RelayCommand(o => LifeGameManager.Instance.StopGame()));
}
}
先ほどの構成図では省略しましたが、ViewModelの基底クラスとしてViewModelBaseクラスを定義し、
InteractionRequests名前空間内のクラス型プロパティを定義しています。こちらも省略します。
Model
Modelはライフゲーム管理クラスのみです
namespace LifeGameModel
{
using System;
using System.Linq;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Generic;
public class LifeGameManager
{
private Dictionary<int, List<bool>> _preGeneration = new Dictionary<int, List<bool>>();
private Dictionary<int, List<bool>> _curGeneration = new Dictionary<int, List<bool>>();
private Task _task = null;
private CancellationTokenSource _tokenSource = null;
public event EventHandler LayoutUpdated = null;
private LifeGameManager() { }
public static LifeGameManager Instance { get; } = new LifeGameManager();
public int Rank { get; set; } = 30;
private double _density = 0.25;
public double Density
{
get => _density;
set
{
if (value <= 0 || 1.0 <= value) return;
_density = value;
}
}
public void InitializeGeneration()
{
_preGeneration.Clear();
_curGeneration.Clear();
_preGeneration = Enumerable.Range(0, Rank).ToDictionary(_ => _, _ => Enumerable.Repeat(false, Rank).ToList());
_curGeneration = Enumerable.Range(0, Rank).ToDictionary(_ => _, _ => Enumerable.Repeat(false, Rank).ToList());
}
public Dictionary<int, List<bool>> GetCurrentGeneration()
=> _curGeneration;
public void ToggleAlive(int i, int j)
// 指定された部分を真偽反転させる
=> _curGeneration[i][j] = !_curGeneration[i][j];
public void StartGame()
{
if (_task?.Status == TaskStatus.Running)
{
Trace.WriteLine("[INF] LifeGame is already running");
return;
}
_tokenSource = new CancellationTokenSource();
_task = Task.Factory.StartNew(() =>
{
while (!_tokenSource.IsCancellationRequested)
{
UpdateGeneration();
LayoutUpdated?.Invoke(null, null);
Thread.Sleep(500);
}
}, _tokenSource.Token);
Trace.WriteLine("[INF] LifeGame is started");
}
public void StopGame()
{
if (_task?.Status != TaskStatus.Running || _tokenSource == null)
{
Trace.WriteLine("[INF] LifeGame is NOT running");
return;
}
_tokenSource.Cancel();
_task.Wait();
_tokenSource.Dispose();
_task.Dispose();
_tokenSource = null;
_task = null;
Trace.WriteLine("[INF] LifeGame is stopped");
}
private void UpdateGeneration()
{
// 世代交代
_preGeneration = (from pair in _curGeneration select pair)
.ToDictionary(_p => _p.Key, _p => new List<bool>(_p.Value));
// 現世代の算出
for (int i = 0; i < Rank; i++)
{
for (int j = 0; j < Rank; j++)
{
int aliveCell = GetAliveSurroundCellCount(i, j);
if (Birth(i, j, aliveCell))
{
// 誕生
_curGeneration[i][j] = true;
}
else if (Depopulation(i, j, aliveCell))
{
// 過疎
_curGeneration[i][j] = false;
}
else if (Survive(i, j, aliveCell))
{
// 生存
_curGeneration[i][j] = true;
}
else if (Overcrowded(i, j, aliveCell))
{
// 過密
_curGeneration[i][j] = false;
}
}
}
}
private int GetAliveSurroundCellCount(int i, int j)
{
int above = i == 0 ? Rank - 1 : i - 1;
int below = i == Rank - 1 ? 0 : i + 1;
int left = j == 0 ? Rank - 1 : j - 1;
int right = j == Rank - 1 ? 0 : j + 1;
int aliveCell = 0;
if (_preGeneration[above][left]) aliveCell++;
if (_preGeneration[above][j]) aliveCell++;
if (_preGeneration[above][right]) aliveCell++;
if (_preGeneration[i][left]) aliveCell++;
if (_preGeneration[i][right]) aliveCell++;
if (_preGeneration[below][left]) aliveCell++;
if (_preGeneration[below][j]) aliveCell++;
if (_preGeneration[below][right]) aliveCell++;
return aliveCell;
}
private bool Birth(int i, int j, int aliveSurroundCells)
=> !_preGeneration[i][j] && aliveSurroundCells == 3;
private bool Depopulation(int i, int j, int aliveSurroundCells)
=> _preGeneration[i][j] && aliveSurroundCells <= 1;
private bool Survive(int i, int j, int aliveSurroundCells)
=> _preGeneration[i][j] && (aliveSurroundCells == 2 || aliveSurroundCells == 3);
private bool Overcrowded(int i, int j, int aliveSurroundCells)
=> _preGeneration[i][j] && aliveSurroundCells >= 4;
public void RandomInitializeGeneration()
{
_preGeneration.Clear();
_preGeneration = Enumerable.Range(0, Rank).ToDictionary(_ => _, _ => Enumerable.Repeat(false, Rank).ToList());
int totalCellCount = Rank * Rank;
var randomList = GetRandomRange(0, totalCellCount, (int)(totalCellCount * Density));
for (int i = 0; i < Rank; i++)
{
for (int j = 0; j < Rank; j++)
{
if (randomList.Any(_r => _r / Rank == i && _r % Rank == j))
_curGeneration[i][j] = true;
else
_curGeneration[i][j] = false;
}
}
}
private List<int> GetRandomRange(int min, int max, int count)
{
if (min > max || count <= 0) return null;
Random random = new Random(DateTime.Now.Millisecond);
List<int> list = new List<int>();
while (list.Count < count)
{
int r = random.Next(min, max);
if (list.Contains(r)) continue;
list.Add(r);
}
return list;
}
}
}
まとめ
だいぶいい感じになってきました。ただDictionaryのコピーが無駄が多い感じがして改善したいですね。
今はDictionaryにしてますが、ILookupにすると早いんでしょうかね?
あと、次元数やランダム初期化時の密度(Density)、過疎・過密の条件変更など
画面から動的に変更できるようにアップデートできるといろいろと実験しやすくて面白そうですね。
それは次回のver3で実施しましょう。今回はここまでです。