bear_hug: juegos en arte ASCII en Python3.6 +



Para mis juegos en el arte ASCII, escribí la biblioteca bear_hug con una cola de eventos, una colección de widgets, soporte ECS y otras pequeñas cosas útiles. En este artículo veremos cómo usarlo para hacer un juego de trabajo mínimo.

Renuncias
  • Soy el único desarrollador de la biblioteca, por lo que puedo ser parcial.
  • bear_hug es esencialmente una envoltura alrededor de bearlibterminal , por lo que no habrá operaciones de nivel relativamente bajo con glifos.
  • Hay una funcionalidad similar en clubsandwich , pero no la usé y no puedo compararla.

Bajo el capó de bear_hug está bearlibterminal , una biblioteca SDL para crear una ventana de pseudo-consola. Es decir, en TTY puro, como algunos ncurses, no funcionará. Pero la imagen es la misma en Linux, en Windows, y no depende de la configuración del terminal de usuario. Esto es importante, especialmente para los juegos, porque cuando cambias la fuente ASCII-art, Dios puede convertirse en lo que:


El mismo dibujo en su forma original y después de copiar y pegar en diferentes programas.

Por supuesto, la biblioteca fue escrita para proyectos relativamente a gran escala. Pero para no distraerse con el diseño y la arquitectura del juego, en este artículo crearemos algo simple. Un proyecto de una noche, en el que hay algo para mostrar las funciones básicas de la biblioteca. A saber: un clon simplificado de esos mismos tanques con Dandy (también son Battle City). Habrá un tanque de jugador, tanques enemigos, muros destruibles, sonido y puntuación. Pero el menú principal, los niveles y las bonificaciones seleccionadas no lo serán. No porque sea imposible agregarlos, sino porque este proyecto no es más que un Halloworld.


Habrá un gameover, pero no habrá victoria. Porque la vida es dolor.

Todo el material utilizado en el artículo está en el github ; la biblioteca en sí también está en PyPI (bajo la licencia MIT).

En primer lugar, necesitamos activos. Para dibujar arte ASCII, utilizo REXpaint de Josh Ge (también conocido como Kyzrati), el desarrollador del bagel de ciencia ficción Cogmind . El editor es gratuito, aunque no de código abierto; La versión oficial es solo para Windows, pero todo funciona bien con Wine. La interfaz es bastante clara y conveniente:



Guardamos en el formato binario local .xp y copiamos desde /path/to/rexpaint/images a la carpeta con el juego futuro. En principio, también se admite cargar imágenes desde archivos .txt, pero obviamente es imposible guardar los colores de los caracteres individuales en un archivo de texto. Sí, y editar el arte ASCII en un cuaderno no es conveniente para mí personalmente. Para no codificar las coordenadas y el tamaño de cada elemento, estos datos se almacenan en un archivo JSON separado:

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


Sonidos bajo licencias gratuitas de descarga de Internet. Hasta ahora, solo se admite .wav. Eso es todo con activos, puede comenzar a codificar. En primer lugar, debe inicializar el terminal y la cola de eventos.

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


El terminal es la ventana real del juego. Puede colocar widgets en él, y arroja eventos de entrada según sea necesario. Como teclas al crear un terminal, puede usar todas las opciones del terminal bearlibterminal ; en este caso, establecemos la fuente, el tamaño de la ventana (en caracteres), el título de la ventana y los métodos de entrada que nos interesan.

En cuanto a la cola de eventos, tiene una interfaz muy simple: dispatcher.add_event (event) agrega el evento a la cola, y dispatcher.register_listener (listener, event_types) le permite suscribirse. Un firmante (por ejemplo, un widget o componente) debe tener una devolución de llamada on_event, que toma un evento como un argumento único y no devuelve nada o devuelve otro evento o conjunto de eventos. El evento en sí consiste en tipo y valor; el tipo aquí no es en el sentido de str o int, sino en el sentido de "variedad", por ejemplo, 'key_down' o 'tick'. La cola solo acepta eventos de tipos conocidos (integrados o creados por el usuario) y los envía a on_event a todos aquellos que se suscriban a este tipo. No verifica los valores de ninguna manera, pero existen convenciones dentro de la biblioteca sobre qué es un valor válido para cada tipo de evento.

