跳转至

Topic 4.3 - 地图类的设计与实现

地图类是我们这个游戏的核心,当中很多重要的逻辑都在这里实现。我们先来回顾一下地图类的设计思路:

  • 首先,地图类会加载地图文件,并将地图内容存储在一个二维列表中

    • 例如,如果我们的地图中的两行是 #P..S..^...##^^^..~..H.#,那么存储在二维列表中的形式就是:
    [   
        ['#', 'P', '.', '.', 'S', '.', '.', '^', '.', '.', '.', '#'],
        ['#', '^', '^', '^', '.', '.', '~', '.', '.', 'H', '.', '#']
    ]
    
    • 并且,我们使用一个二元组 (x, y) 来表示地图上的位置坐标,x 表示列索引,y 表示行索引,比方说上面例子中:

      • 玩家 P 的位置就是 (1, 0),在二维列表中的索引是 [0][1]

      • S 的位置是 (4, 0),在二维列表中的索引是 [0][4]

      • 生命药水 H 的位置是 (9, 1),在二维列表中的索引是 [1][9]

  • 其次,地图还负责玩家移动

    • 比方说,玩家当前的位置是 (1, 0),玩家输入向右移动,那么新的位置就是 (2, 0)
    • 玩家在到了新的位置 (2, 0) 后,如果该位置是空地 .,那么玩家就可以成功移动过去,此时 (2, 0) 应该是 P,而 (1, 0) 变成空地 .
    • 如果新位置 (2, 0) 是障碍物 #^~,那么玩家就无法移动过去,玩家位置不变
    • 如果新位置 (2, 0) 是怪物 M,那么就触发战斗逻辑

      • 如果玩家获胜,那么 (2, 0) 位置就应该是玩家 P,而 (1, 0) 变成空地 .,怪物 M 就从地图上移除了
      • 如果玩家失败,游戏结束
    • 如果新位置 (2, 0) 是道具 SDH,那么玩家就拾取道具

      • 拾取道具后,玩家位置变成 (2, 0)P,而 (1, 0) 变成空地 .,原来的道具 SDH 就从地图上移除了
      • 并且道具的效果会应用到玩家身上

1. 地图的加载与显示

首先,我们可以实现的一个简单功能是读取地图文件,并将地图内容存储在一个二维列表中:

  • 在读取地图文件时,我们其实没必要一次性整个读取,因为我们最终还是要把每一行拆成字符列表存储在二维列表中,所以我们可以逐行读取文件内容,并将每一行转换成字符列表后追加到 self.map
  • 例如,如果我们的地图中的一行是 #P..S..^...# ,那么存储在二维列表中的形式就是 ['#', 'P', '.', '.', 'S', '.', '.', '^', '.', '.', '.', '#'],如果是这样,我们可以直接使用 list(line) 来将字符串转换成字符列表
  • 也就是说,self.map 其实就是我们之前讲过的二维列表:
[
    ['#', 'P', '.', '.', 'S', '.', '.', '^', '.', '.', '.', '#'],
    ['#', '^', '^', '^', '.', '.', '~', '.', '.', 'H', '.', '#']
]

除此之外,游戏中还有个重要的功能,就是地图的显示:

  • 也就是如何把二维列表中的内容再变回字符串形式并打印出来
  • 这个其实很简单,我们把一行的字符列表使用 "".join(row) 拼接成字符串,然后打印出来就行了

地图的读取与显示这部分,我们的代码实现如下:

# src/world.py
from characters import Player, Monster
from items import Sword, Shield, Potion
from battle import fight

class World:

    def __init__(self, map_file):
        self.map = []

    def map_load(self, file_path):
        with open(file_path, 'r', encoding='utf-8') as f:
            for line in f:
                # 行尾的换行符要去掉
                line = line.rstrip('\n')
                self.map.append(list(line))

    def map_show(self):
        for row in self.map:
            print("".join(row))

2. 地图解析

接下来,我们需要解析地图中的各种元素:

  • 怪物 M:我们需要找到所有怪物的位置,并为每个怪物创建一个 Monster 对象,存储在一个字典中,键是位置 (x, y),值是 Monster 对象,例如:
self.monsters = {
    (3, 4): Monster(),
    (7, 2): Monster(),
}
  • 道具 SDH:我们需要找到所有道具的位置,并为每个道具创建相应的道具对象,存储在一个字典中,键是位置 (x, y),值是道具对象,例如:
self.items = {
    (5, 1): Sword(),
    (8, 3): Shield(),
    (2, 6): Potion(),
}
  • 玩家 P:这个是稍微特殊一点的,因为玩家只有一个,而且玩家的位置是会变的:

    • 如果像上面的怪物和道具那样存储在字典中,反而不方便我们后续的移动操作,因为字典的键是不可变的,我们没法直接修改键来更新玩家位置
    • 因此,我们直接使用一个二元组 self.player_pos 来存储玩家的位置 (x, y) 即可
    • 所以这里,我们需要两个属性变量,self.player 用来存储玩家对象,self.player_pos 用来存储玩家位置

综上所述,我们实现一个 find_elements 方法,遍历地图中的每个位置,根据字符创建相应的对象并存储到对应的属性变量中:

  • 这里,我们使用 enumerate 函数来获取每个位置的坐标 (x, y) 和字符 symbol,这么做的好处是,又可以获得索引,又可以获得字符
  • 之后,我们根据字符的不同,创建不同的对象,玩家就直接创建并存储位置,怪物和道具则存储在各自的字典中

这段代码实现如下:

# src/world.py
from characters import Player, Monster
from items import Sword, Shield, Potion
from battle import fight

class World:

    def __init__(self, map_file):
        self.map = []
        self.player = Player()
        self.player_pos = None
        self.monsters = {}
        self.items = {}

    def map_load(self, file_path):
        with open(file_path, 'r', encoding='utf-8') as f:
            for line in f:
                # 行尾的换行符要去掉
                line = line.rstrip('\n')
                self.map.append(list(line))

    def map_show(self):
        for row in self.map:
            print("".join(row))

    def find_elements(self):
        for y, row in enumerate(self.map):
            for x, symbol in enumerate(row):
                if symbol == 'P':
                    self.player_pos = (x, y)
                elif symbol == 'M':
                    self.monsters[(x, y)] = Monster()
                elif symbol == 'S':
                    self.items[(x, y)] = Sword()
                elif symbol == 'D':
                    self.items[(x, y)] = Shield()
                elif symbol == 'H':
                    self.items[(x, y)] = Potion()

3. 玩家移动与交互

这个功能是地图类中最复杂也是最重要的的部分,因为它涉及到很多逻辑判断:

  • 首先,这个功能最重要的功能就是要移动玩家的位置,我们这里定义一个通用的函数,给定玩家当前位置 (x, y) 和移动增量 (dx, dy),计算出新的位置 (nx, ny)

    • 玩家当前位置 (x, y) 通过 self.player_pos 获取即可

    • 移动的增量 dxdy 分别表示玩家在 x 轴和 y 轴上的移动增量:

      • 如果玩家想向左移动一格,那么 dx=-1dy=0
      • 如果玩家想向右移动一格,那么 dx=1dy=0
      • 如果玩家想向上移动一格,那么 dx=0dy=-1
      • 如果玩家想向下移动一格,那么 dx=0dy=1
      • 注意,这里的坐标系是以左上角为原点,x 轴向右递增,y 轴向下递增
    • 新的位置由 (nx, ny) = (x + dx, y + dy) 计算得到

  • 接着,在移动到新位置后,我们需要根据新位置的内容进行不同的处理:

    • 如果新位置是障碍物 #^~,那么玩家就无法移动过去,玩家位置不变,这时直接用 return 终止移动的方法即可

    • 如果新位置是怪物 M,那么就触发战斗逻辑:

      • 调用 fight(self.player, monster) 函数进行战斗
      • 如果玩家获胜,那么更新玩家位置为新位置,并且从地图上移除怪物
      • 如果玩家失败,游戏结束,同样,使用 return 终止移动的方法即可
    • 如果新位置是道具 SDH,那么玩家就拾取道具:

      • 调用道具的 apply(self.player) 方法应用道具效果
      • 更新玩家位置为新位置,并且从地图上移除道具
  • 在以上所有情况下,只要函数没有被 return 终止,那么最后一步就是更新玩家位置:

    • 把旧位置设置为空地 .,把新位置设置为玩家 P
    • 更新 self.player_pos 为新位置 (nx, ny)

整个移动与交互的逻辑我们就梳理清楚了,那么完整的代码实现起来就很清晰了,如下所示:

# src/world.py
def move_player(self, dx, dy):
    x, y = self.player_pos
    nx, ny = x + dx, y + dy

    # 障碍物检测:# 边界墙,^ 山,~ 水
    if self.map[ny][nx] in {'#', '^', '~'}:
        block = self.map[ny][nx]
        if block == '#':
            print("前方是边界墙,无法通过。")
        elif block == '^':
            print("前方是高山,无法通过。")
        else:  # '~'
            print("前方是水域,无法通过。")
        return

    # 怪物:踩到触发战斗
    if (nx, ny) in self.monsters:
        monster = self.monsters[(nx, ny)]
        win = fight(self.player, monster)
        # 如果玩家赢了
        if win:
            print("怪物被击败!")
            del self.monsters[(nx, ny)]
        # 如果玩家输了
        else:
            print("你被怪物击败了……")
            return

    # 道具:踩到自动使用
    if (nx, ny) in self.items:
        item = self.items[(nx, ny)]
        item.apply(self.player)
        if isinstance(item, Potion):
            print(f"你捡到了生命药水,生命值为: {self.player.hp} 点!")
        elif isinstance(item, Sword):
            print(f"你捡到了剑,攻击力为: {self.player.attack} 点!")
        elif isinstance(item, Shield):
            print(f"你捡到了盾牌,防御力为: {self.player.defense} 点!")
        del self.items[(nx, ny)]

    # 移动玩家
    self.map[y][x] = '.'
    self.map[ny][nx] = 'P'
    self.player_pos = (nx, ny)

到此,地图类中最重要的一个功能,玩家的移动与交互,我们就实现完成了。

4. 地图类的测试

地图类的测试,我们使用一种比较新颖的方式:

  • 我们加载一个地图进来,并且模拟用户输入固定的移动命令
  • 在我们模拟的移动路径中,会经过道具和怪物,以测试地图类中的完整功能
  • 并且我们模拟两条线路,一条成功一条失败

我们使用一个新的更简单的地图 data/map/map_tiny.txt

############
#P.S^^^^^^^#
#...D..~~~~#
#H..M..M..M#
############
  • 这个地图比较简单,我们可以看到一条比较清楚的成功路径(先拿剑和盾,打死一个怪之后拿血瓶,之后打剩下两个怪):

  • 同时,我们也可以模拟一条失败路径(直接去三个打怪,没装备和血瓶)

  • 这样,我们只需搭建一个函数 test_world_movement,传入不同的路径即可

根据这个思路,完整的测试代码如下:

# tests/test_world.py
from config_test import *
from world import World
from config import Config
import time

def test_world(commands):

    """
    测试地图:
    ############
    #P.S^^^^^^^#
    #...D..~~~~#
    #H..M..M..M#
    ############
    """

    world = World(f"{Config.PATH_DATA}/map/map_tiny.txt")
    for command in commands:
        world.map_show()
        print(f"\n当前命令:{command}")
        if "左" in command:
            world.move_player(-1, 0)
        elif "右" in command:
            world.move_player(1, 0)
        elif "上" in command:
            world.move_player(0, -1)
        elif "下" in command:
            world.move_player(0, 1)

        if not world.player.is_alive():
            break

        time.sleep(1)

    if world.player.is_alive():

        print("\n玩家获胜,游戏结束。")
    else:
        print("\n玩家战败,测试结束。")

if __name__ == "__main__":

    commands_win = ["右", "右(剑)", "下", "右(盾)", "下(怪)", "左", "左", "左(血)", "右", "右", "右", "右", "右", "右(怪)", "右", "右", "右(怪)"]
    commands_los = ["下", "右", "下", "右", "右(怪)", "右", "右", "右(怪)", "右", "右", "右(怪)"]

    print("===== 测试玩家获胜 =====")
    test_world(commands_win)
    print()
    print("===== 测试玩家失败 =====")
    test_world(commands_los)

我们运行一下,看下测试结果,这里的输出有点多,大家可以耐心查看一下(运行到这里,这个游戏终于有点游戏的样子了):

===== 测试玩家获胜 =====
############
#P.S^^^^^^^#
#...D..~~~~#
#H..M..M..M#
############

当前命令:右
############
#.PS^^^^^^^#
#...D..~~~~#
#H..M..M..M#
############

当前命令:右(剑)
你捡到了剑,攻击力为: 10 点!
############
#..P^^^^^^^#
#...D..~~~~#
#H..M..M..M#
############

当前命令:下
############
#...^^^^^^^#
#..PD..~~~~#
#H..M..M..M#
############

当前命令:右(盾)
你捡到了盾牌,防御力为: 6 点!
############
#...^^^^^^^#
#...P..~~~~#
#H..M..M..M#
############

当前命令:下(怪)

【战斗开始】 玩家 VS 怪物
你的有效攻击:7
怪物的有效攻击:2

--- 第 1 回合 ---
玩家 攻击了 怪物,怪物 剩余血量:13
怪物 攻击了 玩家,玩家 剩余血量:28

--- 第 2 回合 ---
玩家 攻击了 怪物,怪物 剩余血量:6
怪物 攻击了 玩家,玩家 剩余血量:26

--- 第 3 回合 ---
玩家 攻击了 怪物,怪物 剩余血量:0
怪物 被击败了!
怪物被击败!
############
#...^^^^^^^#
#......~~~~#
#H..P..M..M#
############

