bear_hug:Python3.6中的ASCII艺术游戏+



对于我的ASCII艺术游戏,我编写了bear_hug库,其中包含事件队列,小部件集合,ECS支持以及其他有用的小东西。 在本文中,我们将看到如何使用它来制作最小的游戏。

免责声明
  • 我是该库的唯一开发人员,因此我可能会有所偏见。
  • bear_hug本质上是绕着bearlibterminal的包装器,因此不会有相对低级的带有字形的操作。
  • clubsandwich也有类似的功能,但是我没有使用它,也无法比较。

在bear_hug的背后是bearlibterminal ,这是一个SDL库,用于创建伪控制台窗口。 也就是说,在纯TTY中,像某些ncurses一样,它将不起作用。 但是,随后的情况在Linux和Windows上是相同的,并且与用户终端的设置无关。 这很重要,尤其是对于游戏而言,因为当您更改ASCII-art字体时,上帝可能会变成:


原始图形的相同图形,复制后粘贴到不同的程序中

当然,该库是为较大规模的项目编写的。 但是为了不被游戏设计和体系结构所干扰,在本文中,我们将创建一些简单的东西。 一个为期一夜的项目,其中包含一些内容来展示库的基本功能。 即-与Dandy(它们也是Battle City)相同的坦克的简化克隆。 会有玩家的坦克,敌方坦克,可破坏的墙壁,声音和计分。 但是主菜单,级别和选定的奖金都不会。 不是因为不可能添加它们,而是因为这个项目不过是一个Halloworld。


会有一场比赛,但不会有胜利。 因为生活是痛苦。

本文中使用的所有材料都在github上 ; 该库本身也在 PyPI上 (根据MIT许可)。

首先,我们需要资产。 为了绘制ASCII艺术,我使用科幻百吉饼Cogmind的开发商Josh Ge(又名Kyzrati)的REXpaint 。 编辑器是免费的,尽管不是开源的。 官方版本仅适用于Windows,但在wine下一切正常。 界面非常清晰方便:



我们以本地二进制格式.xp保存并从/path/to/rexpaint/images复制到将来的游戏文件夹中。 原则上也支持从.txt文件加载图像,但是显然不可能将单个字符的颜色保存在文本文件中。 是的,在我自己的笔记本上编辑ASCII艺术作品并不方便。 为了不对每个元素的坐标和大小进行硬编码,此数据存储在单独的JSON文件中:

Battlecity.json
 [ { "name": "player_r", "x": 1, "y": 2, "xsize": 6, "ysize": 6 }, { "name": "player_l", "x": 1, "y": 8, "xsize": 6, "ysize": 6 }, ... ] 


可从Internet下载免费许可证下的声音。 到目前为止,仅支持.wav。 这就是资产,您可以开始编码。 首先,您需要初始化终端和事件队列。

game.py
 #  terminal = BearTerminal(font_path='cp437_12x12.png', size='91x60', title='AsciiCity', filter=['keyboard', 'mouse']) #   dispatcher = BearEventDispatcher() # ,         loop = BearLoop(terminal, dispatcher) 


终端是游戏的实际窗口。 您可以在其上放置小部件,并在必要时引发输入事件 。 作为创建终端时的键,可以使用所有终端选项bearlibterminal ; 在这种情况下,我们设置字体,窗口大小(以字符为单位),窗口标题和我们感兴趣的输入法。

至于事件队列,它有一个非常简单的界面:dispatcher.add_event(事件)将事件添加到队列中,而dispatcher.register_listener(listener,event_types)允许您订阅它。 签名者(例如,小部件或组件)必须具有on_event回调,该回调将事件作为单个参数,并且不返回任何内容或返回另一个事件或一组事件。 事件本身由类型和值组成。 这里的类型不是在str或int的意义上,而是在“ variety”的意义上,例如“ key_down”或“ tick”。 队列仅接受其已知类型的事件(内置或由用户创建),并将所有订阅此类型的事件发送给on_event。 它不会以任何方式检查值,但是库中存在关于每种事件的有效值是什么的约定。

