SMX II 游戏脚本设计

2012-09-29

相信大部分体验过SMX2最新版本的玩家都已尝试了其附带的小游戏FinalExam, 玩家需要在布满陷阱的场景中不断计算系统随机给出的数学题目. 该小游戏在一定程度上体现了SMX2内置脚本引擎GSVM灵活度的飞跃, 本文也将通过对FinalExam的介绍让大家对GS语言有一个基本认识.

游戏介绍

为了降低难度, 本文将仅介绍经过简化的游戏, 其设定如下:

  1. Mario碰到地面中间的方块后, 游戏正式开始.
  2. 系统将有3秒的倒计时供玩家准备, 倒计时结束后, 系统将给出随机生成的题目(加减法两则运算),
    并给出3个选项, 其中包含了正确答案及另外2个错误的选项.
  3. 如果Mario选择正确, 加1分, 选择错误, 则扣2分.
  4. 如果分数未达到要求(10分), 则倒计时并继续生成下一道题目, 反之, 游戏完成.

原理解析

首先分析此游戏程序设计的难点, 此部分主要面向编程领域的初学者, 具备一定经验的读者可跳至下一节

  1. 如何随机生成题目
  2. 如何判断玩家选择答案的正确性

一个专业的智力游戏需要保证题目的随机性, 那么我们应该如何控制程序随机生成题目呢?
FinalExam中一道题目包含了多个数字(整数)和符号(+/-).
生成随机整数我们可以使用GS语言提供的Rand及Int函数, Rand()用于产生一个大于或等于0且小于1的值, Int(x)用于去除x的小数部分从而得到一个整数, 通过Int(Rand() * 10)我们可以得到一个0~9之间的整数. 如果希望得到一个10~19之间的整数, 上述表达式可以改为Int(Rand() * 10) + 10. 更进一步, 如果希望得到一个rangeFrom~rangeTo之间的整数, 可以这样编写代码: Int(Rand() * (rangeTo - rangeFrom + 1)) + rangeFrom.
到这里我们已经可以生成随机整数了, 那符号应该如何产生呢? "Int(Rand() * (加号 - 减号 + 1)) + 加号"? 这么写当然是不对滴, 但这里似乎给了我们某种暗示, 如果我们用0~3代表加减乘除4则运算, 那么我们就可以使用Int(Rand() * 2)来产生表示加法或者减法的0和1了. 然后我们就只需要生成两个或以上的整数及符号并连接为一串文字就可以得到随机的题目了.

另一个问题是如何判断玩家答案的正确性, 当然前提是我们如何提供一个正确的选项及另外两个错误的选项.
正确答案我们可以在产生题目的同时计算一个正确的结果. 如1+3-2, 程序产生的第一个数为1, 接下来程序产生"+"和数字3, 则进行加法得到结果4, 接下来产生"-"和数字2, 可得到最终结果2. 另外两个错误答案再次利用我们的Rand函数辅助生成, 为了保证错误答案的效果我们可以采用随机生成较小的数字并加在正确答案上. 然后我们只需要将答案显示在一个特定的方块上, 然后判断玩家是否选择了此方块即可.

场景设计

场景单位摆放布局见下图:

最上方显示分数的文本标签我们命名为textScoreBoard, 下方的题目文本为textQuestion. 紧接下来三个问号方块及方块上方的文本我们分别命名为blockRight, blockWrong1, blockWrong2, textRight, textWrong1, textWrong2. 地面上的叹号方块及一旁的文本命名为blockStartTrigger, textStartHint. 使用规范的名称可以方便我们后面编写交互相关代码.

事件引入

完成了场景的设计, 下面我们开始编写游戏逻辑代码. SMX定义了多类事件, 我们只需要对我们所关心的某些事件编写程序, 在这些事件发生时, 游戏就会运行我们编写好的代码.
SMX为普通单位定义了4个事件, Hitting: 马里奥碰到单位的瞬间, Hitted:马里奥碰到单位后, Responding: 单位向马里奥发出响应的瞬间(如顶了问号方块但蘑菇还没出来的阶段), Responded:单位对马里奥发出响应后. 我们只需在单位上点击右键并选择我们希望处理的事件名称即可进入此单位相应事件的编辑.
另一类事件则是针对整个游戏, 场景或者数据的变化, 在场景空白区域单击右键即可添加此类事件处理. 如有菜单未提供或者未公开的事件, 可选择自定义事件菜单项添加.

关卡代码

下面开始实际代码讲解, 代码大体含义可参考注释:
载入场景后, 我们需要初始化我们的游戏数据, 所以首先添加SceneLoaded事件的处理程序:

global int passedCount, failedCount; // 定义全局变量用于记录答对及答错的题目数量
global int score; // 记录分数
global int setupTime; // 记录倒计时剩余时间

/* init game */
passedCount = failedCount = 0;
score = 0;
Timer.Stop(); // 停止计时器, 保证不受特殊情况(如上次的计时器未关闭)的影响

blockRight.Top = blockWrong1.Top = blockWrong2.Top = -32; // 把方块移到屏幕上方

DataBar.SetTitle("TEST 0");
DataBar.FlashTitle(3);

我们希望在马里奥碰到地面中间的叹号方块后开始游戏, 那么添加blockStartTrigger单位的Hitted事件处理:

setupTime = 3; // 设置倒计时时间为3秒, 后面会使用到此值

// 题目文本内容区域显示Ready?字样及倒计时时间
// _property0代表textQuestion的第1个非通用属性, 对应了属性设置面板Visible下面的第一个即Text属性
textQuestion._property0 = Concat("Ready? ", setupTime); 

// 隐藏开始方块
blockStartTrigger.Visible = textStartHint.Visible = 0;

// 启动计时器, 此时每隔1000毫秒SMX会发生一次Timer事件
// 所以我们可以在Timer事件中完成倒计时和生成题目的逻辑
Timer.Start(1000);

Timer事件主要负责倒计时并生成题目和选项, 处理代码略长:

setupTime = setupTime - 1; // 倒计时
if (setupTime > 0)
{
	// 倒计时过程, 显示剩下的准备时间
	textQuestion._property0 = Concat("Ready? ", setupTime);
}
else if (setupTime == 0)
{
	// 倒计时结束, 停止计时器, 并产生题目...
	Timer.Stop();

	int ADD = 0, SUB = 1, MUL = 2;

	// 我们会先定义一些参数用于后续生成题目
	/* init game config */
	int countOfNumber = 2; // 产生的题目包含两个数字
	int numRangeFrom = 2, numRangeTo = 9; // 数字范围为2~9
	int mix = 1; // 可以使用不同的运算符号
	int defaultOperator = ADD; // 默认运算符号为加法
	int optRangeFrom = 1, optRangeTo = 2; // 产生的错误答案与正确答案相差值的范围

	/* generate test */
	string question; // 用于保存整个题目
	int result; // 用于保存题目结果
	int newNumber; // 每次新产生的数字
	int operator = defaultOperator; // 每次新产生的符号
	int i = 0; // 产生到第几个数字
	while (i < countOfNumber) // 循环产生数字及符号
	{
		newNumber = Int(Rand() * (numRangeTo - numRangeFrom + 1)) + numRangeFrom;
		if (i > 0)
		{
			if (mix == 1) operator = Int(Rand() * 2);
			
			if (operator == ADD) question.Append(" + ");
			else if (operator == SUB) question.Append(" - ");
		}

		// 拼接题目
		if (newNumber < 0) question.Append(Concat("(", newNumber, ")"));
		else question.Append(newNumber);
		
		// 逐步计算题目的结果
		if (i == 0)
		{
			result = newNumber;
		}
		else
		{
			if (operator == ADD) result = result + newNumber;
			else /*SUB*/ result = result - newNumber;
		}

		i = i + 1;
	}

	question.Append(" = ?");

	/* build scene */
	textQuestion._property0 = question;
	textRight._property0 = result;
	
	// 将3个选项方块设置为活动的问号方块, ClsId=3表示活动的问号方块
	blockRight.ClsId = blockWrong1.ClsId = blockWrong2.ClsId = 3;
	
	// 设置正确的方块有一个金币
	blockRight._property0 = 1;
	blockRight._property1 = 1;

	// 打乱正确和错误方块的位置
	int blockRightX1, blockWrong1X1, blockWrong2X1;
	int flag = Int(Rand() * 3);
	if (flag == 0)
	{
		blockRightX1 = 64;
		blockWrong1X1 = 128;
		blockWrong2X1 = 192;
		textWrong2._property0
			 = (textWrong1._property0
				 = result + (Int(Rand() * (optRangeTo - optRangeFrom)) + optRangeFrom))
					+ (Int(Rand() * (optRangeTo - optRangeFrom)) + optRangeFrom);
	}
	else if (flag == 1)
	{
		// 此处代码省略, 具体见源文件
	}
	else
	{
		// 此处代码省略, 具体见源文件
	}
	
	textRight.Left = blockRight.Left = blockRightX1;
	textWrong1.Left = blockWrong1.Left = blockWrong1X1;
	textWrong2.Left = blockWrong2.Left = blockWrong2X1;
	blockRight.Top = blockWrong1.Top = blockWrong2.Top = 120;
}

产生了题目后, 就是选项方块的逻辑了, 我们在blockRight的Responding事件中处理分数:

// 加分
score = score + 1;
passedCount = passedCount + 1;

blockRight.ClsId = blockWrong1.ClsId = blockWrong2.ClsId = 9; // 把方块置为无效方块
textRight._property0 = textWrong1._property0 = textWrong2._property0 = "";
textScoreBoard._property0 = Concat("Score: ", score,
							"     YES: ", passedCount,
							"  Total: ", passedCount + failedCount);

if (score >= 10)
{
	// 达到10分以上游戏完成
	Game.SceneComplete();
}

并在blockRight的Responded事件中启动计时器继续下一道题:

setupTime = 3;
textQuestion._property0 = Concat("Ready? ", setupTime);
Timer.Start(1000);

两个错误选项方块的Responding事件中, 处理扣分的逻辑:

score = score - 2;
failedCount = failedCount + 1;

blockRight.ClsId = blockWrong1.ClsId = blockWrong2.ClsId = 9;
textRight._property0 = textWrong1._property0 = textWrong2._property0 = "";
textScoreBoard._property0 = Concat("Score: ",
							score, "     YES: ", passedCount,
							"  Total: ", passedCount + failedCount);

错误选项方块的Responded事件与正确选项方块的逻辑相同.

如果我们在关卡中增加了陷阱, 则还需要在MarioLifesChanged事件中处理游戏机会用完的逻辑:

global int passedCount, failedCount;
global int score;
if (__LIFECOUNT__ == 0) // 判断马里奥的剩余机会
{
	// 通过消息框显示分数
	MessageBox.Show(Concat("You score:", score));
	
	// 重置分数及统计数据
	score = 0;
	passedCount = failedCount = 0;
}

写在最后

至此, 我们完成了FinalExam精简版的整个制作, 大家可右键另存FinalExam源文件. 由于多方面的原因, 本文可能存在错误或描述不清楚的地方, 欢迎大家反馈或提问.