当前命令:左
############
#...^^^^^^^#
#......~~~~#
#H.P...M..M#
############

当前命令:左
############
#...^^^^^^^#
#......~~~~#
#HP....M..M#
############

当前命令:左(血)
你捡到了生命药水,生命值为: 36 点!
############
#...^^^^^^^#
#......~~~~#
#P.....M..M#
############

当前命令:右
############
#...^^^^^^^#
#......~~~~#
#.P....M..M#
############

当前命令:右
############
#...^^^^^^^#
#......~~~~#
#..P...M..M#
############

当前命令:右
############
#...^^^^^^^#
#......~~~~#
#...P..M..M#
############

当前命令:右
############
#...^^^^^^^#
#......~~~~#
#....P.M..M#
############

当前命令:右
############
#...^^^^^^^#
#......~~~~#
#.....PM..M#
############

当前命令:右(怪)

【战斗开始】 玩家 VS 怪物
你的有效攻击:7
怪物的有效攻击:2

--- 第 1 回合 ---
玩家 攻击了 怪物,怪物 剩余血量:13
怪物 攻击了 玩家,玩家 剩余血量:34

--- 第 2 回合 ---
玩家 攻击了 怪物,怪物 剩余血量:6
怪物 攻击了 玩家,玩家 剩余血量:32

--- 第 3 回合 ---
玩家 攻击了 怪物,怪物 剩余血量:0
怪物 被击败了!
怪物被击败!
############
#...^^^^^^^#
#......~~~~#
#......P..M#
############

当前命令:右
############
#...^^^^^^^#
#......~~~~#
#.......P.M#
############

当前命令:右
############
#...^^^^^^^#
#......~~~~#
#........PM#
############

当前命令:右(怪)

【战斗开始】 玩家 VS 怪物
你的有效攻击:7
怪物的有效攻击:2

--- 第 1 回合 ---
玩家 攻击了 怪物,怪物 剩余血量:13
怪物 攻击了 玩家,玩家 剩余血量:30

--- 第 2 回合 ---
玩家 攻击了 怪物,怪物 剩余血量:6
怪物 攻击了 玩家,玩家 剩余血量:28

--- 第 3 回合 ---
玩家 攻击了 怪物,怪物 剩余血量:0
怪物 被击败了!
怪物被击败!

玩家获胜,游戏结束。

===== 测试玩家失败 =====
############
#P.S^^^^^^^#
#...D..~~~~#
#H..M..M..M#
############

当前命令:下
############
#..S^^^^^^^#
#P..D..~~~~#
#H..M..M..M#
############

当前命令:右
############
#..S^^^^^^^#
#.P.D..~~~~#
#H..M..M..M#
############

当前命令:下
############
#..S^^^^^^^#
#...D..~~~~#
#HP.M..M..M#
############

当前命令:右
############
#..S^^^^^^^#
#...D..~~~~#
#H.PM..M..M#
############

当前命令:右(怪)

【战斗开始】 玩家 VS 怪物
你的有效攻击:4
怪物的有效攻击:5

--- 第 1 回合 ---
玩家 攻击了 怪物,怪物 剩余血量:16
怪物 攻击了 玩家,玩家 剩余血量:25

--- 第 2 回合 ---
玩家 攻击了 怪物,怪物 剩余血量:12
怪物 攻击了 玩家,玩家 剩余血量:20

--- 第 3 回合 ---
玩家 攻击了 怪物,怪物 剩余血量:8
怪物 攻击了 玩家,玩家 剩余血量:15

--- 第 4 回合 ---
玩家 攻击了 怪物,怪物 剩余血量:4
怪物 攻击了 玩家,玩家 剩余血量:10

--- 第 5 回合 ---
玩家 攻击了 怪物,怪物 剩余血量:0
怪物 被击败了!
怪物被击败!
############
#..S^^^^^^^#
#...D..~~~~#
#H..P..M..M#
############

当前命令:右
############
#..S^^^^^^^#
#...D..~~~~#
#H...P.M..M#
############

当前命令:右
############
#..S^^^^^^^#
#...D..~~~~#
#H....PM..M#
############

当前命令:右(怪)

【战斗开始】 玩家 VS 怪物
你的有效攻击:4
怪物的有效攻击:5

--- 第 1 回合 ---
玩家 攻击了 怪物,怪物 剩余血量:16
怪物 攻击了 玩家,玩家 剩余血量:5

--- 第 2 回合 ---
玩家 攻击了 怪物,怪物 剩余血量:12
怪物 攻击了 玩家,玩家 剩余血量:0
玩家 被击败了!
你被怪物击败了……

玩家战败,测试结束。