diff --git a/.gitignore b/.gitignore index 01356bf..fafe0f2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__/ +node_modules/ .vscode/ -clicker.py/ \ No newline at end of file +clicker.py/ diff --git a/.tasks b/.tasks new file mode 100644 index 0000000..55c6a7d --- /dev/null +++ b/.tasks @@ -0,0 +1,10 @@ + +[run] +command=DISPLAY=:0 python main.py +cwd=. +output=terminal + +[run-tty] +command=termite --hold -e "bash -lic \"python main.py\"" +cwd=. +output=terminal diff --git a/actionmenu.py b/actionmenu.py new file mode 100644 index 0000000..7b7c9d3 --- /dev/null +++ b/actionmenu.py @@ -0,0 +1,242 @@ +""" +Action Menu +----------- + +An action menu holds a list of actions (a key binding and a function), +a dictionary of arbitrary arguments to pass between menus, +and a menu queue (a list defining what the next menu(s) should be). + +* * * + +The next_menu() method pops the next menu off the queue and returns it. +If the menu queue is empty, next_menu() will return 'self', causing the next +menu to be itself. + +* * * + +The handle_input(cmd) method should be called whenever the Player either +presses a key (then 'cmd' will be the character corresponding to that key), or +types a command (activated by clicking ':' and typing a command, then 'cmd' +will be the entered command) + +If 'cmd' matches any of the bindings in the menu's list of actions, then the +corresponding action should be called. + +If an action was called, return next_menu(), otherwise return 'self'. + +* * * + +The ActionMenu class should be subclassed to create different menus. +In the __init__() method of the subclass, use the @self.add_option +decorator to add the decorated function as an action to the menu. + +If the action needs to pass an argument to the next menu, set a key in the +'self.args' dictionary. 'self.args' will be passed to the next menu. +""" + +from actions import QuickAction + + +class ActionMenu: + """A handler for the action menu.""" + + def __init__(self, game, **kwargs): + + # a reference to the game + self.game = game + + # a list of actions in this menu + self.action_list = [] + + # an optional dict of arguments to pass state between menus + self.args: dict = kwargs or {} + + def handle_input(self, cmd: str) -> 'ActionMenu': + """Handle an input from the player (a key or a command). + Return the menu to display after handling the input. """ + + for action in self.action_list: + if cmd == action.key: + action() + return self.next_menu() + + # no key matches; don't change the menu + return self + + def next_menu(self) -> 'ActionMenu': + """Pops the next menu in the stack and return it. + + If the popped menu is the last menu in the stack, additionally + run the function inside args['end_fn']. + """ + + if 'menu_stack' in self.args: + + # pop the next menu from the queue + menu_cls = self.args['menu_stack'].pop(0) + + # print(f'going to menu: {menu_cls.__name__} {[menu.__name__ for menu in self.args["menu_stack"]]}') + # print(self.args) + + # if the queue is empty, run the end function + if len(self.args['menu_stack']) == 0: + self.args['end_fn'](self.args) + # if the end function changed the player's location, + # update the room + self.game.set_current_room() + # then continue the game + self.game.game_state = 'running' + + return menu_cls(self.game, **self.args) + + return self + + def add_option(self, key: str, desc: str = 'a custom action', menu_stack=None): + """Decorator that creates a QuickAction and adds it to this menu.""" + + if menu_stack: + main = True + else: + main = False + menu_stack = self.args['menu_stack'] + + def register_inner(func): + + def do_action(): + self.args['menu_stack'] = menu_stack + if main: + # set the wrapped function to run at the end; + # then goto the next menu + self.args['end_fn'] = func + else: + # run the wrapped function; + # then goto the next menu + func() + + # add an instance of this class to our list of actions + self.action_list.append(QuickAction(key, desc, do_action)) + + return func + + return register_inner + + +class InventoryMenu(ActionMenu): + """A menu that lists all items in the player's inventory.""" + + def __init__(self, game, **kwargs): + super().__init__(game, **kwargs) + + for i, item in enumerate(list(self.game.player.inventory.keys())): + + @self.add_option(str(i+1), f'select {item.name}') + def _select_item(self=self, item=item): + # self.game.log(f'chose {item.name}') + self.args['item'] = item + + +class BodyPartMenu(ActionMenu): + """A menu that lists all the player's body parts that can hold an item.""" + + def __init__(self, game, **kwargs): + super().__init__(game, **kwargs) + + for i, part in enumerate(self.game.player.body.get_equippable_parts()): + + @self.add_option(str(i+1), f'select {part.name}') + def _select_part(self=self, part=part): + # self.game.log(f'chose {part.name}') + self.args['part'] = part + + +class EntityMenu(ActionMenu): + + def __init__(self, game, **kwargs): + super().__init__(game, **kwargs) + + # add 'yourself' as an option + @self.add_option('1', 'yourself') + def _self_attack(): + self.args['ent'] = self.game.player + + for i, ent in enumerate(self.game.room.entities): + + @self.add_option(str(i+2), f'{ent.get_name()}') + def _attack(ent=ent): + self.args['ent'] = ent + + +class MainMenu(ActionMenu): + + def __init__(self, game, **kwargs): + super().__init__(game, **kwargs) + + # movement action + for action in self.game.get_movement_actions(): + self.add_option(action.key, action.desc, [MainMenu])(action.do_action) + + # TODO: look action + # TODO: examine action + @self.add_option('x', 'Examine...', [EntityMenu, MainMenu]) + def _examine(args): + ent = args['ent'] + check = self.game.player.ability_check(10, 'INT') + if check: + self.game.log(f'You examine the {ent.get_name()}. {check}') + self.game.log(f'The abilities of {ent.get_name()} are:') + for ability, score in ent.ability_stats.items(): + self.game.log(f'{ability}: {score} ({score+2} - {score+12})') + else: + self.game.log(f'You fail to examine the {ent.get_name()}. {check}') + + @self.add_option('e', 'Equip an item...', [InventoryMenu, BodyPartMenu, MainMenu]) + def _equip(args): + item, part = args['item'], args['part'] + part.held_item = item + self.game.log(f'your {part.name} is now holding {item.name}') + + @self.add_option('t', 'Take...', [EntityMenu, MainMenu]) + def _take(args): + ent = args['ent'] + self.game.player.give_item(ent) + # self.game.log(f'You take the {ent.get_name()}') + + @self.add_option('k', 'Attack...', [EntityMenu, MainMenu]) + def _attack(args): + self.game.player.attack(args['ent']) + + @self.add_option('l', 'Look around', [MainMenu]) + def _look(args): + """Get a description of the room based on what the player sees. + If a skill check is provided, change the description based + on the value of the skill check. + """ + room = self.game.room + + if not room: + return "You are out of bounds." + + lines = [] + + check = self.game.player.ability_check(2, 'WIS') + + self.game.info(f'You look around the room... {check}') + + if not check: # failed check + lines += ["You cannot see anything."] + + else: + if room.light_level == 0: + lines += ["It is too dark to see anything."] + + else: + # get number of paths + num_paths = len(self.game.get_movement_actions()) + lines += [f"There are {num_paths} paths."] + + # get entities in room + for ent in room.entities: + lines += [f"You see a {ent.get_name()}."] + + self.game.log(' '.join(lines)) + diff --git a/actions.py b/actions.py index 2e4a33d..8599283 100644 --- a/actions.py +++ b/actions.py @@ -1,79 +1,244 @@ #!/usr/bin/python3 env -from player import Player + +from vector import vec2 +from typing import ( + TYPE_CHECKING, + Callable +) + +if TYPE_CHECKING: + from player import Player + + +def format_actions(actions: list) -> str: + """Formats a list of actions as a user-readable string.""" + + lines = [f'{ action.key }: { action.desc }' for action in actions] + + return '\n'.join(lines) + + +def format_vector(vec: vec2) -> str: + """Formats a vector as a user-readable string.""" + + lines = [] + + if vec.x > 0: + lines.append(f'up by { vec.x }') + elif vec.x < 0: + lines.append(f'down by { -vec.x }') + + if vec.y > 0: + lines.append(f'right by { vec.y }') + elif vec.y < 0: + lines.append(f'left by { -vec.y }') + + return 'Move ' + 'and'.join(lines) class Action(): - def __init__(self, method, name, hotkey, **kwargs): - self.method = method - self.hotkey = hotkey - self.name = name - self.kwargs = kwargs + """An action that can be taken.""" + + def __init__(self, desc: str, fn=None): + # the description of this action + self.desc = desc or "unknown action" + + self.fn = fn or self.do_action + + def do_action(self): + """Run the action.""" + pass + + def __call__(self): + self.fn() + + +class QuickAction(Action): + """An action that is bound to a hotkey.""" + + def __init__(self, key: str, desc: str, fn=None): + Action.__init__(self, desc, fn) + + # the hotkey that will run this action + self.key = key + + @staticmethod + def register(actions: list, key: str, desc: str = 'a custom action'): + """Decorator that creates an Action and adds it to a list.""" + + def register_inner(func): + + # define a custom class that runs the wrapped fn + class CustomAction(QuickAction): + def __init__(self): + QuickAction.__init__(self, key, desc) + + def do_action(self): + return func() + + # add an instance of this class to our list of actions + actions.append(CustomAction()) + + return func + return register_inner + + +class CommandAction(Action): + """An action that is bound to a command.""" + + def __init__(self, terms: list, desc: str): + Action.__init__(self, desc) + + # the list of command terms that will run this action + self.terms = terms + + def do_command(self, *args): + pass + + def do_action(self): + return self.do_command() + + @staticmethod + def register(actions: list, terms: list, desc: str = 'a custom action'): + """Decorator that creates an Action and adds it to a list.""" + + def register_inner(func): + + # define a custom class that runs the wrapped fn + class CustomAction(CommandAction): + def __init__(self): + CommandAction.__init__(self, terms, desc) + + def do_command(self, *args): + return func(*args) + + # add an instance of this class to our list of actions + actions.append(CustomAction()) - def __str__(self): - return '{}: {}'.format(self.hotkey, self.name) + return func + return register_inner -class MoveUp(Action): - def __init__(self): - super().__init__( - method=Player.move_up, - name='Move up', - hotkey='w' - ) +def create_action(desc: str, fn: Callable, key: str = '', terms: list = []) -> Action: + """Create a new action.""" + if key: + # make a quick action + class CustomAction(QuickAction): + def __init__(self): + super().__init__(key, desc) + def do_action(self): + fn() -class MoveDown(Action): - def __init__(self): - super().__init__( - method=Player.move_down, - name='Move down', - hotkey='s' - ) + elif terms: + # make a command action + class CustomAction(CommandAction): + def __init__(self): + super().__init__(terms, desc) + def do_command(self, *args): + fn(*args) -class MoveRight(Action): - def __init__(self): - super().__init__( - method=Player.move_right, - name='Move right', - hotkey='d' - ) + else: + # make a normal action + class CustomAction(Action): + def __init__(self): + super().__init__(desc) + def do_action(self): + fn() -class MoveLeft(Action): - def __init__(self): - super().__init__( - method=Player.move_left, - name='Move left', - hotkey='a' - ) + return CustomAction() -class ViewInventory(Action): +class PlayerAction(Action): + """An action that is run on a player.""" + + def __init__(self, player: 'Player', key: str, desc: str): + + super().__init__(desc) + + self.key = key + + # the player to run this action on + self.player = player + + +class MoveUp(PlayerAction): + + def __init__(self, player): + super().__init__(player, 'w', 'move up') + + def do_action(self, *args): + self.player.move_up() + + +class MoveDown(PlayerAction): + + def __init__(self, player): + super().__init__(player, 's', 'move down') + + def do_action(self, *args): + self.player.move_down() + + +class MoveRight(PlayerAction): + + def __init__(self, player): + super().__init__(player, 'd', 'move right') + + def do_action(self, *args): + self.player.move_right() + + +class MoveLeft(PlayerAction): + + def __init__(self, player): + super().__init__(player, 'a', 'move left') + + def do_action(self, *args): + self.player.move_left() + + +class Attack(PlayerAction): + + def __init__(self, player: 'Player', enemy): + super().__init__(player, 'k', 'Attack') + self.enemy = enemy + + def do_action(self): + self.player.attack(self.enemy) + + +class Flee(PlayerAction): + + def __init__(self, player: 'Player'): + super().__init__(player, 'j', 'Flee!') + + def do_action(self): + self.player.flee() + + +class CheckInventory(PlayerAction): """Prints the player's inventory""" - def __init__(self): - super().__init__( - method=Player.print_inventory, - name='View inventory', - hotkey='i' - ) - - -class Attack(Action): - def __init__(self, enemy): - super().__init__( - method=Player.attack, - name='Attack', - hotkey='k', - enemy=enemy - ) - - -class Flee(Action): - def __init__(self, tile): - super().__init__( - method=Player.flee, - name='Flee!', - hotkey='j', - tile=tile - ) + def __init__(self, player): + super().__init__(player, 'i', 'View inventory') + + def do_action(self): + + desc = self.player.describe_inventory() + self.player.game.log(desc) + + +class CheckBodyAction(PlayerAction): + + def __init__(self, player): + super().__init__(player, 'c', '(debug) check body') + self.player = player + + def do_action(self): + lines = ['You have:'] + for part in self.player.body.parts.values(): + lines.append(f'{part.name} ({ part.status }) -> {part.attachments}') + + self.player.game.log('\n'.join(lines)) diff --git a/body.py b/body.py new file mode 100644 index 0000000..21ede02 --- /dev/null +++ b/body.py @@ -0,0 +1,196 @@ + +import random + +from dataclasses import dataclass, field +from typing import ( + TYPE_CHECKING, + Optional, + List, + Dict +) + + +if TYPE_CHECKING: + from items import Item + + +@dataclass +class BodyPart: + + # the name of the part + name: str + + # a list of parts (by name) that this part is attached to. + # if this part is deattached, then these parts will be too. + attachments: list = field(default_factory=lambda: []) + + # a list of greater attachments that this part is attached to. + # if any part in this list is deattached, then this part will be + # deattached. + main_attachments: list = field(default_factory=lambda: []) + + # the status of the body part + status: str = 'healthy' + + # the weight used in probability checks. + # the lower the number, the less likely this body part will get + # targeted + weight: float = 100 + + # if true, will kill the entity instantly if disattached + is_vital: bool = False + + # can this part hold an item? + can_hold_item: bool = False + + # can this part be used to attack? (unarmed) + can_attack_bare: bool = False + + # can this part attack with a weapon? + can_attack_armed: bool = False + + # the item this part is holding (if can_hold_item) + held_item: 'Optional[Item]' = None + + # TODO: per-part health + + def get_base_damage(self): + """Get the damage of the weapon this part is holding, or the damage of + this body part if it can attack unarmed.""" + + if self.can_attack_armed and self.held_item: + return self.held_item.damage + + elif self.can_attack_bare: + return 1.0 + + else: + return 0.0 + + +class Body: + """A representation of parts composing an entitys' body.""" + + def __init__(self, game, ent): + + # the game instance + self.game = game + + # the entity that this body belongs to + self.owner = ent + + self.parts: Dict[str, BodyPart] = {} + + self.max_health = 100 + + self.health = self.max_health + + def is_alive(self) -> bool: + return self.health > 0 + + def get_health_perc(self): + """Get the health of the body as a percentage.""" + + return self.health / self.max_health + + def add_part(self, bodypart: BodyPart): + + self.parts[bodypart.name] = bodypart + + def deattach_part(self, partname: str): + + bodypart = self.parts.get(partname) + if bodypart: + + self.game.log(self.owner.str_possessive + ' ' + partname + ' has flown off !') + self.remove_part(bodypart) + + def get_equippable_parts(self) -> List[BodyPart]: + """Get all body parts that can hold an item.""" + + return [part for part in self.parts.values() + if part.can_hold_item] + + def equip_item(self, part: BodyPart, item: 'Item') -> bool: + """Set an item to be held by a body part.""" + + if part.can_hold_item: + part.held_item = item + return True + else: + return False + + def get_targetable_parts(self): + """Get all body parts that can be targeted e.g. not missing.""" + + return [part for part in self.parts.values() + if part.status != 'missing'] + + def pick_part(self): + """Pick a random part of the body using weighted + probability distribution. + + Use this when not targeting any particular body part. + """ + + parts = self.get_targetable_parts() + weights = [part.weight for part in parts] + + if len(parts) == 0: + return None + else: + return random.choices(parts, weights)[0] + + def remove_part(self, bodypart: BodyPart): + """Remove this part and any attachments from the body.""" + + bodypart.status = 'missing' + for partname in bodypart.attachments: + child = self.parts.get(partname) + if child: + self.remove_part(child) + + def hurt(self, damage: int, bodypart=None): + + self.health -= damage + + # choose a random bodypart to target + bodypart = bodypart or self.pick_part() + + if bodypart is None: + self.game.log('No body part to target!') + + else: + # self.deattach_part(bodypart.name) + # return the bodypart that was damaged + return bodypart + + +class HumanoidBody(Body): + + def __init__(self, game, ent): + + super().__init__(game, ent) + + self.add_part(BodyPart("head", [], is_vital=True, weight=10)) + + self.add_part(BodyPart("chest", [ + "head", "left_arm", "right_arm", "left_leg", "right_leg" + ], is_vital=True, weight=100)) + + self.add_part(BodyPart("left_arm", ["left_hand"], ["chest"], weight=20)) + self.add_part(BodyPart("left_hand", [], ["left_arm"], weight=5, + can_attack_armed=True, can_attack_bare=True, can_hold_item=True + )) + + self.add_part(BodyPart("right_arm", ["right_hand"], ["chest"], weight=20)) + self.add_part(BodyPart("right_hand", [], ["right_arm"], weight=5, + can_attack_armed=True, can_attack_bare=True, can_hold_item=True + )) + + self.add_part(BodyPart("left_leg", ["left_foot"], ["chest"], weight=20)) + self.add_part(BodyPart("left_foot", [], ["left_leg"], weight=5)) + + self.add_part(BodyPart("right_leg", ["right_foot"], ["chest"], weight=20)) + self.add_part(BodyPart("right_foot", [], ["right_leg"], weight=5)) + diff --git a/colors.py b/colors.py new file mode 100644 index 0000000..c441c5e --- /dev/null +++ b/colors.py @@ -0,0 +1,44 @@ + +import os +import subprocess + + +def _get_colors(): + + try: # attempt to get the current colors used by pywal. + res = subprocess.run(['cat', os.path.expanduser('~/.cache/wal/colors')], + stdout=subprocess.PIPE) + lines = res.stdout + colors = [] + + if lines: + for line in lines.splitlines(): + # print('{} {} {}'.format(line[1:3], line[3:5], line[5:7])) + r = int(line[1:3], 16) + g = int(line[3:5], 16) + b = int(line[5:7], 16) + colors.append((r, g, b)) + + return colors + + except Exception: + return [(0, 0, 0), + (255, 255, 255), + (255, 255, 255), + (255, 255, 255), + (255, 255, 255), + (255, 255, 255), + (255, 255, 255), + (255, 255, 255) + ] * 2 + + +class Colors: + """A colorscheme to use for the game.""" + + def __init__(self): + # try to automatically find a colorscheme + self.colors: list = _get_colors() + + def __getitem__(self, i): + return self.colors[i] diff --git a/design.md b/design.md new file mode 100644 index 0000000..8e4af01 --- /dev/null +++ b/design.md @@ -0,0 +1,261 @@ +A text-based RPG. + +Gameplay +-------- + +The game will be designed to be turn-based and take place in a system made up of +rooms. + +- On the player's turn, they can perform an action. There will be two different + types of actions: "quick actions" triggered by pressing the key assigned to + that action, and "command actions" triggered by pressing a command entry + button (:) and typing a command. + +- On an NPC's (or enemy's) turn, they can think() in order to decide what action + to do, and afterwards will trigger that action. + +Turn Order +---------- + +When around other NPCs, a turn order will be decided. On an NPC's turn, they +will decide what action to do, and on the player's turn, a list of avaliable +actions will be displayed before prompting the player for input. + +Logic Loop +----------- + +On enter new room: init turn order + +For NPC turn => run think() to decide action then do action (takes 1 game tick) +For player turn => change gamestate to "input", process player input while in +this state, do decided action, then change gamestate to "running" + +Body Part System +---------------- + +All creatures have a body made up of a set of body parts. Internally this +is defined as a list of part names, each with references to what other body +parts they are connected to. + +A body part has different flags that define how it can be used and how it +behaves. + +`status: str`: the state of the part, + - `'healthy'`: the body part is functioning normally. + - `'missing'`: the body part is no longer attached to the rest of the body. +The body part cannot be targeted if it is missing. + - `'burnt'` + - `'poisoned'` + - `'mangled'`: the body part is attached but no longer functions. +`weight`: the weight to use in probability checks (not actual weight). +These weights are used to determine a targeted body part when an enemy attacks +blindly. +`is_vital`: if true, damaging/removing this part will kill the entity +instantly. +`can_hold_item`: if true, is able to hold items i.e. hands +`can_attack_unarmed`: if true, is able to attack unarmed i.e. hands and feet +`can_attack_armed`: if true, is able to attack armed i.e. hands + +Creature Stats +-------------- + +All creatures have a set of stats which can be used when processing certain +actions. In general, `0` indicates no skill in that stat, `10` indicates average +skill, and `20` indicates professional skill. + +NOTE: right now there is no upper limit for a stat. + +For now the list of stats are similar to DnD. + +- `STR`: strength - used to determine power when attacking +- `DEX`: dexterity - used to determine profeciency with weapons and the result + of certain actions such as dodging or jumping +- `CON`: constituition - used to determine effect of getting hit +- `INT`: intelligence - used to determine result of reading or examining + objects, and also effeciency when talking to NPCs +- `WIS`: wisdom - used to determine general awareness of the sitation, to + determine results of looking around (or whether to try to look around) +- `CHR`: charisma - used to determine effect when talking to NPCs, possibly to + persuade, barter, etc. + +Stat Checks +----------- + +Some actions may require checks against one or more stats. These actions will +provide a "difficulty" for each checked stat to determine the stats needed to +successfully perform the action. + +#### Check Algorithm #### + +When a check is needed for an action (`difficulty` is set by the action): + +(?) The base value of the stat is added to two D6 rolls. + +`(base stat) + (2D6) = (total stat)` + +For example, if the action is to jump a small gap with the difficulty being +`{DEX: 5}`, and the player's base DEX stat is `2`, and the result of two D6 +rolls are a `4` and `3`, then the `total stat` is: + +`2 + 4 + 3 = 9` + +and since `9 >= 5`, the player passes the check. + +(?) Catastrophic Failure: if a player rolls snake eyes +(double 1s => a 1/36 chance of 2.778%), then the `total stat` will be half the +base stat. + +#### Check Results #### + +The result are a set of _success rates_ (percentages) which can be used to +determine how successful the check was. + +If a result is needed to check if an action passes/fails, then check if +`(success rate) >= 1.0`. + +If a result is needed to check the effectiveness of an action, then use the +success rate on its own. (`1.0 => 100% effectiveness, 2.0 => 200% +effectiveness`) + + +Items +----- + +Items are a type of entity and represent any object that can be picked up, held, +wielded, worn, etc. + +All items carry this metadata: + +`name`: the actual name of the item i.e `Gold Coin` +`description`: the description of this item (what does this item look like?) +i.e. `A flat, round object made of gold` +`explanation`: an explanation of this item (what is the purpose of this item?) +i.e. `Used as currency` +`value`: the price value per item i.e. `1 Gold (or whichever +currency system is being used)` + +examine stats: + +`describe_req`: the INT required to describe this object +`explain_req`: the INT required to explain this object + +damage-related stats: + +`phys_damage`: the damage that this item can deal if wielded +`proj_damage`: the damage that this item can deal if thrown/shot + +`is_blunt`: if true, this item can deal `blunt force` damage +`is_sharp`: if true, this item can `cut` or `slice` +`is_pointed`: if true, this item can `stab` + +Action: Examining +----------------- + +[`examine`, `ex`, `x`] + +Requirements: `INT` + +Attempt to examine something. + +Examining an entity will show the description and explanation of it if `INT >= +item.explain_req`. +Examining an entity will show only the description if `INT >= item.describe_req`. +Examining an entity will show `"You fail to identify what this object is"` if +`INT < item.describe_req`. + +- `examine `: try to examine this object + +Action: Looking +--------------- + +[`look`, `l`] + +Requirements: `INT` + +Attempt to look around the room. Can be affected by lighting conditions and/or +visibility of entities. + +Action: Attacking +----------------- + +[`attack`, `k`] + +Requirements: `STR`, `DEX`, `CON` + +Attack an entity physically i.e. swinging a sword or punching + +`STR` and `DEX` are checked (when attacking) to determine the damage output and +accuracy. +`DEX` is checked (when being attacked) to determine if the attack can be dodged. +`CON` is checked (when getting hit) to determine the damage taken. + +- `attack blindly... `: will attack an enemy at a random part (easy roll) +- `attack precisely... ... `: will attack an enemy at a specific + part (harder roll, but easier to hit a specific part) + +Inanimate objects (like items) can be attacked with mixed results. + +Action: Throw +------------- + +[`throw`, `shoot`] + +Requirements: `DEX` + +Throw or shoot something. Counts as a ranged attack. + +`STR` and `DEX` are checked (when attacking) to determine the damage output and +accuracy. +`DEX` is checked (when being attacked) to determine if the attack can be dodged. +`CON` is checked (when getting hit) to determine the damage taken. + +- `throw... ... `: will throw or shoot an object at an entity. + +NOTE: `shooting` an object that is a tool to shoot ammo will instead shoot the +ammo. + +Action: Open +------------ + +[`open`] + +Requirements: `STR`, `INT`, `WIS` + +Open something, like a door, chest, etc. + +`INT` is checked to identify that the object can be opened. +(on fail, assume it can) +`WIS` is checked to identify if the object should be opened. (if rigged etc.) +(on fail, open it anyway) +If locked, `INT` is checked to see if it can be picked. +(otherwise, try `attacking` the object instead) +`STR` is checked to open the object. (might be high for heavy objects like stone +doors etc.) + +(?) Creatures can be "opened" with mixed results. + +Action: Jump +------------ + +[`jump`, `leap`, `cross`] + +Requirements: `INT`, `DEX` + +Jump a gap or similar obstacle. Also extends to navigating traps, etc. + +`DEX` is checked to determine success of navigating the obstacle. + +Action: Scream +-------------- + +[`scream`, `yell`, `scare`] + +Requirements: `CHR` + +Scream. + +Screaming will make anything living notice you. +`CHR` is checked to determine if it will also scare them. + + + diff --git a/docs/ai.md b/docs/ai.md new file mode 100644 index 0000000..55438d2 --- /dev/null +++ b/docs/ai.md @@ -0,0 +1,10 @@ + +# Behavior of AI + +Enemies +------- + +If enemies are not aware of you, they will roll to look around every turn. + +If enemies _notice_ your presence, they will choose to attack you every turn. + diff --git a/elements/__init__.py b/elements/__init__.py new file mode 100644 index 0000000..85acff0 --- /dev/null +++ b/elements/__init__.py @@ -0,0 +1,3 @@ +""" +Contains objects that can be drawn to the screen. +""" diff --git a/elements/map.py b/elements/map.py new file mode 100644 index 0000000..ee7b2cf --- /dev/null +++ b/elements/map.py @@ -0,0 +1,45 @@ + +from vector import vec2 +from pyglet import (text, shapes, graphics) +from typing import (TYPE_CHECKING) + + +if TYPE_CHECKING: + from game import Game + + +class Map: + """A top-down map showing the player's current location.""" + cols: int = 10 + rows: int = 10 + size: int = 20 + + def __init__(self, game: 'Game', x: int, y: int): + + self.pos: vec2 = vec2(x, y) + + self.game = game + + self.batch = graphics.Batch() + + self.e_boxes = [] + + for pos in self.game.world.room_map.keys(): + + box = shapes.Rectangle( + x + (pos.x * Map.size), y + ((Map.rows - pos.y) * Map.size), + Map.size, Map.size, + color=(50, 50, 50), batch=self.batch + ) + self.e_boxes.append(box) + + self.e_player = shapes.Rectangle(x, y, Map.size, Map.size, color=(255, 255, 255), batch=self.batch) + + def draw(self): + + x, y = self.game.player.pos + + self.e_player.x = self.pos.x + (x * Map.size) + self.e_player.y = self.pos.y + ((Map.rows - y) * Map.size) + + self.batch.draw() diff --git a/elements/text.py b/elements/text.py new file mode 100644 index 0000000..822cecb --- /dev/null +++ b/elements/text.py @@ -0,0 +1,58 @@ + +import pyglet + +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from game import Game + + +class Text: + + def __init__(self, game: 'Game', x, y, text: str, style: str = 'normal', + fg=None, bg=None, alpha: float = 1.0, + padding=10): + + def _get_color(idx, default): + try: + return game.colorscheme[idx] + except Exception: + return default + + if not fg or type(fg) is int: + self.fg_color = _get_color(fg, (255, 255, 255)) + (int(255 * alpha),) + else: + self.fg_color = fg + + if not bg or type(bg) is int: + self.bg_color = _get_color(bg, (0, 0, 0)) + else: + self.bg_color = bg + + self.e_label = pyglet.text.Label( + text, + font_name=['Courier New', 'Hack'], + font_size=11, + x=x, y=y, + width=800, + multiline=True, + anchor_y='bottom', + color=self.fg_color, + ) + + self.e_background = pyglet.shapes.Rectangle( + x - padding/2, y - padding/2, + self.e_label.content_width + padding, + self.e_label.content_height + padding, + color=self.bg_color + ) + + def draw(self): + self.e_background.draw() + self.e_label.draw() + + +if __name__ == '__main__': + # test get_colors() + get_colors() diff --git a/enemies.py b/enemies.py index 9e42a75..6d04a75 100644 --- a/enemies.py +++ b/enemies.py @@ -1,29 +1,48 @@ #!/usr/bin/python3 env +from entity import LivingEntity +from player import Player +from actions import create_action + + +class Enemy(LivingEntity): + + def __init__(self, game, name, damage): + + super().__init__(game, name) -class Enemy: - def __init__(self, name, hp, damage): - self.name = name - self.hp = hp self.damage = damage - def is_alive(self): - return self.hp > 0 + #TODO: be able to set HP + + def think(self): + + # find player + for ent in self.game.get_entities(): + if type(ent) is Player: + + def _attack(): + # self.game.log(f'{self.name} is attacking {ent.get_name()}!', fg=1) + self.attack(ent) + + return create_action('', _attack) + + return super().think() class GiantSpider(Enemy): - def __init__(self): - super().__init__( + def __init__(self, game): + super().__init__(game, name='Giant Spider', - hp=10, + # hp=10, damage=2 ) class Ogre(Enemy): - def __init__(self): - super().__init__( + def __init__(self, game): + super().__init__(game, name='Ogre', - hp=30, + # hp=30, damage=15 ) diff --git a/entity.py b/entity.py new file mode 100644 index 0000000..3047b84 --- /dev/null +++ b/entity.py @@ -0,0 +1,134 @@ + +import typing +import body +import random +from stats import AbilityStats +from actions import Action, create_action + + +if typing.TYPE_CHECKING: + from game import Game + + +class Entity: + """Represents an entity.""" + + def __init__(self, game: 'Game', name: str): + + # a reference to the game + self.game: 'Game' = game + + # the name of the entity + self.name = name + + def get_name(self) -> str: + return self.name + + def __eq__(self, o): + + if type(self) == type(o): + return self.name == o.name + else: + return self == o + + def __hash__(self): + return hash(self.name) + + +class LivingEntity(Entity, AbilityStats): + """Represents an entity that has a body and is able to die.""" + + def __init__(self, game: 'Game', name: str): + + Entity.__init__(self, game, name) + AbilityStats.__init__(self) + + # the body of the entity + self.body = body.HumanoidBody(game, self) + + def hurt(self, damage): + return self.body.hurt(damage) + + def get_health(self) -> int: + return self.body.health + + def get_name(self) -> str: + if self.is_alive(): + return self.name + else: + return 'corpse of a ' + self.name + + def is_alive(self) -> int: + return self.body.is_alive() + + def attack(self, ent: Entity): + """Attack an entity.""" + + # attacker checks + dmg_check = self.ability_check(10, 'STR') + hit_check = self.ability_roll('DEX', 3) + + # get body parts that can attack + parts = [part for part in list(self.body.parts.values()) + if part.can_attack_bare or + (part.can_attack_armed and part.held_item)] + + if len(parts) == 0: + self.game.log( + f'{self.name} tries to attack, but has nothing to attack with!' + ) + return + + # TODO: be able to choose a specific way to attack + # for now automatically find the best weapon + part = parts[0] + for _part in parts: + if _part.get_base_damage() > part.get_base_damage(): + part = _part + + # calculate damage to deal + + dmg = 1 + + if part.can_attack_armed and part.held_item: + # self.game.log( + # f'{self.name} attacks {ent.get_name()} with {part.name} holding {part.held_item.name}!' + # ) + dmg = part.held_item.damage + weapon = part.held_item + elif part.can_attack_bare: + # self.game.log( + # f'{self.name} attacks {ent.get_name()} with the {part.name}!' + # ) + weapon = part + # TODO: implement unarmed damage stat + + # calculate effect of damage + + if isinstance(ent, LivingEntity): + # defender checks + dodge_check = ent.ability_check(sum(hit_check), 'DEX') + + if dodge_check: + # self.game.log(f'The attack misses!') + self.game.log(f'{self.name} misses an attack on {ent.get_name()}!') + else: + self.game.log(f'{self.name} hits {ent.get_name()} with a {weapon.name}!') + def_check = ent.ability_check(10, 'CON') + + dmg = int(dmg * dmg_check.get_percent()) + 1 + hurt_part = ent.hurt(dmg) + self.game.info(f'{ent.get_name()} takes {dmg} damage to the {hurt_part.name}!') + if not ent.is_alive(): + self.game.info(f'{ent.get_name()} has died') + + else: + self.game.log('It does nothing!') + + def think(self) -> 'Action': + """Decide an action to take for this entity's turn.""" + + def _do_nothing(): + self.game.log(f'{self.name} is doing nothing.') + + return create_action('do nothing', _do_nothing) diff --git a/game.py b/game.py index a763321..724892c 100644 --- a/game.py +++ b/game.py @@ -1,28 +1,184 @@ #!/usr/bin/python3 env + import world +import actions +from typing import (TYPE_CHECKING, List) + +from actionmenu import MainMenu +from colors import Colors +from tiles import Room +from turns import TurnManager +from dataclasses import dataclass +from vector import vec2 from player import Player +from mixins.logger import Logger + +QuickAction = actions.QuickAction +CommandAction = actions.CommandAction + + +if TYPE_CHECKING: + from screen import CursesScreen + + +class Game(Logger): + + def __init__(self): + + Logger.__init__(self, 20) + + # self.scr: 'CursesScreen' = scr + + # the current colorscheme to use + self.colorscheme = Colors() + + # a string describing the current situation + self.status = '' + + # the next key to read from the user + self.input_key = "" + + # the current room the player is in + self.room = None + + self.turns = None + + # load the world + self.world = world.parse_world(self) + + # the current Player instance + self.player: Player = Player(self) + + self.player.pos = vec2(2, 4) + + # the state of the game + # 'input' -> game is waiting for player input + # 'running' -> time is progressing + self.game_state = 'input' + + self.menu = MainMenu(self) + + # the state of the action menu + # tells the game what actions to display + self.menu_state = 'main' + + # holds an item previously selected through the menu + self.menu_selected_item = None + + # holds the command string that is being typed by the user + self.menu_cmd_buffer = '' + + # if true, try to quit the game + self.quit = False + + # update the current room + self.set_current_room() + + print('loaded a new game') + + def set_current_room(self): + + # self.log(f'retrieving room @ { self.player.pos }', 8) + # get the tile at the current position + self.room = self.world.get_room_at(self.player.pos) + self.turns = TurnManager(self, self.room.entities + [self.player]) + self.turns.start_order() + + # TODO: Room.intro_text() is deprecated + + def get_entities(self): + """Get all entities (in the current room).""" + + return self.room.entities + [self.player] + + def do_tick(self): + """Calculate one unit of time.""" + + if self.room: + self.room.modify_player(self.player) + + def on_key_pressed(self, key): + """Set the next key to input and update the game.""" + + if self.game_state != 'input': return # noqa: E701 + + if self.menu_state == 'cmd': + # add the last pressed key to the cmd buffer + if key == 'KEY_BACKSPACE' or key == 'backspace': + # remove last character + self.menu_cmd_buffer = self.menu_cmd_buffer[:-1] + elif key == 'space': + self.menu_cmd_buffer += ' ' + elif key == '\n' or key == 'enter': + # TODO: run a command + # self.log('running command: ' + self.menu_cmd_buffer) + self.run_command(self.menu_cmd_buffer) + self.menu_cmd_buffer = '' + self.menu_state = 'main' + else: + self.menu_cmd_buffer += key + + elif key == ':' or key == 'colon': + # set the menu to cmd mode + self.menu_state = 'cmd' + + elif self.player.is_alive() and not self.player.victory: + # check to do any actions + self.menu = self.menu.handle_input(key) + # for action in self.get_actions(): + # if key == action.key: + # action.do_action() + # self.set_current_room() + # # resume game + # self.game_state = 'running' + # # self.do_tick() + # break + + def get_room(self, x, y) -> str: + """Get a room by its coordinates.""" + + return self.world.get_room_at(vec2(x, y)) + + def update(self): + """Update the game.""" + + if self.game_state == 'running': + # run next game tick + self.turns.run_next() + + def get_commands(self) -> List[CommandAction]: + """Get all commands that the player can do.""" + + commands: List[CommandAction] = [] + + @CommandAction.register(commands, ['help'], 'get a list of commands') + def _cmd_help(*args): + self.log('There is no help.') + + @CommandAction.register(commands, ['quit'], 'quit the game') + def _cmd_quit(*args): + self.quit = True + + return commands + + def run_command(self, cmd: str): + + args = cmd.split(' ') + root = args[0] + args = args[1:] + + for command in self.get_commands(): + if root in command.terms: + command.do_command(args) + return + + self.log(f'I don\'t know what you mean by "{cmd}"') + + def get_movement_actions(self) -> list: + """Get all movement actions that the player can do.""" + # TODO: move this into actionmenu.py + return self.world.get_movement_actions(self.player.pos, self.player) -def play(): - world.load_tiles() - player = Player() - room = world.tile_exists(player.location_x, player.location_y) - print(room.intro_text()) - while player.is_alive() and not player.victory: - room = world.tile_exists(player.location_x, player.location_y) - room.modify_player(player) - # Check again since the room could have changed the player's state - if player.is_alive() and not player.victory: - print('Choose an action:\n') - available_actions = room.available_actions() - for action in available_actions: - print(action) - action_input = input('Action: ') - for action in available_actions: - if action_input == action.hotkey: - player.do_action(action, **action.kwargs) - break - - -if __name__ == '__main__': - play() +# if __name__ == '__main__': + # play() diff --git a/inventory.py b/inventory.py new file mode 100644 index 0000000..eebd23c --- /dev/null +++ b/inventory.py @@ -0,0 +1,62 @@ + +import typing +from entity import Entity + + +if typing.TYPE_CHECKING: + from game import Game + + +class InventoryHolder(Entity): + """An object that can hold an inventory.""" + + def __init__(self, game: 'Game'): + + Entity.__init__(self, game, 'Unnamed Inventory') + + self.game: 'Game' = game + + # a dict of inventory items and stack amount + self.inventory = {} + + def has_item(self, name: str): + """Return if this inventory has an item (by name).""" + + return any([item.name == name for item in list(self.inventory.keys())]) + + def give_item(self, *items): + """Add items to this inventory.""" + + for item in items: + if self.inventory.get(item): + self.inventory[item] += 1 # increment the stack + else: + self.inventory[item] = 1 # initialize the stack to 1 + + item_str = ', '.join([item.name for item in items]) + self.game.log(f'{self.name} picked up {item_str}') + + def remove_item(self, name: str): + """Remove an item from this inventory. + + If multiple items with the same name exist, only remove one of them. + """ + + found = False + for i, item in enumerate(self.inventory): + if item.name == name: + del self.inventory[i] + found = True + break + + return found + + def describe_inventory(self) -> str: + """Get a description of this inventory in readable format.""" + + lines = ["Inventory:"] + + for item, amount in self.inventory.items(): + lines += [f"- {item.name} ({amount})"] + + return '\n'.join(lines) diff --git a/items.py b/items.py index 8fef072..681c82e 100644 --- a/items.py +++ b/items.py @@ -1,45 +1,59 @@ #!/usr/bin/python3 env +from entity import Entity -class Item(): + +class Item(Entity): """The base class for all items""" - def __init__(self, name, description, value): - self.name = name + def __init__(self, game, name, description, value, amount=1): + + Entity.__init__(self, game, name) + + # a description of the item self.description = description + + # the price of this item (per item) self.value = value + # damage when attacking + self.damage = 1 + def __str__(self): return "{}\n=====\n{}\nValue: {}\n".format( - self.name, self.description, self.value + self.name, self.description, self.amount ) class Gold(Item): - def __init__(self, amount): - self.amount = amount + def __init__(self, game, value): super().__init__( - name="Gold", + game, + name=f"{value} Gold", description="A round coin with {} stamped on the front.".format( - str(self.amount) + str(value) ), - value=self.amount + value=value, ) class Weapon(Item): - def __init__(self, name, description, value, damage): + def __init__(self, game, name, description, value, damage): + + super().__init__(game, name, description, value) + + # the base damage of the weapon self.damage = damage - super().__init__(name, description, value) def __str__(self): return "{}\n=====\n{}\nValue: {}\nDamage: {}".format( self.name, self.description, self.value, self.damage - ) + ) class Rock(Weapon): - def __init__(self): + def __init__(self, game): super().__init__( + game, name="Rock", description="A fist-sized rock, suitable for bludgeoning.", value=0, @@ -48,8 +62,9 @@ def __init__(self): class Dagger(Weapon): - def __init__(self): + def __init__(self, game): super().__init__( + game, name="Dagger", description="A small dagger with some rust." "Somewhat more dangerous than a rock.", diff --git a/main.py b/main.py new file mode 100644 index 0000000..9358d66 --- /dev/null +++ b/main.py @@ -0,0 +1,134 @@ + +from vector import vec2 +from screen import CursesScreen +from elements.map import Map +from elements.text import Text +from game import Game +from entity import LivingEntity + +from pyglet import (window, app, text, clock, gl) +import re +import actions + + +win = window.Window(1280, 720) +game = Game() + +map_size = vec2(10, 10) +# map_center = map_size // 2 +map_center = vec2(0, 0) +map_view = None + + +@win.event +def on_key_press(key, modifiers): + + if key == window.key.Q: + win.close() + + keymap = { + '_1': '1', + '_2': '2', + '_3': '3', + '_4': '4', + '_5': '5', + '_6': '6', + '_7': '7', + '_8': '8', + '_9': '9', + } + + strkey = str(window.key.symbol_string(key)).lower() + strkey = keymap.get(strkey, strkey) + + game.on_key_pressed(strkey) + + +l_title = Text(game, win.width // 2, win.height - 20, + 'Welcome to Super Fuck You', 'normal', + fg=0, bg=7 +) + + +def draw_text(x, y, t: str, style: str = 'normal', fg=None, bg=None, alpha=1.0, *args, **kwargs): + + label = Text(game, x, win.height-y, t, style, fg, bg, alpha=alpha) + label.draw() + + +e_map = Map(game, 800, 20) + +# set clear color +gl.glClearColor(game.colorscheme[0][0]/255.0, + game.colorscheme[0][1]/255.0, + game.colorscheme[0][2]/255.0, 1) + + +@win.event +def on_draw(): + + width, height = win.width, win.height + + win.clear() + + l_title.draw() + + e_map.draw() + + draw_text(21, 100, ( + "Location: " + str(game.player.pos) + "\n" + "State: " + game.game_state + )) + + # draw actions + # draw_text(width - 400, 200, actions.format_actions(game.get_actions()), bg=8) + draw_text(width - 400, 200, actions.format_actions(game.menu.action_list), bg=8) + + # draw console messages + con_y = height - 60 # start y position of console + color = [255, 255, 255, 255] + alpha = 1.0 + for i, msg in enumerate(game.get_log()): + draw_text(50, con_y, msg.text, style=msg.style, fg=msg.fg, bg=msg.bg, + alpha=alpha) + con_y -= 25 + if i == 0: + alpha -= 0.1 + else: + alpha -= 0.03 + + # draw health + draw_text(0, height, 'Health: ' + str(game.player.get_health()), + fg=0, bg=9) + + # draw health of other entities + hp_y = height + for ent in [ent for ent in game.room.entities if isinstance(ent, LivingEntity)]: + draw_text(width - 200, hp_y, f'{ent.get_name()}: {ent.get_health()}', fg=0, bg=11) + hp_y -= 25 + + # draw turns + draw_text(16 * 12, height-1, + f' Turn: {game.turns.get_last_entity().name}, Order: {[ent.get_name() for ent in game.turns.order]} ', + fg=0, bg=10) + + # draw fps + # draw_text(width - 16, height - 1, + # ' FPS: {:.2f} '.format(scr.fps), + # fg=0, bg=11) + + # draw command buffer + if game.menu_state == 'cmd': + draw_text(12, height-20, ':' + game.menu_cmd_buffer, 'standout') + + # win.flip() + + +def on_update(dt): + game.update() + + +clock.schedule_interval(on_update, 1.0/60.0) + +# scr.start() +app.run() diff --git a/main_curses.py b/main_curses.py new file mode 100644 index 0000000..ee529ef --- /dev/null +++ b/main_curses.py @@ -0,0 +1,221 @@ + +from vector import vec2 +from screen import CursesScreen +from game import Game + +from pyglet import (window, app, text, clock) +import re +import actions + + +scr = CursesScreen() +win = window.Window(1280, 720) +game = Game() + +map_size = vec2(10, 10) +# map_center = map_size // 2 +map_center = vec2(0, 0) +map_view = None + + +# @scr.init +def on_init(scr: CursesScreen): + pass + + # global map_view + # map_view = scr.create_view(0, 1, *map_size) + +# gl draw loop +# ------------ + + +@win.event +def on_key_press(key, modifiers): + + if key == window.key.Q: + win.close() + + keymap = { + '_1': '1', + '_2': '2', + '_3': '3', + '_4': '4', + '_5': '5', + '_6': '6', + '_7': '7', + '_8': '8', + '_9': '9', + } + + strkey = str(window.key.symbol_string(key)).lower() + strkey = keymap.get(strkey, strkey) + + game.on_key_pressed(strkey) + + +l_title = text.Label( + 'Welcome to Super Fuck You', + font_name='Fantasque Sans Mono', + font_size=12, + x=win.width//2, y=win.height-20, + anchor_x='center' +) + + +def draw_text(x, y, t: str, *args, **kwargs): + + label = text.Label(t, + font_name='Fantasque Sans Mono', + font_size=12, + x=x, y=win.height-y, + width=800, + multiline=True, + color=kwargs.get('color', (255, 255, 255, 255)) + # anchor_y='bottom' + ) + + label.draw() + + +@win.event +def on_draw(): + + width, height = win.width, win.height + + win.clear() + + l_title.draw() + + draw_text(21, 100, ( + "Location: " + str(game.player.pos) + "\n" + "State: " + game.game_state + )) + + # draw actions + draw_text(width - 400, 200, actions.format_actions(game.get_actions())) + + # draw console messages + con_y = height - 60 # start y position of console + color = [255, 255, 255, 255] + for msg in game.get_log(): + draw_text(50, con_y, msg.text, style=msg.style, fg=msg.fg, bg=msg.bg, color=tuple(color)) + con_y -= (msg.text.count('\n') + 1) * 20 # increment y by # of lines + color[3] -= 10 + + # draw health + draw_text(0, height, 'Health: ' + str(game.player.get_health()), + fg=0, bg=9) + + # draw turns + draw_text(16 * 12, height-1, + f' Turn: {game.turns.get_last_entity().name}, Order: {[ent.get_name() for ent in game.turns.order]} ', + fg=0, bg=10) + + # draw fps + # draw_text(width - 16, height - 1, + # ' FPS: {:.2f} '.format(scr.fps), + # fg=0, bg=11) + + # draw command buffer + if game.menu_state == 'cmd': + draw_text(12, height-20, ':' + game.menu_cmd_buffer, 'standout') + + win.flip() + + +def on_update(dt): + game.update() + + +clock.schedule_interval(on_update, 1.0/60.0) + + +# curses draw loop +# ---------------- + +# @scr.render +def on_draw(scr: CursesScreen, key: str): + + if game.quit: + scr.end() + return + + global map_view + + game.on_key_pressed(key) + game.update() + + width, height = scr.get_size() + + title = "~ Welcome to Super Fuck You ~" + + scr.draw_text(0, 0, title.center(width), style='standout') + scr.draw_text(21, 2, ( + "Location: " + str(game.player.pos) + "\n" + "Last Keypress: " + str(key) + "\n" + "State: " + game.game_state + )) + + # desc = re.sub(' +', ' ', game.get_description()) + # scr.draw_text(21, 5, '\n'.join(textwrap.wrap(desc, width-21))) + + # draw actions + scr.draw_text(width - 24, 2, actions.format_actions(game.get_actions())) + + # draw console messages + con_y = 15 # start y position of console + for msg in game.get_log(): + scr.draw_text(0, con_y, msg.text, style=msg.style, fg=msg.fg, bg=msg.bg) + con_y += msg.text.count('\n') + 1 # increment y by # of lines + + # draw health + scr.draw_text(0, height-1, 'Health: ' + str(game.player.get_health()), + fg=0, bg=9) + + # draw turns + scr.draw_text(16, height-1, + f' Turn: {game.turns.get_last_entity().name}, Order: {[ent.get_name() for ent in game.turns.order]} ', + fg=0, bg=10) + + # draw fps + scr.draw_text(width - 16, height - 1, + ' FPS: {:.2f} '.format(scr.fps), + fg=0, bg=11) + + # draw command buffer + if game.menu_state == 'cmd': + scr.draw_text(12, height-1, ':' + game.menu_cmd_buffer, 'standout') + + scr.set_view(map_view) + + # map_view.box() + + # fill screen except room locations + for x in range(-int(map_size.x // 2), int(map_size.x // 2)): + for y in range(-int(map_size.y // 2), int(map_size.y // 2)): + try: + room = game.get_room(x, y) + if room is None: + scr.draw_text(x + map_center.x, y + map_center.y, u'█') + except Exception: + pass + + world_pos = game.player.pos + map_center + scr.draw_text(world_pos.x, world_pos.y, "@".encode("UTF-8")) + + scr.draw_outline() + + # for x in range(center[0] - game_map_size[0], game_map_size[0] - center[0]): + # for y in range(center[1] - game_map_size[1], game_map_size[1] - center[1]): + # try: + # room = game.get_room(x, y) + # if room is None: + # draw_text(x + center[0], y + center[1], u'█') + # except: + # pass + + # if world_pos[0] >= 0 and world_pos[1] >= 0: + # draw_text(world_pos[0], world_pos[1], "@".encode("UTF-8")) + +# scr.start() +app.run() diff --git a/mixins/__init__.py b/mixins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mixins/logger.py b/mixins/logger.py new file mode 100644 index 0000000..63cf65d --- /dev/null +++ b/mixins/logger.py @@ -0,0 +1,45 @@ + +from dataclasses import dataclass +from typing import List + + +@dataclass +class Message(): + text: str + style: str = 'normal' + fg: int = 0 + bg: int = 0 + + +class Logger: + """An object that can log messages.""" + + def __init__(self, log_limit: int = 5): + + # the max amount of messages to display + self.log_limit = log_limit + + # contains all the logged messages + self.log_messages: List[Message] = [] + + def get_log(self) -> List[Message]: + return self.log_messages + + def log(self, text: str, fg=15, bg=0, style='normal'): + """Log a message.""" + + lines = text.splitlines() + + for line in lines: + + # insert to the front of the list + self.log_messages.insert(0, Message(line, style, fg, bg)) + # self.log_messages.append(Message(text, style, fg, bg)) + + # delete last message if above log_limit + if len(self.log_messages) > self.log_limit: + del self.log_messages[self.log_limit] + # del self.log_messages[0] + + def info(self, text: str): + self.log(text, 8) # dark gray diff --git a/player.py b/player.py index 0d21e97..d9c5c8a 100644 --- a/player.py +++ b/player.py @@ -1,27 +1,42 @@ #!/usr/bin/python3 env import items -import world import random +from vector import vec2 +from entity import LivingEntity +from inventory import InventoryHolder + + +class Player(LivingEntity, InventoryHolder): + + def __init__(self, game): + + InventoryHolder.__init__(self, game) + LivingEntity.__init__(self, game, name='You') + + # the position of the player + self.pos = vec2(0, 0) -class Player(): - def __init__(self): - self.inventory = [items.Gold(15), items.Rock()] - self.hp = 100 - self.location_x, self.location_y = world.starting_position self.victory = False - def is_alive(self): - return self.hp > 0 + self.game = game + + self.give_item(items.Gold(game, 15), items.Rock(game)) + + # slots + self.item_slots = {} + + # strings + # ------- - def print_inventory(self): - for item in self.inventory: - print(item, '\n') + self.str_possessive = "Your" + + self.str_name = "You" def move(self, dx, dy): - self.location_x += dx - self.location_y += dy - print(world.tile_exists(self.location_x, self.location_y).intro_text()) + self.pos += (dx, dy) + + # self.game.log(world.tile_exists(self.pos).intro_text()) def move_up(self): self.move(dx=0, dy=-1) @@ -35,29 +50,22 @@ def move_right(self): def move_left(self): self.move(dx=-1, dy=0) - def attack(self, enemy): - best_weapon = None - max_damage = 0 - for item in self.inventory: - if isinstance(item, items.Weapon): - if item.damage > max_damage: - max_damage = item.damage - best_weapon = item - - print('You use {} against {}!'.format(best_weapon.name, enemy.name)) - enemy.hp -= best_weapon.damage - if not enemy.is_alive(): - print('You killed a {}!'.format(enemy.name)) - else: - print('{} HP is {}.'.format(enemy.name, enemy.hp)) - def do_action(self, action, **kwargs): action_method = getattr(self, action.method.__name__) if action_method: action_method(**kwargs) - def flee(self, tile): + def flee(self): """Moves the player randomly to an adjacent tile""" - available_moves = tile.adjacent_moves() + + available_moves = self.game.get_movement_actions() + print(f"av moves: { available_moves }") random_room = random.randint(0, len(available_moves) - 1) + + self.game.log('You have fled to another room') + self.do_action(available_moves[random_room]) + + def think(self): + + self.game.game_state = 'input' diff --git a/renderer/gl.py b/renderer/gl.py new file mode 100644 index 0000000..2b05daa --- /dev/null +++ b/renderer/gl.py @@ -0,0 +1,22 @@ +""" +A GL renderer using Pyglet. +""" + +import pyglet + + +class GLScreen: + """A window managed by Pyglet.""" + + def __init__(self): + + + def set_view(self, view=None): + pass + + def read_input(self) -> str: + return '' + + def create_view + + diff --git a/resources/map.txt b/resources/map.txt index 1aa05b8..8885b6c 100644 --- a/resources/map.txt +++ b/resources/map.txt @@ -1,8 +1,8 @@ - LeaveCaveRoom - FindGoldRoom EmptyCavePath GiantSpiderRoom - EmptyCavePath - EmptyCavePath -GiantSpiderRoom EmptyCavePath StartingRoom EmptyCavePath FindDaggerRoom - FindGoldRoom - EmptyCavePath - OgreRoom \ No newline at end of file + 5 + 1.3 + . + . +3.0.2 + 1 + . + 4 diff --git a/resources/map.txt.old b/resources/map.txt.old new file mode 100644 index 0000000..1aa05b8 --- /dev/null +++ b/resources/map.txt.old @@ -0,0 +1,8 @@ + LeaveCaveRoom + FindGoldRoom EmptyCavePath GiantSpiderRoom + EmptyCavePath + EmptyCavePath +GiantSpiderRoom EmptyCavePath StartingRoom EmptyCavePath FindDaggerRoom + FindGoldRoom + EmptyCavePath + OgreRoom \ No newline at end of file diff --git a/screen.py b/screen.py new file mode 100644 index 0000000..3bc0d84 --- /dev/null +++ b/screen.py @@ -0,0 +1,229 @@ + +import curses +import traceback +import time + + +class CursesScreen: + """A terminal managed by the curses API.""" + + def __init__(self): + + self.stdscr = None + + # the current view - any draw calls will use this view object + self.view = None + + # a list of views + self.views = [] + + # a map of colors + self.colors = {} + + # if false, stop the render loop + self.running = True + + self.fps = 0.0 + + # called on every frame + self.on_render = lambda *args: None + + # called before the render loop starts + self.on_init = lambda *args: None + + # called when a key is pressed + self.on_key_pressed = lambda *args: None + + def render(self, func): + """Set the function used to render a frame.""" + self.on_render = func + return func + + def init(self, func): + """Set the method called before the render loop.""" + self.on_init = func + return func + + def key_pressed(self, func): + """Set the method called before the render loop.""" + self.on_key_pressed = func + return func + + def set_view(self, view=None) -> None: + """Set the active view. + + Any draw calls will use the active view. + """ + + if view is None: + self.view = self.stdscr + else: + self.view = view + + def read_input(self) -> str: + """Read input from the user. + + If the user has pressed a key, the string representation of that key + will be returned. + If not, then an empty string will be returned. + """ + + try: + return self.stdscr.getkey() + except Exception: + return '' + + def create_view(self, x, y, width, height): + """Create a new view.""" + + win = curses.newwin(height, width, y, x) + self.views.append(win) + return win + + def init_colors(self): + """Initialize the color pairs used by curses.""" + + curses.start_color() + curses.use_default_colors() + color_id = 1 + for fg in range(0, 16): + for bg in range(0, 16): + self.colors[(fg, bg)] = color_id + curses.init_pair(color_id, fg, bg) + color_id += 1 + + def get_color(self, fg=None, bg=0): + """Get a color pair by its fg and bg color.""" + return curses.color_pair(self.colors.get((fg, bg), 0)) + + def get_size(self): + """Get the size of the window as a tuple.""" + + yx = self.stdscr.getmaxyx() + return yx[1], yx[0] # flip coordinates + + def start(self): + """Start the curses screen.""" + + # initialize terminal + + self.stdscr = curses.initscr() + curses.noecho() + curses.cbreak() + self.stdscr.nodelay(True) + self.stdscr.keypad(True) + + # initialize colors (16 colors) + + self.init_colors() + + # disable cursor + + curses.curs_set(False) + + # create window + + self.stdscr.refresh() + + # game loop + # --------- + + self.on_init(self) + + # assign some of our methods to new variables to shorten them + + key = None + set_view = self.set_view + + frate = (1.0 / 60.0) + last_ftime = time.time() + + while self.running: + + try: + + key = self.read_input() + + if key == "Q": # debug quit key + self.end() + break + + elif key != '': + self.on_key_pressed(self, key) + + # clear screen + # ------------ + + self.stdscr.erase() + for view in self.views: + view.erase() + + # render + # ------ + + # draws to the entire screen + + set_view() + self.on_render(self, key) + + # display screen + + self.stdscr.refresh() + for view in self.views: + view.refresh() + + ftime = time.time() + + # framerate limiter + if (ftime - last_ftime) < frate: + time.sleep(frate - (ftime - last_ftime)) + ftime = time.time() + + self.fps = 1.0 / (ftime - last_ftime) + + last_ftime = ftime + + # read input (blocks) + # key = self.read_input() + + except Exception: + + self.end() + traceback.print_exc() + break + + def draw_text(self, x, y, text, + style='normal', + fg=None, bg=0): + """Draw text to the screen.""" + + style_map = { + 'normal': curses.A_NORMAL, + 'bold': curses.A_BOLD, + 'italic': curses.A_ITALIC, + 'underline': curses.A_UNDERLINE, + 'standout': curses.A_STANDOUT + } + + attr = style_map.get(style, curses.A_NORMAL) + + color = self.get_color(fg, bg) + + for offset, line in enumerate(text.splitlines()): + try: + self.view.addstr(y + offset, x, line, color | attr) + except Exception: + pass + + def draw_outline(self): + """Draw an outline around the screen.""" + + self.view.box() + + def end(self): + self.running = False + # shut down terminal + curses.nocbreak() + self.stdscr.keypad(False) + curses.echo() + curses.endwin() diff --git a/stats.py b/stats.py new file mode 100644 index 0000000..f866d22 --- /dev/null +++ b/stats.py @@ -0,0 +1,84 @@ + +import random +from dataclasses import dataclass + + +@dataclass +class StatCheck: + + # the ability that was checked + ability: str + + # the minimum stat to "pass" this check + difficulty: int + + # the base stat of the creature + base: int + + # a list of rolls to add onto this base stat + rolls: list + + def get_total(self): + return (self.base + sum(self.rolls)) + + def is_passed(self): + return self.get_total() >= self.difficulty + + def get_percent(self): + return self.get_total() / self.difficulty + + def __str__(self): + eq = str(self.base) + for roll in self.rolls: + eq += f'+{roll}' + + if self.is_passed(): + # return f'({self.ability}?{self.difficulty} => {eq} success)' + return f'({self.ability} {self.difficulty}: success)' + else: + # return f'({self.ability}?{self.difficulty} => {eq} failure)' + return f'({self.ability} {self.difficulty}: failure)' + + def __bool__(self): + return self.is_passed() + + +class AbilityStats: + + def __init__(self): + + self.ability_stats = {} + + points = 20 + abilities = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHR'] + + for ability in abilities: + self.ability_stats[ability] = 0 + + for _ in range(points): + self.ability_stats[random.choice(abilities)] += 1 + + def get_base_ability_score(self, ability): + """Get the score for an ability.""" + return self.ability_stats[ability] + + def ability_roll(self, ability, num_dice=2): + + rolls = [] + for _ in range(num_dice): # number of rolls + rolls.append(random.randint(1, 6)) + + return [self.get_base_ability_score(ability)] + rolls + + def ability_check(self, difficulty, ability): + + rolls = [] + for _ in range(2): # number of rolls + rolls.append(random.randint(1, 6)) + + return StatCheck( + ability, + difficulty, + self.get_base_ability_score(ability), + rolls + ) diff --git a/tiles.py b/tiles.py index e932e66..3d4d615 100644 --- a/tiles.py +++ b/tiles.py @@ -1,14 +1,40 @@ #!/usr/bin/python3 env +import typing import items import enemies import actions -import world +from entity import LivingEntity +from vector import vec2, Direction -class MapTile: - def __init__(self, x, y): - self.x = x - self.y = y + +if typing.TYPE_CHECKING: + from player import Player + from game import Game + + +class Room: + def __init__(self, game: 'Game'): + + self.game: 'Game' = game + + # a list of entities in this room + self.entities = [] + + # a description of this room + self.desc = "a room" + + # the level of brightness in the room + # 0 => pitch black + # 50 => normal + # 100 => blinding + self.light_level = 50 + + # the temperature level in the room + # 0 => freezing + # 50 => normal + # 100 => extremely hot + self.temp_level = 50 def intro_text(self): raise NotImplementedError() @@ -16,76 +42,80 @@ def intro_text(self): def modify_player(self, player): raise NotImplementedError() - def adjacent_moves(self): - """Returns all move actions for adjacent tiles.""" - moves = [] - if world.tile_exists(self.x + 1, self.y): - moves.append(actions.MoveRight()) - if world.tile_exists(self.x - 1, self.y): - moves.append(actions.MoveLeft()) - if world.tile_exists(self.x, self.y - 1): - moves.append(actions.MoveUp()) - if world.tile_exists(self.x, self.y + 1): - moves.append(actions.MoveDown()) - - return moves - - def available_actions(self): + def get_actions(self, ent: LivingEntity) -> list: """Returns all of the available actions in this room.""" - moves = self.adjacent_moves() - moves.append(actions.ViewInventory()) - return moves + return [] + +class StartingRoom(Room): + + def __init__(self, game: 'Game'): + Room.__init__(self, game) -class StartingRoom(MapTile): def intro_text(self): return """ You find yourself in a cave with a flickering torch on the wall. You can make out four paths, each equally as dark and foreboding. - """ + """.strip() def modify_player(self, player): # Room has no action on player pass -class LootRoom(MapTile): - def __init__(self, x, y, item): - self.item = item - super().__init__(x, y) +class LootRoom(Room): + + def __init__(self, game: 'Game', item): - def add_loot(self, player): - player.inventory.append(self.item) + Room.__init__(self, game) + + self.entities.append(item) + + def add_loot(self, player: 'Player'): + for item in [ent for ent in self.entities if isinstance(ent, items.Item)]: + player.give_item(item) + self.entities.remove(item) def modify_player(self, player): self.add_loot(player) -class EnemyRoom(MapTile): - def __init__(self, x, y, enemy): +class EnemyRoom(Room): + + def __init__(self, game: 'Game', enemy): + + Room.__init__(self, game) + + self.entities.append(enemy) self.enemy = enemy - super().__init__(x, y) def modify_player(self, the_player): + if self.enemy.is_alive(): - the_player.hp = the_player.hp - self.enemy.damage - print('Enemy does {} damage. You have {} HP remaining.'.format( - self.enemy.damage, the_player.hp - )) + # TODO: put into an Enemy.attack method + part = the_player.hurt(self.enemy.damage) + self.game.log(f'An enemy hits your { part.name } and ' + f'does { self.enemy.damage } !') + + def get_actions(self, ent: LivingEntity): - def available_actions(self): if self.enemy.is_alive(): - return [actions.Flee(tile=self), actions.Attack(enemy=self.enemy)] + # return [actions.Flee(ent), actions.Attack(ent, enemy=self.enemy)] + return [actions.Flee(ent)] else: - return self.adjacent_moves() + return [] + +class EmptyCavePath(Room): + + def __init__(self, game: 'Game'): + Room.__init__(self, game) -class EmptyCavePath(MapTile): def intro_text(self): return """ Another unremarkable part of the cave. You must forge onwards. - """ + """.strip() def modify_player(self, player): # Room has no action on player @@ -93,65 +123,66 @@ def modify_player(self, player): class GiantSpiderRoom(EnemyRoom): - def __init__(self, x, y): - super().__init__(x, y, enemies.GiantSpider()) + + def __init__(self, game: 'Game'): + EnemyRoom.__init__(self, game, enemies.GiantSpider(game)) def intro_text(self): if self.enemy.is_alive(): return """ A giant spider jumps down from its web in front of you! - """ + """.strip() else: return """ The corpse of a dead spider rots on the ground. - """ + """.strip() class OgreRoom(EnemyRoom): - def __init__(self, x, y): - super().__init__(x, y, enemies.Ogre()) + def __init__(self, game): + super().__init__(game, enemies.Ogre(game)) def intro_text(self): if self.enemy.is_alive(): return """ An Ogre appears from the shadows! - """ + """.strip() else: return """ The corpse of a dead ogre rots on the ground. - """ + """.strip() class FindGoldRoom(LootRoom): - def __init__(self, x, y): - super().__init__(x, y, items.Gold(10)) + def __init__(self, game): + super().__init__(game, items.Gold(game, 10)) def intro_text(self): return """ Your torch lits a faint gold coin in the room. What luck! - """ + """.strip() class FindDaggerRoom(LootRoom): - def __init__(self, x, y): - super().__init__(x, y, items.Dagger()) + def __init__(self, game): + super().__init__(game, items.Dagger(game)) def intro_text(self): return """ Your notice something shiny in the corner. It's a dagger! You pick it up. - """ + """.strip() -class LeaveCaveRoom(MapTile): +class LeaveCaveRoom(Room): def intro_text(self): return """ You see a bright light in the distance... ... it grows as you get closer! It's sunlight! Victory is yours! - """ + """.strip() def modify_player(self, player): player.victory = True diff --git a/turns.py b/turns.py new file mode 100644 index 0000000..71e62d2 --- /dev/null +++ b/turns.py @@ -0,0 +1,58 @@ + +from entity import LivingEntity +from typing import ( + List, + TYPE_CHECKING +) + + +if TYPE_CHECKING: + from game import Game + + +class TurnManager: + """A manager for the turn order of entities.""" + + def __init__(self, game: 'Game', ents: 'List[LivingEntity]'): + + self.game = game + self.ents = ents + self.order = [] + self.idx = 0 + self.last_idx = 0 + + def start_order(self): + """Decide the turn order of the given list of entities.""" + + # for now just use the same order as the list + order = [ent for ent in self.ents + if isinstance(ent, LivingEntity)] + + # if len(order): + # self.game.info(f'** order: {[ent.get_name() for ent in order]} **') + + self.idx = 0 + self.last_idx = 0 + self.order = order + + def get_last_entity(self) -> LivingEntity: + return self.order[self.last_idx] + + def get_current_entity(self) -> LivingEntity: + return self.order[self.idx] + + def run_next(self): + """Run the next turn in the order.""" + + if len(self.order) > 0: + + ent = self.order[self.idx] + + if ent.is_alive(): + # self.game.info(f'it is now {ent.get_name()} turn') + action = ent.think() + if action: action.do_action() + + # increment index + self.last_idx = self.idx + self.idx = (self.idx + 1) % len(self.order) diff --git a/vector.py b/vector.py new file mode 100644 index 0000000..34119fd --- /dev/null +++ b/vector.py @@ -0,0 +1,74 @@ + +import math + +from dataclasses import dataclass + + +@dataclass +class vec2(): + """A 2D vector.""" + + x: float + y: float + + + def distance(self, vec): + """Get the distance from this vector to another.""" + + return math.sqrt( (self.x + vec.x)**2 + (self.y + vec.y)**2 ) + + + def __add__(self, o): + + if type(o) is tuple and len(o) >= 2: + # try to add the first and second elts to our x and y + return vec2(self.x + o[0], self.y + o[1]) + + elif type(o) is vec2: + return vec2(self.x + o.x, self.y + o.y) + + else: + raise TypeError('Cannot add vec2 and ' + type(o).__name__) + + def __eq__(self, o): + + if type(o) is tuple and len(o) >= 2: + # try to add the first and second elts to our x and y + return self.x == o[0] and self.y == o[1] + + elif type(o) is vec2: + return self.x == o.x and self.y == o.y + + else: + return self == o + + def __truediv__(self, o) -> 'vec2': + + if type(o) is vec2: + return vec2(self.x / o.x, self.y / o.y) + else: + return vec2(self.x / o, self.y / o) + + def __floordiv__(self, o) -> 'vec2': + + if type(o) is vec2: + return vec2(self.x // o.x, self.y // o.y) + else: + return vec2(self.x // o, self.y // o) + + def __iter__(self): + + yield self.x + yield self.y + + def __hash__(self): + + return hash((self.x, self.y)) + +class Direction: + + UP: vec2 = vec2(0, -1) + DOWN: vec2 = vec2(0, 1) + LEFT: vec2 = vec2(-1, 0) + RIGHT: vec2= vec2(1, 0) + diff --git a/world.py b/world.py index 6c4f338..1dd8752 100644 --- a/world.py +++ b/world.py @@ -1,6 +1,98 @@ #!/usr/bin/python3 env + +from vector import vec2, Direction +import tiles +import actions + + +class World: + + def __init__(self, game): + + self.game = game + + # a dict of rooms with their position as the key + self.room_map = {} + + def set_room_at(self, pos: vec2, room: tiles.Room): + """Add a room to the map. Will overwrite a room if it exists in the same + location.""" + + self.room_map[pos] = room + + room.game = self.game + + def get_room_at(self, pos: vec2): + """Get a room from the map. Returns None if there is no room at that + location.""" + + return self.room_map.get(pos, None) + + def get_movement_actions(self, pos: vec2, player) -> list: + """Returns all move actions for adjacent tiles.""" + + mapping = { + Direction.UP: actions.MoveUp, + Direction.LEFT: actions.MoveLeft, + Direction.RIGHT: actions.MoveRight, + Direction.DOWN: actions.MoveDown + } + + move_actions = [] + + for direction, action in mapping.items(): + if self.get_room_at(pos + direction): + move_actions.append(action(player)) + + return move_actions + + +room_mapping = { + '.': tiles.EmptyCavePath, + '0': tiles.StartingRoom, + '1': tiles.FindGoldRoom, + '2': tiles.FindDaggerRoom, + '3': tiles.GiantSpiderRoom, + '4': tiles.OgreRoom, + '5': tiles.LeaveCaveRoom, +} + + +def parse_world(game, filename='resources/map.txt') -> World: + """Parse a world file.""" + + world = World(game) + + x, y = 0, 0 # the current world location we are reading at + + print('loading world...') + + with open(filename, 'r') as file: + for c in file.read(): + + if c == '\n': + x = 0 # reset x back to 0 + y += 1 # increment y + continue + + # try to find the room type in our mapping + room_type = room_mapping.get(c, None) + + if room_type: + print(f'\tsetting loc {x, y} to {room_type.__name__}') + world.set_room_at(vec2(x, y), room_type(game)) + + x += 1 + + print('world loaded.') + return world + + +# old parser +# ------------------------------------------------------------------------------ + _world = {} -starting_position = (0, 0) +starting_position = vec2(2, 4) def load_tiles(): @@ -19,6 +111,13 @@ def load_tiles(): __import__('tiles'), tile_name )(x, y) + for x, y in _world: + print(f"{ x }, { y }: { _world[(x, y)] }") + + +def tile_exists(pos: vec2): + return _world.get((pos.x, pos.y)) + -def tile_exists(x, y): - return _world.get((x, y)) +if __name__ == '__main__': + parse_world()