commit 53728db74251d1f651c5b3db61d80ad22b797fe2 Author: Dominic Date: Tue May 13 13:55:07 2025 -0400 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd18660 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +save.pickle +venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3bf7159 --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# Scoundrel + +This is a terminal port of the rogue-lite card game Scoundrel. I got really into Balatro for a week +and immediately started wanting to code a game. I stumbled upon a youtube video for a rogue-lite +card game you can play with just a deck of cards and decided to port it to the terminal as a preliminary exercise before I begin to write a new game (which may never happen, I have 5 other pressing projects that take priority over game development). + +# Installation + +You must have python installed on your machine... + +1. clone the repository +2. cd into the repository and create a virtual environment `python3 -m venv venv` +3. activate the virtual environment `source venv/bin/activate` +4. install requirements `pip install -r requirements.txt` +5. run the game `python3 main.py` + +# Rules +### 1. INTRODUCTION: + +Welcome to Scoundrel! The solo rogue-lite dungeon crawler card game! +This is the unneccessary terminal port of the game by Dominic DiTaranto. +If you enjoy this port, check out my website for other cool stuff +https://www.domdit.com + +Table Of Contents: +1. INTRODUCTION +3. CONTROLS +2. RULES + +### 2. CONTROLS + +Arrow Keys - Select a Card + +Enter Button - Engage with a Card + +s - Skip a Room + +b - Fight Barehanded + +p - Pause/Unpause + +h - See High Scores (from start menu) + +u - Change User Name (from start menu) + +Esc - Exit Game + +? - Takes you to this help page. + +### 3.a RULES - Setup and Rules + +You are a dungeon explorer making your way through a series of rooms. +A room is made up of 4 cards. + +In each room you may encounter: +- Equippable Weapons (Diamond Suit Cards) +- Health Potions (Heart Suit Cards) +- Enemies (Spade and Club Suit Cards) +- Merchants (Joker Cards) [Not Yet Implemented] + +Your goal is to traverse the rooms until the deck runs out of cards. + +### 3.b RULES - Selecting a Card + +When you enter a room, select a card by navigating to it with the arrow keys +and pressing Enter. + +If the card is a weapon, you equip that weapon. Each weapon does as much damage +as its value. Picking up a weapon will automatically discard your currently equipped weapon. + +If the card is a health potion, you will gain life equal to the value of the card. +Your life may not exceed 20. + +You can skip a room by pressing s, but you cannot skip two rooms in a row. the room +that you skip gets moved to the bottom of the deck to be traversed through later. + +### 3.c RULES - Combat + +If you select an enemy, you enter the combat phase. + +If you do not have a weapon equipped you will take damage to your life points +equal to the value of the enemy card. This is called barehanded combat. + +If you have an equipped weapon and the value of the weapon is greater than the enemy value +You do not take any damage to your health. Your weapon becomes damaged and you can only +attack enemies with a value lower than the previous enemy's value. This is the weapons damage rating. + +If you attack an enemy with a weapon that has a value lower than the enemy's value, you +take damage equal to the enemy value - the weapon value. Example: if your weapon is a 5 +and the enemy is an 8, you will take 3 damage. + +### 3.d RULES - Combat Continued + +If you attack an enemy with a value higher than the weapon's damage rating, +you take all of that damage as if it were barehanded. You will lose life equal +to the value of the enemy. + +You should only attack enemies with a weapon that has a damage rating above or equal +to the enemy's value. There will be situations where you have no choice but to take +the barehanded damage combat. This is part of the strategy element. + +Your Weapon's damage rating is shown in the brackets next to the weapon. + +When a room only has 1 card left, you move on to the next room. That card follows you +into the next room + +### 3.e RULES - Scoring + +If you die, the remaining monster's values are subtracted from your score. +The negative value is your score. + +If you beat the whole dungeon, your score is your positive life points. + + +# Future Updates +- Endless mode: reshuffle the deck and keep playing +- Jokers as Merchants: Add jokers back to the deck and have them either buff you or hurt you based on RNG. diff --git a/assets/ascii_art.py b/assets/ascii_art.py new file mode 100644 index 0000000..46507c7 --- /dev/null +++ b/assets/ascii_art.py @@ -0,0 +1,41 @@ +START_SCREEN = ''' + ▄████████ ▄████████ ▄██████▄ ███ █▄ ███▄▄▄▄ ████████▄ ▄████████ ▄████████ ▄█ + ███ ███ ███ ███ ███ ███ ███ ███ ███▀▀▀██▄ ███ ▀███ ███ ███ ███ ███ ███ + ███ █▀ ███ █▀ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ █▀ ███ + ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ▄███▄▄▄▄██▀ ▄███▄▄▄ ███ +▀███████████ ███ ███ ███ ███ ███ ███ ███ ███ ███ ▀▀███▀▀▀▀▀ ▀▀███▀▀▀ ███ + ███ ███ █▄ ███ ███ ███ ███ ███ ███ ███ ███ ▀███████████ ███ █▄ ███ + ▄█ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ▄███ ███ ███ ███ ███ ███▌ ▄ + ▄████████▀ ████████▀ ▀██████▀ ████████▀ ▀█ █▀ ████████▀ ███ ███ ██████████ █████▄▄██ + ███ ███ ▀ + Port by Dominic Ditaranto (https://www.domdit.com) v1.0.0 + Press ? for Rules and Controls | Press Enter to Start + Press u to Set Your Username! (Currently: {username}) + Press h to See High Score List! + + + +''' + +GAME_OVER = ''' + SCORE: {score} + ▄██████▄ ▄████████ ▄▄▄▄███▄▄▄▄ ▄████████ + ███ ███ ███ ███ ▄██▀▀▀███▀▀▀██▄ ███ ███ + ███ █▀ ███ ███ ███ ███ ███ ███ █▀ + ▄███ ███ ███ ███ ███ ███ ▄███▄▄▄ +▀▀███ ████▄ ▀███████████ ███ ███ ███ ▀▀███▀▀▀ + ███ ███ ███ ███ ███ ███ ███ ███ █▄ + ███ ███ ███ ███ ███ ███ ███ ███ ███ + ████████▀ ███ █▀ ▀█ ███ █▀ ██████████ + + ▄██████▄ ▄█ █▄ ▄████████ ▄████████ +███ ███ ███ ███ ███ ███ ███ ███ +███ ███ ███ ███ ███ █▀ ███ ███ +███ ███ ███ ███ ▄███▄▄▄ ▄███▄▄▄▄██▀ +███ ███ ███ ███ ▀▀███▀▀▀ ▀▀███▀▀▀▀▀ +███ ███ ███ ███ ███ █▄ ▀███████████ +███ ███ ███ ███ ███ ███ ███ ███ + ▀██████▀ ▀██████▀ ██████████ ███ ███ + ███ ███ +Press Enter To Return To Start Page | Press Esc to Quit +''' diff --git a/assets/help.py b/assets/help.py new file mode 100644 index 0000000..406f12c --- /dev/null +++ b/assets/help.py @@ -0,0 +1,116 @@ +PAGE_ONE = ''' +1. INTRODUCTION: + +Welcome to Scoundrel! The solo rogue-lite dungeon crawler card game! +This is the unneccessary terminal port of the game by Dominic DiTaranto. +If you enjoy this port, check out my website for other cool stuff +https://www.domdit.com + +Table Of Contents: +1. INTRODUCTION +3. CONTROLS +2. RULES + +Use the up and down arrow keys to scroll through the help pages.. + + 1 +''' + +PAGE_TWO = ''' +2. CONTROLS + +Arrow Keys Select a Card +Enter Button Engage with a Card +s Skip a Room +b Fight Barehanded +p Pause/Unpause +h See High Scores (from start menu) +u Change User Name (from start menu) +Esc Exit Game +? Takes you to this help page. + 2 +''' + + +PAGE_THREE = ''' +3.a RULES - Setup and Rules + +You are a dungeon explorer making your way through a series of rooms. +A room is made up of 4 cards. + +In each room you may encounter: +- Equippable Weapons (Diamond Suit Cards) +- Health Potions (Heart Suit Cards) +- Enemies (Spade and Club Suit Cards) +- Merchants (Joker Cards) [Not Yet Implemented] + +Your goal is to traverse the rooms until the deck runs out of cards. + 3 +''' + +PAGE_FOUR = ''' +3.b RULES - Selecting a Card + +When you enter a room, select a card by navigating to it with the arrow keys +and pressing Enter. + +If the card is a weapon, you equip that weapon. Each weapon does as much damage +as its value. Picking up a weapon will automatically discard your currently equipped weapon. + +If the card is a health potion, you will gain life equal to the value of the card. +Your life may not exceed 20. + +You can skip a room by pressing s, but you cannot skip two rooms in a row. the room +that you skip gets moved to the bottom of the deck to be traversed through later. + 4 +''' + +PAGE_FIVE = ''' +3.c RULES - Combat + +If you select an enemy, you enter the combat phase. + +If you do not have a weapon equipped you will take damage to your life points +equal to the value of the enemy card. This is called barehanded combat. + +If you have an equipped weapon and the value of the weapon is greater than the enemy value +You do not take any damage to your health. Your weapon becomes damaged and you can only +attack enemies with a value lower than the previous enemy's value. This is the weapons damage rating. + +If you attack an enemy with a weapon that has a value lower than the enemy's value, you +take damage equal to the enemy value - the weapon value. Example: if your weapon is a 5 +and the enemy is an 8, you will take 3 damage. + 5 +''' + +PAGE_SIX = ''' +3.d RULES - Combat Continued + +If you attack an enemy with a value higher than the weapon's damage rating, +you take all of that damage as if it were barehanded. You will lose life equal +to the value of the enemy. + +You should only attack enemies with a weapon that has a damage rating above or equal +to the enemy's value. There will be situations where you have no choice but to take +the barehanded damage combat. This is part of the strategy element. + +Your Weapon's damage rating is shown in the brackets next to the weapon. + +When a room only has 1 card left, you move on to the next room. That card follows you +into the next room + 6 +''' + +PAGE_SEVEN = ''' +3.e RULES - Scoring + +If you die, the remaining monster's values are subtracted from your score. +The negative value is your score. + +If you beat the whole dungeon, your score is your positive life points. + + + + + 7 +''' diff --git a/card.py b/card.py new file mode 100644 index 0000000..b24fd1e --- /dev/null +++ b/card.py @@ -0,0 +1,38 @@ +from colorama import Fore, Back, Style + + +class Card: + def __init__(self, value, suit): + self.value = value + self.suit = suit + self.selected = False + + self.suit_map = { + 0: '♠', + 1: '❤', + 2: '♣', + 3: '♦' + } + + self.value_map = { + 1: 'A', + 11: 'J', + 12: 'Q', + 13: 'K' + } + + def display_card(self): + return f"{Back.WHITE}{Fore.RED if self.suit in [1,3] else Fore.BLUE}{self.value_map.get(self.value, self.value)}{self.suit_map[self.suit]} {Style.RESET_ALL}" + + def __repr__(self): + s = Back.WHITE if not self.selected else Back.GREEN + return f"{s}{Fore.RED if self.suit in [1,3] else Fore.BLUE}{self.value_map.get(self.value, self.value)}{self.suit_map[self.suit]} {Style.RESET_ALL}" + + def help_text(self, weapon=None): + if self.suit in [0, 2]: + return f'Attack {self.display_card()} {"barehanded?" if not weapon else "with {}? [b]".format(weapon)}' + elif self.suit == 1: + return f"Use {self.display_card()} as a health potion?" + elif self.suit == 3: + return f"Equip {self.display_card()} as a weapon?" + diff --git a/deck.py b/deck.py new file mode 100644 index 0000000..186ce06 --- /dev/null +++ b/deck.py @@ -0,0 +1,29 @@ +import random +from collections import deque + +from card import Card + + +class Deck: + def __init__(self): + self.deck = deque() + + def generate_deck(self): + for suit in range(4): + if suit in [0, 2]: + for i in range(1, 14): + c = Card(i, suit) + self.deck.append(c) + elif suit in [1, 3]: + for i in range(2, 11): + c = Card(i, suit) + self.deck.append(c) + + + def shuffle(self): + random.shuffle(self.deck) + + def draw(self): + if len(self.deck) > 0: + return self.deck.popleft() + diff --git a/game.py b/game.py new file mode 100644 index 0000000..6748bfd --- /dev/null +++ b/game.py @@ -0,0 +1,240 @@ +import os +import pickle +import sys +import time + +from pynput.keyboard import Key, Listener, KeyCode + +from assets.help import PAGE_ONE, PAGE_TWO, PAGE_THREE, PAGE_FOUR, PAGE_FIVE, PAGE_SIX, PAGE_SEVEN +from deck import Deck + + +class Game: + def __init__(self, username='Wayne Skyler'): + self.username = username + + self.deck = Deck() + self.current_room_cards = [] + self.selected_card = 0 + self.graveyard = [] + + self.paused = False + + self.room = 1 + self.block_skip = False + + self.weapon = None + self.defeated = [] + + self.max_health = 20 + self.health = 20 + + self.history = [] + + self.help_screens = [PAGE_ONE, PAGE_TWO, PAGE_THREE, PAGE_FOUR, PAGE_FIVE, PAGE_SIX, PAGE_SEVEN] + self.current_help_screen = 0 + self.on_help_screen = False + self.from_screen = None + + + self.score = len(self.deck.deck) * -1 + self.save_file = 'save.pickle' + + @property + def current_card(self): + return self.current_room_cards[self.selected_card] + + def initialize(self): + self.deck = Deck() + self.deck.generate_deck() + self.deck.shuffle() + + while len(self.current_room_cards) < 4 and len(self.deck.deck) > 0: + self.current_room_cards.append(self.deck.draw()) + + self.current_room_cards[0].selected = True + + def display(self): + os.system('clear') + print(f"Current Room: {self.room} | Health: {self.health}/{self.max_health} | ? for Rules & Controls") + print('\t'.join([str(x) for x in self.current_room_cards])) + print('\n') + print('\n') + print(f"Weapon: {self.weapon if self.weapon else 'None'} {'[{}]'.format(' '.join([str(x) for x in self.defeated]) if self.graveyard else None)}") + print(f"{self.current_card.help_text(self.weapon)} [Enter]") + + def flash_message(self, msg): + for i in reversed(range(3)): + os.system('clear') + print(msg) + print(f'\n\tWait {str(i+1)} Seconds...') + time.sleep(1) + + def toggle_pause(self): + self.paused = True if not self.paused else False + + def input(self): + + def on_press(key): + self.history.append(key) + + if self.paused and key != KeyCode.from_char('p'): + return False + + if key == Key.right: + self.change_selected_card() + elif key == Key.left: + self.change_selected_card(False) + elif key == Key.enter: + if self.current_card.suit in [0, 2]: # Interact with selected card + self.attack() + elif self.current_card.suit == 1: + self.health_potion() + elif self.current_card.suit == 3: + self.equip_weapon() + elif key == KeyCode.from_char('s'): # Skip Room + self.skip_room() + elif key == KeyCode.from_char('b'): # Attack Barehanded + weapon_copy = self.weapon + self.weapon = None + self.attack() + self.weapon = weapon_copy + elif key == KeyCode.from_char('p'): + self.toggle_pause() + elif key == Key.esc: # Quit The Game + self.store_high_score() + sys.exit() + if key == KeyCode.from_char('?'): + self.on_help_screen = True if not self.on_help_screen else False + + if key == Key.up: + if self.current_help_screen != 0: + self.current_help_screen -= 1 + self.help_screen() + if key == Key.down: + if self.current_help_screen < len(self.help_screens) - 1: + self.current_help_screen += 1 + self.help_screen() + + return False + + with Listener(on_press=on_press) as listener: + listener.join() + + def help_screen(self): + os.system('clear') + print(self.help_screens[self.current_help_screen]) + + def change_selected_card(self, right=True): + self.current_card.selected = False + if right: + if self.selected_card != len(self.current_room_cards) - 1: + self.selected_card += 1 + else: + if self.selected_card > 0: + self.selected_card -= 1 + self.current_card.selected = True + + def attack(self): + if self.current_card.suit not in [0, 2]: + self.flash_message('\n\n\n\tSelected card is not an enemy!') + return + + if not self.weapon: + self.health -= self.current_card.value + + if self.health > 0: + self.send_selected_to_grave() + else: + if self.defeated and self.defeated[-1].value < self.current_card.value: + self.health -= self.current_card.value + self.flash_message('\n\n\n\tYou should not have done that! \n\tYour weapon was not strong enough; causing you to take all of the damage!' ) + self.send_selected_to_grave() + return + if self.weapon.value < self.current_card.value: + damage = self.current_card.value - self.weapon.value + self.health -= damage + if self.health <= 0: + pass + self.defeated.append(self.current_card) + self.send_selected_to_grave() + + def send_selected_to_grave(self): + self.graveyard.append(self.current_room_cards.pop(self.selected_card)) + self.check_win() + self.selected_card = 0 + if len(self.current_room_cards) == 1: + self.advance_room() + if len(self.current_room_cards) > 0: + self.current_card.selected = True + self.deselect_defeated() + + def health_potion(self): + self.health += self.current_card.value + if self.health > 20: + self.health = 20 + self.send_selected_to_grave() + + def equip_weapon(self): + self.defeated = [] + self.weapon = self.current_card + self.current_card.selected = False + self.send_selected_to_grave() + + def deselect_defeated(self): + for i in self.defeated: + i.selected = False + + def advance_room(self): + self.block_skip = False + self.room += 1 + + # TODO: This can be simplified with initialize room func + while len(self.current_room_cards) < 4: + self.current_room_cards.append(self.deck.draw()) + self.current_room_cards[0].selected = True + + def skip_room(self): + if self.block_skip: + self.flash_message('\n\n\n\tYou cannot run away!') + else: + for card in self.current_room_cards: + card.selected = False + self.deck.deck.append(card) + self.current_room_cards = [] + self.advance_room() + self.block_skip = True + self.flash_message('\n\n\n\tRun Away, Coward!! 🤡') + + def cleanup(self): + self.current_room_cards = [x for x in self.current_room_cards if x is not None] + + def store_high_score(self): + self.calculate_score() + with open(self.save_file, 'rb') as f: + save_data = pickle.load(f) + save_data['high_scores'].append((self.username, self.score)) + sorted_high_scores = sorted(save_data['high_scores'], key=lambda x: x[1], reverse=True)[:9] + save_data['high_scores'] = sorted_high_scores + + with open(self.save_file, 'wb') as f: + pickle.dump(save_data, f) + + def calculate_score(self): + self.score = 0 + for card in self.current_room_cards + list(self.deck.deck): + if card.suit in [0, 2]: + self.score -= card.value + + if self.score == 0: + self.score += self.health + for card in self.current_room_cards + list(self.deck.deck): + if card.suit == 1: + self.score += card.value + + def check_win(self): + if len(self.deck.deck) <= 0 and len(self.current_room_cards) <= 0: + self.calculate_score() + self.store_high_score() + return True + diff --git a/main.py b/main.py new file mode 100644 index 0000000..11fef9f --- /dev/null +++ b/main.py @@ -0,0 +1,163 @@ +import os +import pickle +import sys +import termios + +from game import Game +from assets.ascii_art import START_SCREEN, GAME_OVER +from assets.help import PAGE_ONE, PAGE_TWO, PAGE_THREE, PAGE_FOUR, PAGE_FIVE, PAGE_SIX, PAGE_SEVEN + +from pynput.keyboard import Key, Listener, KeyCode + + +class Main: + def __init__(self): + self.screen_mode = True + self.game = None + self.username = 'Wayne Skyler' + + self.save_file = 'save.pickle' + self.save_data = None + + self.help_screens = [PAGE_ONE, PAGE_TWO, PAGE_THREE, PAGE_FOUR, PAGE_FIVE, PAGE_SIX, PAGE_SEVEN] + self.current_help_screen = 0 + self.on_help_screen = False + self.from_screen = None + + if not os.path.isfile(self.save_file): + + with open(self.save_file, 'wb') as f: + save_data = { + 'username': self.username, + 'high_scores': [] + } + + pickle.dump(save_data, f) + + else: + with open(self.save_file, 'rb') as f: + self.save_data = pickle.load(f) + self.username = self.save_data['username'] + + def main(self): + self.screens() + + def screens(self, mode=None): + while self.screen_mode: + if self.game and mode != 'win': + if self.game.health <= 0: + os.system('clear') + self.game.store_high_score() + print(GAME_OVER.format(score=self.game.score)) + self.input(mode='game_over') + else: + os.system('clear') + if mode == 'win': + self.game.health = 0 + self.game.flash_message(f'\n\n\n\twin. Congratulashon. {self.game.score}') + self.game = None + self.screens(mode=None) + else: + print(START_SCREEN.format(username=self.username)) + self.input(mode='start') + + def gameplay(self): + self.game = Game(username=self.username) + self.game.initialize() + while self.game.health > 0: + if not self.game.paused and not self.game.on_help_screen: + self.game.cleanup() + if self.game.check_win(): + self.screen_mode = True + self.screens(mode='win') + if None in self.game.current_room_cards: + breakpoint() + self.game = None + self.game.display() + self.game.input() + else: + if self.game.paused: + self.pause_game() + self.game.input() + elif self.game.on_help_screen: + self.game.help_screen() + self.game.input() + + self.screen_mode = True + self.screens() + + def pause_game(self): + os.system('clear') + print('\n\n\t PAUSED') + print('\t Press p to unpause...') + + def high_scores(self): + with open(self.save_file, 'rb') as f: + save_data = pickle.load(f) + + os.system('clear') + print('\t\tHIGH SCORES\n') + for score in save_data['high_scores']: + print(f"\t{score[0]}\t{score[1]}") + + print('\n\tPress Enter to Return to Start Screen \n') + + self.input('game_over') + + def help_screen(self): + os.system('clear') + print(self.help_screens[self.current_help_screen]) + + def input(self, mode='start'): + def on_press(key): + termios.tcflush(sys.stdin, termios.TCIOFLUSH) + if key == Key.enter: + if mode == 'start': + self.screen_mode = False + self.gameplay() + if mode == 'game_over': + self.game = None + self.screen_mode = True + self.screens() + if key == Key.esc: + sys.exit() + if key == KeyCode.from_char('h'): + self.high_scores() + if key == KeyCode.from_char('u'): + self.username = input('Input Your Username: ') + with open(self.save_file, 'rb') as f: + self.save_data = pickle.load(f) + + self.save_data['username'] = self.username + + with open(self.save_file, 'wb') as f: + pickle.dump(self.save_data, f) + + self.screens() + if key == KeyCode.from_char('?'): + if mode != 'game_help': + self.on_help_screen = True if not self.on_help_screen else False + if self.on_help_screen: + self.help_screen() + else: + self.screens() + elif mode == 'game_help': + self.game.on_help_screen = True if not self.game.on_help_screen else False + + if key == Key.up: + if self.current_help_screen != 0: + self.current_help_screen -= 1 + self.help_screen() + if key == Key.down: + if self.current_help_screen < len(self.help_screens) - 1: + self.current_help_screen += 1 + self.help_screen() + + + with Listener(on_press=on_press) as listener: + listener.join() + + +if __name__ == '__main__': + main = Main() + main.main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..be1bc1d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +colorama==0.4.6 +evdev==1.9.1 +keyboard==0.13.5 +pynput==1.8.0 +PyRect==0.2.0 +python-xlib==0.33 +six==1.17.0 +wheel==0.45.1