Primero, ponemos en cola a un par de oyentes. Esta es la clase base para objetos que pueden suscribirse a eventos, pero no son widgets o componentes. En principio, no es necesario usarlo, siempre que el firmante tenga el método on_event.

Oyentes
 #       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') 


Una lista completa de los tipos de eventos integrados se encuentra en la documentación . Es fácil ver que hay eventos para la creación y destrucción de entidades, pero no para daños. Dado que tendremos objetos que no se separan de un solo disparo (las paredes y el tanque del jugador), lo crearemos:

Registro de tipo de evento
 #      ,    #   dispatcher.register_event_type('ac_damage') #       logger = LoggingListener(sys.stderr) dispatcher.register_listener(logger, ['ac_damage', 'play_sound']) 


Aceptamos que, como valor, este evento tendrá una tupla de la identificación de la entidad que sufrió el daño y el valor del daño. LoggingListener es solo una herramienta de depuración que imprime todos los eventos recibidos donde sea que digan, en este caso en stderr. En este caso, quería asegurarme de que el daño pasara correctamente y que el sonido se solicita siempre cuando debería.

Con Listeners por ahora, puede agregar el primer widget. Tenemos este campo de juego de clase ECSLayout. Este es un diseño de este tipo, que puede colocar widgets en entidades y moverlos en respuesta a eventos ecs_move, y al mismo tiempo considera colisiones. Como la mayoría de los widgets, tiene dos argumentos obligatorios: una lista anidada de caracteres (posiblemente vacía, un espacio o Ninguno) y una lista anidada de colores para cada carácter. Los colores con nombre se aceptan como colores, RGB en el formato `0xAARRGGBB` (o` 0xARGB`, `0xRGB`,` 0xRRGGBB`) y en el formato '#fff'. Los tamaños de ambas listas deben coincidir; de lo contrario, se lanza una excepción.

Primer widget
 #   .  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') 


Como ahora tenemos en qué colocar los objetos del juego, podemos comenzar a crear entidades. Todo el código de entidades y componentes se mueve a un archivo separado. El más simple de ellos es un muro de ladrillo destructible. Ella sabe cómo estar en un lugar determinado, mostrar su widget, servir como objeto de colisión y recibir daños. Después de un daño suficiente, la pared desaparece.

entidades.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))) 


En primer lugar, se crea el objeto de entidad en sí. Contiene solo un nombre (que debe ser único) y un conjunto de componentes. Se pueden transferir de una vez al momento de la creación o, como aquí, agregarse uno a la vez. Luego se crean todos los componentes necesarios. Como widget, se usa SwitchWidget, que contiene varios dibujos del mismo tamaño y puede cambiarlos por comando. Por cierto, los dibujos se cargan desde el atlas al crear un widget. Y, finalmente, el anuncio de la creación de la entidad y el orden de dibujarlo en las coordenadas necesarias entran en la cola.

De los componentes no integrados, solo hay salud. Creé el componente base "Componente de salud" y heredé de él el "widget de cambio del componente de salud" (para mostrar el muro intacto y en varias etapas de destrucción).

clase 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}) 


