在Lazarus下的Free Pascal编程教程——使用JSON存储程序运行数据

0.前言

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

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

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

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

在前面写的文章中我们已经构建了pTetris项目的框架,并逐步添加了一些功能,作为示例的应用程序俄罗斯方块游戏已经完成了游戏的全过程,并提供了丰富的操作方法。在这篇文章中我们将通过示例讲述使用JSON存储程序运行数据的方法。

俄罗斯方块游戏中操作方块是核心。在前面的示例中我们已经让作为示例的应用程序俄罗斯方块游戏已经完成了游戏的全过程,并通过配置数据提供了丰富的玩法,在这篇文章中我们将增加排行榜的统计与显示功能,从而增加游戏的挑战性。

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

  1. Lazarus中json数据管理概述
  2. pTetris项目中排行榜的设计构想
  3. 给pTetris项目定制排行榜数据管理的类
  4. 在pTetris项目中使用排行榜数据管理的类
  5. 在pTetris项目中使用表格动态显示排行榜数据
  6. 结束语

1.Lazarus中json数据管理概述

1.1 关于在Lazarus中使用json的介绍

(注:这一小节的内容在我的另一篇文章中已经存在,如果你读过那篇文章可以跳过这一小节。之所以复制这部分内容到这里,一方面是为了内容描述的完整性,也是为了给新读者提供方便。)

JSON (JavaScript Object Notation) 是种数据表现形式,它提供了简单的文本标准化数据交换格式。正如其名称所暗示的,它是基于JavaScript编程语言的一个子集,但它与语言无关,除了易于阅读和编写,更易于机器解析和生成。相对于XML,它更简洁。
想了解更多关于json以及在Lazarus中使用json的知识,请阅读以下链接中的文章:

本来想写一篇在Lazarus中使用json的专题文章,可在网上搜索的时候,发现这方面的文章太多了,而且都比我写得好,所以我就不敢写了。
无论是Delphi还是Lazarus,作为官方首推的json支持都有各自的特点,在网上也有大量的第三方的可用的实现方法。在这些方法中,我曾经对能找到的方法都做了测试,经过对比,我最终选择了SuperObject。我编写的很多应用程序的配置数据管理都是使用了SuperObject,甚至有些内部数据管理也使用了SuperObject。
SuperObject最大的好处是使用方便,但并不是说它是完美无缺的。以下是我的几点体会:

  • SuperObject采用的是接口技术,使用的时候不必为了内存漏洞或者说内存残留而烦恼,这是因为接口是自动释放的,不用了也就释放了内存,不会出现残留
  • SuperObject定义了丰富的数据存取函数,几乎涵盖了我们能够用到的所有数据格式
  • SuperObject的路径管理很方便,使用的时候你只需要关心你的路径(小数点分隔的字符串),而不需要关心节点对象
  • SuperObject的容错能力有一点缺陷,我在用它来解析网页中的json数据的时候,经常因为数据错误而造成解析失败,后来我重写了它的解析函数,提高了容错能力
  • SuperObject作为内存数据管理虽然方便,但若干用来管理频繁使用的大量数据是不合适的,这是因为它解析路径的方法是单字符循环分析,耗费时间太长导致程序运行变得很慢。对于少量的数据管理,这种变慢是可以忽略的,但大量数据就不一样了,会让你能够感觉到程序运行中短暂的“停顿”

如果你想使用SuperObject,可以从网上下载,上面给出的链接中有一个是SuperObject的下载地址。下载的文件包中有两个文件:

    • superobject.pas:提供了完整的json支持,我们使用的就是这个文件
    • superxmlparser.pas:提供了xml格式转换为json格式的方法,算是一个辅助文件,如果你不需要数据转换就可以不用管这个文件

不过,既然我们介绍的是Lazarus程序设计,还是使用Lazarus提供的方案更合适。

1.2 Lazarus中实现json管理的类简介

在Lazarus中集成的实现json的代码主要包括以下单元:

  • fpjson单元:提供json数据管理的基础实现
  • jsonparser单元:提供json数据的解析的TJSONParser类的实现,实现将字符串或文件中的json格式数据解析成fpjson单元中的代码管理的数据
  • jsonConf单元:提供使用json格式保存程序配置数据的TJSONConfig类的实现
    • TJSONConfig的数据管理基于fpjson单元提供的功能
    • TJSONConfig的数据读入基于jsonparser单元提供的功能
  • 其他单元:Lazarus还提供了其他的json管理相关的单元,包括jsonreader、jsonscanner等,这些单元作为其他单元的基础,一般不会直接使用

fpjson单元作为json数据管理的基础单元提供了以下类:

  • TJSONData = class(TObject) :json数据管理节点的基类,提供通用的基础功能。Lazarus的json数据在内存中呈树形,其中所有的枝叶节点都以TJSONData为基类 
    • TJSONNumber = class(TJSONData):数字类型数据节点的基类,是数字类型的叶节点
      • 以TJSONNumber 为基类的类包括:TJSONFloatNumber、TJSONIntegerNumber、TJSONInt64Number、TJSONNativeIntNumber、TJSONQWordNumber
    • TJSONString = class(TJSONData) :字符串类型的叶节点
    • TJSONBoolean = class(TJSONData) :布尔类型的叶节点
    • TJSONNull = class(TJSONData) :空值的叶节点
    • TJSONArray = class(TJSONData):数组类型的枝节点,提供按索引存取子节点的方式
    • TJSONObject = class(TJSONData):对象类型的枝节点,提供按节点名称存取子节点的方式

jsonConf单元提供的TJSONConfig类可以作为使用Lazarus提供的json数据管理功能的应用示例,几乎包括了全部常用的操作,可以作为使用json数据管理功能的参考。其中重要的知识点包括:

  • 使用jsonparser单元的TJSONParser类实现从文件读入数据转换为TJSONData节点构成的树
  • 使用TJSONData的成员函数AsJSON、FormatJSON将TJSONData节点构成的树中保存的数据转换成字符串格式,然后保存到文件中
  • 多种形式的GetValue函数从TJSONData节点构成的树中读取数据
  • 多种形式的SetValue、SetDeleteValue将数据保存到TJSONData节点构成的树中

2.pTetris项目中排行榜的设计构想

pTetris项目中玩游戏的成绩记录在排行榜中,用户通过争取更好的排名来达到竞技游戏的挑战性效果。
pTetris项目的排行榜的设计构想包括:

  • pTetris项目提供了计分规则、加速规则、起始难度等多种选项,针对每种选项组合设立一个排行榜
  • 游戏进行中实时记录游戏的成绩,并显示当前游戏在排行榜中的位置(名次)
    • 排行榜显示在程序主界面右侧的表格中,表格中动态显示当前游戏的成绩
    • 当前游戏的成绩及相关信息显示在游戏的方块移动区右侧的信息显示区

3.给pTetris项目定制排行榜数据管理的类

3.1 排行榜管理类概述

pTetris项目的排行榜管理建立两个类:

  • cxRankTable类:用来管理单个排行榜数据形成的表
    • 提供当前配置对应的排行榜表的数据存取、排序
  • cxRankings类:用来管理所有的排行榜,包含所有的排行榜表,
    • 提供从排行榜数据中选取当前配置对应的排行榜数据表提供给cxRankTable对象
    • 提供将排行榜数据保存到磁盘文件、从磁盘文件读入内存的功能

3.2 排行榜管理类的定义及实现

考虑到文章的篇幅过长,将排行榜类的定义与实现的代码及讲解放在单独的一篇文章中,这样可能给阅读带来一些麻烦,但可以添加更详细的讲解,应该是利大于弊,希望你不会因此心生厌恶。
要查看代码的实现过程请打开文章链接:《在Lazarus下的Free Pascal编程教程——使用JSON管理游戏排行榜 - lexyao - 博客园

4.在pTetris项目中使用排行榜数据管理的类

4.1 创建cxRankings类的实例

要使用cxRankings管理游戏排行榜数据,首先要创建cxRankings类的实例。在这篇文章中,我们将介绍一种安全使用cxRankings类实例的方法。
很多程序员在编写程序的时候都会遇到这样一种情况:

  1. 为了使用类TClass,定义一个保存类实例的变量FClass:TClass
  2. 在程序的某个地方添加代码创建类的实例FClass:=TClass.Create
  3. 在程序中通过FClass使用类的成员函数完成需要的操作

理论上说以上三步走得没有任何错误,但是在程序运行时却出现了错误,原因是FClass无效。为什么会出现这样的错误呢?原因有两种可能:

  • 如果FClass是空值,则说明还没有创建TClass类的实例,也就是说语句FClass:=TClass.Create还没有执行
  • 如果FClass不是空值,则说明FClass中保存的类的实例已经释放了

后一种情况通常会在程序结束时出现,这一个问题我们暂时不考虑。在这里我们讨论前一种情况:为什么语句FClass:=TClass.Create还没有执行呢?很显然,这个语句放置的位置有问题。
怎么才能避免前一种问题呢?通常需要考虑把语句FClass:=TClass.Create放到所有使用FClass的语句之前。在简单的程序中要做到这一点是很容易的,但是如果程序比较复杂,或者说是在多线程编程时,有可能很难做到这一点。
下面我们介绍一种安全的方法,也就是我们使用cxRankings类实例的方法:

  1. 在TfrmMain类中定义变量FRankings: cxRankings; 
  2. 在TfrmMain类中定义函数function Rankings: cxRankings;  
  3. 在所有使用cxRankings类实例的地方使用函数Rankings而不是使用FRankings

函数Rankings的代码如下:

function TfrmMain.Rankings: cxRankings;
begin
  if not Assigned(FRankings) then
  begin
    FRankings := cxRankings.Create(Self);
    FRankings.LoadFromFile;
  end;
  Result := FRankings;
end;  

从函数Rankings的代码可以看出,函数Rankings还是引用了FRankings变量,但在引用变量之前检查了变量中是否存在类的实例,如果不存在,则创建类的实例,然后再引用FRankings。通过这样的机制,我们可以确保在任何地方使用函数Rankings是得到的都是有效的类实例,从而避免了考虑在什么地方创建类实例的问题。

4.2 收集当前游戏的信息加入排行榜

将当前游戏的信息加入排行榜需要做的工作包括:

  1. 游戏开始前,从磁盘文件中读入以往的游戏排行榜数据。
    • 这一项工作在第一次使用函数Rankings时调用了LoadFromFile完成了,程序中不需要再考虑这个问题。
  2. 在游戏开始时,当前游戏的成绩为0,排列在排行榜的最后一行,这需要在排行榜中添加一个成绩为0的新行
  3. 在游戏进行中,当前游戏的成绩发生变化,需要用变化了的成绩数据替换排行榜中记录的成绩
  4. 排行榜的排名是以总成绩排序的,当前游戏的总成绩发生变化时,检查当前游戏的排名是否发生了变化,确定新的排名。最后一次更新的数据为当前游戏的最后成绩
  5. 退出应用程序时,保存排行榜的信息到磁盘文件中,以备下一次运行程序时能够获得以往游戏的排行榜数据。
    • 这一项工作在释放类实例执行析构函数cxRankings.Destroy时在析构函数中调用SaveToFile完成了,由于cxRankings类设计成了组件,在程序结束时自动释放cxRankings类的实例,所以程序中不需要再考虑这个问题。

从以上分析可以看出,我们只需要考虑中间的三个问题,其中3和4是需要合并在一起考虑的。

4.2.1 游戏开始时在排行榜中添加当前游戏的排名

游戏开始时的初始化工作都是在GameBegin中完成的,所以我们要做的工作是在函数GameBegin中添加以下代码:

procedure TfrmMain.GameBegin;
begin
  ......
  { #todo : 计时计分归零 }
  grdRankings.Row := Rankings.RankTable.NewRow + 1;
  grdRankings.Invalidate;
  ......
end;   

以上代码完成了以下工作:

  • 代码Rankings.RankTable.NewRow完成获得(需要时创建)cxRankings类实例->获得当前配置下的排行榜表cxRankTable->在表的末尾添加新行作为当前游戏的成绩并返回当前名次
  • 使用当前名次设置显示排行榜的表格的当前行grdRankings.Row
  • grdRankings.Invalidate刷新显示排行榜的表格的显示

4.2.2 更新当前游戏在排行榜中的成绩数据

游戏中移动一个方块盒子后的计分工作是在函数OpenBoxAndCalcScore中完成的,我们在函数OpenBoxAndCalcScore中添加更新当前游戏在排行榜中的数据的代码。添加的代码如下:

procedure TfrmMain.OpenBoxAndCalcScore(bxs: cxBoxs);
var
  ......
begin
  ......
  //记录到排行榜
  rtb := Rankings.RankTable;
  rtb.ColValueAsDouble[rtb.CurrentRow, 'time'] := GameKeptTime;
  rtb.ColValueAsInteger[rtb.CurrentRow, 'boxs'] := BoxCount;
  rtb.ColValueAsInteger[rtb.CurrentRow, 'rows'] := BoxFullRows;
  rtb.ColValueAsInteger[rtb.CurrentRow, 'scores'] := BoxScores;
  //检查排名变化并刷新表格显示
  if rtb.CheckRow then
  begin
    grdRankings.Row := rtb.CurrentRow + 1;
    grdRankings.Invalidate;
  end
  else
    grdRankings.InvalidateRow(grdRankings.Row);
end;    

 作为排行榜,关键有两个元素:名次和得分。如果觉得有必要,也可以收集其他的元素。
我们在这里收集了四个元素:得分、耗时、消除满行数、移动方块数。由于数据保存在列表中,列表是按分数排序的,排序的位置也就是名次。

4.2.3 增加计算游戏持续时间的函数

在上面的更新当前游戏排行榜数据的代码中我们用到了一个函数GameKeptTime,从字面意思可以看出是游戏持续时间。要完成这个函数需要完成以下几个方面的工作:

定义游戏开始的变量:

FGameStartTime: TDateTime; 

在游戏开始时初始化这个变量,我们选择记录游戏开始的时间:

procedure TfrmMain.GameBegin;
begin
  ......
  { #todo : 计时计分归零 }
  ......
  FGameStartTime := Now;  
  ......
end;   

计算游戏持续时间的函数:

function TfrmMain.GameKeptTime: TDateTime;
begin
  Result := Now - FGameStartTime;
end; 

通过以上代码实现了游戏持续时间的计算。这个方法有一个缺陷,就是把游戏暂停的时间也计算在了游戏持续时间当中。
如果想要精确地计算持续时间,可以使用Lazarus提供的秒表类,可以设置或重置开始时间,有暂停计时功能,是比较完善的计时工具。

5.在pTetris项目中使用表格动态显示排行榜数据

5.1 排行榜显示的思路

我们计划将排行榜显示提出以下要求:

  • 排行榜显示在主界面右边的表格grdRankings中
  • 表格不显示滚动条
    • 表格宽度尽可能小,随着数据的变化,能够自动调整宽度,确保数据能够完整显示
    • 表格高度由主窗口高度决定,显示的行数按表格高度能够容纳的行数
  • 当前游戏所在的行在表格中突出显示
    • 当前游戏排名在表格显示行数之内时,表格显示排行榜前列的成绩
    • 当前游戏排名在表格显示行数之外时,
      • 表格倒数第一行显示当前游戏成绩
      • 表格倒数第二行显示比当前游戏靠前一位的成绩
      • 表格前部的其他行显示排行榜前列的成绩
  • 每完成一组方块移动,更新当前游戏的成绩数据,并根据成绩调整当前游戏的排名位置

5.2 在表格中显示排行榜数据

Lazarus提供的表格组件有TDrawGrid和TStringGrid可以直接使用,还可以下载安装其他的第三方组件。考虑到我们只是简单地显示数据,我们选择可以直接使用的表格组件。

  • 对于静态的数据显示,使用TStringGrid会比较简单。
  • 对于动态的数据显示,使用TDrawGrid会更加灵活。

基于以上两方面的特点,我们选择使用TDrawGrid作为显示排行榜的表格。

5.2.1 设置表格的初始宽度

我们已经计划自动调整表格的宽度,那么在设计时我们设置表格有尽可能小的宽度。在属性列表中设置grdRankings的以下属性为比较小的数值:

  • 表格列数ColCount按要显示列的数量设置,在这里选择5
  • DefaultColWidth取小于两个字符宽度的值,比如10。实际显示时会根据显示内容需要的最大宽度自动加宽,而不会在单元格中留有多余的空白
  • Width取比可能显示的最小宽度小一点的数值,比如55,实际显示时会根据需要自动加宽,而不会因为设置的宽度过大而留有空白。

5.2.2 编写显示表格内容的事件的处理函数

给表格组件grdRankings添加OnDrawCell事件的处理函数grdRankingsDrawCell,其代码如下:

procedure TfrmMain.grdRankingsDrawCell(Sender: TObject; aCol, aRow: Integer;
  aRect: TRect; aState: TGridDrawState);  
const
  BW = 2;
  BH = 0;
var
  ss: string;
  te: TSize;
  w, h: integer;
  grd: TDrawGrid;
  ts: TTextStyle;
  rtb: cxRankTable;
  grdChanged: boolean;
  tm: TDateTime;
  cRow: integer;
begin
  grd := Sender as TDrawGrid;
  rtb := Rankings.RankTable;
  //取得要显示的文字
  if ARow = 0 then
    ss := rtb.Header[ACol]
  else
  begin
    //计算数据行号
    if aRow > rtb.CurrentRow then
    begin
      cRow := aRow - 1;
    end
    else if aRow < grdRankings.RowCount - 2 then
    begin
      cRow := aRow - 1;
    end
    else if aRow = grdRankings.RowCount - 2 then
    begin
      cRow := rtb.CurrentRow - 1;
    end
    else
    begin
      cRow := rtb.CurrentRow;
    end;
    //取得显示文字
    if ACol = 0 then
    begin
      ss := IntToStr(cRow + 1);
    end
    else if aCol = 2 then
    begin
      tm := rtb.ColValueAsDouble[cRow, rtb.Names[aCol]];
      ss := TimeToStr(tm);
    end
    else
    begin
      ss := rtb.ColValueAsString[cRow, rtb.Names[aCol]];
      if ss = '' then
        ss := '0';
    end;
  end;
  //调整表格列宽、行高
  grdChanged := False;
  te := grd.Canvas.TextExtent(ss);
  w := aRect.Right - aRect.Left - BW * 2;
  h := aRect.Bottom - aRect.Top - BH * 2;
  if w < te.cx then
  begin
    grd.ColWidths[ACol] := grd.ColWidths[ACol] + te.cx - w;
    grdChanged := True;
  end;
  if h < te.cy then
  begin
    //grd.RowHeights[ARow] := grd.RowHeights[ARow] + te.cy - h;
    grd.DefaultRowHeight := grd.DefaultRowHeight + te.cy - h;
    grdChanged:=True;
  end;
  //设置显示对齐方式
  ts := grd.Canvas.TextStyle;
  ts.Alignment := taCenter;
  ts.Layout := tlCenter;
  //在表格中显示文字
  grd.Canvas.TextRect(aRect, ARect.Left, ARect.Top, ss, ts);
  //表格列宽、行高调整后重置窗口大小
  if grdChanged then
  begin
    DoResize;
  end;
end;  

关于以上代码的说明:

  • 常数BW、BH是为了显示美观而在单元格的边框内保留的空白宽度
  • grd := Sender as TDrawGrid是通过事件参数获得表格,与直接使用grd := grdRankings效果相同
  • rtb := Rankings.RankTable获得当前游戏的排行榜表的cxRankTable对象,需要显示的内容来自rtb
  • rtb.Header是在cxRankTable中定义的表格标题显示的文字常数,这个常数在grdRankingsDrawCell的const中定义也是一样的效果
  • ts := grd.Canvas.TextStyle获得表格显示的当前对齐方式。这一点在我以前使用的Delphi中是没有的,不知道是我使用的Delphi版本偏低还是Lazarus做了改进,总之比以前使用要方便了
  • 使用Canvas.TextRect显示文字的方法是从TStringGrid的代码中找到的。这也许就是开源代码的好处,这些开源的代码就是我们学习编程的最好的参考
  • grdChanged是表格行列宽度改变的标志,如果行列宽度改变了,则通过调用函数DoResize重新调整界面尺寸以确保在不显示滚动条的情况下能够显示完整的内容

5.2.3 调整界面尺寸的函数

DoResize是我们以前的代码中的函数,它包含了所有需要调整界面的代码:

procedure TfrmMain.DoResize;
begin
  DoGridResize;
  DoPanelResize;
  DoFormResize;
end;  

其中的DoGridResize包含了调整排行榜表格及其容器大小的代码,以前只写了一个而空函数,现在我们补全它的代码:

procedure TfrmMain.DoGridResize;
var
  dw, dh, gwc, gwp, i, gwd,gcc: integer;
begin
  //按列宽调整排行榜表格组件宽度,使得所有的列都能完整显示
  gwc := 4;
  gcc := grdRankings.ColCount;
  for i := 0 to gcc-1 do
    gwc := gwc + grdRankings.ColWidths[i];
  gwd := gwc - grdRankings.Width; //grdRankings.ClientWidth
  if gwd > 0 then
  begin
    grdRankings.Width := grdRankings.Width + gwd;
    gwp := pnRight.ClientWidth - grdRankings.Width;
    if gwp > 0 then
      pnRight.Width := pnRight.Width + gwp;
  end;
  grdRankings.RowCount:=grdRankings.ClientHeight Div grdRankings.DefaultRowHeight;
end; 

至此,编译运行pTetris项目,主窗口右边会显示当前配置下的排行榜。游戏过程中,当前游戏的成绩会实时刷新。

6.结束语

在这篇文章中,我们介绍了使用json保存程序运行数据的方法。
能够保存数据的方式有很多,在程序中使用现有的成熟代码来管理自己的数据是一个好的习惯。至于选择怎样的方式保存数据,可以按着程序员自己的爱好去决定,但基本的原则是易于使用和维护,复杂程度与要管理的数据相适应。如果是简单的数据,可以使用记录文件、json、xml等格式保存,如果是大量的数据,可以选择数据库管理方式。

posted @ 2025-04-06 15:31  lexyao  阅读(257)  评论(0)    收藏  举报