diff --git a/dev-notes.md b/dev-notes.md index 56314aa..89903ae 100644 --- a/dev-notes.md +++ b/dev-notes.md @@ -13,7 +13,7 @@ https://pyinstaller.org/en/stable/ python3 -m venv venv #useful pre-commit hook -lint_results=$(echo "$(grep -Pro '[\t]+$' */*.py)") +lint_results="$(echo "$(grep -Pro '[\t]+$' */*.py)") $(echo "$(grep -Pro 'usable' */*.py)")" lint_errors=$(echo "$lint_results" | wc -w) if [ $lint_errors -gt 0 ]; then echo "pre-commit lint errors found: $lint_errors" @@ -72,6 +72,7 @@ To-wound: If you use IRL time and date as a mechanic, go big or go home. Maybe the horror dimension is only accessible during full/new moons? Should the selected dimension be known at the start of a run? More of a certain dungeon feature in some runs i.e. too many fountains run? + Inventory should show item weights ## Healing Items diff --git a/source/actions.py b/source/actions.py index e474d0c..8a2b486 100644 --- a/source/actions.py +++ b/source/actions.py @@ -8,7 +8,6 @@ from useable import BaseUseable if TYPE_CHECKING: from engine import Engine - from event_handlers import OptionSelector, TileSelector from entity import Entity class BaseAction: @@ -142,6 +141,8 @@ class PickupAction(BaseAction): self.entity.inventory.insert(item) engine.message_log.add_message(msg, color=colors.terminal_light) else: + from event_handlers import OptionSelector + #build an options list options: List[str] = [] for item in item_pile: @@ -263,29 +264,29 @@ class UsageAction(BaseAction): engine: Engine = self.entity.floor_map.engine item: Entity = inventory.access(self.index) - usable: BaseUseable = item.useable + useable: BaseUseable = item.useable #TODO: also check visibility? #check range distance = self.entity.get_distance_to(self.target.x, self.target.y) - if usable.minimum_range > distance: + if useable.minimum_range >= 0 and useable.minimum_range > distance: engine.message_log.add_message("You're too close to use this!", color=colors.terminal_light) return False - elif usable.maximum_range < distance: + elif useable.maximum_range >= 0 and useable.maximum_range < distance: engine.message_log.add_message("You're too far to use this!", color=colors.terminal_light) return False - elif usable.apply(self.target.stats) and usable.is_stack_empty(): + elif useable.apply(self.target) and useable.is_stack_empty(): #remove the item from the inventory inventory.discard(self.index) if self.display_callback: self.display_callback(-1) - msg: str = usable.get_used_msg(item.name) + msg: str = useable.get_used_msg() if not msg: msg = f"What is a(n) {item.name}?" diff --git a/source/ai.py b/source/ai.py index f1db22e..55da0b1 100644 --- a/source/ai.py +++ b/source/ai.py @@ -1,26 +1,37 @@ from __future__ import annotations from typing import List, Tuple, TYPE_CHECKING +import random import numpy as np import tcod -from actions import BaseAction, MeleeAction, MovementAction, WaitAction +from actions import ( + BaseAction, + MeleeAction, + MovementAction, + BumpAction, + WaitAction, +) if TYPE_CHECKING: from entity import Entity class BaseAI: - """Base type for monster AI, with various utilities""" + """Base type for creature AI, with various utilities.""" entity: Entity - def __init__(self, entity): + def __init__(self, entity: Entity): + #grab the entity self.entity = entity + + #utilities self.path: List[Tuple[int, int]] = [] def process(self) -> BaseAction: - """Decides what action to take""" + """Allow the AI to think.""" raise NotImplementedError() + #utils def generate_path_to(self, dest_x: int, dest_y: int) -> List[Tuple[int, int]]: #copy the walkables cost = np.array(self.entity.floor_map.tiles["walkable"], dtype=np.int8) @@ -44,16 +55,18 @@ class BaseAI: class AggressiveWhenSeen(BaseAI): """ - If the player can seem me, try to approach and attack. - Otherwise, idle. + If I'm close enough to the player, attack. + If I see where the player is, approach that point. + If I know where the player has been, approach that point. + If all else fails, idle. """ def process(self) -> BaseAction: - target = self.entity.floor_map.player + target = self.entity.floor_map.player #TODO: friendly fire? xdir = target.x - self.entity.x ydir = target.y - self.entity.y distance = max(abs(xdir), abs(ydir)) - #if the player can see me + #if the player can see me, create or update my path if self.entity.floor_map.visible[self.entity.x, self.entity.y]: #if I'm close enough to attack if distance <= 1: @@ -61,7 +74,7 @@ class AggressiveWhenSeen(BaseAI): self.path = self.generate_path_to(target.x, target.y) - #if I have a path to follow + #if I have a path to follow, regardless of current visability if self.path: dest_x, dest_y = self.path.pop(0) return MovementAction( @@ -72,3 +85,41 @@ class AggressiveWhenSeen(BaseAI): #idle return WaitAction(self.entity) + + +class ConfusedAI(BaseAI): + """ + """ + parent_ai: BaseAI + duration: int + + def __init__(self, entity: Entity, duration: int): + super().__init__(entity) + self.parent_ai = entity.ai + self.duration = duration + + def process(self) -> BaseAction: + if self.duration <= 0: + self.entity.ai = self.parent_ai + return self.entity.ai.process() + + self.duration -= 1 + + xdir, ydir = random.choice( + [ + [ -1, -1], + [ -1, 0], + [ -1, 1], + + [ 0, -1], + #[ 0, 0], + [ 0, 1], + + [ 1, -1], + [ 1, 0], + [ 1, 1], + ] + ) + + #Oh, this allows for friendly fire! + return BumpAction(self.entity, xdir, ydir) \ No newline at end of file diff --git a/source/colors.py b/source/colors.py index 43bff93..f1325e0 100644 --- a/source/colors.py +++ b/source/colors.py @@ -21,6 +21,7 @@ purple = (0x80, 0x00, 0x80) #identical to magenta #CSS extended colors (incomplete selection, may be expanded later) pink = (0xFF, 0xC0, 0xCB) orange = (0xFF, 0xA5, 0x00) +orange_red = (0xFF, 0x45, 0x00) deep_sky_blue = (0x00, 0xBF, 0xFF) navajo_white = (0xFF, 0xDE, 0xAD) #misnomer goldenrod = (0xDA, 0xA5, 0x20) diff --git a/source/entity.py b/source/entity.py index 7d3c6c5..40164f1 100644 --- a/source/entity.py +++ b/source/entity.py @@ -59,6 +59,7 @@ class Entity: if useable: self.useable = useable + self.useable.entity = self #generic entity stuff def spawn(self, x: int, y: int, floor_map: FloorMap): diff --git a/source/entity_types.py b/source/entity_types.py index 47e66b4..c5547dd 100644 --- a/source/entity_types.py +++ b/source/entity_types.py @@ -38,7 +38,7 @@ gobbo_red = Entity( #items - conumables potion_of_healing = Entity( char = "!", - color = colors.deep_sky_blue, + color = colors.green, name = "Potion of Healing", walkable = True, useable=useable.PotionOfHealing(consumable=True, current_stack=1, maximum_stack=255), @@ -52,5 +52,18 @@ scroll_of_lightning = Entity( useable=useable.ScrollOfLightning(consumable=True, current_stack=1, maximum_stack=255, minimum_range=0, maximum_range=6), ) -#TODO: scroll of confusion, using "confused AI" -#TODO: scroll of fireball, dealing AOE damage \ No newline at end of file +scroll_of_confusion = Entity( + char = "!", + color = colors.navajo_white, + name = "Scroll of Confusion", + walkable = True, + useable=useable.ScrollOfConfusion(consumable=True, current_stack=1, maximum_stack=255, minimum_range=1, maximum_range=6), +) + +scroll_of_fireball = Entity( + char = "!", + color = colors.orange_red, + name = "Scroll of Fireball", + walkable = True, + useable=useable.ScrollOfFireball(consumable=True, current_stack=1, maximum_stack=255, minimum_range=1, maximum_range=6), +) \ No newline at end of file diff --git a/source/event_handlers.py b/source/event_handlers.py index fdab855..9be24d5 100644 --- a/source/event_handlers.py +++ b/source/event_handlers.py @@ -128,16 +128,25 @@ class EventHandler(tcod.event.EventDispatch[BaseAction]): class GameoverViewer(EventHandler): """Game over, man, GAME OVER!""" + def __init__(self,engine: Engine, parent_handler: EventHandler): + super().__init__(engine, parent_handler) + #Hacky fix, re-parent until you find the gameplay viewer + while self.parent_handler and self.parent_handler is not GameplayViewer: + self.parent_handler = self.parent_handler.parent_handler + def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[BaseAction]: key = event.sym #SDL stuff, neat. - #player input + #special keys if key == tcod.event.KeySym.ESCAPE: return QuitAction() + #menu keys if key == tcod.event.KeySym.BACKQUOTE: #lowercase tilde self.engine.event_handler = LogHistoryViewer(self.engine, self) + #TODO: read-only inventory viewer + return None @@ -175,7 +184,7 @@ class GameplayViewer(EventHandler): self.engine, self, title = "Debug Selector", - options = ["Tile Selector"], + options = ["Zero", "One", "Two", "Three", "Four", "Five", "Six"], callback = lambda x: self.dbg_callback(x), margin_x = 20, margin_y = 12, @@ -186,16 +195,16 @@ class GameplayViewer(EventHandler): self.engine.mouse_location = (event.tile.x, event.tile.y) def dbg_callback(self, selected: int) -> Optional[BaseAction]: - if selected == 0: - player: Entity = self.engine.player + player: Entity = self.engine.player - self.engine.event_handler = TileSelector( - self.engine, - self, - floor_map = self.engine.floor_map, - initial_pointer = (player.x, player.y), - callback=lambda: None - ) + self.engine.event_handler = TileSelector( + self.engine, + self, + floor_map = self.engine.floor_map, + initial_cursor = (player.x, player.y), + callback=lambda x, y: print(f"Fireball at ({x},{y})"), + cursor_radius=selected + ) return None @@ -371,16 +380,17 @@ class InventoryViewer(EventHandler): """Use the item at the cursor's position.""" if self.length > 0: item: Entity = self.entity.inventory.access(self.cursor) - usable: BaseUseable = item.useable + useable: BaseUseable = item.useable #for ranged items, delegate to the tile selector - if usable.maximum_range > 0: + if useable.maximum_range > 0: self.engine.event_handler = TileSelector( self.engine, parent_handler=self.engine.event_handler, floor_map = self.engine.floor_map, - initial_pointer = (self.entity.x, self.entity.y), - callback=lambda x, y: self.use_at_range(x, y) + initial_cursor = (self.entity.x, self.entity.y), + callback=lambda x, y: self.use_at_range(x, y), #TODO: Radius is not rerolled on cancel + cursor_radius=0 if not hasattr(useable, 'radius') else useable.radius, ) else: #non-ranged items target the entity @@ -390,7 +400,6 @@ class InventoryViewer(EventHandler): #TODO: For now, just target living entities targets: List[Entity] = list(filter(lambda entity: entity.is_alive() and entity.x == target_x and entity.y == target_y, self.engine.floor_map.entities)) - #TODO: skip targeting for AOE #TODO: close the inventory if you've used a consumable? if len(targets) > 0: return UsageAction(self.entity, self.cursor, targets.pop(), lambda x: self.adjust_length(x)) @@ -497,6 +506,7 @@ class TileSelector(EventHandler): floor_map: FloorMap cursor_x: int cursor_y: int + cursor_radius: int callback: function def __init__( @@ -505,12 +515,14 @@ class TileSelector(EventHandler): parent_handler, *, floor_map: FloorMap, - initial_pointer: Tuple[int, int], + initial_cursor: Tuple[int, int], callback: function, + cursor_radius: int = 0, ): super().__init__(engine, parent_handler) self.floor_map = floor_map - self.cursor_x, self.cursor_y = initial_pointer + self.cursor_x, self.cursor_y = initial_cursor + self.cursor_radius = cursor_radius self.callback = callback def render(self, console: tcod.console.Console) -> None: @@ -521,9 +533,19 @@ class TileSelector(EventHandler): if parent: parent.render(console) - #highlight via inverting colors - console.rgb["fg"][self.cursor_x, self.cursor_y] ^= 0xff - console.rgb["bg"][self.cursor_x, self.cursor_y] ^= 0xff + radius: int = max(self.cursor_radius, 0) + + #radius is for display only - the item's actual effect is cacl'd elsewhere + for i in range(self.cursor_x - radius, self.cursor_x + radius + 1): + for j in range(self.cursor_y - radius, self.cursor_y + radius + 1): + #highlight via inverting colors + console.rgb["fg"][i, j] ^= 0xFF + console.rgb["bg"][i, j] ^= 0xFF + + if radius > 0: + #re-apply an alteration, to show the center + console.rgb["fg"][self.cursor_x, self.cursor_y] ^= 0x4F + console.rgb["bg"][self.cursor_x, self.cursor_y] ^= 0x4F def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[BaseAction]: key = event.sym #SDL stuff, neat. diff --git a/source/procgen.py b/source/procgen.py index a0eb4a4..d569f30 100644 --- a/source/procgen.py +++ b/source/procgen.py @@ -95,8 +95,13 @@ def spawn_items(floor_map: FloorMap, room: RectangularRoom, room_items_max: int, #if there's no entity at that position (not really needed for walkable entities) if not any(entity.x == x and entity.y == y for entity in floor_map.entities): - if random.random() < 0.8: + item_result = random.random() + if item_result < 0.2: + entity_types.scroll_of_fireball.spawn(x, y, floor_map) + elif item_result < 0.4: entity_types.scroll_of_lightning.spawn(x, y, floor_map) + elif item_result < 0.6: + entity_types.scroll_of_confusion.spawn(x, y, floor_map) else: entity_types.potion_of_healing.spawn(x, y, floor_map) floor_map.procgen_cache["item_count"] += 1 diff --git a/source/stats.py b/source/stats.py index 8b4307d..4243054 100644 --- a/source/stats.py +++ b/source/stats.py @@ -47,7 +47,7 @@ class Stats: engine.message_log.add_message(f"The {self.entity.name} died", colors.green) #transform into a dead body - #TODO: dummied in a "usable" to let dead objects be treated like items + #TODO: dummied in a "useable" to let dead objects be treated like items self.entity.char = "%" self.entity.color = (191, 0, 0) self.entity.walkable = True diff --git a/source/useable.py b/source/useable.py index c8d689b..8de39c6 100644 --- a/source/useable.py +++ b/source/useable.py @@ -4,7 +4,7 @@ from typing import Optional, TYPE_CHECKING from utils import roll_dice if TYPE_CHECKING: - from stats import Stats + from entity import Entity class BaseUseable: """ @@ -12,6 +12,7 @@ class BaseUseable: Please note that distances are calculated with the Pythagorean theorem, so a maximum range of `1` won't work in diagonally adjacent tiles. """ + entity: Entity = None consumable: bool current_stack: int maximum_stack: int @@ -31,28 +32,26 @@ class BaseUseable: self.minimum_range = minimum_range self.maximum_range = maximum_range - def apply(self, stats: Stats) -> bool: + def apply(self, target: Entity) -> bool: """ - Use this item's effects. + Use this item's effects to the `target`. - Returns `True` if the item's state changed. + Returns `True` if this item's state changed. """ raise NotImplementedError() - def get_used_msg(self, appearance: str) -> Optional[str]: + def get_used_msg(self) -> Optional[str]: """ - May return a string to display to the user. - - `appearance` is what the item looks like, and can be substituted into the result. + May return a string to display to the player, otherwise returns `None`. """ return None #default #utils def reduce_stack(self, amount: int = 1) -> bool: """ - Reduce the size of a stack by an amount. + Reduce the size of this item stack by a given `amount`. - Returns `True` if this item should be deleted. + Returns `True` if this entity should be deleted. """ if self.maximum_stack > 0: self.current_stack -= amount @@ -79,11 +78,11 @@ class Unuseable(BaseUseable): def __init__(self): super().__init__() #enforce defaults - def apply(self, stats: Stats) -> bool: + def apply(self, target: Entity) -> bool: return None - def get_used_msg(self, appearance: str) -> Optional[str]: - return f"This {appearance} is utterly useless." + def get_used_msg(self) -> Optional[str]: + return f"This {self.entity.name} is utterly useless." class PotionOfHealing(BaseUseable): @@ -95,14 +94,14 @@ class PotionOfHealing(BaseUseable): """Restores 4d4 health when applied.""" __last_roll: int = -1 - def apply(self, stats: Stats) -> bool: + def apply(self, target: Entity) -> bool: self.__last_roll = roll_dice(4, 4) - stats.current_hp += self.__last_roll + target.stats.current_hp += self.__last_roll return self.reduce_stack() - def get_used_msg(self, appearance: str) -> Optional[str]: + def get_used_msg(self) -> Optional[str]: if self.__last_roll >= 0: - return f"The {appearance} restored {self.__last_roll} health." + return f"The {self.entity.name} restored {self.__last_roll} health." else: return None @@ -110,15 +109,74 @@ class PotionOfHealing(BaseUseable): class ScrollOfLightning(BaseUseable): """Deals 2d4 damage when applied.""" __last_roll: int = -1 + __last_target_name: str = "" - def apply(self, stats: Stats) -> bool: + def apply(self, target: Entity) -> bool: self.__last_roll = roll_dice(2, 4) - stats.current_hp -= self.__last_roll + self.__last_target_name = target.name + target.stats.current_hp -= self.__last_roll return self.reduce_stack() - def get_used_msg(self, appearance: str) -> Optional[str]: + def get_used_msg(self) -> Optional[str]: if self.__last_roll >= 0: - return f"The {appearance} dealt {self.__last_roll} damage." + return f"The {self.entity.name} dealt {self.__last_roll} damage to the {self.__last_target_name}!" + else: + return None + + +class ScrollOfConfusion(BaseUseable): + """Causes 3d6 turns of confusion when applied.""" + __last_roll: int = -1 + __last_target_name: str = "" + + def apply(self, target: Entity) -> bool: + from ai import ConfusedAI + + self.__last_roll = roll_dice(3, 6) + self.__last_target_name = target.name + target.ai = ConfusedAI(target, self.__last_roll) + return self.reduce_stack() + + def get_used_msg(self) -> Optional[str]: + if self.__last_roll >= 0: + return f"The {self.__last_target_name} looks confused?" + else: + return None + + +class ScrollOfFireball(BaseUseable): + """Deals 3d6 damage to all creatures within 1d6 radius of the target when applied.""" #TODO: Take a list of targets + __last_damage_roll: int = -1 + __last_radius_roll: int = -1 + __last_target_name: str = "" + __last_entity_hurt: bool = False + + @property + def radius(self) -> int: #TODO: awkwardly hacked in, will fix later + if self.__last_radius_roll < 0: + self.__last_radius_roll = roll_dice(1, 6) + return self.__last_radius_roll + + def reroll_radius(self) -> int: + self.__last_radius_roll = -1 + return self.radius + + def apply(self, target: Entity) -> bool: + self.__last_damage_roll = roll_dice(3, 6) + self.__last_target_name = target.name + + #find and apply to all living entities within range + for entity in target.floor_map.entities: + if entity.is_alive(): + if target.x - self.radius <= entity.x <= target.x + self.radius: + if target.y - self.radius <= entity.y <= target.y + self.radius: + entity.stats.current_hp -= self.__last_damage_roll + + return self.reduce_stack() + + def get_used_msg(self) -> Optional[str]: + if self.__last_damage_roll >= 0: + return f"The {self.entity.name} dealt {self.__last_damage_roll} damage to the {self.__last_target_name} and the surrounding creatures!" else: return None