Al crear un componente, la clave 'nombre' se pasa a super () .__ init__. Cuando se agrega un componente a una entidad, bajo el nombre de esta clave se agregará al __dict__ de la entidad y se puede acceder a través de entity_object.health. Además de la conveniencia de la interfaz, este enfoque es bueno porque prohíbe la emisión de entidades de varios componentes homogéneos. Y el hecho de que esté codificado dentro del componente no le permite insertar por error, por ejemplo, el WidgetComponent en la ranura del componente de mantenimiento. Inmediatamente después de la creación, el componente se suscribe a las clases de eventos que le interesan, en este caso ac_damage. Después de recibir dicho evento, el método on_event verificará si se trata de su propietario durante una hora. Si es así, restará el valor deseado de los puntos de golpe y extraerá la devolución de llamada para cambiar la salud, la clase base es abstracta. También existe el método __repr__, que se utiliza para la serialización en JSON (por ejemplo, para guardar). No es necesario agregarlo, pero todos los componentes integrados y la mayoría de los widgets incorporados lo tienen.

La herencia del componente de salud subyacente de VisualDamageHealthComponent anula la devolución de llamada en el cambio de salud:

clase 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]) 


Mientras la salud está por encima de 0, le pide al componente responsable del widget que dibuje el muro en el estado deseado. Aquí, la llamada descrita anteriormente se usa a través del atributo del objeto de entidad. Una vez que los puntos de vida hayan terminado, el componente responsable de la destrucción correcta de la entidad y todos los componentes se llamarán de la misma manera.

Para otras entidades, todo es similar, solo el conjunto de componentes es diferente. Los tanques se agregan con controladores (entrada para el jugador, IA para oponentes) y widgets giratorios, para proyectiles, un componente de colisión que causa daño a los que golpean. No analizaré cada uno de ellos, porque es voluminoso y bastante trivial; mira solo el colisionador de proyectiles. Tiene un método collided_into, llamado cuando la entidad anfitriona se estrelló contra algo:

colisionador de componentes de bala
 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() 


Para asegurarse de que es realmente posible obtener una víctima (que puede ser incorrecta para, por ejemplo, elementos de fondo), el proyectil usa EntityTracker (). Este es un singleton que rastrea todas las entidades creadas y destruidas; a través de él, puede obtener un objeto de entidad por nombre y hacer algo con sus componentes. En este caso, se verifica que entity.collision (el controlador de colisión de la víctima) existe en absoluto.

Ahora en el archivo principal del juego, simplemente llamamos a todas las funciones necesarias para crear entidades:

Volver a 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) 


Los contadores de puntos y puntos de golpe no son entidades y no están en el campo de batalla. Por lo tanto, no se agregan a ECSLayout, sino directamente a la terminal a la derecha del mapa. Los widgets relevantes heredan de Label (widget de salida de texto) y tienen un método on_event para descubrir qué les interesa. A diferencia de Layout, el terminal no actualiza automáticamente los widgets en cada tic, así que después de cambiar el texto, los widgets le dicen que haga esto:

oyentes.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) 


El generador enemigo y el objeto responsable de la salida de "GAME OVER" no se muestran en absoluto, por lo que heredan de Listener. El principio es el mismo: los objetos escuchan la cola, esperan el momento adecuado y luego crean una entidad o widget.

gameover
 #    - . . . 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) 


Ahora hemos creado todo lo que necesitamos y podemos comenzar el juego.

Lanzamos
 #      ,   Listeners    terminal.start() terminal.add_widget(layout) terminal.add_widget(score, pos=(85, 10)) terminal.add_widget(hp, pos=(85, 15)) 


Los widgets se agregan a la pantalla solo después de que comienza. Las entidades podrían agregarse al mapa anteriormente: los eventos de creación (en los que se almacena toda la entidad, incluido el widget) simplemente se acumulan en la cola y se resuelven en el primer tic. Pero el terminal puede agregar widgets solo después de que se haya creado una ventana con éxito.

En este punto, tenemos un prototipo que funciona, puede emitir en Early Access por veinte dólares agregar funciones y pulir el juego. Pero esto ya está más allá del alcance de Halloworld, y de ahí el artículo. Solo agregaré que la compilación independiente del sistema python se puede construir usando pyinstaller .

Source: https://habr.com/ru/post/470950/


All Articles