FireMonkey3D之中国象棋程序(三)初步搜索算法

这篇具有很好参考价值的文章主要介绍了FireMonkey3D之中国象棋程序(三)初步搜索算法。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

  声明:本程序设计参考象棋巫师源码(开发工具dephi 11,建议用delphi 10.3以上版本)。

  这一章计划初步实现搜索算法,前两章基本上按照我自己对中国象棋的理解来设计程序,从这章开始参照象棋巫师算法。

  本章目标:

  • 用子力位置价值表实现局面评价函数;
  • 用超出边界(Fail-Soft)的Alpha-Beta搜索让电脑走棋;
  • 用迭代加深技术实现时间控制;
  • 实现历史表启发,优化Alpha-Beta搜索的效率;
  • 实现杀棋步数评价,能有效搜索杀棋。

  3.1 局面评价  

  中国象棋共有7种棋子:将(帅)、士、相、马、车、炮、兵,局面评价中最关键的因素是每种棋子的价值,子力价值是跟它的绝对位置相关的。比如兵(卒),过河前基本上没有什么威胁,子力价值就很低,过河后分数大涨,越接近九宫分数就越高,九宫中心甚至接近一个马或炮的值。再比如马,在卧槽的位置和在挂角的位置对将(帅)的影响非常大,在此位置子力评分很高。如此一来,每个兵种就都会有一个与绝对位置相关的价值,因此我们定义一个三维常量数组:vlPc:array [0..6,0..9,0..8] of Byte(从“象眼”中照搬过来的),这个子力价值表水平左右对称,以红方为基准,黑方使用时只须颠倒纵向数据即可。

我们在TPieceMove内定义两个常量:vlRed, vlBlack:Integer;   用来记录红、黑双方的子力价值;定义Evaluate函数来评价局面分,即红黑双方的子力价值差,先走棋再加3分。我们可以每次走棋后,全盘搜索每个棋子的位置来计算局面分,但是这样做太浪费时间了,因为根本没有必要每次都把棋盘扫描一遍。我们定义了两个函数来实现每步走棋计算分值:调用 AddPiece (放一枚棋子) DelPiece (取走一枚棋子),可以趁这个机会更新 Evaluate这样的局面评价函数已经足够了。

  3.2 极大极小搜索算法

  以上均为准备工作。现在我们先从最简单的搜索算法学起:极大极小搜索算法。这个算法这样首先这样评价局面:

  如果黑方被将死了,那么评价函数返回一个充分大的正数;如果红方被将死了,那么返回一个充分大的负数。如果红方是赢棋或者很有希望赢,那么函数通常会返回正数;如果黑方是赢棋或者很有希望赢,那么返回负数;如果棋局是均势或者是和棋,那么返回在零左右的数值。

     按照搜索算法的思路,我们先定义常量:MATE_VALUE = 10000; 最高分值,即将死的分值。极大搜索时,初始化始最优值为负无穷,即—MATE_VALUE;极小搜索时,为正MATE_VALUE这样定义的好处是可以确定没有走过任何棋。

  简要描述一下这个函数是如何运作的。假设根局面(棋盘上当前局面)是红方走,先生成红方所有合理走法,逐一走这些走法,调用“maxSearch”函数,生成黑方所有合理着法。在每个后续局面中,调用的是“MinSearch”函数,它对局面作出评价并返回。由于现在是红方走,因此红方需要让评价尽可能地大,能得到最大值的那个着法被认为是最好的,因此返回这个着法的评价。“minSearch”函数正好相反,当黑方走时调用“Min”函数,而黑方需要尽可能地小,因此选择能得到最小值的那个着法。这两个函数是互相递归的,即它们互相调用,直到达到所需要的深度为止。当函数到达最底层时,它们就返回“Evaluate”函数的值。如果在深度为1时调用“MinMax”函数,那么“Evaluate”函数在走完每个合理着法之后就调用,选择一个能达到最佳值的那个着法导致的局面。如果层数大于1,那么另一方有权选择局面,并找一个最好的。

  举个例子,电脑为A,人类为B,A在走棋之前需要思考,A走了某一步后,看看B有哪些走法,B又不傻,所以B肯定是要选择让A得分最少的走法走棋,而A会选择在所有走法中B认为得分最少的走法中分值最高的走法,也就是说A的走法取决于B,反之亦然。听起来大概会比较抽象比较绕吧,试着多读几遍,多理解理解。
