在球拍中实现Ishido

2020-07-06 20:20:56

…。其中我们尝试使用球拍图形工具从90年代重新创建一场比赛,并且我们设法在不到1000行代码中做到了这一点。

与其解释游戏,不如展示游戏的玩法。在下面的视频中,有效的瓷砖是用他们的分数来标记的,但这在实际游戏中会被认为是作弊。然而,这里的目标是编写游戏程序,而不是玩它。

顺便说一下,如果你赶时间,你可以直接去游戏的源代码。

这个游戏是在一块8x12的棋盘上进行的,棋盘有72块瓷砖。有六种颜色和六个图像,它们结合在一起产生了36个独特的瓷砖。每块瓷砖中的两块被组合在一起构成一套游戏。这个游戏的目标是按照以下规则将所有的瓷砖放在棋盘上:

如果一个瓷砖的颜色或图像匹配,则可以将其放置在另一个瓷砖的旁边。如果瓷砖周围有多个瓷砖,则每个邻居必须与颜色或图像匹配。

游戏开始时,棋盘上已经有了前六块牌,两角各一块,中间两块。这些块被选择成使得每个可能的颜色和每个可能的图像都出现在板上,从而确保可以将第一个块放置在板上。

打分是为了反映放置的复杂性,如果一块瓷砖紧挨着一块瓷砖放置,那么就得1分;如果把它放在两块瓷砖旁边,就会得2分;如果把它放在3个邻居旁边,就会得4分;如果把它放在4块瓷砖旁边,就会得8分。

当棋盘上没有更多的瓷砖要放置,或者没有有效的位置放置瓷砖时,游戏结束。

考虑到这是一个交互式游戏,用户需要在棋盘上拖动瓷砖,实现此游戏最简单的方法是使用剪贴板和粘贴板。粘贴板%是球拍GUI框架提供的两个编辑器之一,它将处理游戏所需的大部分拖放功能。要使用它,我们需要提供两个类:

表示平铺的Snip%类。剪贴画是一种知道其大小和如何绘制自身的对象。您可以使用截图来做一些很酷的事情,比如渲染地图,甚至是基本的GUI控件。

一个管理剪贴的粘贴板%类(它也可以自己画东西)。虽然我们可以直接使用粘贴板%进行测试,但我们需要定义自己的类来实现游戏施加的限制。

游戏还将使用球拍/抽签设施直接绘制到设备上下文。snip%和pasteboard%提供的绘图函数已经使用了dc<;%>;对象,虽然我们可以使用pict库进行渲染,但是对于这个简单的例子,我们认为没有必要这样做。

虽然游戏棋盘可以只使用线条绘制,但游戏瓷砖将需要六种颜色和六个图像,如果这些图像是好的,而不是简单地使用圆形、正方形和三角形,那将是有帮助的。Unicode字符集有很多表情符号可供选择,而这些表情符号是由racket直接支持的,这意味着它们可以存储在字符串中。

在游戏代码中,我选择使用后跟代码点的\U转义序列来表示Unicode字符,并在开发过程中使用Display或文本PICT构造函数来查看Drracket REPL中的实际字形:

如果我们想要看起来漂亮,视觉上清晰,而且对色盲人士来说也很明显的颜色,选择六种颜色也可能是一项困难的任务。我不知道如何选择符合所有这些标准的颜色,所以我更喜欢的方法是找到其他人定义的配色方案,在这种情况下,是Paul Tol的配色方案之一,即“明亮的定性”配色方案:

可以使用助手类来管理所有截图的通用功能和资源,而不是将图形资源(颜色和字形)存储在每个瓷砖对象中。主题类还允许更改“主题”,即运行时的颜色和图像-这在这个游戏中没有实现,但可以很容易地添加。即使不能更改主题,在单独的类中移动一些常用功能也会使Tiles类变得更简单。

除了管理绘图的图形资产外,Theme类还提供每个平铺的大小(单元宽度和单元高度字段)。这些值在调整大小时由棋盘自身计算,并传达给主题,然后由游戏中的所有瓷砖使用:

