概要
この記事はライフゲーム Advent Calendar 2017の17日目の記事です。
ギリギリ(23時30分ぐらい)間に合いました。
C#+WPFでMVVMにライフゲームを作ってみました。
まえがきWPFとMVVMについて
WPFとMVVMについて使ったことない人向けにものすごくざっくりとした説明をします。
WPF
WindowsGUIアプリケーション用のフレームワークです。
画面部分はXAMLというXML拡張言語を使って、それ以外はC#またはVisualBasicを使います。
MVVM
アプリケーションをModel/View/ViewModelの3層構造に分けるアーキテクチャです。
まぁMVCとかのMV◯系統の1種です。
とりあえず、この記事では以下のように分けます。
Viewは直接のUIの記述、つまりボタンやコンボボックスの配置とViewModelへのバインディングのみです。
ModelはGUIに依存しない部分、つまりライフゲームのセルの状態や登録されたパターンの管理、ゲームルールなどです。
例えばアプリケーションのコンソール版を作ろうとした場合にはModelは全て使いまわせるのが理想です(理想なんですが。。。)。
ViewModelはModelとViewの橋渡しです。今回はModelをほぼそのまま渡しているのであまり仕事をしていません。
実行結果
緑色が生きているセル、黒が死んでいるセルです。
ライフゲームでの有名なパターン"パルサー"が表示されています。
基本操作は以下の4つ
「Start」でゲーム開始
「Stop」でゲーム停止
「Random」で全セルの状態をランダムに変更
マウスクリックで任意のセルの生死を変更
加えて、更新間隔[msec]を左のテキストボックスで変更できます。
またセルの状態パターンを名前を付けて管理できます。
右下のコンボボックスで登録されたパターンを選択すると、セルの状態が変更されます。
中央のテキストボックスでパターンの名前を入力
「Save」で現在のパターンを保存
「Remove」でパターンの登録を解除
View
ItemsControlのItemSourceがセルのコレクションCells
とバインディングされています。
ItemsControlのPanelはUniformGridになっており、列数がCountRowColumn
とバインディングされています。
これにより1次元配列のCellsが強制的に列数ごとに改行され、2次元のように見えます。
ItemsControlのItemはToggleButtonになっており、チェック状態がセルの生死にバインディングされています。
<Window
x:Class="LifeGame.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:LifeGame"
Title="LifeGame" Width="600" Height="680">
<Window.Resources>
<DataTemplate x:Key="Checked">
<Grid Background="LawnGreen" />
</DataTemplate>
<Style x:Key="toggleButton" TargetType="{x:Type ToggleButton}">
<Style.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Stretch" />
<Setter Property="ContentTemplate" Value="{StaticResource Checked}" />
</Trigger>
</Style.Triggers>
</Style>
<Style BasedOn="{StaticResource {x:Type Button}}" TargetType="{x:Type Button}">
<Setter Property="Margin" Value="5" />
</Style>
<Style BasedOn="{StaticResource {x:Type TextBlock}}" TargetType="{x:Type TextBlock}">
<Setter Property="Margin" Value="5" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
</Window.Resources>
<Window.DataContext>
<local:MainWindowViewModel />
</Window.DataContext>
<Grid>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition Height="50" />
</Grid.RowDefinitions>
<!-- Grid0 セル -->
<ItemsControl Grid.Row="0" ItemsSource="{Binding Cells}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="{Binding CountRowColumn}" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<ToggleButton
Background="Black"
BorderBrush="Transparent"
BorderThickness="0.1"
IsChecked="{Binding IsAlive, Mode=TwoWay}"
Style="{StaticResource toggleButton}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Grid1操作パネル -->
<WrapPanel
Grid.Row="1"
Margin="5"
Orientation="Horizontal">
<TextBlock Text="{Binding Generation.Value, StringFormat=Gen:\{0\}}" />
<TextBox
MinWidth="40"
Margin="5,5,0,5"
Text="{Binding IntervalMSec.Value}" />
<TextBlock Margin="0,5,5,5" Text="msec" />
<Button Command="{Binding StartGameCommand}" Content="Start" />
<Button Command="{Binding StopGameCommand}" Content="Stop" />
<Button Command="{Binding RandomStateCommand}" Content="Random" />
<TextBox
MinWidth="80"
Margin="5"
Text="{Binding SaveName.Value}" />
<Button Command="{Binding SaveStateCommand}" Content="Save" />
<ComboBox
MinWidth="100"
Margin="5"
ItemsSource="{Binding SavedStates}"
SelectedItem="{Binding SelectedState.Value}">
<ComboBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Name}" />
<TextBlock Text="{Binding AliveCellPositions.Count, Mode=OneWay, StringFormat=(Alive:\{0\})}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Command="{Binding RemoveStateCommand}" Content="Remove" />
</WrapPanel>
</Grid>
</Window>
コードビハインドには何も書いていないので省略します。
ViewModel
ViewModelでは基本的にModelと結びつけているだけです。
class MainWindowViewModel
{
private Model appModel = new Model();
public int CountRowColumn => appModel.CountRowColumn;
public Cell[] Cells => appModel.Cells;
public ReadOnlyReactiveProperty<long> Generation => appModel.Generation;
public ReactiveProperty<int> IntervalMSec => appModel.IntervalMSec;
public ReactiveCommand StartGameCommand { get; } = new ReactiveCommand();
public ReactiveCommand StopGameCommand { get; } = new ReactiveCommand();
public ReactiveCommandRandomStateCommand { get; } = new ReactiveCommand();
public ReactiveProperty<string> SaveName { get; } = new ReactiveProperty<string>();
public ReactiveCommand SaveStateCommand { get; } = new ReactiveCommand();
public ObservableCollection<WorldState> SavedStates => appModel.SavedStates;
public ReactiveProperty<WorldState> SelectedState { get; } = new ReactiveProperty<WorldState>(mode: ReactivePropertyMode.None);
public ReactiveCommand RemoveStateCommand { get; } = new ReactiveCommand();
public MainWindowViewModel()
{
StartGameCommand.Subscribe(appModel.StartGame);
StopGameCommand.Subscribe(appModel.StopGame);
RandomStateCommand.Subscribe(appModel.RandomizeState);
SaveStateCommand.Subscribe(_ => appModel.SaveState(SaveName.Value));
SelectedState.Subscribe(appModel.LoadState);
RemoveStateCommand.Subscribe(_ => appModel.SavedStates.Remove(SelectedState.Value));
}
}
Model
全体モデルをModel
個別のセルがCell
セルの中の位置がPosition
特定のセルのパターンをWorldState
としてクラス化します。
class Model
{
/// <summary>
/// Cellの行数&列数
/// </summary>
public int CountRowColumn => 30;
/// <summary>
/// 世代の更新速度[msec]
/// </summary>
public ReactiveProperty<int> IntervalMSec { get; } = new ReactiveProperty<int>(100);
/// <summary>
/// Cellのコレクション
/// </summary>
public Cell[] Cells { get; }
/// <summary>
/// 現在の世代
/// </summary>
public ReadOnlyReactiveProperty<long> Generation { get; }
ReactiveTimer timer = new ReactiveTimer(TimeSpan.FromSeconds(1));
/// <summary>
/// 保存されたパターンコレクション
/// </summary>
public ObservableCollection<WorldState> SavedStates { get; private set; } = new ObservableCollection<WorldState>();
/// <summary>
/// パターン保存先ファイルパス
/// </summary>
private const string filePath = "worldState.json";
public Model()
{
//セルの生成
Cells = Enumerable.Range(0, CountRowColumn)
.Select(row =>
Enumerable.Range(0, CountRowColumn)
///最初に2次元の位置コレクションを生成
.Select(col => new Position
{
Row = row,
Column = col
})
//位置をセルに変換
.Select(p => new Cell { Position = p }))
//1次元に平坦化
.SelectMany(x => x)
.ToArray();
//全セルに自身の隣人を登録させる
Cells.ForEach(cell => cell.SetNeiberCells(Cells));
IntervalMSec.Subscribe(x => timer.Interval = TimeSpan.FromMilliseconds(x));
//Timerの更新回数を現在の世代として公開
Generation = timer.ToReadOnlyReactiveProperty();
//Timer実行内容の登録
timer.Subscribe(_ =>
{
//並列で全セルに次の世代での生死を決定
Cells.AsParallel().ForEach(c => c.DetermineNextGeneration());
//生死の更新
Cells.ForEach(c => c.UpdateGeneration());
});
//登録パターンをファイルから呼び出す
ReadSavedStatesFromFile();
}
/// <summary>
/// ゲーム開始
/// </summary>
internal void StartGame()
{
timer.Reset();
timer.Start();
}
/// <summary>
/// ゲーム停止
/// </summary>
internal void StopGame() => timer.Stop();
/// <summary>
/// 全てのCellの状態をランダムに変更
/// </summary>
internal void RandomizeState()
{
var rand = new Random(DateTime.Now.Millisecond);
Cells.ForEach(c => c.IsAlive = rand.NextDouble() < 0.5);
}
/// <summary>
/// 現在のセルパターンを保存
/// </summary>
/// <param name="saveName">登録名</param>
internal void SaveState(string saveName)
{
//生存しているセルの位置情報を集める
var aliveCellPositions = GetAlivePositions();
//左上端の位置を計算
var minPosition = new Position();
if (aliveCellPositions.Count > 0)
{
minPosition.Row = aliveCellPositions.Select(c => c.Row).Min();
minPosition.Column = aliveCellPositions.Select(c => c.Column).Min();
};
//保存パターンの生成
var currentState = new WorldState
{
Name = saveName,
AliveCellPositions = aliveCellPositions
//位置パターンを左上つめ(最小化)する
.Select(x => x - minPosition)
.ToList()
};
//パターンコレクションに追加
this.SavedStates.Add(currentState);
//パターンコレクションをファイル保存
WriteSavedStatesToFile();
}
/// <summary>
/// 現在のセル状態から生きているセルのポジションのリストを取得
/// </summary>
private List<Position> GetAlivePositions()
=> Cells
.Where(c => c.IsAlive)
.Select(c => c.Position)
.ToList();
/// <summary>
/// セルパターンを現在のセルに反映させる
/// </summary>
/// <param name="state">セルパターン</param>
internal void LoadState(WorldState state)
{
var savedAlivePositions = SavedStates.FirstOrDefault(x => x.Name == state.Name)?.AliveCellPositions;
if (savedAlivePositions == null)
{
return;
}
//生存セルが中央に配置されるように調整
var maxPosition = new Position();
if (savedAlivePositions.Count() >= 1)
{
maxPosition.Row = savedAlivePositions.Select(c => c.Row).Max();
maxPosition.Column = savedAlivePositions.Select(c => c.Column).Max();
};
var edgePosition = new Position { Row = CountRowColumn, Column = CountRowColumn, };
var offsetPosition = (edgePosition - maxPosition) / 2;
var centerdAlivePositions = savedAlivePositions.Select(p => p + offsetPosition).ToList();
//生存セルリストと位置が一致していたら生、していなかったら死に変更
Cells.ForEach(c =>
c.IsAlive = centerdAlivePositions.Any(p => p == c.Position));
}
/// <summary>
/// パターンコレクションをファイルから読込
/// </summary>
private void ReadSavedStatesFromFile()
{
var jsonText = File.ReadAllText(filePath);
JsonConvert.DeserializeObject<List<WorldState>>(jsonText)
.Where(x => !String.IsNullOrWhiteSpace(x?.Name))
.ToList()
.ForEach(SavedStates.Add);
}
/// <summary>
/// パターンコレクションをファイルに保存
/// </summary>
internal void WriteSavedStatesToFile()
{
var jsonText = JsonConvert.SerializeObject(SavedStates);
File.WriteAllText(filePath, $"{jsonText}");
}
}
public class Cell : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
private void RaisePropertyChanged([CallerMemberName]string propertyName = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
private bool _IsAlive;
/// <summary>
/// 生存しているか
/// </summary>
public bool IsAlive
{
get => _IsAlive;
set
{
if (_IsAlive == value)
return;
_IsAlive = value;
RaisePropertyChanged();
}
}
/// <summary>
/// セルの位置
/// </summary>
public Position Position { get; set; }
private bool nextAlive;
private List<Cell> neibers;
/// <summary>
/// 次の世代での生存状態の決定
/// </summary>
internal void DetermineNextGeneration()
=> nextAlive = DetermineNextAlive(IsAlive, neibers.Select(c => c.IsAlive));
/// <summary>
/// 生存状態の更新
/// </summary>
internal void UpdateGeneration()
=> IsAlive = nextAlive;
/// <summary>
/// 隣接セルの受け取り
/// </summary>
/// <param name="cells">隣接セル候補</param>
internal void SetNeiberCells(IEnumerable<Cell> cells)
=> this.neibers = cells.Where(c => IsNeiber(c)).ToList();
/// <summary>
/// 与えられたCellが隣接セルか判定する
/// </summary>
internal bool IsNeiber(Cell another)
=> (this != another) &&
Math.Abs(another.Position.Row - this.Position.Row) <= 1 &&
Math.Abs(another.Position.Column - this.Position.Column) <= 1;
/// <summary>
/// 次の世代での生死を決定する
/// </summary>
private static bool DetermineNextAlive(bool currentAlive, IEnumerable<bool> neiberAlives)
{
var countneiberAlive = neiberAlives.Where(x => x).Count();
//現在生きていて、
if (currentAlive)
{
//過疎なら死
if (countneiberAlive <= 1)
{
return false;
}
//過密なら死
if (countneiberAlive >= 4)
{
return false;
}
}
//死んでいて、
else
{
//誕生なら生
if (countneiberAlive == 3)
{
return true;
}
}
//それ以外はそのまま
return currentAlive;
}
public override string ToString() => IsAlive ? "Live" : "Death" + $"{Position}";
}
public class Position
{
/// <summary>
/// 行位置
/// </summary>
public int Row { get; set; }
/// <summary>
/// 列位置
/// </summary>
public int Column { get; set; }
public static bool operator ==(Position p1, Position p2)
=> p1.Row == p2.Row && p1.Column == p2.Column;
public static bool operator !=(Position p1, Position p2) => !(p1 == p2);
public static Position operator +(Position p1, Position p2)
=> new Position { Row = p1.Row + p2.Row, Column = p1.Column + p2.Column };
public static Position operator -(Position p1, Position p2)
=> new Position { Row = p1.Row - p2.Row, Column = p1.Column - p2.Column };
public static Position operator *(Position p1, int value)
=> new Position { Row = p1.Row * value, Column = p1.Column * value };
public static Position operator /(Position p1, int value)
=> new Position { Row = p1.Row / value, Column = p1.Column / value };
public override string ToString() => $"({Row},{Column})";
}
public class WorldState
{
/// <summary>
/// 登録名
/// </summary>
public string Name { get; set; }
/// <summary>
/// 生存しているセル位置のコレクション
/// </summary>
public List<Position> AliveCellPositions { get; set; } = new List<Position>();
public static bool operator ==(WorldState w1, WorldState w2)
=> w1.AliveCellPositions?.Count() == w2.AliveCellPositions?.Count() &&
Enumerable
.Zip(w1.AliveCellPositions, w2.AliveCellPositions, (p1, p2) => p1 == p2)
.All(x => x);
public static bool operator !=(WorldState p1, WorldState p2) => !(p1 == p2);
}
参考
WPF:LifeGame(ライフゲーム) - Gushwell's C# Programming Page
環境
VisualStudio2017
.NET Framework 4.7
ReactiveProperty3.6