通过极大极小搜索算法,电脑就可以初步自动回应走棋了。极大极小搜索算法可以合成负极大搜索,搜索过程如下图:

FireMonkey3D之中国象棋程序(三)初步搜索算法

  红色数字是当前节点的分值,蓝色数字是父节点对子节点返回值取负后的值。

  搜索进行到C1、C2、C3时,要做评估,得到的估值是12、15、13。由于此时轮到红方走棋,表示红方在三个局面分别有12、15、13的优势。在极大极小搜索中,要在B1求这三个局面估值的最小值。现在我们可以这样来看,黑方在C1、C2、C3的优势分别为-12、-15、-13。黑方显然在B1点需要对三个局面做一个选择,选择的目标当然是优势最大化。可以这样表示:max(-12, -15, -13);

  在极大极小搜索中,B1是黑方走棋,是极小点,应该求最小值。现在由于多加了一个负号,也变成求极大值了,B1点也成了极大点。

  黑方在B1、B2、B3分别取得了-12、-5、-14的优势,对于红方来讲,则在这三个点分别有12、5、14的优势。所以在A点,轮到红方走棋,它会在B1、B2、B3中选择最大值,即:max(12, 5, 14),这里看出A的选择了吗?

  代码如下:

function negaMaxSearch (depth:Integer):Integer;
var
  vlBest,i,value:Integer;
  mvs:TArray<TMoves>;
  s,d:TPoint;
  id:Byte;
begin
  // 深度为0,调用评估函数并返回分值
  if depth = 0 then
    Exit(pcMove.evaluate);
  vlBest:=-MATE_VALUE;		// 初始最优值为负无穷
  mvs:= pcmove.generateMoves;	// 生成当前局面的所有走法
  value:= 0;
  for i:= 0 to Length(mvs)-1 do
  begin
    s:=mvs[i].src;
    d:=mvs[i].dest;
    if not pcMove.makeMove(s,d,id) then Continue;
    value:=-negaMaxSearch(depth - 1);
    PcMove.undoMakeMove(s,d,id);
    if value > vlBest then
      vlBest:= value;
    if depth = MINMAXDEPTH then
      Search.mvResult:= mvs[i];
  end;
  Result:=vlBest;// 返回当前节点的最优值
end;

这个函数有一个常量:MINMAXDEPTH=3,调用时,参数depth必须也等于3。通过测试,我们会发现这个搜索算法运行时要检查整个博弈树,然后尽可能选择最好的线路,但因为分枝因子太大导致效率非常低,无法做到很深的搜索,搜索5层基本卡死。

  3.3 Alpha-Beta搜索

  这是本章的重点,要把这个算法搞明白得费一番功夫。Alpha-Beta搜索好处在于裁剪了不必要的分枝因子。简单来讲就是这个搜索算法就是在负极大搜索算法的基础上加上范围[Alpha-Beta],在这个范围内的算法可以考虑,同时不断缩小Alpha的范围,超过Beta就要截断。最开始,Alpha、Beta的值也是“负MATE_VALUE”和“正MATE_VALUE”。当函数递归时,AlphaBeta不但取负数而且位置交换了,这样缩小了搜索范围,从而减少搜索数量。

  • 算法思路:这个算法思想是在搜索中传递两个值,第一个值是Alpha,即搜索到的最好值,任何比它更小的值就没用了,因为策略就是任何小于或等于Alpha的值都要舍弃。第二个值是Beta,即对于对手来说最坏的值。对中国象棋来说,这是对手不能承受的最坏的结果,因为对手达到这个值就等于输棋。我们知道在对手看来,他总是要找到一个对策比Beta好的着法,走棋的一方没有机会使用这种策略。在搜索着法时,每个搜索过的着法都返回跟AlphaBeta有关的值,它们之间的关系非常重要,或许意味着搜索可以停止并返回。如果某个着法的结果小于或等于Alpha,那么它就是很差的着法,可以抛弃。因为在这个策略中,局面对走棋的一方来说是以Alpha为评价的。如果某个着法的结果大于或等于Beta,那么整个结点就作废了,因为对手不希望走到这个局面,而它有别的着法可以避免到达这个局面。因此如果我们找到的评价大于或等于Beta,就证明了这个结点是不会发生的,剩下的合理着法没有必要再搜索。如果某个着法的结果大于Alpha但小于Beta,那么这个着法就是走棋一方可以考虑走的,除非以后有所变化。因此Alpha会不断增加以反映新的情况。有时候可能一个合理着法也不超过Alpha,这在实战中是经常发生的,此时这种局面是不予考虑的,为了避免这样的局面,我们必须在博弈树的上一个层局面选择另外一个着法。Alpha-Beta搜索裁剪因子如下图演示:

FireMonkey3D之中国象棋程序(三)初步搜索算法

 文章来源地址https://www.toymoban.com/news/detail-807410.html

    从图示上看,Alpha剪掉比自己的小的分枝,Beta减掉比自己大的分枝,关键点在于Alpha-Beta搜索必须进行排序。

  • 算法实现:我们根据以上思路写出一个Alpha-Beta搜索函数,伪代码(以下来自象棋巫师):
function AlphaBeta(var vlAlpha, vlBeta, nDepth:integer):Integer;
var
  vl:integer;
begin
 if nDepth=0 then
  Exit(局面评价函数);
 生成全部走法;
 排序全部走法;
 for (每个生成的走法) do 
  begin
  走这个走法;
  vl =:-AlphaBeta(-vlBeta, -vlAlpha, nDepth - 1);
  撤消这个走法; 
  if vl >= vlBet then
   Exit(vlBeta);
  if vl > vlAlpha then
   vlAlpha = vl;
  end;
 Result:= vlAlpha;
end;

  但是,这样的程序根本走不出棋来,因为它返回的是一个分数而不是一个走法。另外,我们还发现几个问题:  

  (1) 排序的依据是什么?  

  (2) 是不是每个生成的走法都可以走?  

  (3) 如果什么走法都走不出来,那么返回vlAlpha合理吗?   

  针对以上几个问题,我们对程序做如下改进:  

  (0) 如果函数在根节点处被调用,就把最佳走法作为电脑要走的棋;  

  (1) 国际象棋程序的经验证明,历史表是很好的走法排序依据;  

  (2) 由于我们的走法生成器并没有考虑自杀(被将军)的情况,因此走完一步后要检查是否被将军了,被将军时应立即退回来;  

  (3) 如果没有走出过任何走法,说明当前局面是杀棋或困毙局面,应该返回杀棋的分数。  

  下面是改进过的程序,改进的地方已标出:

function AlphaBeta(vlAlpha,vlBeta,nDepth:Integer):Integer;
var
  vl:integer;
begin
 if nDepth = 0 then
  Exit(局面评价函数);
 生成全部走法;
 按历史表排序全部走法;{--添加---}
 for (每个生成的走法) do
  begin
  走这个走法;
  if (被将军) then {--添加---}
   撤消这个走法    {--添加---}
    else           {--添加---}
	begin
   int vl:= -AlphaBeta(-vlBeta, -vlAlpha, nDepth - 1);
   撤消这个走法; 
   if vl >= vlBeta then 
      begin
    将这个走法记录到历史表中;{--添加---}
    Exit(vlBeta);
   end;
   if vl > vlAlpha then 
      begin
    设置最佳走法;{--添加---}
    vlAlpha = vl;
   end;
  end;
 end;
 if (没有走过任何走法) then {--添加---}
  Exit(杀棋的分数);        {--添加---}
 将最佳走法记录到历史表中;    {--添加---}
 if (根节点) then           {--添加---}
  最佳走法就是电脑要走的棋;   {--添加---}
 Resutl:= vlAlpha;