(定义主题%(类对象%(初始字段颜色字形)(超新)(定义单元格宽度100)(定义单元格高度100)(定义字体(发送-font-list find-or-create-font 24';default';Normal';正常)(定义/公共(获取颜色键)(矢量参考颜色(键-材质键)(定义/公共(获取材质键)(定义颜色(获取颜色键))(发送笔刷列表查找或创建笔刷颜色';实体)(Define/public(get-glyph key)(string(string-ref glphs(key-sigil key)(Define/public(get-font)font)(Define/public(get-text-forecround)";WhiteSmoke";)(Define/public(get-glyph-size DC key);;.(Values glyph-width glyph-Height))(Define/public(get-cell-size)(Values cell-width cell-Height))(Define/public(set-cell-size w h)(set!单元格宽度w)(设置!单元格高度h)。

这些瓷砖表示可以在电路板上移动的对象,并且它们是使用剪辑实现的。创建新的Snip需要两件事:定义一个Snip类(不要与球拍类%混淆),并创建一个从snip%派生的类,覆盖Get-Extent和Draw方法。

Snip类用于序列化和反序列化Snip对象,但是即使我们在游戏中不使用此功能,我们仍然需要定义一个。任何Snip类的定义都是相同的,只是它们需要有唯一的名称。请注意,snip类实际上是snip-class%的实例,本身不是一个类:

(定义Ishido-Tiles-Snip-class(make-object(class snip-class%(Super-new)(发送此集合类名";Ishido-Tiles-Snip";);;向系统注册我们的Snip类。(Send(get-the-snip-class-list)add Ishido-tile-snip-class)。

我们定义了从snip%类派生的我们自己的til%类。就粘贴板而言,新对象需要:(1)为每个实例设置Snip类(请参阅下面的Set-snipclass调用),(2)定义粘贴板可以用来确定剪贴板大小的Get-Extension方法,以及(3)定义用于绘制剪贴板的Draw方法。

除了剪贴式界面,还有一个按键、一个主题和一个位置。键是包含小块的颜色和字形代码的结构,主题是保存游戏使用的颜色和字形的对象(小块类将要求主题使用对应于其键的实际颜色)。键和主题用于绘制剪辑。最后,位置是一种结构,它保持图块在板上的位置(列和行)。它根本不被Tiles类使用,但是它需要在粘贴板上知道每个Tiles的位置,所以不妨将它存储在Tiles对象中,您可以在游戏源代码中找到完整的定义。

(定义TILE%(class snip%(初始字段键主题[location#f]))(超级新)(发送this set-snipclass Ishido-Tiles-snip-class)(定义/public(get-location)location)(定义/public(set-location l)(set!位置l)(定义/公共(获取关键字)键)(定义/覆盖(获取范围dc x y w h下降空间lspace rspace);;获取范围方法实现)(定义/覆盖(绘制dc x y.。其他);绘制方法实现))

定义了磁贴剪辑后,我们可以编写一个快速测试程序来查看它是如何工作的。在这里,我们将只使用一个普通的粘贴板%,它将显示剪辑,并允许使用鼠标移动它们。有几个对象组成了“游戏”:

bq颜色和鸟字形定义了用于平铺的颜色和图像,它们由主题对象管理。

TopLevel是应用程序的TopLevel GUI窗口,它是Frame%类的实例。

Canvas是编辑器-Canvas%,它充当粘贴板%的视图(粘贴板本身仅管理剪辑,显示由一个或多个编辑器-Canvas%实例处理)。

该代码还创建了六个瓷砖,每种颜色和图像一个瓷砖,并将它们插入到粘贴板中。

(定义bq颜色(Vector(make-color 68 119 170)(make-color 102 204 238)(make-color 34 136 51)(make-color 204 187 68)(make-color 238 102 119)(make-color 170 51 119)(定义鸟形";\U1F99A\U1F99C\U1F9A9\U1F989\U1F986\U1F985";)(定义主题(新主题%[Colors bq-Colors][字形鸟形]))(定义板(新粘贴板%))(定义顶层(新帧%[Label";Ishido";][宽度850][高度600])(定义画布(新编辑器-画布%[父顶层][编辑板]))(for([Material(In-Range 6)][Sigil(In-range 6)])(定义瓷砖(新瓷砖%[key(关键材料符号)][主题]))(发送板插入瓷砖))(发送顶层显示#t)。

结果是一个交互式应用程序,它允许拖动剪辑。粘贴板%提供了很多开箱即用的功能,但仍需要进一步定制,以根据游戏规则限制其功能(例如,剪贴板只应放置在板上的精确位置):

在定义了瓷砖之后,我们需要生产72块瓷砖,最好是随机顺序的,这样它们就可以在游戏中使用了。要生成所有可能的平铺,我们可以对所有可能的颜色和图像使用嵌套的for循环:

(定义全部(FOR*/LIST([GROUP(范围内2)][材质(范围内6)][符号(范围内6)])(新平铺%[KEY(关键材质SIGIL)][主题主题]))。

前面的列表是按颜色和图像排序的,我们需要对其进行调整:

最后,这是一个棘手的问题,前六个瓷砖需要有独特的颜色和图像。这是因为当游戏开始时,前六个瓷砖已经在棋盘上了,为了确保用户总是可以放置下一个瓷砖,所有的颜色和图像都需要在棋盘上。为了实现这一点,我们在洗牌的瓷砖上循环,并将前六个独特的瓷砖移到前面。这样做可以确保,虽然第一个瓷砖是唯一的,但它们也会按每个游戏的随机顺序开始。

(定义-Pocket(LET LOOP([RELEVING SHUFFLED][HEAD';()];包含独特的材质+符号瓷砖[Tail&39;()];包含所有其他瓷砖;;我们尚未看到的材质[Material(for/list([x(in-range 6)])x)];;尚未看到[Sigils(for/list([x(in-range 6)])x)])(cond((null?剩余)(追加头部尾部))(AND(NULL?材料)(空?符号));我们已经看到所有材料和标记(附加头部尾部剩余)(#t(let([候选(剩余汽车)])(Match-Define(关键材料符号)(发送候选获取密钥))(IF(AND(成员材料)(成员符号)(循环(剩余CDR)(CONS候选者头部)尾部(移除材料符号))(LOOP(。剩余CDR)头部(CONS候选尾部)材料标志)。

我们可以更新我们的小测试程序,将袋子中的内容插入到粘贴板中,并将它们并排放置-我们使用粘贴板上的Move-To方法将瓷砖一个接一个地移动,否则,它们就会一个接一个地坐在一起。请注意前六个瓷砖如何具有独特的颜色和图像:

(DEFINE-VALUES(CW CH)(SEND TIME GET-CELL-SIZE))(FOR([(平铺索引)(索引内袋)])(DEFINE-VALUES(ROW COL)(商/余数索引12)(发送板插入平铺)(发送板移动到平铺(*COLCW)(*ROW)。

粘贴板%对象可以管理瓷砖剪切,并提供免费的“拖动”功能,但是在移动瓷砖时,它允许太多的自由。我们需要实现我们自己的棋盘类,派生自Pastboard%,并对其进行调整,以使其适合作为石岛游戏板。从高层次来看,董事会需要实施以下内容:

画出额外的“东西”,比如游戏分数和“游戏结束”消息,再加上“助手”,比如每个有效单元格的分数。

根据游戏规则,只允许将瓷砖从“下一个瓷砖”方块拖到有效位置。

管理游戏中的棋子,棋盘上的棋子和袋子里的棋子。

粘贴板是游戏中最复杂的部分,当计算代码行时,它是程序的一半,但它都是从粘贴板%派生而来的:

与其使用静态电路板尺寸,最好编写GUI应用程序,以便它们能够有效地利用可用的空间。所有绘图和位置计算都将基于知道电路板在哪里而完成,显示大小方法将计算这些位置。通常响应于改变显示粘贴板的画布的大小,系统自动调用显示大小以通知粘贴板其大小已改变。

在实现这个类方法之前,我们需要定义保存黑板和“下一个平铺”正方形的位置和尺寸的字段,On-Display-Size的作用是根据画布尺寸计算这些字段的值:

(定义板%(类粘贴板%(初始字段主题)(超新)(定义值(板-x板-y板宽板高)(值0 0 0)(定义值(下一块瓦片-x下一块瓦片-y下一块瓦片宽度下一块瓦片高度)(值0 0 0))(定义/自动网格(On-Display-Size);.))。

计算电路板的位置和尺寸并不是特别困难,它们毕竟是矩形的,但有几点需要注意:

一个粘贴板可以显示在多个画布中,每个画布可以有不同的尺寸,所以我们不能谈论粘贴板本身的尺寸。不过,最常见的用例是让单个画布显示粘贴板,这样我们就可以获得该画布并查询其尺寸,这对这个游戏来说已经很好了。

编辑器画布有一个“inset”,它是画布周围的内部边框,默认为5个绘图单位。不能在此插图区域中进行绘制,所有计算都需要考虑到这一点(另一种方法是创建水平和垂直插图均为0的Editor-canvas%)。

球拍/绘图库绘制的线条具有宽度,实际线条以绘制坐标给出的理想方向为中心绘制。由于绘图区域是围绕有效绘图区域剪裁的,因此在该区域的边缘右绘制线将导致该线的一半位于外部,看起来就像该线较细一样。为了解决这一问题,代码使用内部边框来略微减少可用绘图区域,因此沿边缘的线条以全宽绘制。

有些如果绘图API使用一个框来获取“输出”值。例如,get-view方法将返回需要在调用前定义的框中的值,而不是返回四个值。

(定义/augride(on-display-size)(定义admin(发送此get-admin)(定义canvas(发送此get-canvas))(定义内部边界2)(When(和admin canvas)(let((x(Box 0))(y(Box 0))(w(Box 0))(h(Box 0)(send admin get-view x y w h#f);注:单板的x,y坐标需要调整;;编辑器画布插图,宽度和高度不需要调整。(设置!Board-x(+内部边框(Unbox X)(设置!board-y(+内部边界(Unbox Y)(设置!板宽(-(*0.8(Unbox W))内部边界内部边界))(设置!单板高度(-(*1.0(Unbox H))内部边框内部边框))(定义值(单元格宽度单元格高度)(值(/单板宽度单元格列)(/单板高度单板-行)(set!NEXT-TILE-WIDTH(*1.7 CELL-WIDTH))(设置!下一个平铺高度(*1.7单元格高度))(设置!NEXT-TILE-x(+board-x board-width(/(-(Unbox W)board-x board-width内框NEXT-TILE-WIDTH)2)(set!NEXT-TILE-y(+(Unbox Y)内部边框)(发送主题集-单元格大小单元格宽度单元格高度)(刷新所有片段))。

完成大小计算后,我们需要通知所有片段和画布尺寸已经更改:我们设置主题中的单元格宽度和高度,然后调用fresh-all-snips,因为它们将需要更新。粘贴板%不提供获取所有剪辑的简单接口,而是将剪辑存储在链接列表中:第一个剪辑使用find-first-snip获得,然后通过调用Next获得下一个剪辑。刷新所有剪接将通知剪接管理员需要调整剪接的大小,并且它还称为Place-Tiles-on-board,因为剪接的位置也可能已经改变。还要注意,整个块都包装在BEGIN-EDIT-SEQUENCE/END-EDIT-SEQUENCE调用中。通常,随着每个片段的更新,重绘操作会立即排队,因为我们必须更新许多片段,这将导致大量的重绘。对开始和结束编辑序列的调用可确保将重绘请求延迟到操作块完成,并且在调用结束时进行单次重绘:

(DEFINE/PRIVATE(REFRESH-ALL-SNIPS)(发送此BEGIN-EDIT-SEND))(LET LOOP([SNIP(SEND this find-First-SNIP)])(WHEN SNIP(定义ADMIN(SEND SNIP GET-ADMIN))(SEND ADMIN RESIZED SNIP#t)(Place-Tiles-on-Board Snip)(LOOP(Send This Snip Next)。

Place-Tiles-on-board方法是一个帮助器,它根据瓷砖的位置在板上定位瓷砖(如果瓷砖有一个位置),或者如果它没有位置,则将其放置在“下一个瓷砖”空间中。该函数只需调用Location->;xy来确定某个位置的坐标,并通过调用Move-to来移动平铺:

(DEFINE/PRIVATE(在板上放置瓷砖)(DEFINE-VALUES(CELL-WIDTH CELL-HEIGHT)(SEND TIME GET-CELL-SIZE))(IF(SEND TILE GET-LOCATION)(LET-VALUES([(Xy)(Location-&>;xy(发送平铺获取位置)])(发送此移动到平铺x y))(发送此移动到平铺(+下一平铺-x(/(-下一平铺宽度单元格宽度)2))(+下一平铺-y(/(-下一平铺高度单元格高度)2)

Location->;xy是一个简单的函数,它只是将位置的行和列与单元格的宽度和高度相乘:

(DEFINE/PRIVATE(LOCATION-&>;XY l)(DEFINE-VALUES(CELL-WIDTH CELL-HEIGHT)(SEND TIME TIME GET-CELL-SIZE))(MATCH-DEFINE(LOCATION列行)l)(VALUES(+board-x(*cell-width column))(+board-y(*cell-high row)。

我们还没有介绍电路板本身的图纸,但这里有一个演示它是如何工作的。由于所有大小都是动态计算的,因此绘制棋盘时会填满可用区域,同时调整切片大小以适合方块o。

..