batgrl
batgrl copied to clipboard
badass terminal graphics library
nurses_2 - Terminal Graphics
A widgetful and async-centric library for creating graphical applications in the terminal.
Create games:
Simulations:
Or entire feature-filled applications:
See it in action: Video Preview.
Read the docs (work in progress)
Getting Started: A Pong Tutorial
Create a new file, pong.py
. Let's just get our app running first:
from nurses_2.app import App
class Pong(App):
async def on_start():
pass
if __name__ == "__main__":
Pong().run()
This should show just a blank terminal. To exit, press escape
. We can create a green play field
by adding a new widget with a background color pair with green background. (ColorPair
s include a foreground color and a background color. In this case, the foreground color is not used.)
from nurses_2.app import App
from nurses_2.colors import GREEN, WHITE, ColorPair
from nurses_2.widgets.widget import Widget
FIELD_HEIGHT = 25
FIELD_WIDTH = 100
WHITE_ON_GREEN = ColorPair.from_colors(WHITE, GREEN)
class Pong(App):
async def on_start(self):
game_field = Widget(
size=(FIELD_HEIGHT, FIELD_WIDTH),
background_color_pair=WHITE_ON_GREEN,
)
self.add_widget(game_field)
if __name__ == "__main__":
Pong().run()
Next, let's add paddles for each player. We can respond to key presses by implementing a widget's on_keypress
method. Player 1's paddle can be moved up and down with w
and s
and Player 2's paddle can be moved up and down with up
and down
:
from nurses_2.app import App
from nurses_2.colors import GREEN, BLUE, WHITE, ColorPair
from nurses_2.widgets.widget import Widget
FIELD_HEIGHT = 25
FIELD_WIDTH = 100
WHITE_ON_GREEN = ColorPair.from_colors(WHITE, GREEN)
PADDLE_HEIGHT = 5
PADDLE_WIDTH = 1
WHITE_ON_BLUE = ColorPair.from_colors(WHITE, BLUE)
class Paddle(Widget):
def __init__(self, player, *args, **kwargs):
super().__init__(*args, **kwargs)
self.player = player
def on_keypress(self, key_press_event):
if self.player == 1:
if key_press_event.key == "w":
self.y -= 1
elif key_press_event.key == "s":
self.y += 1
elif self.player == 2:
if key_press_event.key == "up":
self.y -= 1
elif key_press_event.key == "down":
self.y += 1
if self.y < 0:
self.y = 0
elif self.y > FIELD_HEIGHT - PADDLE_HEIGHT:
self.y = FIELD_HEIGHT - PADDLE_HEIGHT
class Pong(App):
async def on_start(self):
game_field = Widget(
size=(FIELD_HEIGHT, FIELD_WIDTH),
background_color_pair=WHITE_ON_GREEN,
)
vertical_center = FIELD_HEIGHT // 2 - PADDLE_HEIGHT // 2
left_paddle = Paddle(
player=1,
size=(PADDLE_HEIGHT, PADDLE_WIDTH),
pos=(vertical_center, 1),
background_color_pair=WHITE_ON_BLUE,
)
right_paddle = Paddle(
player=2,
size=(PADDLE_HEIGHT, PADDLE_WIDTH),
pos=(vertical_center, FIELD_WIDTH - 2),
background_color_pair=WHITE_ON_BLUE,
)
game_field.add_widgets(left_paddle, right_paddle)
self.add_widget(game_field)
if __name__ == "__main__":
Pong().run()
With size hints and position hints we can easily place a divider in the game field:
...
right_paddle = Paddle(
player=2,
size=(PADDLE_HEIGHT, PADDLE_WIDTH),
pos=(vertical_center, FIELD_WIDTH - 2),
background_color_pair=WHITE_ON_BLUE,
)
divider = Widget(
size=(1, 1),
size_hint=(1.0, None),
pos_hint=(None, .5),
background_color_pair=WHITE_ON_BLUE,
)
game_field.add_widgets(left_paddle, right_paddle, divider)
self.add_widget(game_field)
We should add a ball. We want the ball to bounce off the top and bottom of the play field and off the paddles. To keep the ball moving we can just create an asyncio.Task
:
import asyncio
import random
from nurses_2.app import App
from nurses_2.colors import GREEN, BLUE, WHITE, ColorPair
from nurses_2.widgets.widget import Widget
FIELD_HEIGHT = 25
FIELD_WIDTH = 100
WHITE_ON_GREEN = ColorPair.from_colors(WHITE, GREEN)
PADDLE_HEIGHT = 5
PADDLE_WIDTH = 1
WHITE_ON_BLUE = ColorPair.from_colors(WHITE, BLUE)
class Paddle(Widget):
def __init__(self, player, *args, **kwargs):
super().__init__(*args, **kwargs)
self.player = player
def on_keypress(self, key_press_event):
if self.player == 1:
if key_press_event.key == "w":
self.y -= 1
elif key_press_event.key == "s":
self.y += 1
elif self.player == 2:
if key_press_event.key == "up":
self.y -= 1
elif key_press_event.key == "down":
self.y += 1
if self.y < 0:
self.y = 0
elif self.y > FIELD_HEIGHT - PADDLE_HEIGHT:
self.y = FIELD_HEIGHT - PADDLE_HEIGHT
class Ball(Widget):
def __init__(self, left_paddle, right_paddle, *args, **kwargs):
super().__init__(*args, **kwargs)
self.left_paddle = left_paddle
self.right_paddle = right_paddle
asyncio.create_task(self.update())
def reset(self):
self.y_pos = FIELD_HEIGHT / 2
self.x_pos = FIELD_WIDTH / 2 - 1
self.y_velocity = 0.0
self.x_velocity = 1.0
self.speed = 1.0
def bounce_paddle(self, paddle):
self.x_velocity *= -1
self.x_pos += 2 * self.x_velocity
paddle_middle = (paddle.bottom + paddle.top) // 2
self.y_velocity += (self.y - paddle_middle) / 5 + random.random() / 20
self.speed *= 1.1
def normalize_speed(self):
current_speed = (self.y_velocity**2 + self.x_velocity**2)**.5
if current_speed > self.speed:
self.x_velocity *= self.speed / current_speed
self.y_velocity *= self.speed / current_speed
async def update(self):
self.reset()
while True:
self.y_pos += self.y_velocity
self.x_pos += self.x_velocity
if self.collides_widget(self.left_paddle):
self.bounce_paddle(self.left_paddle)
elif self.collides_widget(self.right_paddle):
self.bounce_paddle(self.right_paddle)
if self.y_pos < 0 or self.y_pos >= FIELD_HEIGHT:
self.y_velocity *= -1
self.y_pos += 2 * self.y_velocity
if self.x_pos < 0 or self.x_pos >= FIELD_WIDTH:
self.reset()
self.normalize_speed()
self.y = int(self.y_pos)
self.x = int(self.x_pos)
await asyncio.sleep(.04)
class Pong(App):
async def on_start(self):
game_field = Widget(
size=(FIELD_HEIGHT, FIELD_WIDTH),
background_color_pair=WHITE_ON_GREEN,
)
vertical_center = FIELD_HEIGHT // 2 - PADDLE_HEIGHT // 2
left_paddle = Paddle(
player=1,
size=(PADDLE_HEIGHT, PADDLE_WIDTH),
pos=(vertical_center, 1),
background_color_pair=WHITE_ON_BLUE,
)
right_paddle = Paddle(
player=2,
size=(PADDLE_HEIGHT, PADDLE_WIDTH),
pos=(vertical_center, FIELD_WIDTH - 2),
background_color_pair=WHITE_ON_BLUE,
)
divider = Widget(
size=(1, 1),
size_hint=(1.0, None),
pos_hint=(None, .5),
background_color_pair=WHITE_ON_BLUE,
)
ball = Ball(
left_paddle,
right_paddle,
size=(1, 2),
background_color_pair=WHITE_ON_BLUE,
)
game_field.add_widgets(left_paddle, right_paddle, divider, ball)
self.add_widget(game_field)
if __name__ == "__main__":
Pong().run()
Finally, let's keep track of the players' score. To place the scores in the correct place, we can use pos_hint
again, but we'll also use the anchor
kwarg to make sure the hint is applied to the center of the widgets and not the default (top-left). We'll also need a TextWidget
which can contain arbitrary text, as normal Widget
s only have at most a single background character:
import asyncio
import random
from nurses_2.app import App
from nurses_2.colors import GREEN, BLUE, WHITE, ColorPair
from nurses_2.widgets.widget import Widget
from nurses_2.widgets.text_widget import TextWidget
FIELD_HEIGHT = 25
FIELD_WIDTH = 100
WHITE_ON_GREEN = ColorPair.from_colors(WHITE, GREEN)
PADDLE_HEIGHT = 5
PADDLE_WIDTH = 1
WHITE_ON_BLUE = ColorPair.from_colors(WHITE, BLUE)
class Paddle(Widget):
def __init__(self, player, *args, **kwargs):
super().__init__(*args, **kwargs)
self.player = player
def on_keypress(self, key_press_event):
if self.player == 1:
if key_press_event.key == "w":
self.y -= 1
elif key_press_event.key == "s":
self.y += 1
elif self.player == 2:
if key_press_event.key == "up":
self.y -= 1
elif key_press_event.key == "down":
self.y += 1
if self.y < 0:
self.y = 0
elif self.y > FIELD_HEIGHT - PADDLE_HEIGHT:
self.y = FIELD_HEIGHT - PADDLE_HEIGHT
class Ball(Widget):
def __init__(self, left_paddle, right_paddle, left_label, right_label, *args, **kwargs):
super().__init__(*args, **kwargs)
self.left_paddle = left_paddle
self.right_paddle = right_paddle
self.left_label = left_label
self.right_label = right_label
self.left_score = self.right_score = 0
asyncio.create_task(self.update())
def reset(self):
self.y_pos = FIELD_HEIGHT / 2
self.x_pos = FIELD_WIDTH / 2 - 1
self.y_velocity = 0.0
self.x_velocity = 1.0
self.speed = 1.0
def bounce_paddle(self, paddle):
self.x_velocity *= -1
self.x_pos += 2 * self.x_velocity
paddle_middle = (paddle.bottom + paddle.top) // 2
self.y_velocity += (self.y - paddle_middle) / 5 + random.random() / 20
self.speed *= 1.1
def normalize_speed(self):
current_speed = (self.y_velocity**2 + self.x_velocity**2)**.5
if current_speed > self.speed:
self.x_velocity *= self.speed / current_speed
self.y_velocity *= self.speed / current_speed
async def update(self):
self.reset()
left_score = right_score = 0
self.left_label.add_text(f"{0:^5}")
self.right_label.add_text(f"{0:^5}")
while True:
self.y_pos += self.y_velocity
self.x_pos += self.x_velocity
if self.collides_widget(self.left_paddle):
self.bounce_paddle(self.left_paddle)
elif self.collides_widget(self.right_paddle):
self.bounce_paddle(self.right_paddle)
if self.y_pos < 0 or self.y_pos >= FIELD_HEIGHT:
self.y_velocity *= -1
self.y_pos += 2 * self.y_velocity
if self.x_pos < 0:
self.reset()
right_score += 1
self.right_label.add_text(f"{right_score:^5}")
elif self.x_pos >= FIELD_WIDTH:
self.reset()
left_score += 1
self.left_label.add_text(f"{left_score:^5}")
self.normalize_speed()
self.y = int(self.y_pos)
self.x = int(self.x_pos)
await asyncio.sleep(.04)
class Pong(App):
async def on_start(self):
game_field = Widget(
size=(FIELD_HEIGHT, FIELD_WIDTH),
background_color_pair=WHITE_ON_GREEN,
)
vertical_center = FIELD_HEIGHT // 2 - PADDLE_HEIGHT // 2
left_paddle = Paddle(
player=1,
size=(PADDLE_HEIGHT, PADDLE_WIDTH),
pos=(vertical_center, 1),
background_color_pair=WHITE_ON_BLUE,
)
right_paddle = Paddle(
player=2,
size=(PADDLE_HEIGHT, PADDLE_WIDTH),
pos=(vertical_center, FIELD_WIDTH - 2),
background_color_pair=WHITE_ON_BLUE,
)
divider = Widget(
size=(1, 1),
size_hint=(1.0, None),
pos_hint=(None, .5),
background_color_pair=WHITE_ON_BLUE,
)
left_score_label = TextWidget(
size=(1, 5),
pos=(1, 1),
pos_hint=(None, .25),
anchor="center",
)
right_score_label = TextWidget(
size=(1, 5),
pos=(1, 1),
pos_hint=(None, .75),
anchor="center",
)
ball = Ball(
left_paddle,
right_paddle,
left_score_label,
right_score_label,
size=(1, 2),
background_color_pair=WHITE_ON_BLUE,
)
game_field.add_widgets(left_paddle, right_paddle, divider, left_score_label, right_score_label, ball)
self.add_widget(game_field)
if __name__ == "__main__":
Pong(title="Pong").run()