お疲れ様です、poppyです。本日もこちらのブログを見に来てくださりありがとうございます。
今回もUnityでアセットを使わずにノベルゲームを作るところをお見せいたします。
第5回は「変数・論理演算編」ということで、「変数」を定義し、論理演算(比較演算も含む)を行います。今後やる予定の選択肢実装などに役立つと考えています。
制作環境
制作環境は第1~4回と同様です。また、変数についても第1~4回のものを使い回すのでご注意ください。
- バージョン:2022.3.24f1 (日本語化パッチ適用済み)
- テンプレート:Universal 2D
- スクリプト:Visual Studio 2022 (Windows)
今回は「宴」などのアセットは用いませんのでご了承ください。(多分使ったほうが早くできるとは思うけど)
当ブログの記載内容や情報の信頼性については可能な限り十分注意をしておりますが、その完全性、正確性、妥当性及び公正性について保証するものではありません。
情報の誤りや不適切な表現があった場合には予告なしに記事の編集・削除を行うこともございます。あくまでもご自身の判断にてご覧頂くようにお願い致します。
当ブログの記載内容によって被った損害・損失については一切の責任を負いかねます。ご了承ください。
今回の目標
第5回では以下のことができるようにします。
・変数の定義
・比較演算(>=や==など)の処理・評価
・論理演算(ANDやORなど)の処理・評価を行い、結果をTrue/Falseで返す
第4回までからの修正点
第4回から修正したところが2点あります。
・GoToTheNextLine()をUserScriptManagerに移動
・text_script.csvのB列に数値列を追加
GoToTheNextLine()をUserScriptManagerに移動
第4回まではMainTextControllerにありましたGoToTheNextLine()ですが、よくよく考えたらこれは「スクリプトの次の行に進む」というメソッドの機能を考えるとテキストを司るMainTextControllerではなく、スクリプトを管理するUserScriptManagerにあって然るべきなのでこちらに移動させました。ただGoToTheNextLine()の中にはMainTextControllerで使う変数も含まれているので、その部分は独立させることにしました。
GoToTheNextLine() (UserScriptManager.cs)
// 次の行へ移動
public void GoToTheNextLine()
{
//次の行に移動する際のテキスト部分の初期化
GameManager.Instance.mainTextController.NextLineInitial();
GameManager.Instance.lineNumber++;
string sentence = GetCurrentSentence();
bool isStatement = IsStatement(sentence);
GameManager.Instance.mainTextController.DisplayText(sentence, isStatement);
if (isStatement)
{
GameManager.Instance.userScriptManager.ExecuteStatement(sentence);
}
//現在の文章が文章なのにノベルモードじゃなければ
else if (GameManager.Instance.gameUpdateManager.getStateMode() != "novel")
{
GameManager.Instance.gameUpdateManager.ToggleToNovelMode();
}
}
NextLineInitial() (MainTextController.cs)
// 次の行へ移動したので初期化
public void NextLineInitial()
{
_displayedSentenceLength = 0;
_time = 0f;
_mainTextObject.maxVisibleCharacters = 0;
}
その他、今までGameManager.Instance.MainTextController.GoToTheNextLine()となっている部分が軒並みエラーになるのでGameManager.Instance.UserScriptManager.GoToTheNextLine()に修正します(UserScriptManager内にあるものは単にGoToTheNextLine()にする)。
text_script.csvのB列に数値列を追加
条件分岐を行うにあたり、ネストの深さを測る「インデント番号」を定義することにしたので、それをB列に置くことにしました。そのため、基本的に1個ずれます(今までwords[1]で取得していたのがwords[2]になるなど)。特に影響を受けるのはUserScriptManagerのExecuteStatement()やMainTextControllerのDisplayText()などでしょうか。多分今後も間に列を追加してそのたびに数値がずれることがあるでしょう。嫌な人は末尾に追加しておいてください。
VariablesManagerの記述
変数を置く変数置き場を設定します。VariablesManagerの「スクリプト実効化」を行います(スクリプト実効化については第2回の「ヒエラルキー・アタッチ・インスペクターの設定」参照)
そして、以下のようにスクリプトを記述します。
VariablesManager.cs
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;
using System.Text.RegularExpressions;
namespace NovelGame
{
public class VariablesManager : MonoBehaviour
{
Dictionary<string, object> variables;
private static int indexOfExpression;
private static bool intFlag;
void Awake()
{
//変数置き場
variables = new Dictionary<string, object>();
indexOfExpression = 0;
intFlag = true;
}
/* メソッドを追加 */
}
}
いろいろusingの箇所が増えてたり、indexOfExpressionやらintFlagやらありますが後ほど使うので気にしないでくさい。注目点は辞書型のvariablesであり、文字列(変数名)をキー、object型(intやfloatなどの元となっている型)を値としています。これに随時変数名とそれに対応した値を追加・更新する予定です。以下、このクラス部分は記述せず、メソッド部分のみ記述しますので皆様は「メソッドを追加」部分からメソッドを追加していってください。
第5回では以下の部分を実装する予定です。
・GetVariable():変数の取得
・ReplaceVariablesInExpression():変数名の入った文字列を対応する変数の値に置き換える
・EvaluateCompareExpression():(単一の)比較演算子を評価する
・ProcessCompareExpression():(複数の)比較演算子を処理して論理式(列)にする
・EvaluateExpression()(+その取り巻き):論理式を評価してTrue/Falseを返す
・SetVariable():変数を設定する(ただし第5回では数値(int・float)の計算はできない不完全なものである)
※text_script上で変数を用いて条件分岐や計算などを行うときは「v[変数名]」を用いることにします。例えばシナリオ番号を管理する変数scenarioNum(int型)が定義しているとして、これを1増やしたい場合、v[scenarioNum] = v[scenarioNum] + 1を記述することで実現するようにします。
GetVariable():変数の取得
キーが存在すれば値を返し、そうでなければLogErrorを返す単純なメソッドです。実際はGetVariableそのものを使うというよりは、次のReplaceVariablesInExpressionのために実装したところがあります。
//変数名から変数を取得する
public object GetVariable(string variableName)
{
if (variables.ContainsKey(variableName))
{
return variables[variableName];
}
else
{
Debug.LogError("Variable not found: " + variableName);
return null;
}
}
ReplaceVariablesInExpression():変数名の入った文字列を対応する変数の値に置き換える
text_script上にあるv[変数名]を変数名対応する値に置き換えるメソッドです。当然ながらv[変数名]のままだと今後やりたい条件分岐や計算処理ができないためまずはそこを一気に処理します。
実装としてはv[変数名]の部分を正規表現で取り出し、GetVariable()を使って変数値を持ってきて置換します。
// 文字列として与えられた式を変数を値に置換して文字列として返すメソッド
public string ReplaceVariablesInExpression(string expression)
{
// 文字列中の"v[変数名]"のような部分を正規表現で取り出す
var matches = Regex.Matches(expression, @"v\[(\w+)\]");
foreach (Match match in matches)
{
string variableName = match.Groups[1].Value;
object variableValue = GetVariable(variableName);
expression = expression.Replace(match.Value, variableValue.ToString());
}
return expression;
}
EvaluateCompareExpression():(単一の)比較演算子を評価する
続いて比較演算子を評価します。ここで処理する比較演算子は以下の6つです。
・「==」:等しい
・「!=」:等しくない
・「>」:より大きい
・「<」:より小さい
・「>=」:以上
・「<=」:以下
やり方としては、まず正規表現で比較演算子の前後の部分を抽出し、左辺、比較演算子、右辺を取得します。そして数値に変換可能なら数値に変換した上で、比較演算子に従って左辺と右辺を評価し、真の場合は”T”、偽の場合は”F”を返すようにします。
// 文字列として与えられた式を評価し、結果を返すメソッド
private string EvaluateCompareExpression(string expression)
{
//v[変数名]になっているのを置き換える
expression = ReplaceVariablesInExpression(expression);
// 正規表現で比較演算子とその前後の部分を抽出する
Regex regex = new Regex(@"(.*?)(==|!=|<=|>=|<|>)(.*)");
Match match = regex.Match(expression);
if (!match.Success)
{
// 式のフォーマットが不正の場合、エラーメッセージを出力して空文字を返す
Debug.LogError("Invalid expression format: " + expression);
return "";
}
// 左辺と右辺、比較演算子を取得
string left = match.Groups[1].Value.Trim(); // 左辺
string comparisonOperator = match.Groups[2].Value.Trim(); // 比較演算子
string right = match.Groups[3].Value.Trim(); // 右辺
// 左辺と右辺をオブジェクト型に設定(数値の場合は変換)
object leftValue = left;
object rightValue = right;
// 左辺が数値に変換可能か確認し、変換する
if (double.TryParse(left, out double leftDouble))
{
leftValue = leftDouble;
}
// 右辺が数値に変換可能か確認し、変換する
if (double.TryParse(right, out double rightDouble))
{
rightValue = rightDouble;
}
// 比較演算子に基づいて比較を実行し、結果を返す
switch (comparisonOperator)
{
case "==":
return leftValue.Equals(rightValue) ? "T" : "F";
case "!=":
return (!leftValue.Equals(rightValue)) ? "T" : "F";
case "<":
return (Comparer.Default.Compare(leftValue, rightValue) < 0) ? "T" : "F";
case "<=":
return (Comparer.Default.Compare(leftValue, rightValue) <= 0) ? "T" : "F";
case ">":
return (Comparer.Default.Compare(leftValue, rightValue) > 0) ? "T" : "F";
case ">=":
return (Comparer.Default.Compare(leftValue, rightValue) >= 0) ? "T" : "F";
default:
// 不明な比較演算子の場合、エラーメッセージを出力して空文字を返す
Debug.LogError("Invalid comparison operator: " + comparisonOperator);
return "";
}
}
・ProcessCompareExpression():(複数の)比較演算子を処理して論理式(列)にする
先ほどのEvaluateCompareExpression()は「v[変数名] > 3」などの単一の比較演算に対応していましたが、実際は「v[変数名] > 3 かつ v[変数名] <= 10」のような論理演算で結んで複数の比較をしたい場合があると思います。ここではこのような論理演算と比較演算が組み合わさっているものを以下に定義した論理式の状態に変換します。ここでは論理演算子を以下のように定義します。
・NOT():否定(中身がTrueならFalseに、FalseならTrueに)
・A AND B:論理積(AかつBがTrueのときはTrue、どちらかがFalseのときはFalse
・A OR B:論理和(AまたはBがTrueのときはTrue、どちらもFalseのときはFalse
※論理式というのはTrue、False、NOT、AND、OR、()の組み合わせで表された式を指す。
例:NOT( True AND False OR False) AND ((NOT(False) AND True) OR False)
処理としてはTrue、False、NOT、AND、OR、()に特別なマーカーを付与した上でこれらの前後で分離します(それらを「トークン」と呼ぶことにします)。その後マーカーを削除しつつ、それぞれのトークンにEvaluateCompareExpression()をかけていきます。ただし、先程のEvaluateCompareExpression()は比較演算が来ることを前提にしていますので、True、False、NOT、AND、OR、()が呼ばれた場合は別の処理を行うように変更します。最後にそれをくっつけます。
特別なマーカーを付与したのは分離したときにTrue、False、NOT、AND、OR、()を消さないようにするためです(普通に分離すると消滅してしまうため)。
private string ProcessCompareExpression(string expression)
{
// expression 文字列に特別なマーカーを追加する
expression = expression.Replace("NOT", "?_NOT?_");
expression = expression.Replace("AND", "?_AND?_");
expression = expression.Replace("OR", "?_OR?_");
expression = expression.Replace("(", "?_(?_");
expression = expression.Replace(")", "?_)?_");
expression = expression.Replace("true", "?_true?_");
expression = expression.Replace("false", "?_false?_");
expression = expression.Replace("True", "?_true?_");
expression = expression.Replace("False", "?_false?_");
// マーカーを使用して文字列を分割する
string[] parts = expression.Split(new[] { "?_" }, StringSplitOptions.RemoveEmptyEntries);
int partsLength = parts.Length;
string[] afterparts = new string[partsLength];
for (int i = 0; i < partsLength; i++)
{
parts[i] = parts[i].Replace("?_", ""); // マーカーを削除
afterparts[i] = EvaluateCompareExpression(parts[i]);
}
string afterExpression = "";
foreach (string part in afterparts)
{
afterExpression += part;
}
return afterExpression;
}
// 文字列として与えられた式を評価し、結果を返すメソッド(追加部分をハイライトで表示)
private string EvaluateCompareExpression(string expression)
{
expression = ReplaceVariablesInExpression(expression);
if (expression == "(" || expression == ")")
{
return expression;
}
if (expression.ToLower() == "true")
{
return "T";
}
if (expression.ToLower() == "false")
{
return "F";
}
if (expression == "NOT")
{
return "!";
}
if (expression == "AND")
{
return "&";
}
if (expression == "OR")
{
return "|";
}
if (expression == " ")
{
return "";
}
// 正規表現で比較演算子とその前後の部分を抽出する
Regex regex = new Regex(@"(.*?)(==|!=|<=|>=|<|>)(.*)");
Match match = regex.Match(expression);
if (!match.Success)
{
// 式のフォーマットが不正の場合、エラーメッセージを出力して空文字を返す
Debug.LogError("Invalid expression format: " + expression);
return "";
}
// 左辺と右辺、比較演算子を取得
string left = match.Groups[1].Value.Trim(); // 左辺
string comparisonOperator = match.Groups[2].Value.Trim(); // 比較演算子
string right = match.Groups[3].Value.Trim(); // 右辺
// 左辺と右辺をオブジェクト型に設定(数値の場合は変換)
object leftValue = left;
object rightValue = right;
// 左辺が数値に変換可能か確認し、変換する
if (double.TryParse(left, out double leftDouble))
{
leftValue = leftDouble;
}
// 右辺が数値に変換可能か確認し、変換する
if (double.TryParse(right, out double rightDouble))
{
rightValue = rightDouble;
}
// 比較演算子に基づいて比較を実行し、結果を返す
switch (comparisonOperator)
{
case "==":
return leftValue.Equals(rightValue) ? "T" : "F";
case "!=":
return (!leftValue.Equals(rightValue)) ? "T" : "F";
case "<":
return (Comparer.Default.Compare(leftValue, rightValue) < 0) ? "T" : "F";
case "<=":
return (Comparer.Default.Compare(leftValue, rightValue) <= 0) ? "T" : "F";
case ">":
return (Comparer.Default.Compare(leftValue, rightValue) > 0) ? "T" : "F";
case ">=":
return (Comparer.Default.Compare(leftValue, rightValue) >= 0) ? "T" : "F";
default:
// 不明な比較演算子の場合、エラーメッセージを出力して空文字を返す
Debug.LogError("Invalid comparison operator: " + comparisonOperator);
return "";
}
}
EvaluateExpression()(+その取り巻き):論理式を評価してTrue/Falseを返す
ProcessCompareExpression()を用いることで論理式ができましたが、我々が最終的にほしいのはTrueまたはFalseのどちらかなので論理式を評価してあげる必要があります。
ここが最大の難関で、単純な論理式であれば比較的容易(全列挙すれば良い)ですが、複雑な論理式を想定すると、「構文解析」をする必要があります。
ここでは以下のサイトを参考に、「再帰下降構文解析」を実装してみます。ChatGPTが(もちろん完璧なコードが出力されるわけじゃないので多少の修正は必要でしたが)。
※下記のサイトは全てC#ではなく四則演算ですが、これをC#かつ論理式バージョンにアレンジしました。第6回(?)かそれぐらいに四則演算もやるので同様に行う予定です。
あくまでもゲーム制作のための手段としての構文解析なので、詳しい内容については省略しますが、大体以下のような形で構文解析をしていると理解しています。
・式(Expression) → 項(Term) [ || 項(Term)]*
※[ ]*は中のものが0回以上繰り返されていることを表す。つまり式は項(Term)単独or複数の項同士を「||(または)」で結合したものということです。
・項(Term) → 因子(Factor) [ && 因子(Factor) ]*
・因子(Factor) → !(因子(Factor)) または 変数(Variable) または ‘(‘ 式(Expression) ‘)’
・変数(Variable) → 真(True) または 偽(False)
これらを再帰的に繰り返すことでやがてTrueまたはFalseのどちらかの結果が得られるようにできています。それを実装したのが以下になります。
public bool EvaluateExpression(string s)
{
indexOfExpression = 0;
s = ProcessCompareExpression(s).Replace(" ", "");
return ParseExpression(s);
}
// 解析および評価のメインメソッド
private bool ParseExpression(string s)
{
bool val = ParseTerm(s);
while (indexOfExpression < s.Length && s[indexOfExpression] == '|')
{
// OR演算子の場合
indexOfExpression++;
bool val2 = ParseTerm(s);
val = val || val2;
}
return val;
}
// 項を解析して評価
private bool ParseTerm(string s)
{
bool val = ParseFactor(s);
while (indexOfExpression < s.Length && s[indexOfExpression] == '&')
{
// AND演算子の場合
indexOfExpression++;
bool val2 = ParseFactor(s);
val = val && val2;
}
return val;
}
// 因子を解析して評価
private bool ParseFactor(string s)
{
if (s[indexOfExpression] == '!')
{
// NOT演算子の場合、次の項を否定して返す
indexOfExpression++;
return !ParseFactor(s);
}
else if (s[indexOfExpression] == '(')
{
// 括弧の場合、内部の式を解析して評価
indexOfExpression++;
bool val = ParseExpression(s);
indexOfExpression++; // Skip ')'
return val;
}
else
{
// 変数の場合、その値を返す
return ParseVariable(s);
}
}
// 変数を解析して評価
private bool ParseVariable(string s)
{
bool val = s[indexOfExpression] == 'T' || s[indexOfExpression] == 't'; // Assuming 'T' or 't' represents true and 'F' or 'f' represents false
indexOfExpression++;
return val;
}
SetVariable():変数を設定する
最後に得られた結果を変数にぶち込みます。bool型を入れるときは先程のEvaluateExpression()を用いて得るようにしましょう。string型、int型、float型はとりあえずそのままにします。次回はint、floatについては数値計算の結果を変数にいれられるように改良する予定です。
/*変数設定系 */
public void SetVariable(string varName, string operand, string expression, string typeName)
{
if (typeName == "bool" || typeName == "boolean")
{
variables[varName] = EvaluateExpression(expression);
}
else if (typeName == "int" || typeName == "float" || typeName == "number")
{
if (typeName == "int")
{
variables[varName] = Convert.ToInt32(expression);
}
else
{
variables[varName] = (float)Convert.ToDouble(expression);
}
}
else
{
variables[varName] = expression;
}
}
スクリプトから変数をセットできるようにする
次にスクリプトから変数をセットするようにします。今回はこのように、C列に命令名setvar、D列に型(int,bool,stringなど)、E列に変数名、F列にオペランド(今回は全て代入を表す=)、G列に値または計算式を入れる形で変数をセットするようにしたいです。
そして1番下の行でそれぞれの値を取得して表示するようにしたいです。
setvarの設定(UserScriptManager)
setvarの設定は以下のようにします。
// ステートメントを実行するメソッド
public void ExecuteStatement(string sentence)
{
string[] words = sentence.Split(','); // 文章を単語に分割する
// 単語によって処理を分岐する
switch (words[2].ToLower())
{
/* 今まで実装したものはそのままで以下を追加 */
case "setvar":
string typeName = words[3];
string varName = words[4];
string operand = words[5];
string expression = words[6];
GameManager.Instance.variablesManager.SetVariable(varName, operand, expression, typeName);
GoToTheNextLine();
break;
/* ここまで */
default:
break;
}
}
文章上にあるv[変数名]を対応した値に置き換える(MainTextController)
v[変数名]を対応した値に置き換えるメソッドはReplaceVariablesInExpression()で実装してあるので、文章を読み込んだ際にそのメソッドを通してやればOKです。
具体的には string textsentence = GameManager.Instance.variablesManager.ReplaceVariablesInExpression(words[3]); とすれば良いです。
実際に正しく出ていることがわかります。もう少し論理演算を複雑にしてみます。自分の調べではこの論理式はFalseになるはずです。
狙い通りFalseが出力されました。
終わりに
ということで今回はノベルゲーム開発の第5回ということで変数の定義と論理演算がメインとなりました。本当は条件分岐や四則演算もやりたかったですが、長くなってしまったので次回にまわしたいと思います!
それではまた!