Skip to content

Latest commit

 

History

History
844 lines (583 loc) · 42.5 KB

AlgoAndDesign.md

File metadata and controls

844 lines (583 loc) · 42.5 KB

算法&设计

unruly-2021-12-22

说到计算机程序,我们肯定会谈到算法,算法决定了一个程序怎么去完成需求。咱这个破游戏当然也不意外,这一节我准备写几个游戏里比较重要的算法设计

这一节我准备分模块来写...

前言

本游戏赖以的TUIcurses的坐标系统由于历史原因是有点反直觉的:

addstr()newwin()等有坐标参数的方法中,形参y坐标总是在x坐标的前面,所以传入的时候是(y,x)这样的形式。

还有一点,x轴的正方向水平向右,这点和我们接触的二维坐标系是一致的。但是y轴的正方向竖直向下

resource.py

  • x_offset方法

    x_offset方法是搭配curses窗口的addstr使用的,用于处理字符串的偏移。

    为什么需要这个方法呢?

    比如说我有个带换行符的字符串string='1\n2\n3\n4\n5\n6',我们把这个字符串打印在屏幕上:

    screen.addstr(1,0,string) # 在y=1,x=0的地方打印

    效果:

    1
    2
    3
    4
    5
    6
    

    这样是没有任何问题,但我们给addstr()指定一个水平方向上的偏移呢?

    screen.addstr(1,2,string) # 在y=1,x=2的地方打印

    效果:

      1
    2
    3
    4
    5
    6
    

    问题出现了,除了第一行有了偏移,其他行是不受影响的。为了让所有行拥有相同的偏移,我专门写了这个x_offset偏移方法:

    @staticmethod  # 作为一个静态方法
    def x_offset(string, offset):
        '''搭配addstr,处理字符串的偏移。如果只用addstr的x-offset的话就第一行有偏移,其他行都是一个样,这个方法将字符串除第一行之外所有行头部都加上offset空格'''
        lines = string.splitlines(keepends=True)
        first_line = lines.pop(0)  # 除了第一行
        # Python竟然有这么方便的方法,可以直接按行分割,太棒了。keepends=True,每行保留换行符
        # 除了第一行每一行都加上偏移
        return first_line+''.join(map(lambda x: offset*' '+x, lines))

    原理其实很简单,先把第一行单独提取出来赋值给first_line(不做处理),然后使用map将匿名函数lambda x: offset*' '+x映射到字符串剩下的中,最后将first_line和可迭代map对象重新连接成字符串返回。

    其中offset是偏移的量,offset*' '则是在每行字符串前加上对应长度的空白符。这样就能完美解决这个问题了:

    screen.addstr(1,2,Res.x_offset(string,2)) # 在y=1,x=2的地方打印

    效果:

      1
      2
      3
      4
      5
      6
    
  • rgb方法

    最开始我调用curses.init_color来初始化颜色,传入了个0-255的RGB颜色值,结果在绘制的时候无论我怎么用亮色,展现出来的颜色是非常昏暗的

    后来去查了一下才知道,也是因为历史问题,curses支持的颜色是0-1000的。要解决这个问题其实很简单,按比例换算一下就行了:

    @staticmethod
    def rgb(color): # 传入元组(R,G,B)
        ratio = 255/1000
        return map(lambda x: floor(x/ratio), color)

    RGB三个分量的比例255:1000,仍然是用map函数,将color元组的每个分量按比例转换成千制RGB,这个方法会返回一个map对象。按需求来说只能使用一次的map对象是完全足够了。

  • ratio_rand方法

    这个方法主要是为了按比率随机抽取一个键,用于随机抽取触发点类型

    @staticmethod
    def ratio_rand(dic):
        pointer = 1  # 指针从1开始
        luck = random.randint(1, 1000)  # 从1到1000中选
        choice = False
        for k, v in dic.items():
            cover = v*1000  # 找出该比率在1000中占的份额
            # 划分区域,像抽奖转盘一样
            if luck >= pointer and luck <= (pointer+cover-1):
                choice = k
                break
            pointer += cover
        return choice

    传入函数的参数是一个字典,这个字典的键值对中,键是待选项,而键对应的值是一个比率,比如:

    rand_dict={
        'choice1':0.3, # choice1被抽出的概率是30%
        'choice2':0.5, # choice2被抽出的概率是50%
        'choice3':0.2 # choice3被抽出的概率是20%
    }

    这个抽取的算法其实很简单,思路就是我们平常在商场里看到的抽奖转盘

    只不过我们这里是在1-1000范围中根据比率来分配罢了,拿rand_dict为例,我们把1-1000划为三个区域:

    1-300301-800,801-1000

    然后从1-1000中随机抽取一个数,看落在哪个区域

    这个方法里就是先抽取出了一个1-1000范围中的随机数,然后在找落在哪个区域里,最后返回这个区域对应的键

    因为抽数范围是1-1000,所以比率是支持小数点后3位的

    值得注意的是所有的比率加起来要为1,不然可能会返回False

