# storyforge/ui.py
import os
import sys
from typing import Optional, Tuple
import pygame
from pygame.locals import *
from storyforge.menu import StoryMenu
from storyforge.story import Story
IMAGE_SCENE = "images/scene"
IMAGE_CHAR = "images/character"
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
CHOICE_COLOR_NORMAL = (0, 0, 0, 100)
CHOICE_COLOR_HOVER = (0, 0, 0, 180)
[docs]
class PygameUI:
"""
Class to manage game UI using Pygame.
Attributes:
- story (Story): The game's story object.
Public Methods:
- __init__(story: Story): Initializes the game's user interface.
- show_initial_menu(): Displays the game's initial menu.
- get_player_name(default_name: str) -> str: Gets the player's name when running the game.
- render_text_with_background(text: str, height: int, bottom_aligned: bool = False, opacity: int = 128, speech_text: Optional[str] = None) -> pygame.Rect: Renders text with background.
- render_input_screen(input_string: str, default_name: str) -> None: Renders the input screen for the player name.
- show_input_screen(default_name: str) -> None: Displays the input screen for the player name.
- render_choice(choice_text: str, x: int, y: int) -> Tuple[pygame.Rect, int]: Renders a choice on the screen.
- render_text(text: str, x: int, y: int) -> pygame.Rect: Renders text on the screen.
- show_scene() -> None: Displays the current game scene on the screen.
- wait_for_click() -> None: Waits for a mouse click on the screen.
- show_choices(hover_choice: Optional[int] = None) -> Optional[int]: Displays the choice options on the screen.
- handle_events() -> None: Handles game events.
- run() -> None: Starts running the game.
- check_for_click() -> bool: Checks whether a click has occurred on the screen.
- check_text_and_speech_displayed() -> bool: Checks whether the scene text and the character's speech (if any) have already been displayed.
- process_click(pos: Tuple[int, int]) -> None: Processes the mouse click on the screen.
- save_and_quit() -> None: Saves the current state and closes the game window.
"""
def __init__(self, story: Story) -> None:
"""
Initializes the game's user interface.
Parameters:
- story (Story): The game's story object.
"""
self.story = story
pygame.init()
self.screen = pygame.display.set_mode((0, 0), pygame.FULLSCREEN)
pygame.display.set_caption("StoryForge::")
self.font = pygame.font.SysFont("Arial", 24)
self.choice_rects = []
self.player_name = ""
self.show_character_speech = False
self.character_speech_text = ""
self.show_text_displayed = False
self.show_speech_displayed = False
self.menu = StoryMenu(story, self)
self.close_window = True
pygame.event.set_allowed(pygame.QUIT)
pygame.event.set_allowed(pygame.KEYDOWN)
[docs]
def welcome() -> None:
"""
Displays the welcome message in terminal.
Return:
None
"""
print("Welcome to StoryForge!")
[docs]
def get_player_name(self, default_name: str) -> str:
"""
Gets the player's name when running the game.
Parameters:
- default_name (str): The default name of the player.
Return:
str: The player's name.
"""
# Checks if the player name is already defined in the story object
if self.story.character["name"]:
return self.story.character["name"] # Returns the name of the already defined player
else:
input_active = True
input_text = []
clock = pygame.time.Clock()
while input_active:
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
quit()
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_RETURN:
input_active = False
elif event.key == pygame.K_BACKSPACE:
input_text = input_text[:-1]
else:
input_text.append(event.unicode)
input_string = "".join(input_text)
self.render_input_screen(input_string, default_name)
pygame.display.flip()
clock.tick(30)
return input_string if input_string else default_name
[docs]
def render_text_with_background(self, text: str, height: int, bottom_aligned: bool = False, opacity: int = 128, speech_text: Optional[str] = None) -> pygame.Rect:
"""
Renders text with background on the screen.
Parameters:
- text (str): The text to be rendered.
- height (int): The height of the background rectangle.
- bottom_aligned (bool): If True, the text is aligned at the bottom of the rectangle.
- opacity (int): The opacity of the background.
- speech_text (Optional[str]): The text of the character's speech.
Return:
pygame.Rect: The rectangle surrounding the rendered text.
"""
width = self.screen.get_width()
x = self.screen.get_width() // 2
y = self.screen.get_height() - height if bottom_aligned else self.screen.get_height() // 2
background_rect = pygame.Rect(x - width // 2, y, width, height)
black_surface = pygame.Surface((width, height), pygame.SRCALPHA)
black_surface.fill((0, 0, 0, opacity))
self.screen.blit(black_surface, background_rect.topleft)
if speech_text:
rendered_text = self.font.render(speech_text, True, (255, 255, 255))
else:
rendered_text = self.font.render(text, True, (255, 255, 255))
text_rect = rendered_text.get_rect(center=(x, y + height // 2))
self.screen.blit(rendered_text, text_rect)
return background_rect
[docs]
def render_choice(self, choice_text: str, x: int, y: int) -> Tuple[pygame.Rect, int]:
"""
Renders a choice on the screen.
Parameters:
- choice_text (str): The text of the choice.
- x (int): The x coordinate of the center of the pick.
- y (int): The y-coordinate of the center of the pick.
Return:
Tuple[pygame.Rect, int]: A tuple containing the rectangle of the choice and the vertical space between choices.
"""
rect_width = 600
rect_height = 40
space_between_choices = 50
rect = pygame.Rect(x - rect_width // 2, y - rect_height // 2, rect_width, rect_height)
is_hover = rect.collidepoint(pygame.mouse.get_pos())
choice_color = CHOICE_COLOR_HOVER if is_hover else CHOICE_COLOR_NORMAL
choice_surface = pygame.Surface((rect_width, rect_height), pygame.SRCALPHA)
choice_surface.fill(choice_color)
rendered_text = self.font.render(choice_text, True, (255, 255, 255))
text_rect = rendered_text.get_rect(center=(rect_width // 2, rect_height // 2))
choice_surface.blit(rendered_text, text_rect)
self.screen.blit(choice_surface, rect.topleft)
return rect, space_between_choices
[docs]
def render_text(self, text: str, x: int, y: int) -> pygame.Rect:
"""
Renders text on the screen.
Parameters:
- text (str): The text to be rendered.
- x (int): The x-coordinate of the center of the text.
- y (int): The y-coordinate of the center of the text.
Return:
pygame.Rect: The rectangle surrounding the rendered text.
"""
rendered_text = self.font.render(text, True, (255, 255, 255))
rect = rendered_text.get_rect(center=(x, y))
self.screen.blit(rendered_text, rect)
return rect
[docs]
def show_scene(self) -> None:
"""
Displays the current game scene on the screen.
Return:
None
"""
self.handle_events()
scene = self.story.scenes[self.story.current_scene]
if scene["image"]:
background = pygame.image.load(os.path.join(IMAGE_SCENE, scene["image"]))
self.screen.blit(background, (0, 0))
if scene["character_image"]:
character_image = pygame.image.load(os.path.join(IMAGE_CHAR, scene["character_image"]))
character_rect = character_image.get_rect(center=(self.screen.get_width() // 2, 900))
self.screen.blit(character_image, character_rect)
# Checks for character_speech in the scene
if scene["character_speech"]:
if not self.show_character_speech:
# If there is character_speech and the flag is not activated, render the scene text and wait for the click
self.render_text_with_background(scene["text"], 150, bottom_aligned=True, opacity=180)
pygame.display.flip()
self.wait_for_click() # Method that waits for click on the screen
self.show_text_displayed = True # Marks that the scene text has been displayed
self.show_character_speech = True
self.show_speech_displayed = False # Resets the character's speech marking
return
# If the flag is activated, displays the character's speech
full_speech_text = f"{self.story.character['name']}: {scene['character_speech']}"
self.character_speech_text = scene["character_speech"]
self.render_text_with_background(full_speech_text, 150, bottom_aligned=True, opacity=180)
self.show_speech_displayed = True # Mark that the character's speech was displayed
elif not self.show_character_speech:
# If the flag is not activated and there is no character speech, displays the scene's default text
self.character_speech_text = "" # Clears the text of the character's speech
self.render_text_with_background(scene["text"], 150, bottom_aligned=True, opacity=180)
self.show_text_displayed = True # Marks that the scene text has been displayed
[docs]
def wait_for_click(self) -> None:
"""
Wait for a mouse click on the screen.
Returns:
None
"""
waiting_for_click = True
while waiting_for_click:
for event in pygame.event.get():
if event.type == pygame.MOUSEBUTTONDOWN:
waiting_for_click = False
[docs]
def show_choices(self, hover_choice: Optional[int] = None) -> Optional[int]:
"""
Displays choice options on the screen.
Parameters:
- hover_choice (Optional[int]): The number of the choice being highlighted by the mouse.
Return:
Optional[int]: The number of the choice highlighted by the mouse.
"""
self.choice_rects = []
y_position = 400
scene = self.story.scenes[self.story.current_scene]
# Check if "character_speech" exists in the scene
if "character_speech" in scene:
if scene["character_speech"]:
if not self.show_character_speech:
# If there is character_speech and the flag is not activated, render the character_speech before the choice buttons
self.show_character_speech = True
self.show_scene()
for i, (choice_text, _) in enumerate(scene["choices"], start=1):
rect, space_between = self.render_choice(choice_text, self.screen.get_width() // 2, y_position)
#print(f"Choice {i}: {rect}")
if rect.collidepoint(pygame.mouse.get_pos()): # Check hover directly here
hover_choice = i
y_position += space_between
return hover_choice
[docs]
def handle_events(self) -> None:
"""
Handles game events.
Return:
None
"""
for event in pygame.event.get():
if event.type == pygame.QUIT:
self.save_and_quit() # Calls save_and_quit() when the window close event is triggered
elif event.type == pygame.MOUSEBUTTONDOWN:
if event.button == 1:
self.process_click(event.pos)
[docs]
def run(self, max_iterations: int = None) -> None:
"""
Starts running the game.
Return:
None
"""
running = True
clock = pygame.time.Clock()
iterations = 0
while running:
if max_iterations is not None and iterations >= max_iterations:
break
# Gets the player's name while playing the game
player_name = self.get_player_name(default_name=self.story.default_player_name["name"])
# Sets the player's name
self.story.set_character_name(player_name)
self.handle_events()
hover_choice = None
for i, rect in enumerate(self.choice_rects):
if rect.collidepoint(pygame.mouse.get_pos()):
hover_choice = i + 1
self.show_scene()
hover_choice = self.show_choices(hover_choice)
pygame.display.flip()
clock.tick(30)
# Check if the story has come to an end
if not self.story.has_choices():
if os.path.exists("story_state.json"):
os.remove("story_state.json")
# If there are no choices, check whether the scene text and the character's speech (if any) have already been read.
if self.show_character_speech:
# If the character's speech has already been displayed and the screen is clicked, the game ends
if self.show_speech_displayed and self.check_for_click():
self.story.save_state()
print("If the character's speech has already been displayed and the screen is clicked, the game ends")
self.show_initial_menu()
else:
# If there is no speech from the character, end the game by clicking on the screen
if self.check_for_click():
self.story.save_state()
print("If there is no speech from the character, end the game by clicking on the screen")
self.show_initial_menu()
iterations += 1
[docs]
def check_for_click(self) -> bool:
"""
Checks whether a click has occurred on the screen.
Return:
bool: True if a click occurred, False otherwise.
"""
# Checks whether a click has occurred on the screen
for event in pygame.event.get():
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
# self.save_and_quit()
return True
return False
[docs]
def check_text_and_speech_displayed(self) -> bool:
"""
Checks whether the scene text and character speech (if any) have already been displayed.
Return:
bool: True if the scene text and character speech have already been displayed, False otherwise.
"""
# Checks whether the scene text and character speech (if any) have already been displayed
return self.show_text_displayed and (not self.character_speech_text or self.show_speech_displayed)
[docs]
def process_click(self, pos: Tuple[int, int]) -> None:
"""
Processes the mouse click on the screen.
Parameters:
- pos (Tuple[int, int]): The (x, y) coordinates of the mouse click.
Return:
None
"""
y_position = 400
scene = self.story.scenes[self.story.current_scene]
for i, (choice_text, _) in enumerate(scene["choices"], start=1):
rect_width = 600
rect_height = 40
space_between_choices = 50
rect = pygame.Rect(self.screen.get_width() // 2 - rect_width // 2, y_position - rect_height // 2, rect_width, rect_height)
if rect.collidepoint(pos):
print(f"Choice made: {choice_text}, show_character_speech: {self.show_character_speech}")
# Regardless of the value of show_character_speech, advance to the next scene
self.story.make_choice(str(i))
self.show_character_speech = False
# If there is character_speech, switch between default text and character speech
if scene["character_speech"]:
self.show_character_speech = not self.show_character_speech
if self.show_character_speech:
self.show_scene() # Shows the character's speech
break
y_position += space_between_choices
# Handle click on main screen
if not any(rect.collidepoint(pos) for rect in self.choice_rects):
# If clicked on the main screen and there is a character_speech, toggle between the default text and the character's speech
if scene["character_speech"]:
self.show_character_speech = not self.show_character_speech
if self.show_character_speech:
self.show_scene() # Shows the character's speech
else:
pass # Show default scene text
[docs]
def save_and_quit(self) -> None:
"""
Saves the current state and closes the game window.
Returns:
None
"""
self.screen.fill(BLACK) # Black background
# Asks the player if they want to save before exiting
self.render_text("Do you really want to leave?", self.screen.get_width() // 2, 400)
choices = ["Yes", "No"]
rects = []
for idx, choice in enumerate(choices):
rect_choice, _ = self.render_choice(choice, self.screen.get_width() // 2, 450 + (idx * 50))
rects.append(rect_choice)
pygame.display.flip()
print("Leaving...")
while True:
for event in pygame.event.get():
if event.type == pygame.MOUSEBUTTONDOWN:
# Checks whether the click was on the "Yes" or "No" options
for idx, rect in enumerate(rects):
if rect.collidepoint(event.pos):
print(choices[idx])
if choices[idx] == "Yes":
self.story.save_state() # Saves the current state
pygame.quit()
sys.exit()
elif choices[idx] == "No":
pygame.event.clear() # Clears all events from the pygame event queue
self.close_window = False # Sets close_window to False
return # Return to the game without saving