end;
  • 杀棋的分数:遇到将死或困毙的局面时,应该返回 nDistance - MATE_VALUE,这样程序就能找到最短的搜索路线。nDistance 是当前节点距离根节点的步数,每走一个走法,nDistance 就增加1,每撤消一个走法,nDistance 就减少1。如果程序中使用了置换表,这个问题将变得更加复杂,我们以后再讨论。
  • 历史表:国际象棋程序的经验证明,历史表是很好的走法排序依据。那么,什么样的走法要记录到历史表中去呢?象棋小巫师选择了以下两类走法:  

   A. 产生Beta截断的走法;  

  B. 不能产生Beta截断,但它是所有PV走法(vl > vlAlpha)中最好的走法。

Delphi实现算法需要这样:  

  • 首先,我们必须建立一个走法历史表,象棋巫师里把走法定义为一个16位无符号整数(Word),结构如下(每个XY各占4位):
Dest Src
Y X Y X

 我们之前的代码也必须做出调整,将生成所有走法的函数更改为返回整数数组,以兼容象棋巫师的算法。定义走法历史表:nHistoryTable:array [Word] of Integer; 象棋巫师的历史表是一个大小为65536的数组,正好能将走法的数值(mv)作为指标,因此根据走法取得历史表的值非常容易,即nHistoryTable[mv]。那么,一个走法记录到历史表,究竟该给 nHistoryTable 中的这个元素加多少分的值呢?沿用国际的经验——深度的平方。所以,更新历史表的代码非常简单: Inc(nHistoryTable[mv], nDepth * nDepth);

  •  其次,对这个历史表按进行排序nDepth的平方从大到小排序,采用了快速排序法,Delphi泛型自带快速排序法,代码如下:
uses System.Generics.Collections,System.Generics.Defaults;//泛型排序必须引用单元
function CompareHistory(const mv1,mv2:Integer):Integer;//定义从大到小的排序函数,mv即为走法
begin
  Result:=Search.nHistoryTable[mv2] -Search.nHistoryTable[mv1];//按深度的平方进行比较
end;
function SortMVS:TArray<Integer>;
var
  Comparer: IComparer<Integer>;
begin
  MVS:=pcMove.GenerateMoves;
  Comparer := TComparer<Integer>.Construct(CompareHistory);
  TArray.Sort<Integer>(MVS,Comparer);
  Result:=MVS;
end;
  •  最后,实现Alpha-Beta搜索算法: 
{超出边界(Fail-Soft)的Alpha-Beta搜索过程}
function SearchFull(vlAlpha,vlBeta,nDepth:Integer):Integer;
var
  i,vl, vlBest:Integer;
  pc:Byte;
  MVS:TArray<Integer>;
  Comparer: IComparer<Integer>;
  s,d:TPoint;
  mvBest:Integer;
begin
  // 一个Alpha-Beta完全搜索分为以下几个阶段
  // 1. 到达水平线,则返回局面评价值
  if nDepth = 0 then
    Exit(pcMove.Evaluate);
  // 2. 初始化最佳值和最佳走法
  vlBest:= -MATE_VALUE; // 这样可以知道,是否一个走法都没走过(杀棋)
  mvBest:=0;            // 这样可以知道,是否搜索到了Beta走法或PV走法,以便保存到历史表
   // 3. 生成全部走法,并根据历史表排序
  MVS:=pcMove.GenerateMoves;
  Comparer := TComparer<Integer>.Construct(CompareHistory);
  TArray.Sort<Integer>(MVS,Comparer);
  // 4. 逐一走这些走法,并进行递归
  for i:= 0 to  High(MVS) do
  begin
    s:=GetSrc(MVS[i]);
    d:=GetDest(MVS[i]);
    if pcMove.MakeMove(s,d, pc) then{新增函数,在移动棋子的基础上,添加了判断被将军和nDistance++}
    begin
      vl:= -SearchFull(-vlBeta, -vlAlpha, nDepth - 1);
      pcMove.UndoMakeMove(s,d, pc);
      // 5. 进行Alpha-Beta大小判断和截断
      if vl > vlBest then    // 找到最佳值(但不能确定是Alpha、PV还是Beta走法)
      begin
        vlBest:= vl;        // "vlBest"就是目前要返回的最佳值,可能超出Alpha-Beta边界
        if vl >= vlBeta then  // 找到一个Beta走法
        begin
          mvBest:=MVS[i];  // Beta走法要保存到历史表
          break;            // Beta截断
        end;
        if vl > vlAlpha then // 找到一个PV走法,即vlBate>vl>vlAlpha
        begin
          mvBest:=MVS[i];  // PV走法要保存到历史表
          vlAlpha:= vl;     // 缩小Alpha-Beta边界
          if pcMove.nDistance = 0 then // 搜索根节点时,总是有一个最佳走法(因为全窗口搜索不会超出边界),将这个走法保存下来
            Search.mvResult:=mvBest;
        end;
      end;
    end;
  end;
  // 5. 所有走法都搜索完了,把最佳走法(不能是Alpha走法)保存到历史表,返回最佳值
  if  vlBest =-MATE_VALUE then
    Exit(pcMove.nDistance - MATE_VALUE); // 如果是杀棋,就根据杀棋步数给出评价
  if mvBest>0 then
    Inc(Search.nHistoryTable[mvBest], nDepth * nDepth);// 如果不是Alpha走法,就将最佳走法保存到历史表
  Result:=vlBest;
end; 

 以上代码即实现了Alpha-Beta搜索算法,电脑开始有一点点智能,可以走一些合理的棋了。

  3.4 核心代码改动说明:

  这一章有较多的改动,有必要说明一下。

  TPieceMove中新增或修改的主要属性和方法:

  (1)vlRed属性:红方所有棋子的价值

  (2)vlBlack属性:黑方所有棋子的价值

  (3)addPiece、DelPiece方法:此方法增加了一项功能,就是在增减棋子时,更新vlRed和vlBlack。

  (4)MakeMove方法:改方法做了一些改进。如果移动棋子后,发现老将被对方攻击,也就是说这步棋是去送死的,那么就要撤销对棋子的移动,并返回false。

  (5)UndoMakeMove方法:撤销对棋子的移动。

  新增csSearch单元:

  (1)负极大搜索算法negaMaxSearch。

  (2)Alpha-Beta搜索算法:SearchFull(vlAlpha,vlBeta,nDepth:Integer):Integer;

  csCommn单元中新增内容:

  (1)vlPc三维数组常量:棋子在棋盘每个位置的子力价值。

  (2)定义了一些常量:

  ADVANCED_VALUE = 3; // 先行权分值
  MATE_VALUE = 10000; // 最高分值,即将死的分值
  WIN_VALUE = MATE_VALUE - 200; // 搜索出胜负的分值界限,超出此值就说明已经搜索出杀棋了
  MINMAXDEPTH=3;//用于负极大搜索算法的搜索层次

  (2)定义TSearch记录,其成员有:

  mvResult://这是搜索算法找到的最佳走法,随后电脑就会执行这步棋。

   nHistoryTable:array [Word] of Integer;  //历史表

  (3)去掉TMoves走法记录

  Chess_Unit单元:

  (1)添加悔棋功能

  (2)添加电脑响应走棋

  其他未说明的函数请参阅源码注释。

  下一章目标:

  • 用Zobrist校验码技术实现重复局面判定;
  • 实现静态(Quiescence)搜索和MVV/LVA启发;
  • 实现将军延伸和空步(Null-Move)裁剪。

本章节源码百度云盘:

链接:中国象棋程序设计(三)制定规则

提取码:1234

 

  

 