view.py

老实说这个模块没有很多值得说说的算法和设计,我就写一下RankingView类实例的show_panel()方法里的分页吧。

def show_panel(self):
    rank_list = Res().get_ranking()['rank_list']
    title, choice_session = self.create_win(Res().art_texts('ranking'))
    chunked = []
    each_chunk = 6
    for i in range(0, len(rank_list), each_chunk):
        the_chunk = rank_list[i:i+each_chunk]
        chunked.append(the_chunk)  # 分片
    ...
    current_page = 0
    max_page = len(chunked)-1

首先获得整张排名表rank_list,这是一个二维列表,列表中的每一项形如[排名登记时间,总分],整个列表已经按降序排列。

我设定每页展示6项,也就是each_chunk=6。接下来就需要把这么多项以6个一组分成多片(页)。

于是我们以列表长度为range尾部、以0为range开头、以each_chunk为步,对这样的range进行遍历。然后采用列表分片,以i为开始下标,以i+each_chunk为结束下标,从rank_list中切出一片储存到chunked列表中。

遍历完成后,chunked列表中有几个元素,就代表有几页,即max_page=len(chunked)-1

之后进入Ranking界面主循环,程序会调用list_maker方法根据当前块current_chunk和开始的位置start_place生成待绘制的排名单,然后打印在屏幕上。

当前块其实就是以标记当前页码的变量current_page为下标,从chunked中取出对应分片(页)start_page标记当前块首个排名项rank_list中下标开始的地方。

list_maker()中,在start_page+1的基础上再加上当前项目在当前分片中的下标,就是当前的排名了:

def list_maker(self, chunk, start=0):
    list_str = 'PLACE             TIME             SCORE\n'
    for key, item in enumerate(chunk):
        place = start+key+1
        date, score = item
        list_str += f'{place}        {date}        {score}\n'
    list_str += '\nPress (D) for Next Page, (A) for Prev Page'
    return list_str

比如我current_page=1,代表是第二页(第一页下标为0),分页总共有5页(下标0-4),

那么当前分页中each_chunk=6,下标是0-5,其中下标为0的项在rank_list中开始的下标是current_page*each_chunk=6,但我们知道,这其实是第7项!

于是在list_maker处理当前分页时,当前页面显示的第一项是第start+1+key=6+1+0=7名,第二项则是第8名,第三项就是第9名...

总的来说,这个分页的原理还是很简单的ヽ(✿゚▽゚)ノ。

game.py

002-2021-12-22

