This commit is contained in:
Dominic 2025-05-13 13:55:07 -04:00
commit 53728db742
9 changed files with 755 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
__pycache__/
save.pickle
venv/

117
README.md Normal file
View file

@ -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.

41
assets/ascii_art.py Normal file
View file

@ -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
'''

116
assets/help.py Normal file
View file

@ -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
'''

38
card.py Normal file
View file

@ -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?"

29
deck.py Normal file
View file

@ -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()

240
game.py Normal file
View file

@ -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

163
main.py Normal file
View file

@ -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()

8
requirements.txt Normal file
View file

@ -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