首先,我们将几个侦听器排队。 这是可以预订事件但不是窗口小部件或组件的对象的基类。 原则上,只要签名者具有on_event方法,就不必使用它。

听众
 #       dispatcher.register_listener(ClosingListener(), ['misc_input', 'tick']) #       dispatcher.register_listener(EntityTracker(), ['ecs_create', 'ecs_destroy']) #   jukebox = SoundListener({'shot': 'shot.wav', 'explosion': 'explosion.wav'}) # https://freesound.org/people/EMSIarma/sounds/108852/ # https://freesound.org/people/FlashTrauma/sounds/398283/ dispatcher.register_listener(jukebox, 'play_sound') 


内置事件类型的完整列表在文档中 。 不难发现,有一些事件创造和破坏实体,但没有破坏。 由于我们拥有的物体不会离一杆射击(玩家的墙壁和坦克)分开,因此我们将创建它:

活动类型注册
 #      ,    #   dispatcher.register_event_type('ac_damage') #       logger = LoggingListener(sys.stderr) dispatcher.register_listener(logger, ['ac_damage', 'play_sound']) 


我们同意,作为一个值,此事件将包含遭受损坏的实体的ID和损坏值的元组。 LoggingListener只是一个调试工具,可以打印所有收到的事件,无论它们说的是什么,在本例中都是stderr。 在这种情况下,我想确保损坏能够正确通过,并且始终在需要的时候发出声音。

