Skip to content

Instantly share code, notes, and snippets.

@Pinacolada64
Last active February 23, 2024 02:47
Show Gist options
  • Save Pinacolada64/a28d39ad241c5d9e03a6b9c1c1c54ed0 to your computer and use it in GitHub Desktop.
Save Pinacolada64/a28d39ad241c5d9e03a6b9c1c1c54ed0 to your computer and use it in GitHub Desktop.
New parser with individual functions per command
# https://gist.github.com/anoryx/c34380a0a3ef4031f41c9ed8035e305b
# https://stackoverflow.com/questions/51575931/class-inheritance-in-python-3-7-dataclasses/53085935#53085935
# combat considerations:
# https://codereview.stackexchange.com/questions/139121/my-implementation-of-item-objects-in-a-text-adventure
# thanks, volca & google gemini (née bard)
import doctest
import logging
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
"""
For attributes that have a closed set of possible choices,
I like to make an Enum object. Normally the Enum values are integers
but you can make them strings if you want, like this:
"""
class Gender(str, Enum):
MALE = "male"
FEMALE = "female"
class Handedness(str, Enum):
NO_HANDS = "hands-free"
ONE_HANDED = "single-handed"
TWO_HANDED = "double-handed"
class SpellTypes(str, Enum):
DISPEL_POISON = "dispel poison"
ELEVATOR_DOWN = "elevator down"
ELEVATOR_UP = "elevator up"
HEAL = "heal"
MAP = "map" # non-destructive scrolls
TELEPORT = "teleport"
# You can also declare enums like this:
Flag = Enum("Flag", ["cursed", "blessed", "cool"])
Size = Enum("Size", ["huge", "large", "big", "man_sized", "short", "small", "swift", "tiny"])
class IncrementItemNumber(object):
item_number = 0
def __init__(self):
IncrementItemNumber.item_number += 1
# The @dataclass decorator handles all the __init__ stuff
@dataclass
class Item(object):
"""
'number' is a holdover from the implementation of "The Land of Spur" on the
memory-constrained Apple //; it was more RAM-efficient to refer to objects
by their numbers instead of a name string. Its usage may be dropped eventually.
"""
# just for funsies, no real *need* to auto-increment item numbers... yet
number: IncrementItemNumber()
# For each attribute, define what type it should be.
# If there's a default value, you can add that with `= default_value`
# 'name' is how the player refers to it on the command line:
name: str = "name"
weight: Optional[int] = None
description: Optional[str] = None
def long_desc(self):
# FIXME: I forget what this was for. possibly just delete it
pass
def __str__(self):
_ = f"{self.name}"
if self.weight:
_ += f" ({self.weight} lb.)"
return _
"""
@dataclass
class GameMap(Room):
rooms: Room = field(default_factory=list)
"""
@dataclass
class Weapon(Item):
handedness: Handedness = Handedness.ONE_HANDED # default
defense_bonus: int = 10
def long_desc(self):
return f"The {self.name} " \
f"{f'[#{self.number}]' if self.number else ''}" \
f"is a {self.handedness.value} weapon."
def __str__(self):
return f"{self.name}" \
f"{f' [#{self.number}]' if self.number else ''}"
class StormWeapon(Weapon):
pass
class Book(Item):
def __init__(self, number, text, title, description,
self_destruct_after_reading=True):
super().__init__(number=number, name=title, description=description) # Call parent's __init__
self.text = text
self.self_destruct_after_reading = self_destruct_after_reading
def read(self, character): # Assuming you have a player object reference
if self.text:
print(self.text)
# Call any additional book-specific reading logic here
if self.self_destruct_after_reading:
print(f"The book {self.name} vanishes in a puff of smoke!")
character.remove_from_inventory(self) # Remove from inventory
else:
print(f"You can't see any writing on the {self.name}.")
@dataclass
class Spell(object):
"""
Spell is subclassed from the Item class, but due to additional attributes with defaults
being added, can't specify defaults here. so it is now just a composed (?) class instead
of inherited.
IncrementItemNumber() is just for funsies, no real *need* to auto-increment item numbers... yet
"""
number: IncrementItemNumber()
# For each attribute, define what type it should be.
# If there's a default value, you can add that with `= default_value`
name: str = "name"
weight: Optional[int] = None
description: Optional[str] = None
# copied from Weapon class:
# handedness: Handedness = Handedness.ONE_HANDED # default
# just to give it *some* default, not necessarily the "right" one:
spell_type: SpellTypes = SpellTypes.HEAL
chance_to_cast: float = .1
# how many times you can cast the spell:
charges: int = 1
def cast(self):
# TODO: if Witch or Wizard class, and if Staff READY, increase chance of spell casting
# if self.carried(Staff): blah
print(f"You cast {self.name}.")
def long_desc(self):
return f"A spell of {self.spell_type.lower()}."
def __str__(self):
# (###x) 12345678901234567890: ###%
# ( 10x) test_spell..........: 100%
"""
>>> test_spell = Spell(name="test_spell", number=1, spell_type="heal",
... chance_to_cast=.5, charges=10)
>>> print(test_spell)
( 10x) test_spell..........: 50%
"""
# TODO: add spell_type output
return f"({self.charges:>3}x) " \
f"{self.name.ljust(20, '.')}: " \
f"{int(self.chance_to_cast * 100):>3}%"
@dataclass
class Scroll(Item, Spell):
self_destruct_after_reading: bool = True
def read(self):
# FIXME: call Book read function?
if self.self_destruct_after_reading:
print(f"The scroll {self.name} vanishes in a puff of smoke!")
# TODO: determine player object holding it, remove from inventory
class MapScroll(Spell):
def __init__(self, map_level):
self.map_level = map_level
self.self_destruct_after_reading: False
def __str__(self):
return f"A level {self.map_level} map scroll"
@dataclass
class PlayerStats:
str_: int = 0 # This one has an underscore at the end because 'str' is a reserved Python keyword
dex: int = 0
con: int = 0
int_: int = 0 # 'int' is also reserved
wis: int = 0
cha: int = 0
@dataclass
class Character:
"""
For these attributes, since it can be either a string or None, the type is: str | None
(This only works in Python 3.10+. If you're using an older version,
upgrade. Otherwise you can use the typing library, which would look like: name: Optional[str] = None)
Details here: https://docs.python.org/3.8/library/typing.html
"""
# inventory is a list of InventoryItems (a custom class which bundles/tracks item name & quntity)
inventory: list = field(default_factory=list)
name: str | None = None
# You can use your other classes here, too. Character.gender will be a Gender object.
gender: Optional[Gender] = None
flag: Optional[Flag] = None
size: Optional[Size] = "man-sized"
agility: Optional[int] = None
max_inventory: int = 10
weapon_readied: Optional[Weapon] = None
# Lists, dicts, and similar data structures are special and need extra handling with 'field()'
def add_to_inventory(self, item_name: [Item | Book | Spell], quantity: int = 1) -> object:
"""
Add an item to the Character's inventory.
:param item_name: thing to add to inventory
:param quantity: number of <item>(s) to add to inventory
:returns True: was successful, False: was not successful
"""
"""
>>> item = InventoryItem("item", 2)
>>> inv = [item]
>>> book = Book(title="Adventurer's Guide")
>>> inv.add_to_inventory(item=book)
>>> self.print_inv()
"""
if self.inventory is None:
self.inventory = []
if len(self.inventory) == self.max_inventory:
print("You can't carry any more.")
return False
else:
if item_name in self.inventory:
# add to the quantity of an existing item:
self.inventory[item_name].count += quantity
else:
# add a new item of the specified quantity:
new_item = InventoryItem(item_name, quantity)
self.inventory.append(new_item)
return True
def remove_from_inventory(self, item_name: str, quantity: int = 1):
if item_name in self.inventory:
item = self.inventory[item_name]
item.count -= quantity
if item.count <= 0:
del self.inventory[item_name]
else:
print(f"You don't have any {item_name} in your inventory.")
# inventory is defined in Character parent class
def print_inventory(self):
logging.info(f"print_inventory(): {self.inventory=}")
if not self.inventory:
print(f"{self.name} is not carrying anything.")
else:
armor_carried = []
books_carried = []
drink_carried = []
food_carried = []
items_carried = []
spells_carried = []
weapons_carried = []
for index, item in enumerate(self.inventory, start=1):
logging.info(f"{item.item_name=}, {item.quantity}")
logging.info(f"{item.item_name} is {type(item.item_name)}")
# if isinstance(item, Armor):
# armor_carried.append(item)
if isinstance(item.item_name, Book):
books_carried.append(item)
# elif isinstance(item, Food):
# food_carried.append(item)
elif isinstance(item.item_name, Item):
items_carried.append(item)
elif isinstance(item.item_name, Spell):
spells_carried.append(item)
elif isinstance(item.item_name, Weapon):
weapons_carried.append(item)
else:
logging.info(f"{item} is of class {type(item)}")
self.list_inventory_category("Food", food_carried)
self.list_inventory_category("Drink", drink_carried)
self.list_inventory_category("Book", books_carried)
self.list_inventory_category("Item", items_carried)
self.list_inventory_category("Spell", spells_carried,
header=" (Qty.) Spell Name..........: Cast %")
self.list_inventory_category("Weapon", weapons_carried)
def list_inventory_category(self, category: str, item_list: list, header: str = None):
if item_list:
print(f"{category}:") if len(item_list) == 1 else print(f'{category}s:')
print()
if header:
print(header)
for index, item in enumerate(item_list, start=1):
if item.quantity == 1:
print(f'{index}) {item.item_name}')
else:
print(f'{index}) {item.item_name} ({item.quantity}x)')
print()
else:
# print("You are not carrying anything.")
logging.info(f"{item_list=}")
def pronoun(self, type: str, uppercase=False):
"""
Return the character's pronoun in several different forms:
personal: he / she
possessive: his / hers
:param type: pronoun type
:param uppercase: True if this starts a sentence
:return: str
"""
if type == "personal":
temp = "he" if self.gender == Gender.MALE else "she"
if type == "possessive":
temp = "his" if self.gender == Gender.MALE else "hers"
return temp.title() if uppercase else temp
@dataclass
class PlayerCharacter(Character):
"""
>>> volca = PlayerCharacter(name="Volca", gender=Gender.MALE)
>>> volca.name
'Volca'
>>> # The type of this is Gender:
>>> volca.gender
<Gender.MALE: 'male'>
>>> # The type of this is str
>>> volca.gender.value
'male'
"""
description: str = None
room_number: int = 1
stats: dict[PlayerStats] = field(default_factory=dict)
race: Optional[str] = None
class_: Optional[str] = None
horse_rider: bool = True
def __str__(self):
return self.description
class InventoryItem:
"""Class to store and display an inventory item's name and quantity"""
def __init__(self, item: Item, quantity: int = 1):
self.item = item
self.quantity = quantity
def __str__(self):
if self.quantity > 1:
return f"{self.item.name} ({self.quantity}x)"
else:
return f"{self.item.name}"
# Room must be defined after PlayerCharacter:
@dataclass
class Room:
number: Optional[int] # e.g., int | None
title: str
description: str
items: Optional[list[Item]] = field(default_factory=list)
players: Optional[list[PlayerCharacter]] = field(default_factory=list)
exits: [Optional[int], Optional[int], Optional[int], Optional[int]] = field(default_factory=list)
# exits are defined in north, east, south, west order
class UserCmd:
def __init__(self, *args):
"""
TODO: for later expansion, when there's a difference between admin-only and user commands
"""
print(f"UserCmd: called {args[0]}")
class Game:
def __init__(self):
self.running = True
# Map commands to functions
self.commands = {
"go": self.cmd_go, # Example function for a "go" command
"help": self.cmd_help, # Example function for a "help" command
"i": self.cmd_inventory,
"inv": self.cmd_inventory,
"l": self.cmd_look,
"look": self.cmd_look, # Example function for a "look" command
"quit": self.cmd_quit, # Example function to quit the game
# TODO: Add more commands and their corresponding functions here
}
# currently used when LOOKing at yourself to reply with the proper pronoun:
# TODO: expand code to refer to "it" and "here"
self.pronouns = {"me": "yourself",
"you": "yourself"}
self.nouns = ["sword", "lance", "spell", "book"]
room_0 = Room(number=0, # this is a placeholder so room # accesses are not off-by-one
title="empty",
description="empty",
items=None,
players=None,
exits=[None, None, None, None])
room_1 = Room(number=1,
title="NW Room",
description="You are in the northwest room.",
items=[lance],
exits=[None, 2, None, None],
players=[volca])
room_2 = Room(2, "NE Room", "You are in the northeast room.",
items=[howling],
exits=[None, None, None, 1],
players=[elissa, strolik])
self.game_map = [room_0, room_1, room_2]
# utility functions ############################################################################
def get_players_in_room(self, character: PlayerCharacter) -> list:
"""
Returns a list containing player names in the same room as the given character,
excluding the character themselves.
The verb ("is"/"are") to make the sentence grammatically correct depends on how
many names are in the list, and is handled by grammatical_player_list().
:returns None: if no characters in room
"""
# TODO: exclude_caller: bool, whether or not to include caller's character name in results;
# e.g. exclude_caller: True to list players in room without listing yourself
# or exclude_caller: False to broadcast a message to all players in room including the caller
players_in_room = []
room = self.game_map[character.room_number]
# Print room.players before the loop in get_players_in_room to inspect player data.
logging.info(f"{room.players=}")
"""
>>> game.game_map[1].players[0].name
'Volca'
"""
if room.players:
for p in room.players:
if p.name != character.name:
players_in_room.append(p.name)
return players_in_room if room.players else None
def look_room(self, character: PlayerCharacter):
"""Show room description (if enabled) and players in room"""
room = self.game_map[character.room_number]
# Display room title and description
print(room.title)
print(room.description)
print()
if room.items:
for i in room.items:
print(f"You see {i.name}.")
available_directions = []
for i, e in enumerate("nesw"):
if room.exits[i]:
available_directions.append(cardinal_directions[e])
# Combine direction names into a comma-separated string
travel_message = f"You may travel {', '.join(available_directions)}."
print(travel_message) # Output: You may travel North, East, South.
player_names_list = self.get_players_in_room(character)
if player_names_list:
"""
Desired example output:
"Elissa is here."
"Elissa and Strolik are here."
"Elissa, Strolik and Volca are here."
"""
logging.info(f"look_room: {player_names_list=}")
print(f"\n{self.grammatical_player_list(player_names=player_names_list)}")
def parser(self, character: PlayerCharacter):
while self.running:
# look at room
print()
self.look_room(character)
print()
command_line = input("What now? ")
if command_line:
args = command_line.split() # .append(dict["character", character])
command = args[0].lower() # Get the first word as the command, lowercase it
args = list(args[1:]) # Keep the remaining words as arguments
logging.info(f"{command=} {args=}")
# TODO: refer to a noun as "it", player as "them", room as "here", etc.
# self.it = something
if command in self.commands:
# Call the appropriate function with any arguments
self.commands[command](character, *args) # Expand arguments using *args
else:
print("I don't understand that command.")
# game commands #################################################################################
def cmd_go(self, character: PlayerCharacter, *args):
"""Travel in the direction <dir>"""
"""
:param character: Character object
:returns: None
"""
direction = args[0][0].lower()
if direction in ["n", "e", "s", "w"]:
dir_index = "nesw".index(direction)
# Access character's current room number:
current_room_number = character.room_number
# TODO: check for room flags, obstacles preventing travel
target_room_number = self.game_map[current_room_number].exits[dir_index]
if target_room_number:
character.room_number = target_room_number
print(f"You move to the {cardinal_directions[direction]}.")
else:
print("You can't go that way.")
else:
print("Invalid direction.")
def cmd_help(self, character: PlayerCharacter, *args):
"""Displays help information for commands. 'help <topic>' displays help for <topic>."""
logging.info("Running 'help' command.")
if args:
# Get the function object based on the first argument
try:
func = getattr(self, args[0]) # Access the function from the class
print(func.__doc__) # Print the function's docstring
except AttributeError:
print(f"No help available for '{args[0]}'.")
else:
print("Available commands:")
# return longest command name length, plus 4 extra for spaces at either end:
command_names = self.commands.items()
longest_command = len(max([x for x in command_names], key=len)) + 8
for command, func in command_names:
# Print command name and its docstring
cmd = command + " "
print(f"{cmd.ljust(longest_command, '.')} {func.__doc__}")
def cmd_inventory(self, character: PlayerCharacter, *args):
"""Show items in your inventory"""
logging.info("Running 'inventory' command.")
character.print_inventory()
def cmd_look(self, character: PlayerCharacter, *args):
"""
'look' at the room
'look <thing>' to look at <thing>
'look <dir>' to look at a room in <dir>ection.
"""
logging.info(f"look: {args}")
if len(args) == 0:
# assuming 'look [here]'
self.look_room(character)
else:
# Access the actual object based on the argument:
# pass character doing the LOOKing, and the base command:
target = self.get_object_by_name(character, args[0])
if target:
if isinstance(target, PlayerCharacter):
print(f"You look at {target.name}:")
print()
print(target.description)
if target.weapon_readied:
print(f"\n{target.pronoun(type='personal', uppercase=True)} "
f"has a {target.weapon_readied} at the ready.")
print(f"{target.pronoun(type='personal', uppercase=True)} is carrying:")
if target.inventory:
for i in target.inventory:
print(f" {i}")
else:
print("nothing")
elif isinstance(target, Room):
# Handle looking at rooms
print(target.description)
elif isinstance(target, Item):
# Handle looking at items
print(target.description)
else:
# Handle other object types
pass
else:
print(f"You can't see {args[0]} clearly.")
def cmd_quit(self, character: PlayerCharacter):
"""Quit the game."""
logging.info("Running 'quit' command.")
self.running = False
# TODO: save character
print("Quitting.")
# helper functions #######################################################
def get_object_by_name(self, character: PlayerCharacter, object_name_request):
"""Retrieves an object from the game world by its name.
:param character: character doing the request
:param object_name_request: object
:returns object_name: if object found, False if not found
"""
# TODO: look for an item in the character's inventory,
# or does PlayerCharacter.in_inventory() fulfil this need?
# look for an item in the current room:
"""
game.game_map[2].players[0].name
'Elissa'
"""
# Search for the object in relevant collections or data structures
room = self.game_map[character.room_number]
# try to match item in room:
for item in room.items:
if item.name.lower() == object_name_request.lower():
logging.info(f"{item.name} matches")
return item
# else:
# print("There are no items in this room.")
# try to match an item in the room:
for item in room.items: # Assuming you have a list of rooms
logging.info(f"{item}")
if item.name.lower() == object_name_request.lower():
logging.info(f"{item.name} matches")
return item
# Check for players present in room:
for player in room.players:
if player.name.lower() == object_name_request.lower():
logging.info(f"{player.name} matches")
return player
# If not found, return None
return None
def grammatical_item_list(self, item_list: list | str):
result_list = []
for item in item_list:
if item.endswith("s"):
result_list.append(f"some {item}")
elif item.startswith(('a', 'e', 'i', 'o', 'u')):
result_list.append(f"an {item}")
else:
result_list.append(f"a {item}")
# tanabi: Add 'and' if we need it
if len(result_list) > 1:
result_list[-1] = f"and {result_list[-1]}"
# Join it together
return ", ".join(result_list)
def grammatical_player_list(self, player_names: list[str] | str) -> str:
"""
>>> print(grammatical_player_list(player_names=['Elissa']))
'Elissa is here.' # if only one player is here.
>>> print(grammatical_player_list(player_names=['Elissa', 'Strolik']))
'Elissa and Strolik are here.' # if two players are here.
>>> print(self.grammatical_player_list(player_names=['Elissa', 'Volca', 'Strolik']))
'Elissa, Volca and Strolik are here.' # if three or more players are here.
"""
if len(player_names) == 1:
# single player in room:
return f"{player_names[0]} is here."
if type(player_names) is list:
logging.info(f"{player_names=}")
if len(player_names) == 2:
# 2 players in room:
return f"{' and '.join(player_names)} are here."
elif len(player_names) > 2:
# 2+ players in room:
# Use conditional join to insert "and" before the last element
return f"{', '.join(player_names[:-1])} and {player_names[-1]} are here."
def header(self, message: str):
# TODO: Player.header will know Player.terminal_width / Player.translation
return f"{message.title()}\n{'-' * len(message)}\n"
if __name__ == '__main__':
doctest.testmod(verbose=False)
logging.basicConfig(level="INFO")
cardinal_directions = {"n": "North", "e": "East", "s": "South", "w": "West"}
# define game items
adventurers_guide = Book(number=1, title="Adventurer's Guide",
description="A worn pamphlet that gives off an aura of understated "
"importance.",
text="""The novice adventurer should take these words to heart:
1) Eat and drink often enough to keep thy health up!
2) READY a weapon before encountering the demon!
3) Shields are of little value unless put to use!
4) Armor carried is armor wasted!
5) Mapping thy journey saves time & lives!
6) Acquisitions made in haste can prove hazardous to thy health!
7) Choose thy weapons well, for the strongest may not always be the most
effective.
....courtesy of The Adventurers Widows & Orphans Guild""")
amulet = Item(number=4, name="Amulet of Life", weight=1,
description="A glittering amulet.")
heal_spell = Spell(name="Midas Touch", number=1, spell_type=SpellTypes.HEAL,
chance_to_cast=.5, charges=10)
howling = Book(number=13, title="The Howling",
description="A faded old book. The cover depicts a shaggy human-like creature howling "
"at the moon.",
text="Be it known that to kill a werewolf requires a special craft--one made of silver.")
lance = Weapon(number=None, name="lance", weight=15,
description="A wooden lance.")
sword = Weapon(number=1, name="sword", weight=5, handedness=Handedness.ONE_HANDED,
description="A sharp-looking short sword.")
# FIXME: to make Volca's sign readable, it must currently be of type Book. Perhaps this could
# be fixed later, and a Sign class of items created.
okapi_sign = Book(number=19, title="World Okapi Day",
description="Volca is holding a sign.",
text="World Okapi Day: October 18th.")
# Players:
volca = PlayerCharacter(name="Volca", gender=Gender.MALE, room_number=1,
description="Volca is a nice okapi! He's Elissa's friend.",
inventory=[okapi_sign])
elissa = PlayerCharacter(name="Elissa", gender=Gender.FEMALE, flag=None,
description="A vixen, Volca's friend.", room_number=1)
strolik = PlayerCharacter(name="Strolik", gender=Gender.MALE, flag=None,
description="A goat, Elissa's friend.", weapon_readied=lance)
# FIXME: Game() depends on objects being referenced prior to calling it, so define them above here
game = Game()
# game.header(message="Instantiate player 'Strolik'")
strolik.add_to_inventory(item_name=adventurers_guide)
# game.header(message="Instantiate player 'Volca'")
# print(volca.name)
# print(volca.gender.value)
# print(volca)
volca.add_to_inventory(sword, 1)
volca.add_to_inventory(howling)
volca.add_to_inventory(item_name=lance, quantity=2)
volca.add_to_inventory(heal_spell, 5)
game.header("Start game using Volca")
game.parser(volca)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment