0.前言

我想通过编写一个完整的游戏程序方式引导读者体验程序设计的全过程。我将采用多种方式编写具有相同效果的应用程序,并通过不同方式形成的代码和实现方法的对比来理解程序开发更深层的知识。
了解我编写教程的思路,请参阅体现我最初想法的那篇文章中的“1.编程计划”和“2.已经编写完成的文章(目录)”:

学习编程从游戏开始——编程计划(目录) - lexyao - 博客园

我已经在下面这篇文章中介绍了使用LCL和FCL组件构建一个项目(pTetris)的过程,后续的使用Lazarus的文章中使用的例子都是以向这个项目添加新功能的方式表述的:

在Lazarus下的Free Pascal编程教程——用向导创建一个使用LCL和FCL组件的项目(pTetris) - lexyao - 博客园

在前面写的文章中我们已经构建了pTetris项目的框架,并逐步添加了一些功能,作为示例的应用程序俄罗斯方块游戏已经达到了可玩的程度,并提供了丰富的操作方法。在这篇文章中我们将通过示例讲述更多组件的使用方法。

俄罗斯方块游戏中操作方块是核心。在前面的示例中我们已经让作为示例的应用程序俄罗斯方块游戏已经达到了可玩的程度,并提供了丰富的操作方法,将定制游戏计分方法、加速算法和难度设置的配置数据保存到了磁盘文件,在这篇文章中我们要把这些配置数据应用到游戏中,从而实现游戏的可定制性和操作的挑战性。

在这篇文章里,我主要讲述以下几个方面的内容:

  1. 应用程序配置数据应用概述
  2. pTetris中计分的实现
  3. pTetris中加速的实现
  4. pTetris中起始难度的实现
  5. 结束语

1.应用程序配置数据应用概述

在应用程序中使用配置数据的有多个方面,在这里我们先保证游戏的基本玩法,完成计分、加速和起始难度的基本实现。具体需要考虑的因素在《在Lazarus下的Free Pascal编程教程——打造有智能感知的用户设置操作界面 - lexyao - 博客园》我们已经有了比较详细的描述,在《在Lazarus下的Free Pascal编程教程——应用程序配置数据的管理 - lexyao - 博客园》中建立的这些配置数据管理的代码,并实现了退出时将配置数据保存到磁盘文件,再次进入时将配置数据恢复到上次退出时的样子。
在这里我们考虑将这些配置数据应用到游戏中,实现游戏的可定制性,从而增加游戏的趣味性和挑战性。
需要应用于游戏控制的配置数据包括计分、加速和起始难度。将这些配置数据应用于游戏需要做的工作就是在程序的适当位置读取相应的配置数据,并使用配置数据确定下一步的操作中或者作为下一步计算的参数。
使用配置数据原则是降低代码维护和代码调试的难度。为此需要注意以下几个关键问题:

  • 尽可能避免多处读取同一数据
  • 尽可能避免同类数据使用过于分散
  • 尽可能减少配置数据在应用程序中的传递

2.pTetris中计分的实现

2.1 计分的需求分析

以下是我们在《在Lazarus下的Free Pascal编程教程——打造有智能感知的用户设置操作界面 - lexyao - 博客园》细化的计分规则:

  • 每移动一个方块到达堆积区得基础分1分,按方块个数计算每组的总基础得分。比如,每组4个方块,可得总基础得分为4分
  • 以下可选的加分规则如果选中,则按设定的加分比例乘以基础分。基础分乘以所有加分倍数得到一组方块的最终得分
    • 放置高度加分:按放置方块的基点所在的行号(即5x5方格的中心点到堆积区底部的距离)计算加分倍数
      • 从最低部的行开始依次为1倍、2倍……
    • 消除行加分:放下方块后形成满行(满行的方块将被消除)会得到加分奖励
      • 消除一行得分增加的基础倍数,可选1-9
        • 消除行的高度加分:消除行的基础倍数乘以消除行所在高度
        • 同时消除多行加分:消除行的基础倍数乘以同时消除的行数

下面我们针对以上的要求确定编写代码需要的工作:

  • 计分基础是移动的方块,可通过当前盒子中方块的个数计算基础得分
    • 作为一种成绩,我们需要一个变量来统计移动方块的个数:property BoxCount: integer
  • 每完成一个方块盒子的移动要进行得分计算,需要有变量来统计移动当前方块盒子的得分和累计得分
    • 当前盒子得分property BoxScore: integer
    • 累计得分property BoxScores: integer
  • 计算放置高度加分,可通过当前移动方块盒子的基点获得,要使用基点的高度,有两种情况:
    • 在打开盒子之前计分,可直接使用当前盒子的基点值
    • 在打开盒子之后计分,需要在打开盒子时保存盒子的基点备用
  • 计算消除行加分,需要在消除满行之前统计满行的数量和满行所在的高度
    • 满行在打开盒子后才能统计
    • 消除满行之前需要完成满行统计结果,或者在消除满行前将统计结果保存下来
    • 作为一种成绩,我们需要一个变量来统计消除满行数:property BoxFullRows: integer
  • 打开盒子是在DoTimerVx的当前版本中完成的,所以统计和计算的工作在DoTimerV5中调用

2.2 计分的实现

根据以上分析,为了统计战绩,我们在TfrmMain中添加以下属性:

    property BoxCount: integer read FBoxCount write SetBoxCount;
    property BoxScore: integer read FBoxScore write SetBoxScore;
    property BoxScores: integer read FBoxScores write SetBoxScores;
    property BoxFullRows: integer read FBoxFullRows write SetBoxFullRows; 

统计数字在游戏开始的时候是从0开始计数的,所以在游戏开始的时候要设置这些属性的初始值为0。
在以前的文章中我们曾经有一个函数GameBegin用来做游戏开始的一些初始化工作,并为计分代码预留的位置。现在我们就将计分属性初始化的代码添加到GameBegin中:

procedure TfrmMain.GameBegin;
begin
  ......
  { #todo : 计时计分归零 }
  BoxCount := 0;
  BoxFullRows := 0;
  BoxScores := 0;
  ......
end;   

在以前的代码中,判断和消除满行的代码是在cxBoxHeap.ClearFullRows中实现的,我们通过在DoTimerV4中使用boxHeap.ClearFullRows消除满行。cxBoxHeap.ClearFullRows是一个过程(procedure),没有返回值。我们要在在DoTimerVx的当前版本中完成移动方块得分的计算,需要用到满行的行数和满行所在的高度,这就需要cxBoxHeap.ClearFullRows能够返回这两个值。现在我们修改这个过程,给它添加两个参数,可以返回满行的行数和高度。不过,为了保持DoTimerV4的代码不被修改,我们重写cxBoxHeap.ClearFullRows,保持一个不带参数的版本。当然,我们已经使用了DoTimerV5,而DoTimerV4已经不需要了,如果已经通过注释的方法去掉了DoTimerV4,那就不需要cxBoxHeap.ClearFullRows不带参数的版本了。

{销毁满行,上部方块下落}
procedure cxBoxHeap.ClearFullRows;
var
  iRows, iRowHeight: integer;
begin
  ClearFullRows(iRows, iRowHeight);
end;

procedure cxBoxHeap.ClearFullRows(var iRows, iRowHeight: Integer);
var
  i, j: integer;
  rw: cxDustbin;
  bx: cxBox;
  xy: TPoint;
begin
  //销毁满行
  iRows := 0;
  iRowHeight:= 0;
  for i := RowCount - 1 downto 0 do
  begin
    if RowFull(i) then
    begin
      rw := Rows(i);
      rw.Free;
      Inc(iRows);
      Inc(iRowHeight, i);
    end;
  end;
  //如果有销毁的行,则需要重新显示堆积的方块(向下移动)
  ......end;

计算得分的代码在DoTimerV5中实现,为了代码的可读性,我们将计算得分的代码放到一个函数OpenBoxAndCalcScore中,然后在DoTimerV5中调用OpenBoxAndCalcScore函数。在OpenBoxAndCalcScore函数中也包含了以前在DoTimerV5中使用的boxHeap.BoxsOpen和boxHeap.ClearFullRows,其中boxHeap.ClearFullRows是新版的带参数的版本。

procedure TfrmMain.OpenBoxAndCalcScore(bxs: cxBoxs);
var
  iScoreDesRows, iScoreDesHeight, iScoreDesBase: integer;
  iScoreDes, iRows, iRowHeight: integer;
  iScoreHeight, iScoreBase: integer;
  iScore: integer;
begin
  //--计算基础分数
  iScoreBase := FConfig.ConfigData.GetValue('trcScoreBase/Position', 1);
  iScore := iScoreBase * bxs.BoxCount;
  //--计算放置高度加分
  if FConfig.ConfigData.GetValue('ckScoreHeight/Checked', True) then
  begin
    iScoreHeight := FConfig.ConfigData.GetValue('trcScoreHeight/Position', 1);
    iRowHeight := boxHeap.RowFromGrid(bxs.BaseY);
    if iRowHeight > 0 then
      iScore := iScore * iScoreHeight * iRowHeight;
  end;
  //--打开盒子
  BoxCount := BoxCount + 1;
  boxHeap.BoxsOpen(bxs);//bxs.Free;
  //--消除满行
  boxHeap.ClearFullRows(iRows, iRowHeight);
  BoxFullRows := BoxFullRows + iRows;
  //--计算消行加分
  if (iRows > 0) and FConfig.ConfigData.GetValue('ckScoreDesBase/Checked', True) then
  begin
    iScoreDesBase := FConfig.ConfigData.GetValue('trcScoreDesBase/Position', 1);
    iScoreDes := iScoreDesBase;
    if (iRowHeight > 0) and FConfig.ConfigData.GetValue('ckScoreDesHeight/Checked', True) then
    begin
      iScoreDesHeight := FConfig.ConfigData.GetValue('trcScoreDecHeight/Position', 1);
      iScoreDes := iScoreDes * iScoreDesHeight * iRowHeight;
    end;
    if (iRows > 0) and FConfig.ConfigData.GetValue('ckScoreDesRows/Checked', True) then
    begin
      iScoreDesRows := FConfig.ConfigData.GetValue('trcScoreDecHeight/Position', 1);
      iScoreDes := iScoreDes * iScoreDesRows * iRows;
    end;
    iScore := iScore * iScoreDes;
  end;
  //--统计得分
  BoxScore := iScore;
  BoxScores := BoxScores + iScore;
end;

在以上代码中,我们使用FConfig.ConfigData.GetValue获取配置数据的当前值,而GetValue参数中的路径则要与cxConfig.ValueFrom中一致。

2.3 当前得分的显示

在界面布局时我们添加了一个组件pnInfo用于当前游戏信息的显示,现在我们就将当前的得分情况显示在这个组件中。
这是一个面板组件,通常用来占位或者作为容器的,现在我们用它的Caption属性来显示游戏信息。
首先,在属性列表中设置pnInfo的Wordwrap属性为true,这样它就可以自动换行,可以用来显示多行文本。
然后,我们编写一个函数,将将游戏的当前信息显示在pnInfo中:

procedure TfrmMain.ViewScores;
var
  sb: TStringBuilder;
begin
  sb := TStringBuilder.Create;
  sb.AppendLine(Format('移动方块%d组', [FBoxCount])); 
  sb.AppendLine(Format('消除满行%d', [FBoxFullRows]));
  sb.AppendLine(Format('累计得分%d', [FBoxScores])); 
  sb.AppendLine(Format('本次得分%d', [FBoxScore]));
  pnInfo.Caption := sb.toString;
  sb.Free;
end;

为了让信息能够显示出来,还需要调用函数ViewScores。我们将函数添加到需要在ViewScores中显示的属性的设置函数中,这样每次有属性值发生改变的时候都会调用ViewScores函数,从而使得信息显示及时刷新。在这里需要添加ViewScores函数的属性设置函数包括:SetBoxCount、SetBoxFullRows、SetBoxScore、SetBoxScores。这几个函数的格式是一样的,我们在这里只粘贴一个函数的代码,其他的函数在同样的位置添加以下代码中的红色代码:

procedure TfrmMain.SetBoxCount(AValue: integer);
begin
  if FBoxCount = AValue then Exit;
  FBoxCount := AValue;
  ViewScores;
end;  

这样,编译运行pTetris项目,开始游戏后就能看到得分的信息了。

 

3 pTetris中加速的实现

3.1 加速的需求分析

以下是我们在《在Lazarus下的Free Pascal编程教程——打造有智能感知的用户设置操作界面 - lexyao - 博客园》细化的加速规则:

  • 方块从移动区顶部自动跳动下落,每次下落一行,两次跳动动作的时间间隔
    • 基础时间间隔在1-1000毫秒之间,默认初始值为1000,可在难度中设置初始值
    • 每放下一组方块跳动动作的时间间隔缩短,缩短的时间按基准时间计算所得
      • 计算方法为:缩短时间=基准时间×加速倍数/得分总数
      • 基准时间在1-10毫秒之间,默认为1毫秒
      • 加速倍数为以下可选项的倍数总和
        • 按下落跳动次数加倍
        • 每按键一次增加1-10倍,默认为1倍

下面我们针对以上的要求确定编写代码需要的工作:

  • 方块移动的速度有两个数值:一个是当前速度,一个是变化的时差。我们设置两个属性记录这两个数值。由于方块移动我们使用了计时器驱动,计时器的最小数值是1毫秒,而我们计算的时差有可能小于1毫秒,所以我们使用Delphi中表示时间的数据格式TDatetime,也就是double
    • property TimerInterval: double;方块移动的当前时间间隔,取整数后可作为Timer的Interval属性值
      • 在每一局游戏开始时初始化
      • 在每一组盒子移动完成后调整
    • property TimerAdjust: double;完成一组方块移动后调整时差的调整值,这是我们通过加速算法计算出来的
      • 在每一组盒子移动完成后计算出来
  • 加速算法考虑了两种因素:手动操作加速和自动下落加速
    • 手动操作加速:按手动操作的次数计算加速的倍数,操作越多加速也越多。为了避免加速过快,让用户尽量减少操作的次数。
      • 需要设置一个属性值统计操作的次数property MoveKey: integer
      • 每一组方块移动开始前要设置MoveKey:=0;
      • 每次操作要设置MoveKey+1
        • 在每个操作按钮的事件处理函数中实现
    • 自动下落加速:方块盒子在计时器的推动下向下跳动,跳动的次数越多加速也越多。为了避免加速过快,用户可以使用“跌落”操作让盒子直接到达底部
      • 需要设置一个属性值统计跳动的次数property MoveTimer: integer
      • 每一组方块移动开始前要设置MoveTimer:=0;
      • 时钟周期要设置MoveTimer+1
        • 在DoTimerVx的当前版本DoTimerV5中实现
  • 计算加速的算法有两种,计算加速的代码可以在一个方块盒子移动结束后,紧跟在计算得分的代码之后完成,也就是在DoTimerVx的当前版本DoTimerV5中调用

 

3.2 加速的实现

根据以上分析,为了统计战绩,我们在TfrmMain中添加以下属性:

    property MoveTimer: integer read FMoveTimer write SetMoveTimer;
    property MoveKey: integer read FMoveKey write SetMoveKey;
    property TimerInterval: double read FTimerInterval write SetTimerInterval;
    property TimerAdjust: double read FTimerAdjust write SetTimerAdjust;  

其中,SetTimerInterval中要完成计时器的调整和当前速度显示组件的调整:

procedure TfrmMain.SetTimerInterval(AValue: double);
begin
  if AValue < 0 then AValue := 0;
  if FTimerInterval = AValue then Exit;
  FTimerInterval := AValue;
  //Timer1.Enabled := False;
  Timer1.Interval := round(AValue);
  //Timer1.Enabled := True;
  trcStart.SelEnd := 1000 - round(AValue);  

在每一局游戏开始时要设置初始化这些属性值:

procedure TfrmMain.GameBegin;
begin
  ......
  { #todo : 计时计分归零 }
  ......
  MoveKey := 0;
  MoveTimer := 0;
  ......
end;  

在DoTimerV5中获得一个新的方块盒子时或者在完成一个方块盒子的计算得分和时间调整后添加以下代码实现两个加速因子的归零。由于获得新盒子的代码隐藏在cxBoxMove中,所以代码添加在调整时间之后:

    //计数归零
    MoveKey := 0;
    MoveTimer := 0; 

在DoTimerV5中添加以下代码统计方块盒子自动跳动的次数:

MoveTimer := MoveTimer + 1; 

在左移、右移、旋转、跌落、下落五个按钮OnClick的事件处理函数中添加以下代码统计操作的次数:

MoveKey := MoveKey + 1;  

计算加速的代码在DoTimerV5中实现,为了代码的可读性,像计算得分一样,我们将计算加速的代码放到一个函数AdjustTimer中,然后在DoTimerV5中调用AdjustTimer函数。

procedure TfrmMain.AdjustTimer;
var
  iSpeed, iSpeedBase, iSpeedKey, iSpeedTimer: integer;
begin
  //取得基础时差
  iSpeedBase := FConfig.ConfigData.GetValue('trcSpeedBase/Position', 1);
  iSpeed := iSpeedBase;
  //计算手动操作加速
  if FConfig.ConfigData.GetValue('ckSpeedKey/Checked', True) and (MoveKey > 0) then
  begin
    iSpeedKey := FConfig.ConfigData.GetValue('trcSpeedKey/Position', 1);
    iSpeed := iSpeed * iSpeedKey * MoveKey;
  end;
  //计算自动下落加速
  if FConfig.ConfigData.GetValue('ckSpeedTimer/Checked', True) and (MoveTimer > 0) then
  begin
    iSpeedTimer := FConfig.ConfigData.GetValue('trcSpeedKey/Position', 1);
    iSpeed := iSpeed * iSpeedTimer * MoveTimer;
  end;
  //计算时差调整值
  case FConfig.ConfigData.GetValue('grpTimeCalc/ItemIndex', 1) of
    0:
    begin
      TimerAdjust := iSpeed;
    end;
    1:
    begin
      TimerAdjust := iSpeed / BoxScore;
    end;
  end;
  TimerInterval := TimerInterval - TimerAdjust;
end;

以下是完成前面的代码之后DoTimerV5的完整代码:

procedure TfrmMain.DoTimerV5;
var
  bxs: cxBoxs;
begin
  if not Gaming then Exit;

  bxs := boxMove.CurrentBoxs;
  if bxs = nil then Exit;
  MoveTimer := MoveTimer + 1;

  if not TryBoxsToDown(bxs) then
  begin
    //已经到达底部
    //--打开盒子,放出方块,计算得分
    OpenBoxAndCalcScore(bxs);
    //--调整计时器时间
    AdjustTimer;
    //--检查游戏是否结束
    if boxHeap.RowCount >= grdBox.Rows then
      Gaming := False;
    //计数归零
    MoveKey := 0;
    MoveTimer := 0;
  end;
end;

3.3 计时器使用方法的调整

完成上面的代码后编译运行应该不存在问题,但是在实际使用中却是出现问题了。经过跟踪调试,找打了出现问题的代码:

procedure TCustomTimer.UpdateTimer;
begin
  KillTimer;
  if (FEnabled) and (FInterval > 0)
  and (([csLoading,csDestroying]*ComponentState=[]))
  and Assigned (FOnTimer) then begin
    //DebugLn(['TCustomTimer.UpdateTimer ',dbgsName(Self),' WidgetSet.CreateTimer']);
    FTimerHandle := WidgetSet.CreateTimer(FInterval, @Timer);
    if FTimerHandle=0 then begin
      FTimerHandle:=cIdNoTimer;
      raise EOutOfResources.Create(SNoTimers);
    end;
    if Assigned(OnStartTimer) then OnStartTimer(Self);
  end;
end; 

在我们的代码中引起这个错误的代码是每次完成一组方块后调整时差的代码:

procedure TfrmMain.SetTimerInterval(AValue: double);
begin
  if AValue < 0 then AValue := 0;
  if FTimerInterval = AValue then Exit;
  FTimerInterval := AValue;
  //Timer1.Enabled := False;
  Timer1.Interval := round(AValue);
  //Timer1.Enabled := True;
  trcStart.SelEnd := 1000 - round(AValue);
end;  

从理论上来说,这个错误是不应该存在的,但实际操作中却发生了。
仔细分析问题的原因,是设置的新的Interval值后WidgetSet.CreateTimer重新构建计时器对象失败,也就出现了FTimerHandle=0的情况。
由于昨天调试代码时为了查找原因没有截图,今天编写这篇文章时为了获得截图,使用原来的代码重新编译运行,却能够正常运行了。也就是说昨天调试时出现的问题不存在了。
不管为什么不存在了,既然发生过就说明有存在错误的可能。再说解决方案已经完成了,作为一种处理问题的方法,我想在这里把解决方案记录下来。在我们的pTetris项目中可以使用以下方案,也可以保持原来的方案。
为了解决这个问题,我考虑了两种方案:

  • 将计时器的Interval设置为1,这样计时器每毫秒激发一次。定义一个变量TimerNext保存方块盒子下一次跳动的时间,在DoTimerV5中检查当前时间不否达到了TimerNext,没有达到则退出DoTimerV5,达到了才执行DoTimerV5中的代码。这个使用了TimerNext的新版本的DoTimerV5命名为DoTimerV6
  • 不再使用计时器,而改用线程。创建一个线程,这个而线程启动后休眠TimerInterval时间,然后向TfrmMain发送一个消息,在这个消息的处理函数中调用DoTimerV5。这个方法相当于用线程消息代替了计时器消息

这两个方案各有优缺点:

  • 计时器时间比较准确,但多次激发会占用cpu时间
  • 线程计时存在误差,有可能存在延迟,是否消耗cpu由操作系统决定

我们采用了前一种方案。
第一步、在属性列表中将Timer1的Interval属性值改为1。
第二步、在TfrmMain.SetTimerInterval中注释掉以下代码:

  //Timer1.Interval := round(AValue); 

第三步、添加一个新的属性

property TimerNext: double read FTimerNext write SetTimerNext; 

第四步、复制DoTimerV5的代码形成函数DoTimerV6,在DoTimerV6中添加设置和检查TimerNext代码:

procedure TfrmMain.DoTimerV6;
var
  bxs: cxBoxs;
begin
  if not Gaming then Exit;
  if Now < TimerNext then Exit;
  ......
  if not TryBoxsToDown(bxs) then
  begin
    //已经到达底部
    ......
  end;
  //设置下一次的响应时间
  TimerNext := Now + TimerInterval / (24 * 3600 * 1000);
end;   

第五步、在TfrmMain.Timer1Timer中用DoTimerV6代替DoTimerV5

经过以上修改后与原来使用DoTimerV5时有同样的效果。

这样我就有了两个方案,这两个方案效果是相同的,可以任选其一:

  • 正统的方案:计时器Timer1.Interval:=TimerInterval,使用DoTimerV5
  • 变通的方案:计时器Timer1.Interval:=1,使用DoTimerV6

4.pTetris中起始难度的实现

4.1 起始难度的需求分析

以下是我们在《在Lazarus下的Free Pascal编程教程——打造有智能感知的用户设置操作界面 - lexyao - 博客园》确定的难度定制包括以下几个方面:

  • 游戏中可变因素的起始值设置,包括
    • 起始行数:指定游戏开始时已经存在方块的行数。这是一格双刃剑,一方面增加了难度,另一方面提供了游戏开始在高位获得高分的机会
    • 起始速度:指定游戏开始时两次动作之间的间隔毫秒数,取值0-1000毫秒。取值越小则移动速度越快,游戏难度也就越大
  • 游戏中不变的因素,包括:
    • 待移动方块提示组数
    • 方块组合样式
    • 可击穿

从以上需求可以看出,作为起始难度的只有两个方面:起始行数和起始速度。下面我们针对以上的要求确定编写代码需要的工作:

  • 起始速度也就是起始难度定制页面中的方块跳动起始时间间隔。这一项的设置我们在加速部分已经实现了,也就是GameBegin函数中的TimerInterval := FConfig.StartTime
  • 起始行数是在开始时在方块移动区下部堆积一部分方块,可以分为两部分来实现:
    • 在cxBoxHeap中添加一个函数StartBox,在指定范围内随机放置一部分方块
    • 在GameBegin中调用StartBox函数实现放置初始方块的操作

4.2 起始难度的实现

根据以上分析,我们编写实现起始难度的代码,分为五个部分。

4.2.1 起始行数

根据以上分析,我们编写实现起始行数的代码。

在开始编写代码之前先做一个准备工作:将cxBoxHeap.ClearFullRows中移动并显示堆积的方块的代码提取出来,形成一个函数MoveBox。修改后的代码如下:

procedure cxBoxHeap.ClearFullRows(var iRows, iRowHeight: Integer);
var
  i: integer;
  rw: cxDustbin;
begin
  //销毁满行
  iRows := 0;
  iRowHeight:= 0;
  for i := RowCount - 1 downto 0 do
  begin
    if RowFull(i) then
    begin
      rw := Rows(i);
      rw.Free;
      Inc(iRows);
      Inc(iRowHeight, i);
    end;
  end;
  //如果有销毁的行,则需要重新显示堆积的方块(向下移动)
  if iRows > 0 then
  begin
    MoveBox;
  end;
end;   

procedure cxBoxHeap.MoveBox;
var
  xy: TPoint;
  bx: cxBox;
  rw: cxDustbin;
  i,j: integer;
begin
  for i := 0 to RowCount - 1 do
  begin
    rw := Rows(i);
    xy.Y := RowToGrid(i);
    for j := 0 to rw.BoxCount - 1 do
    begin
      bx := rw.BoxByIndex(j);
      xy.X := bx.Tag;
      Grid.MoveTo(bx, xy);
    end;
  end;
end;

首先是定义函数cxBoxHeap.StartBox,在指定范围内随机放置方块,再用函数MoveBox将方块显示出来,代码如下:

procedure cxBoxHeap.StartBox(iRows: Integer);
var
  row, col: integer;
  box: cxBox;
begin
  //添加堆积的方块
  for row := 0 to iRows - 1 do
  begin
    for col := 0 to Grid.Cols - 1 do
    begin
      if Random(10) > 5 then
      begin
        box := cxBox.Create(Grid);
        Recycle(box, col, row);
      end;
    end;
  end;
  //显示堆积的方块
  MoveBox;
end;

然后是在GameBegin中调用StartBox函数,已经预留了位置,添加一行代码就行了,代码如下:

procedure TfrmMain.GameBegin;
begin
 ......
  { #todo : 设置移动区的初始方块 }
  boxHeap.StartBox(FConfig.ConfigData.GetValue('trcStartHeight/Position', 0));
 ......
end; 

现在可以编译运行pTetris项目测试添加代码后的效果了。
运行pTetris后,在配置表起始难度页面调整方块堆积起始高度为一个大于0的值,比如10,然后重复点击“开始游戏”按钮。每次点击后游戏都会重新开始,在方块移动区下部会有预先设置的方块,每次开始游戏堆积的方块数量和排列都不相同。

 4.2.2 起始速度

读取保存的配置数据都可以使用ConfigData.GetValue,这其中有一个例外,那就是起始速度。
按着我们的设想,方块盒子自动下落跳动的时间间隔是逐渐缩短的,而游戏开始时需要设定时间间隔的一个初始值,这个初始值越小,游戏难度越大。
在配置数据中,初始时间间隔记录的数据是0-9,而实际使用的时间值是100-1000,这就有一个换算关系。为此,我们添加一个函数,用来完整这个换算关系。

function cxConfig.StartTime: integer;
begin
  //由于需要换算,添加单独使用的函数完成数据换算
  Result := (ConfigData.GetValue('trcStartTime/Position', 9) + 1) * 100;
end; 

在游戏开始时设置跳动间隔的时间为StartTime:

procedure TfrmMain.GameBegin;
begin
  ......{ #todo : 计时器恢复默认的开始速度 }
  TimerInterval := FConfig.StartTime;
  ......
end;  

 

4.2.3 待移动方块提示组数

待移动方块盒子保存在一个队列中,这个队列中的方块盒子形成的代码中有这样的一段:

function cxBoxQueue.NextBoxs: cxBoxs;
......
begin
  ......
  //补足盒子队列
  { #todo : 暂时按4个盒子,将来按配置确定个数 }
  while BoxCount < 4 do
    NewBoxs;
  ......
end;    

这是我们为了使用配置数据预留的信息,只要我们将其中的数字4替换为配置数据中设置的值,就可以通过修改配置数据来改变带移动方块盒子队列中的方块盒子数量。修改后的代码如下:

function cxBoxQueue.NextBoxs: cxBoxs;
var
  bx, by, cc, bm: integer;
begin
  ......
  //补足盒子队列
  { #todo : 暂时按4个盒子,将来按配置确定个数 }
  bm := FConfig.ConfigData.GetValue('trcNextNumber/Position', 4);
  while BoxCount < bm do
    NewBoxs;
  ......
end; 

下面的截图是修改后的效果:

 

4.2.4 方块组合样式

关于方块盒子的样式,我们在《在Lazarus下的Free Pascal编程教程——以数据需求拉动程序运行的模块化程序设计方法 - lexyao - 博客园》中曾经介绍过。在那篇文章里,我们使用cxBoxs.BoxInit确定一个盒子中方块的排列。在cxBoxs.BoxInit中则是调用了cxBoxs.BoxIdle。为了确定合适的方块排列算法,我们尝试了多个版本的cxBoxs.BoxIdle函数,每个版本对应一个方块组合样式,cxBoxs.BoxInit使用了其中一种样式。
在这里,我们修改cxBoxs.BoxInit中的代码,可以依据配置数据中指定的样式编号,调用不同版本的cxBoxs.BoxIdle函数。我们在这里选择了四种样式,如果有兴趣,你也可以根据自己爱好添加更多的样式。当然,有些样式容易形成更多的孔洞,从而增加游戏的难度。
以下是修改后cxBoxs.BoxInit的代码:

procedure cxBoxs.BoxInit;
var
  pt: TPoint;
  cnt:Integer;
  style:integer;
  bIdle:Boolean;
begin
  //取得配置指定的样式
  style:=FConfig.ConfigData.GetValue('grpBoxStyle/ItemIndex', 0);
  //
  case style of
    2: FBoxCount := 5;               //常规5块
    3: FBoxCount := Random(5) + 1;   //1至5块
    else
      FBoxCount := 4;                //常规4块、异形4块
  end;
  //创建中心点的方块
  mBoxs[0,0]:=NewBox;  
  cnt:=1;
  //创建其他方块
  while cnt<FBoxCount do
  begin
    //确定放置方块的位置
    case style of
      1: bIdle:=BoxIdleV0(pt); //异形4块
    else bIdle:=BoxIdleV4(pt); //常规4块、常规5块、1至5块
    end;
    //创建方块
    if bIdle Then
    begin
      mBoxs[pt.X,pt.Y]:=NewBox;
      Inc(cnt);
    end;
  end;
end; 

以下是cxBoxs.BoxInit中引用的计算放置方块位置的代码:

function cxBoxs.BoxIdleV0(out pt: TPoint): boolean;
var
  angle: Int64;
  radius, radian: ValReal;
begin
  //通过随机计算查找下一个可以放置方块的空闲位置
  radius := 0.4 / Sin(22.5 * Pi / 180);
  angle := Random(360);
  radian := angle * Pi / 180;
  pt.X := round(radius * Cos(radian));
  pt.Y := round(radius * Sin(radian));
  Result := mBoxs[pt.X, pt.Y] = nil;
end; 

function cxBoxs.BoxIdleV4(out pt: TPoint): boolean;
var
  angle: Int64;
  radius, radian: ValReal;
begin
  //通过随机计算查找下一个可以放置方块的空闲位置
  radius := 0.4 / Sin(22.5 * Pi / 180);
  angle := Random(360);
  radian := angle * Pi / 180;
  pt.X := round(radius * Cos(radian));
  pt.Y := round(radius * Sin(radian));
  if Abs(pt.X) + Abs(pt.Y) = 2 then
  begin
    Result := (mBoxs[pt.X, pt.Y] = nil) and ((mBoxs[pt.X, 0] <> nil) or
      (mBoxs[0, pt.Y] <> nil));
  end
  else
  begin
    Result := mBoxs[pt.X, pt.Y] = nil;
    if not Result then
    begin
      radius := -radius;
      pt.X := round(radius * Cos(radian));
      pt.Y := round(radius * Sin(radian));
      Result := mBoxs[pt.X, pt.Y] = nil;
      if not Result then
      begin
        radius := 2 * radius;
        pt.X := round(radius * Cos(radian));
        pt.Y := round(radius * Sin(radian));
        if Abs(pt.X)=2 then
          pt.Y:=0
        else
          pt.X:=0;
        Result := mBoxs[pt.X, pt.Y] = nil;
      end;
    end;
  end;
end; 

 

4.2.5 可击穿

所谓的可击穿,是指方块盒子跌落时如果堆积的方块内部有能够放置当前移动的盒子的空洞,则将盒子放置到这个空洞中。如果没有这样的空洞,则如正常的跌落。
实现可击穿的效果只需要在原来的跌落操作函数TfrmMain.TryBoxsToDowned中添加击穿的代码即可。以下是修改后的TfrmMain.TryBoxsToDowned代码: 

procedure TfrmMain.TryBoxsToDowned(bxs: cxBoxs);
var
  pt: TPoint;
  y: integer;
begin
  pt := bxs.Base;
  Inc(pt.Y);
  if FConfig.ConfigData.GetValue('ckPenetrate/Checked', False) then
  begin
    //可击穿时的跌落代码
    y := boxMove.Grid.Height;
    while not boxHeap.TestBoxTo(bxs, pt.X, y) do
      Dec(y);
    if pt.Y <= y then
      grdBox.BoxsTo(bxs, pt.X, y);
  end
  else
  begin
    //不可击穿时的跌落代码
    while boxHeap.TestBoxTo(bxs, pt) do
    begin
      grdBox.BoxsTo(bxs, pt);
      Inc(pt.Y);
    end;
  end;
end; 

下图是运行时选中“盒子跌落可击穿堆积的方块到达下部空穴”时执行[跌落]操作时的效果。

 至此,我们已经完成了pTetris项目中俄罗斯方块游戏玩法的全部代码。剩下的工作只有辅助性的功能喝界面美化了。

5.结束语

在这篇文章里我们实现了配置数据的应用于程序中的流程控制或者算法,这也是实现配置数据存在的价值。
文章中提到了使用配置数据使用的原则,其实这一原则不仅仅是配置数据的使用,这种思路可以应用于程序设计的方方面面。归根结底就是通过规划程序代码的结构增强代码的可读性,减小以后代码维护的工作量。这种指导思想在个人编写程序时很重要,在团队开发时你会觉得它更重要。
每个人开发程序都会有自己的特点喝思路,从一开始就保持一个好的习惯在程序员的职业生涯中至关重要。

posted @ 2025-02-22 11:02 lexyao 阅读(16) 评论(0) 推荐(0) 编辑
摘要: 0.前言 我想通过编写一个完整的游戏程序方式引导读者体验程序设计的全过程。我将采用多种方式编写具有相同效果的应用程序,并通过不同方式形成的代码和实现方法的对比来理解程序开发更深层的知识。了解我编写教程的思路,请参阅体现我最初想法的那篇文章中的“1.编程计划”和“2.已经编写完成的文章(目录)”: 学 阅读全文
posted @ 2025-01-26 22:08 lexyao 阅读(44) 评论(0) 推荐(0) 编辑
摘要: 0.前言 我想通过编写一个完整的游戏程序方式引导读者体验程序设计的全过程。我将采用多种方式编写具有相同效果的应用程序,并通过不同方式形成的代码和实现方法的对比来理解程序开发更深层的知识。了解我编写教程的思路,请参阅体现我最初想法的那篇文章中的“1.编程计划”和“2.已经编写完成的文章(目录)”: 学 阅读全文
posted @ 2025-01-09 23:15 lexyao 阅读(79) 评论(0) 推荐(0) 编辑
摘要: 0.前言 我想通过编写一个完整的游戏程序方式引导读者体验程序设计的全过程。我将采用多种方式编写具有相同效果的应用程序,并通过不同方式形成的代码和实现方法的对比来理解程序开发更深层的知识。了解我编写教程的思路,请参阅体现我最初想法的那篇文章中的“1.编程计划”和“2.已经编写完成的文章(目录)”: 学 阅读全文
posted @ 2025-01-07 14:21 lexyao 阅读(88) 评论(0) 推荐(0) 编辑
摘要: 0.前言 我想通过编写一个完整的游戏程序方式引导读者体验程序设计的全过程。我将采用多种方式编写具有相同效果的应用程序,并通过不同方式形成的代码和实现方法的对比来理解程序开发更深层的知识。 了解我编写教程的思路,请参阅体现我最初想法的那篇文章中的“1.编程计划”和“2.已经编写完成的文章(目录)”: 阅读全文
posted @ 2025-01-06 14:01 lexyao 阅读(34) 评论(0) 推荐(0) 编辑
摘要: 0.前言 我想通过编写一个完整的游戏程序方式引导读者体验程序设计的全过程。我将采用多种方式编写具有相同效果的应用程序,并通过不同方式形成的代码和实现方法的对比来理解程序开发更深层的知识。 了解我编写教程的思路,请参阅体现我最初想法的那篇文章中的“1.编程计划”和“2.已经编写完成的文章(目录)”: 阅读全文
posted @ 2025-01-03 22:00 lexyao 阅读(52) 评论(0) 推荐(0) 编辑
摘要: 0.前言 我想通过编写一个完整的游戏程序方式引导读者体验程序设计的全过程。我将采用多种方式编写具有相同效果的应用程序,并通过不同方式形成的代码和实现方法的对比来理解程序开发更深层的知识。 了解我编写教程的思路,请参阅体现我最初想法的那篇文章中的“1.编程计划”和“2.已经编写完成的文章(目录)”: 阅读全文
posted @ 2025-01-02 23:14 lexyao 阅读(40) 评论(0) 推荐(0) 编辑
摘要: 0.前言 我想通过编写一个完整的游戏程序方式引导读者体验程序设计的全过程。我将采用多种方式编写具有相同效果的应用程序,并通过不同方式形成的代码和实现方法的对比来理解程序开发更深层的知识。了解我编写教程的思路,请参阅体现我最初想法的那篇文章中的“1.编程计划”和“2.已经编写完成的文章(目录)”: 学 阅读全文
posted @ 2025-01-02 22:54 lexyao 阅读(40) 评论(0) 推荐(0) 编辑
摘要: 0.前言 我想通过编写一个完整的游戏程序方式引导读者体验程序设计的全过程。我将采用多种方式编写具有相同效果的应用程序,并通过不同方式形成的代码和实现方法的对比来理解程序开发更深层的知识。了解我编写教程的思路,请参阅体现我最初想法的那篇文章中的“1.编程计划”和“2.已经编写完成的文章(目录)”: 学 阅读全文
posted @ 2024-12-31 13:12 lexyao 阅读(58) 评论(0) 推荐(0) 编辑
摘要: 0.前言 我想通过编写一个完整的游戏程序方式引导读者体验程序设计的全过程。我将采用多种方式编写具有相同效果的应用程序,并通过不同方式形成的代码和实现方法的对比来理解程序开发更深层的知识。 了解我编写教程的思路,请参阅体现我最初想法的那篇文章中的“1.编程计划”和“2.已经编写完成的文章(目录)”: 阅读全文
posted @ 2024-12-29 22:59 lexyao 阅读(51) 评论(0) 推荐(0) 编辑
点击右上角即可分享
微信分享提示