现在,使用侦听器,可以添加第一个小部件。 我们有这个ECSLayout类的竞争环境。 这样的布局可以将小部件放置在实体上并响应ecs_move事件移动它们,同时考虑冲突。 像大多数小部件一样,它具有两个必需的参数:嵌套的字符列表(可能为空-空格或无)和每个字符的颜色嵌套列表。 可接受使用命名颜色作为颜色,RGB格式为'0xAARRGGBB'(或'0xARGB`,'0xRGB`,'0xRRGGBB')和格式为#fff。 两个列表的大小必须匹配; 否则,将引发异常。

第一个小部件
 #   .  8460,   9160. # 7         chars = [[' ' for x in range(84)] for y in range(60)] colors = copy_shape(chars, 'gray') layout = ECSLayout(chars, colors) # 'all' -  ,      dispatcher.register_listener(layout, 'all') 


由于现在有了放置游戏对象的内容,因此可以开始创建实体。 实体和组件的所有代码都移到单独的文件中。 其中最简单的是可破坏的砖墙。 她知道如何在某个地方摆放自己的小部件,充当碰撞对象并受到伤害。 经过足够的破坏后,墙消失了。

Entitys.py
 def create_wall(dispatcher, atlas, entity_id, x, y): #   wall = Entity(entity_id) #  wall.add_component(PositionComponent(dispatcher, x, y)) wall.add_component(CollisionComponent(dispatcher)) wall.add_component(PassingComponent(dispatcher)) wall.add_component(DestructorComponent(dispatcher)) #     ,      #    -  /   images_dict = {'wall_3': atlas.get_element('wall_3'), 'wall_2': atlas.get_element('wall_2'), 'wall_1': atlas.get_element('wall_1')} wall.add_component(SwitchWidgetComponent(dispatcher, SwitchingWidget(images_dict=images_dict, initial_image='wall_3'))) wall.add_component(VisualDamageHealthComponent(dispatcher, hitpoints=3, widgets_dict={3: 'wall_3', 2: 'wall_2', 1: 'wall_1'})) #     dispatcher.add_event(BearEvent('ecs_create', wall)) dispatcher.add_event(BearEvent('ecs_add', (wall.id, wall.position.x, wall.position.y))) 


首先,创建实体对象本身。 它仅包含一个名称(必须唯一)和一组组件。 它们既可以在创建时立即全部转移,也可以一次添加一次。 然后创建所有必要的组件。 作为一个小部件,使用了SwitchWidget,它包含多个相同大小的图形,并且可以通过命令对其进行更改。 顺便说一句,在创建窗口小部件时会从地图集中加载图形。 最后,有关创建实体的公告以及在必要的坐标上绘制实体的命令进入了队列。

在非内置组件中,只有健康。 我创建了“健康组件”基类,并从中继承了“健康组件更改小部件”(以显示完好无损的墙并处于破坏的多个阶段)。

类HealthComponent
 class HealthComponent(Component): def __init__(self, *args, hitpoints=3, **kwargs): super().__init__(*args, name='health', **kwargs) self.dispatcher.register_listener(self, 'ac_damage') self._hitpoints = hitpoints def on_event(self, event): if event.event_type == 'ac_damage' and event.event_value[0] == self.owner.id: self.hitpoints -= event.event_value[1] @property def hitpoints(self): return self._hitpoints @hitpoints.setter def hitpoints(self, value): if not isinstance(value, int): raise BearECSException( f'Attempting to set hitpoints of {self.owner.id} to non-integer {value}') self._hitpoints = value if self._hitpoints < 0: self._hitpoints = 0 self.process_hitpoint_update() def process_hitpoint_update(self): """ Should be overridden by child classes. """ raise NotImplementedError('HP update processing should be overridden') def __repr__(self): #           return dumps({'class': self.__class__.__name__, 'hitpoints': self.hitpoints}) 


创建组件时,“名称”键将传递给super().__ init__。 将组件添加到实体时,将以该键的名称将其添加到实体的__dict__中,并且可以通过entity_object.health对其进行访问。 除了方便界面之外,此方法还不错,因为它禁止发布多个同类组件的实体。 而且它是在组件内部进行硬编码的事实,不允许您错误地将WidgetComponent插入到运行状况组件的插槽中。 创建后,该组件立即订阅与其相关的事件类,在本例中为ac_damage。 收到此类事件后,on_event方法将检查一个小时是否与其所有者有关。 如果是这样,他将从命中点中减去所需的值并拉回回调以更改运行状况,基类是抽象的。 还有__repr__方法,该方法用于JSON中的序列化 (例如,用于保存)。 不需要添加它,但是所有内置组件和大多数内置小部件都具有它。

从VisualDamageHealthComponent的基础运行状况组件继承将覆盖运行状况更改的回调:

类VisualDamageHealthComponent
 class VisualDamageHealthComponent(HealthComponent): """       .    HP=0 """ def __init__(self, *args, widgets_dict={}, **kwargs): super().__init__(*args, **kwargs) self.widgets_dict = OrderedDict() for x in sorted(widgets_dict.keys()): self.widgets_dict[int(x)] = widgets_dict[x] def process_hitpoint_update(self): if self.hitpoints == 0 and hasattr(self.owner, 'destructor'): self.owner.destructor.destroy() for x in self.widgets_dict: if self.hitpoints >= x: self.owner.widget.switch_to_image(self.widgets_dict[x]) 


当健康状况大于0时,他要求负责小部件的组件以所需状态绘制墙。 在此,上述调用通过实体对象的属性使用。 一旦生命周期结束,负责正确破坏实体的组件和所有组件将以相同的方式调用。

对于其他实体,一切都相似,只是组件集不同。 坦克被添加了控制器(玩家的输入,AI是对手的输入)和旋转的小部件(用于炮弹)-一种碰撞组件,会对被击中的人造成伤害。 我不会分析它们中的每一个,因为它们笨重且相当琐碎。 只看射弹对撞机。 它有一个collided_into方法,当主机实体崩溃时会调用该方法:

子弹对撞机
 def collided_into(self, entity): if not entity: self.owner.destructor.destroy() elif hasattr(EntityTracker().entities[entity], 'collision'): self.dispatcher.add_event(BearEvent(event_type='ac_damage', event_value=( entity, self.damage))) self.owner.destructor.destroy() 


为了确保确实有可能成为受害者(例如背景元素可能是错误的),射弹使用EntityTracker()。 这是一个跟踪所有已创建和已销毁实体的单例; 通过它,您可以按名称获取实体对象,并对其组件进行操作。 在这种情况下,可以验证entity.collision(受害者冲突处理程序)是否存在。

现在,在游戏的主文件中,我们只需调用创建实体所需的所有功能:

回到game.py
 #  , -     atlas = Atlas(XpLoader('battlecity.xp'), 'battlecity.json') #   create_player_tank(dispatcher, atlas, 30, 50) #    wall_array = [[0 for _ in range(14)], [0 for _ in range(14)], [1, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0], [1, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0], [1, 1, 1, 0, 1, 0, 1, 0, 0, 1, 1, 0, 1, 1], [1, 0, 1, 0, 1, 1, 1, 0, 0, 1, 1, 0, 1, 0], [1, 0, 1, 0, 1, 0, 1, 0, 0, 1, 1, 0, 1, 0], [0 for _ in range(14)], [0 for _ in range(14)], [0 for _ in range(14)] ] for y in range(10): for x in range(14): if wall_array[y][x] == 1: create_wall(dispatcher, atlas, f'wall{x}{y}', x*6, y*6) #   -  .     . create_spawner_house(dispatcher, atlas, 35, 0) 


点数和命中点计数器不是实体,也不在战场上。 因此,它们不会添加到ECSLayout,而是直接添加到地图右侧的终端。 相关的窗口小部件继承自Label(文本输出窗口小部件),并具有on_event方法来查找它们感兴趣的内容。 与Layout不同,终端不会在每个刻度上自动更新小部件,因此在更改文本后,小部件会告诉他这样做:

listeners.py
 class ScoreLabel(Label): """   """ def __init__(self, *args, **kwargs): super().__init__(text='Score:\n0') self.score = 0 def on_event(self, event): if event.event_type == 'ecs_destroy' and 'enemy' in event.event_value and 'bullet' not in event.event_value: #    self.score += 10 self.text = f'Score:\n{self.score}' self.terminal.update_widget(self) class HPLabel(Label): """   """ def __init__(self, *args, **kwargs): super().__init__(text='HP:\n5') self.hp = 5 def on_event(self, event): if event.event_type == 'ac_damage' and event.event_value[0] == 'player': self.hp -= event.event_value[1] self.text = f'HP:\n{self.hp}' self.terminal.update_widget(self) 


根本不显示敌人生成器和负责“ GAME OVER”输出的对象,因此它们从侦听器继承。 原理是相同的:对象侦听队列,等待正确的时间,然后创建实体或小部件。

游戏结束
 #    - . . . class GameOverListener(Listener): """       """ def __init__(self, *args, widget=None, **kwargs): print (args) super().__init__(*args, *kwargs) self.widget = widget def on_event(self, event): if event.event_type == 'ecs_destroy' and event.event_value == 'player': self.terminal.add_widget(self.widget, pos=(20, 20), layer=5) 


现在,我们已经创建了所需的一切,然后就可以开始游戏了。

我们启动
 #      ,   Listeners    terminal.start() terminal.add_widget(layout) terminal.add_widget(score, pos=(85, 10)) terminal.add_widget(hp, pos=(85, 15)) 


窗口小部件仅在启动后才添加到屏幕。 实体可以在添加到地图之前-创建事件(存储整个实体(包括小部件)的事件)简单地累积在队列中,并在第一个刻度时解决。 但是终端只有在成功为其创建窗口之后才能添加小部件。

至此,我们有了一个可行的原型,您可以在抢先体验中发行二十美元以增加功能并完善游戏玩法。 但是,这已经超出了“幻想世界”的范围,因此也超出了本文的范围。 我只会补充说,可以使用pyinstaller构建独立于系统python的构建。

Source: https://habr.com/ru/post/zh-CN470950/


All Articles