哎嘛,这节要讲的东西可就有点多了!

  • 基本思想:点集合和坐标

    可以说下面的算法和设计多多少少都是围绕着点集合坐标展开的。

    这里写几个基本的点集合:

    • Game.border_points

      储存所有的边界绘制点,在Game.__create_border()中被初始化:

      @classmethod
      def __create_border(cls):  # 创建边界点坐标
          map_w, map_h = map(lambda x: x+1, cls.map_size)  # 获得处理过的地图宽高
          border_points = set()  # 储存边框的点坐标
          for w in range(map_w+1):
              border_points.update({(w, 0), (w, map_h)})
          for h in range(map_h+1):  # 让竖直方向的边框长一点
              border_points.update({(0, h), (map_w, h)})
          cls.border_points = border_points

      初始化的时候首先取出了记录了地图宽高的元组map_size,然后在map()函数映射处理宽高共同+1后把宽高各加一赋值给map_wmap_h

      边框点是从(0,0)绘制到(map_w,map_h)的。我利用了两次for循环来实现这个过程,第一次保持纵坐标不变,改变横坐标从0map_w

      第二次则保持横坐标不变,改变纵坐标从0map_h

      最终形成的点如下,留出了原本的地图区域

    • Game.map_points

      储存地图中的所有坐标,在Game.__cls_init()中被初始化,可以说是所有游戏中点处理的基础。

      map_w, map_h = map_size # 获得真正的地图宽高
      # 根据地图大小生成所有的坐标
      cls.map_points = {(xi, yi) for xi in range(1, map_w+1) for yi in range(1, map_h+1)}

      map_points点集合的生成我直接用了个集合推导式x坐标从1map_wy坐标从1map_h

      生成的map_points点集合表示的就是上面border_points示例图中绿色标出来的区域。

    • Game.explode_points

      临时储存爆炸,代表爆炸时的爆炸范围的点集合,由效果类FxBombapply()方法控制,用于和线体产生交互。

    • Game.__sight_points

      储存近视模式下的点集合,代表角色近视时能看到的区域(也就是视野),这和近视(Myopia)效果的处理息息相关,后面会细说。

    • Game.flow_stones

      储存地图中的流石点集合,代表地图中流石的位置,由效果类FxStonesapply()方法控制,用于和线体产生交互。


  • 线体移动

    线体移动靠的是每次游戏主循环调用Line类实例的move()方法来实现。对于线体运动方向的控制我采用了两个元组:
    速度(水平方向速度大小,竖直方向速度大小)方向(水平方向速度方向,竖直方向速度方向)

    看看move()里我是怎样处理的就知道咱为什么这样设计了:

    def move(self):  # 计算角色移动
        max_x, max_y = self.__map_w, self.__map_h  # 解构赋值最大的x,y坐标值
        attrs = self.attrs  # 获得角色(线体)属性
        head_pos = attrs['head_pos']
        x, y = head_pos  # 解构赋值头部x,y坐标
        prev_x, prev_y = floor(x), floor(y)  # 上一tick的头部坐标
        vx, vy = attrs['velo']  # 解构赋值x,y速度
        dx, dy = attrs['direction']  # 解构赋值x,y的方向
        # 让线头能穿越屏幕,因为窗口绘制偏差,x和y的初始值从1开始,与之相对max_x,max_y也添加了偏移量1
        x = x + (vx*dx) if x >= 1 and x < max_x + \
            1 else (max_x if dx < 0 else 1)
        y = y + (vy*dy) if y >= 1 and y < max_y + \
            1 else (max_y if dy < 0 else 1)
        new_head_pos = (x, y)
        attrs['head_pos'] = new_head_pos  # 更新头部坐标
        # 向下取整后线身前进了一格
        body_pos = attrs['body_pos']  # 引用索引
        body_len = len(body_pos)
        if not (floor(x) == prev_x and floor(y) == prev_y):
            Game.update_myopia_sight()  # 在蛇体移动一格的情况下更新近视情况下的区域
            if body_len > 0:  # 身体长度大于0再进行处理
                body_pos = body_pos[1::]
                body_pos.append((prev_x, prev_y))
                attrs['body_pos'] = body_pos

    从上面我们已经知道,地图大小map_w,map_h对应的也是最大坐标max_xmax_y

    接着我们取出头部坐标head_pos和速度velo以及方向direction,解构赋值给几个变量:

    • 头部坐标 - xy
    • 水平/竖直速度大小 - vxvy
    • 水平/竖直速度方向 - dxdy

    接下来是一坨三元运算,没错,线体的移动计算这两句就可以解决了:

    x = x + (vx*dx) if x >= 1 and x < max_x + 1 else (max_x if dx < 0 else 1)  
    y = y + (vy*dy) if y >= 1 and y < max_y + 1 else (max_y if dy < 0 else 1)  
    • vxvy的取值是0-1dxdy的取值是(-1,1)

    • 其中dx,dy的值为1代表沿正方向运动,为-1代表沿反方向运动

    • vx,vy速度的单位是格/tick,是大于0小于等于1的浮点数。

    这样只需要vx*dx,vy*dy就能控制水平和竖直的速度方向了

    因为每次执行循环语句就相当于一tick(一次运算),就会调用一次move()——

    ——所以每次我只需要在move()中对xy加上每tick的位移就可以了,每tick的位移:Δx=vx*dx*1Δy=vy*dy*1,其实就是vx*dxvy*dy

    到这里你可能想问了,方向完全没必要用dx,dy两个值来确定啊!其实我在最开始设计游戏的时候考虑到线体斜向运动的可能性,所以这样设计了,直至整个课设完成,我仍然保留了这种表达方式,如果以后要修改的话更具灵活性~


    所以...为什么要用三元运算呢?

    我设计的默认情况下线体是可以穿墙的,这样能给后面无敌效果的处理提供很大便利。而这里的三元运算就是为了解决穿墙的问题的:

    • 首先明确一点,这里头部的xy坐标是分开处理的

    • 先判断x >= 1 and x < max_x + 1y >= 1 and y < max_y + 1,也就是判断线体在不在地图区域里(下面这张图的绿色标记区域)

    • 如果超出了地图区域则转向else后面的语句处理

    • 假如x坐标超出地图区域:(max_x if dx < 0 else 1),表示
      如果dx<0,也就是说是往水平负方向运动,是超出地图左边区域了!。要达到穿墙效果,就把线体传送到最右边,也就是x=max_x
      如果dx>0,也就是说是往水平正方向运动,是超出地图右边区域了!。要达到穿墙效果,就把线体传送到最左边,也就是x=1
      (地图区域中x坐标的范围是1max_x)

    • 假如y坐标超出地图区域,处理方法和x是一模一样的。
      (地图区域中y坐标的范围是1max_y)


    搞清楚是怎么穿墙之后,你可能又会疑问判断在地图区域内为什么用的是诸如x >= 1 and x < max_x + 1这种半闭半开,末尾max_x+1的写法呢?

    这其实和上面所述的移动计算息息相关,x虽然取值是1map_x,但从头部的移动来讲并不仅仅是这样。可以看一下绘制头部的部分:

    def draw_line(self):  # 绘制角色
        head_pos = self.attrs['head_pos']
        body_pos = self.attrs['body_pos']
        head_x, head_y = map(floor, head_pos)  # 解构赋值
        ...
        # 使用1号颜色对进行头部绘制
        Game.printer(head_y, head_x, line_body, Game.color_pair(1))

    很容易发现在绘制的时候,取出的头部坐标是经过了floor向下取整的,因为头部坐标实际上是浮点数

    每tick(每次运算)都会往头部坐标上添加每tick对应的位移,但是tick间隔的时间是很短的(假如TPS=10,tick(运算)间隔就是0.1秒),如果每tick都加的是整数,那线体未免跑得太快了!这也是为什么vxvy是大于0小于等于1的浮点数。

    这点光说可能不太能讲清楚,通过一张表能大概表示这个意思,

    下面的部分拿水平方向x坐标举例:

    (假如速度是0.1格/tick

    运算(tick)序号 头部x坐标 floor取整后头部x坐标
    0 1.0 1
    1 1.1 1
    2 1.2 1
    3 1.3 1
    4 1.4 1
    5 1.5 1
    6 1.6 1
    7 1.7 1
    8 1.8 1
    9 1.9 1
    10 2.0 2
    11 2.1 2

    用于打印头部位置的坐标是floor取整后头部x坐标。也就是在游戏中我们看到线头运动一格时,其实按上面这种情况,已经运算了10ticks了!

    正因为向下取整,我需要把判断条件写成x >= 1 and x < max_x + 1这样:

    • x在大于等于1和小于2的区间内,头部在游戏中显示都是在坐标x=1这一格,x=1封底的坐标值。

    封底x>=1没有问题了,如果我把封顶写成x<=max_x呢?

    • 上面的动图可以看出来,x在大于等于15小于16的区间内,头部在游戏中显示都是在坐标x=15这一格,并没有穿过墙

    • max_x=15,如果我写成x<=max_x,当x=15的时候就被传送到x=1的地方了,导致游戏中显示线体头部只在x=15处停留了1tick就被快速传送了,实际上根本没碰到墙,展现出来的问题是这个样的:

    • 写成x < max_x+1的话,当头部x处于大于等于15小于16的区间时就不会被提前传送了,而当x坐标达到16时就立马传送x=1处。
      这样游戏中就能正常显示线体头部x=15处停留了10tick,然后再穿墙到x=1

    对于竖直方向上y坐标的判断同样是y >= 1 and y < max_y + 1,原理一致。


    上面讲的都是头部的运动,接下来讲讲尾巴

    尾巴要做的事其实挺简单,就是跟着头部运动。我将尾巴按每一节的坐标(x,y)放在一个列表中。

    为了使操作的步骤最少,我设计把离头部最近的一节放在列表末尾离头部最远的一节放在列表头部,为什么这么做呢?下面来看看代码

    def move(self):  # 计算角色移动
        max_x, max_y = self.__map_w, self.__map_h  # 解构赋值最大的x,y坐标值
        attrs = self.attrs  # 获得角色(线体)属性
        head_pos = attrs['head_pos']
        x, y = head_pos  # 解构赋值头部x,y坐标
        prev_x, prev_y = floor(x), floor(y)  # 上一tick的头部坐标
        vx, vy = attrs['velo']  # 解构赋值x,y速度
        dx, dy = attrs['direction']  # 解构赋值x,y的方向
        # 让线头能穿越屏幕,因为窗口绘制偏差,x和y的初始值从1开始,与之相对max_x,max_y也添加了偏移量1
        x = x + (vx*dx) if x >= 1 and x < max_x + \
            1 else (max_x if dx < 0 else 1)
        y = y + (vy*dy) if y >= 1 and y < max_y + \
            1 else (max_y if dy < 0 else 1)
        new_head_pos = (x, y)
        attrs['head_pos'] = new_head_pos  # 更新头部坐标
        # 向下取整后线身前进了一格
        body_pos = attrs['body_pos']  # 获得尾巴列表
        body_len = len(body_pos) # 获得尾巴长度
        if not (floor(x) == prev_x and floor(y) == prev_y):
            Game.update_myopia_sight()  # 在蛇体移动一格的情况下更新近视情况下的区域
            if body_len > 0:  # 身体长度大于0再进行处理
                body_pos = body_pos[1::]
                body_pos.append((prev_x, prev_y))
                attrs['body_pos'] = body_pos
    • 首先先获取本次运算对头部坐标进行处理前(也就是上一个tick) 的头部坐标prev_xprev_y,注意这里进行了向下取整,和游戏显示是一样的处理!

    • 获取尾部坐标列表attrs['body_pos]备用

    • 本次运算(这一个tick)对头部坐标进行处理后,拿到当前的头部坐标xy,然后对其进行向下取整

    • 将向下取整后的xyprev_xprev_y进行分别对比,如果任意一组不相等,说明头部在屏幕上显示的位置改变了!

    • 头部在屏幕上显示的位置改变后就应该让尾巴跟进了(不过前提也是body_len>0,要先有尾巴)

    • 我利用列表的切片方法,把离头部最远的坐标去除(切片从下标1开始取)。然后把(prev_x,prev_y),也就是头部之前在屏幕上显示的坐标append到列表body_pos的尾部,然后更新原来的列表attrs['body_pos'],尾巴跟随就完成了!

    形象地用动图展示这个过程:

    其中我使用了列表的append方法,把新的对象直接加到列表末尾,这个操作的时间复杂度是O(1),也就是立刻完成,非常高效。这也是为什么我要把离头部最远的一节放在列表头部

    至此线体的运动算法咱就写完了~


  • 线体初始化

    线体初始化主要做的事是随机生成了线体的初始行进速度方向初始位置

    def __init__(self) -> None:
        self.__map_w, self.__map_h = Game.map_size  # 解构赋值地图长宽
        # 注意,防止生成在边缘,不然开局就G了!
        # 把生成区域x,y从地图区域往内各缩3格,防止生成在边缘
        ava_points = [(xi, yi) for xi in range(4, self.__map_w-2)
                      for yi in range(4, self.__map_h-2)]
        init_velo = Game.game_cfg['init_velo']  # 从配置中取出初始速度
        self.attrs = {  # 线体属性
            'head_pos': random.choice(ava_points),  # 生成随机的头部坐标
            # 头部的运动速度(Vx,Vy),单位:格数/tick,最开始要不沿x轴,要不沿y轴运动
            'velo': random.choice(((init_velo, 0), (0, init_velo))),
            # 运动方向(x,y),-1代表负向。向右为X轴正方向,向下为Y轴正方向
            'direction': (random.choice((1, -1)), random.choice((1, -1))),
            'body_pos': [],  # 身体各节的位置
            'invincibility': False,  # 是否无敌
            'myopia': False  # 是否近视
        }
        ...

    从代码可以看出来,初始速度和方向直接用的random.choice方法从元组里选。

    初始速度要不沿水平方向(v,0),要不沿竖直方向(0,v);而方向则要不沿当前方向的正方向1,要不沿着反方向-1

    值得一说的是可用点集合ava_points,用于抽取初始头部位置

    同样是用推导式,不过这里用的是列表推导式,因为random.choice是针对列表或元组进行选择的。其实也可以写成集合推导式,只不过需要用到另一个方法random.sample了,这个方法的效率相较比较低,就不考虑了。

    ava_points推导式里我把可用点的范围往内缩进了3格:从(1,map_w+1)改成(4,map_w-2),这样防止最开始头部生成在边缘,开局即游戏结束肯定是不行的啦!

    最后用random.choiceava_points里随机选择一个可用点,附到线体属性里作为线体头部初始位置。


  • 线体的致命碰撞

    Line类的实例方法里专门有一个impact()来判断线体头部是否受到致命碰撞,在游戏主循环里每次tick(运算)都会调用一次

    def impact(self):  # 碰撞判断
        attrs = self.attrs
        if attrs['invincibility']:  # 如果无敌就直接跳过
            return False
        max_x, max_y = self.__map_w, self.__map_h  # 解构赋值最大的x,y坐标值
        x, y = attrs['head_pos']
        result = False  # False代表未碰撞,True代表有碰撞
        judge_border_x = x >= 1 and x < max_x+1
        judge_border_y = y >= 1 and y < max_y+1
        floored = (floor(x), floor(y))
        # 第一步先判断是否碰到边框
        if not (judge_border_x and judge_border_y):
            result = True
        # 第二步判断是不是碰到自己了
        elif floored in attrs['body_pos']:
            result = True
        # 第三步判断是不是被炸到了
        elif floored in Game.explode_points:
            result = True
        # 第四步判断是不是被流石撞到了
        elif floored in Game.flow_stones:
            result = True
        return result

    impact()函数的开头有个**无敌模式(Invincibility)**的判断,如果无敌就直接返回False,代表忽略一切致命撞击。

    碰撞这件事是反映在游戏界面上,被用户所看到的。所以我们判断也要按游戏界面的处理方式——对头部坐标向下取整,得到一个点坐标(floor(x), floor(y))

    除开无敌模式,撞击判定我分为了四部分:

    • 撞到墙壁

      处理这一部分我写了两个逻辑表达式进行判断:

      judge_border_x = x >= 1 and x < max_x+1
      judge_border_y = y >= 1 and y < max_y+1

      原理其实和上面线体移动一节的穿墙判断是一致的,这里就不多赘述。

      judge_border_xjudge_border_y的其中一个逻辑值不为真,就代表碰到墙壁了!

    • 撞到自己尾巴被炸弹炸到被流石砸到

      这三个判断的方式都是非常类似的,都是点判断,运用了in运算符:

      elif floored in attrs['body_pos']:
          result = True
      # 第三步判断是不是被炸到了
      elif floored in Game.explode_points:
          result = True
      # 第四步判断是不是被流石撞到了
      elif floored in Game.flow_stones:
          result = True

      原理很简单,attrs['body_pos']Game.explode_pointsGame.flow_stones都是set集合类型,我们只需要判断取整后头部坐标元组floored是否存在于这些点集合中就可以了。

      如果floored存在于这些点集合中,说明碰撞到了这些点

      值得一提的是,我对几乎所有点集都采用set集合类型,是因为对于in运算符来说检索一个对象是否在集合中的时间复杂度O(1)O(n)(其中O(n)是非常不常见的),是非常高效的。

    当判断线体头部受到致命撞击后,impact()方法会返回True

    # 在游戏主循环中
    if line_ins.impact():  # 判断是否有碰撞
        break # 跳出循环

    由此跳出循环,让游戏结束


  • 线体和触发点的碰撞

    线体Line类中还有个很简单的实例方法hit(p_x,p_y),接受一个坐标来判断是否和线体发生碰撞

    def hit(self, p_x, p_y):  # 撞击判断(x,y)
        attrs = self.attrs
        h_x, h_y = attrs['head_pos']
        # 如果<=1会有误判
        return 0 <= h_x-p_x < 1 and 0 <= h_y-p_y < 1

    这个方法主要用于线体和触发点互相碰撞的判断,传入的(p_x,p_y)是触发点的坐标。

    注意!触发点坐标(p_x,p_y)p_x,p_y都是整数,而这里取出的头部坐标h_x,h_y浮点数

    判断碰撞的方法是看头部坐标和触发点坐标的差值,保证h_x-p_xh_y-p_y都落在区间[0,1)中。

    h_x-p_x来解释:

    • h_x-p_x=0时,毫无疑问h_xp_x是重合了
    • 0 < h_x-p_x < 1 时,代表头部h_x坐标向下取整仍然是和触发点的p_x坐标是重合的

    和上面一节说的一样,碰撞这件事是反映在游戏界面上,被用户所看到的,所以这里也要用到头部坐标向下取整的思想。

    h_y-p_y的判断和上述如出一辙。

    当线体和触发点在xy方向上都满足碰撞条件时,代表线体碰到触发点了hit()返回True,交给触发点类Trigger的实例方法继续处理下面的效果(这点在前面 游戏是怎么跑起来的 这一节已经讲过了)


  • 触发点生成的可用点

    在触发点Trigger类中有一个实例方法ava_points(),用来得到触发点可以生成的可用点的集合(也被效果类FxTlpt的实例方法用于生成随机传送点)。

    def ava_points(self, border_offset=False):  # 获得可用的点
        attrs = self.__line.attrs  # 获得线体属性
        exist_points = set(attrs['body_pos'])  # 脱离原来的引用,转换为集合
        exist_points.add(attrs['head_pos'])
        # 获得所有触发点占用的坐标点集合
        exist_triggers = {i['pos'] for i in self.triggers.values()}
        exist_points.update(exist_triggers)  # exist_points储存的是已经使用的坐标点
        exist_points.update(Game.border_points)  # 还要算入边框的点
        # 将所有的坐标点和已经使用的坐标点作差集,就是还可以选用的坐标点
        usable_points = Game.map_points - exist_points
        # 如果不要靠近边界
        if border_offset:
            offset = 3
            map_w, map_h = Game.map_size
            ava_area = {(xi, yi) for xi in range(offset, map_w-offset-1)
                        for yi in range(3, map_h-offset-1)}
            # 利用交集得出可用的点
            usable_points = usable_points & ava_area
        return tuple(usable_points)  # 因为random.choice选不了set

    这个方法做的事情其实也很好理解:

    • 先从线体实例中取出线体属性的尾巴坐标列表,转换为集合作为exist_points,同时将线体的头部坐标加入该集合

    • 接下来是一个集合推导式,遍历所有现存触发点的坐标并储存于一个集合exist_triggers

    • exist_triggers内的坐标点全部加入exists_points,接着把之前生成的边界点Game.border_points也加入exists_points

    • 很好!现在一个包含所有已经使用的点的集合exists_points就完成了,接下来用地图中所有的点Game.map_pointsexists_points作差集就能得到所有可以用的点了!

    • 如果border_offset=True,代表可用点要离边框远一点(和上面线体初始化要离边框远一点是一个原理),则让生成点区域每边往内缩3格:
      利用集合推导式生成一个缩进后的可用区域ava_area
      然后拿ava_area和已经生成的可用点usable_points作交集,得到处理后的可用点。

    最后该方法返回的是一个元组,因为这个方法是搭配random.choice使用的,random.choice只支持有顺序的序列。


  • 近视Myopia效果的处理

    本游戏中有个myopia近视效果挺有趣的,其实现方法其实是基于Game.printer这个Hook的。

    Game类属性初始化时有一个short_sighted属性,用来指示是否近视

    线体碰到myopia触发点后Trigger类实例方法会间接调用FxMyopia效果类的方法apply,从而启动近视效果。

    该方法中有两条语句值得注意:

    Game.update_myopia_sight() # 更新视野区域
    Game.myopia(True) # 启用近视效果  
    • Game.update_myopia_sight()方法用于更新视野区域:

      一个前提:近视视野是以线体头部为中心而展开的。

      @classmethod
      def update_myopia_sight(cls):  # 生成近视区域,搭配Line.move方法
          if cls.short_sighted:
              x, y, sight_w, sight_h = cls.__get_sight_info()
              l_t_x = x - (sight_w-1)//2
              l_t_y = y - (sight_h-1)//2
              # 得到视野区所有坐标点
              sight_points = {(xi, yi) for yi in range(l_t_y, l_t_y+sight_h)
                              for xi in range(l_t_x, l_t_x+sight_w)}
              cls.__sight_points.clear()
              cls.__sight_points.update(sight_points)

      这个方法首先通过__get_sight_info()获取向下取整的头部坐标和近视视野宽高(x,y,sight_w,sight_h),

      接着根据视野宽高头部坐标找出视野区域点集合:

      示例图

      这个地方比较难讲清楚,首先l_t_xl_t_y是为了找出相对头部左上角相应位移的点,让这个点相对头部的xy方向上的偏差分别为视野宽高sight_wsight_h的一半

      至于为什么算的时候要写sight_w-1sight_h-1,其实这个-1写不写都是无所谓的,这是并不影响最终生成的视野点区域大小的。

      接下来又是一个集合推导式,让x坐标从l_t_xl_t_x-1,让y坐标从l_t_yl_t_y-1,得出sight_w × sight_h个视野点的集合sight_points,代表头部近视时能看到的区域

      最后这个方法会将视野点sight_points更新到Game类属性中。


      Game.update_myopia_sight()在整个游戏程序中只被调用了两次,一次是FxMyopia刚刚应用效果的时候,相当于初始化。

      另一次则又回到我们的老朋友:Line类的实例方法move()里,当屏幕中头部移动一格时,会调用其更新近视视野:

      # Line.move里
      if not (floor(x) == prev_x and floor(y) == prev_y):
          Game.update_myopia_sight()  # 在蛇体移动一格的情况下更新近视情况下的区域

      为什么这样做呢?在这一节的开头我已经说过近视视野是以线体头部为中心而展开的,所以当头部在屏幕显示中没有动的时候,视野范围是没有改变的,所以只需要在头部移动一格的时候更新一次视野范围就可以了。

    • Game.myopia(True)方法用于启用近视效果

      @classmethod
      def myopia(cls, toggle):  # 是否近视
          cls.short_sighted = toggle

      调用这个方法其实就是在修改Game.short_sighted这个类属性,当类属性为真即启动近视效果


    启动了近视效果之后,接下来咱说一下近视效果的实现

    近视效果的实现得益于Game.printer()这个Hook,这个类方法接管了curses游戏区域的绘制,在没有近视的时候相当于Game.game_area.addstr()

    • 一个前提:Game.printer()每次只打印一个坐标对应的点到屏幕上,用户看到的画面是遍历点集合时调用Game.printer()打印出来的。

    启用近视之后,Game.printer()就会进行额外的处理:

    ...
    sight_points = cls.__sight_points
    if (pos_x, pos_y) in sight_points:  # 如果要打印的内容在视野区
        x, y, sight_w, sight_h = cls.__get_sight_info()
        map_w, map_h = cls.map_size  # 获得地图尺寸
        x_ratio = map_w//sight_w  # x方向比例
        y_ratio = map_h//sight_h  # y方向比例
        x_center = map_w//2  # 地图中x方向中心
        y_center = map_h//2  # 地图中y方向中心
        relative_x = pos_x-x  # x方向上相对头部距离
        relative_y = pos_y-y  # y方向上相对头部距离
        c_t_x = x_center+relative_x*x_ratio  # 找出在放大视野中的中心x坐标
        c_t_y = y_center+relative_y*y_ratio  # 找出在放大视野中的中心y坐标
        # 接下来要放大这一个点
        half_w = floor((x_ratio-1)/2)  # 先找出一半宽度,向上取整
        half_h = floor((y_ratio-1)/2)  # 找出一半高度
        new_l_t_x = c_t_x-half_w  # 这一个方块的左上角x坐标
        new_l_t_y = c_t_y-half_h  # 这一个方块的左上角y坐标
        # 获得渲染这个方块的点坐标
        block_points = {(bx, by) for by in range(
            new_l_t_y, new_l_t_y+y_ratio) for bx in range(new_l_t_x, new_l_t_x+x_ratio)}
        for pt in cls.cut_points(block_points):  # 去掉地图外面的点,防止出错
            bx, by = pt
            win_obj.addstr(by, bx, string, *args)

    这一部分要做的事我也一一列出:

    • 首先判断待打印点(pos_x,pos_y)是否存在视野范围sight_points内,如果存在于sight_points内就继续处理

    • 一样是先通过__get_sight_info()获取向下取整的头部坐标和近视视野宽高(x,y,sight_w,sight_h)

    • 先算出x_ratio,y_ratio,代表xy方向上视野缩小前后的长宽比例,用于后面放大处理

    • 接着得找出游戏区域的中心坐标x_centery_center,因为在近视视野中,线体头部是一直处在画面中央作为中心的

    • 待打印点(pos_x,pos_y)和头部点(同时也是中心点)(x,y)相减得到的相对距离relative_xrelative_y

    • 把相对距离分别乘以比例x_ratioy_ratio后得到放大后的相对距离relative_x*x_ratiorelative_y*y_ratio

    • 用游戏区域的中心坐标x_centery_center加上放大后的相对距离,就是在视野放大后(pos_x,pos_y)这个点的中心坐标(c_t_x,c_t_y)

    • 接下来我们要将这个点进行放大
      原本画面中一个点的长宽是1×1,那么xy方向各放大x_ratioy_ratio倍后,放大的点的长宽就是x_ratio × y_ratio
      和上面update_myopia_sight()的处理一致,我们也得到放大的点的一半长宽half_whalf_h

      half_w = floor((x_ratio-1)/2)  # 先找出一半宽度,向下取整
      half_h = floor((y_ratio-1)/2)  # 找出一半高度
    • 紧接着基于这个点的中心坐标,得到放大的点的左上角的坐标,
      点放大后变成方块

      new_l_t_x = c_t_x-half_w  # 这一个方块的左上角x坐标
      new_l_t_y = c_t_y-half_h  # 这一个方块的左上角y坐标
    • 最精彩的来了,我们根据这个放大后方块的左上角坐标,通过集合推导式生成用于绘制放大后的方块的点集合
      从左上角(new_l_t_x,new_l_t_y)到右下角(new_l_t_x+x_ratio,new_l_t_y+y_ratio)

      block_points = {(bx, by) for by in range(new_l_t_y, new_l_t_y+y_ratio) for bx in range(new_l_t_x, new_l_t_x+x_ratio)}
    • 最后,将用于绘制放大后的方块的点集合利用Game.cutpoints()进行修剪,本质是和Game.map_points做了一个交集,为的是不绘制超出游戏显示区域的点

    • 修剪完毕后,遍历点集合,将其打印到屏幕上。

    下面两张动图会演示一遍这个过程(因为动画太长了,拆成两张图):

    示例图1

    示例图2


至此,我觉得比较重要的算法和设计就写完了。

其实还有很多没写到的点,像FxStones流石效果类中通过调整TPS来控制流石速度等等...我在源码中已经做了一定的注释,应该是不难理解的~

那么就是这样!接下来进入课设总结部分~