到了这里,关于FireMonkey3D之中国象棋程序(三)初步搜索算法的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • FireMonkey3D之中国象棋程序设计(四)水平效应、检查重复局面

    声明:本程序设计参考象棋巫师源码(开发工具dephi 11,建议用delphi 10.3以上版本)。 上一章我们的程序终于会走棋了,不过很多时候它很低能。由于水平线效应,任何变化都只搜索固定的深度。还有,有时它会长将。我们能做哪些改进呢? 本章的目标: 用Zobrist校验码技术

    2024年01月20日
    浏览(38)
  • Qt CMake 中国象棋程序实现

    C++自学精简实践教程 目录(必读) C++数据结构与算法实现(目录) Qt 入门实战教程(目录) 为学习 Qt 的人提供一个合适的有一定难度的综合型练习项目。 在学会写代码之前,先看别人怎么写的代码。深入其中,扩展完善。 最大限度的模拟企业开发的真实场景。 运行效果 中

    2024年02月09日
    浏览(40)
  • 从0开始写中国象棋-创建棋盘与棋子

    考虑到象棋程序,其实就是数据结构与算法实现。 所以和界面相关的QT部分我们先放一放。 我们从控制台版本开始。这样大家更容易接受,也不影响开发。 后面我们会把控制台嫁接到QT上完成完整的游戏,那时候自然就水到渠成了。 中国象棋的棋盘是一个宽9列,长 5+5 = 10

    2024年02月07日
    浏览(37)
  • 中国象棋AI库AlphaZero_ChineseChess

    AlphaZero_ChineseChess是一个基于AlphaZero算法的中国象棋AI库,它是开源的,使用Python语言编写,托管在GitHub上。以下是对AlphaZero_ChineseChess库的详细介绍: 算法原理 AlphaZero_ChineseChess基于AlphaZero算法,这是一种基于自我对弈的强化学习算法,能够让AI自主学习棋局的优劣、评估策略

    2024年02月14日
    浏览(122)
  • C++实现双人中国象棋(一)——算法篇(附完整代码)

    最近突发奇想,要使用C++做一个双人象棋的程序,昨天肝了一天,终于把算法部分完成了,下面把开发过程中的经验分享一下。 开发环境:Visual Studio 2019 语言标准:C++11及以上 纠错:暂无 知识要求: 熟练掌握C++语言面向对象编程的知识(继承,多态) 掌握STL的基本操作 了

    2024年02月04日
    浏览(61)
  • C++900行代码实现中国象棋游戏规则以及相关功能

    本文章通过C++中的900行代码实现中国象棋游戏以及相关功能,主要的内容如下: 1.设置未进入游戏前的主页面; 2.绘制棋盘(如果有刚好尺寸的图片也可直接加载),包括棋盘网格,炮与兵的特殊标记绘制; 3.绘制和创建棋子,并令其初始化在棋盘的相应位置; 4.游戏开始,

    2024年02月09日
    浏览(49)
  • AI人工智能(调包侠)速成之路十五(中国象棋AI网络机器人:AI模型部署)

    神经网络模型动态加解密的技术这个以后再写吧  书接上文: AI人工智能(调包侠)速成之路十四(中国象棋AI网络机器人:AI技术综合应用实现) 神经网络模型的存储格式         我们训练的神经网络就是一堆模拟神经元的参数集合,给(他/她/它)一个输入信息就会得到

    2024年01月23日
    浏览(50)
  • 从0实现基于Alpha zero的中国象棋AI(会分为多个博客,此处讲解蒙特卡洛树搜索)

    ​ 题主对于阿尔法狗的实现原理好奇,加上毕业在即,因此选择中国象棋版的阿尔法zero,阿尔法zero是阿尔法狗的升级版。在完成代码编写的历程中,深刻感受到深度学习环境的恶劣,网络上固然资料繁多,但要么水平不行,不知所云,要么国外课程,门槛过高。因而碰壁良

    2024年02月06日
    浏览(52)
  • html Antv L7 + mapbox 实现3D地图 3D中国地图 不限于中国地图

    echarts的3D地图实在太丑了,还一堆bug 使用阿里的Antv可视化库L7,实现3D地图,底图是mapbox 参考示例:https://l7.antv.antgroup.com/zh/examples/polygon/3d#floatMap 如果不需要底图样式,可把Scene的style设置为blank 直接上代码了,vue的就不说了,项目是html的 mapbox依赖 L7依赖 body元素 实现

    2024年02月14日
    浏览(52)
  • echarts 5.0——3d中国地图和飞线

    echarts 5.0的版本样式语法与4.0及以下的语法有个别差异,使用旧语法浏览器会警告提示。 3d地图常用的实现方法有两种。一种是使用GL来实现,一种是使用叠层和阴影来实现,本文将使用叠层和阴影来实现。先看效果图: 1. 一定要使用 echarts 5.0及以上的版本; 2. echarts 5.0没有

    2024年04月13日
    浏览(36)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包