diff --git a/README.md b/README.md index af67e52..f3c0de2 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,12 @@ * "Fun value", inspired by `Undertale`, activates different secrets in different runs. * Color-based effects or biomes that only have an impact when in the FOV. -* gobbos, da red gobbo +* Gobbos, da red gobbo. Stepwise needs a genre/setting. Some options are: * Fantasy -* SciFi +* Sci-fi * Nuclear Bunker * Indiana Jones * Post-Apocalypse diff --git a/source/actions.py b/source/actions.py index 6f10640..1bafeba 100644 --- a/source/actions.py +++ b/source/actions.py @@ -1,13 +1,11 @@ -from entity import Entity - -class Action: - def __init__(self, entity: Entity): +class BaseAction: + def __init__(self, entity): self.entity = entity def apply(self) -> None: raise NotImplementedError() -class QuitAction(Action): +class QuitAction(BaseAction): def __init__(self): #override the base __init__ pass @@ -15,8 +13,13 @@ class QuitAction(Action): raise SystemExit() -class DirectionAction(Action): - def __init__(self, entity: Entity, xdir: int, ydir: int): +class WaitAction(BaseAction): + def apply(self) -> None: + pass + + +class DirectionAction(BaseAction): + def __init__(self, entity, xdir: int, ydir: int): super().__init__(entity) self.xdir = xdir self.ydir = ydir @@ -52,7 +55,7 @@ class MeleeAction(DirectionAction): print(f"You kicked the {target.name}, which was funny") -class MoveAction(DirectionAction): +class BumpAction(DirectionAction): #bad name, deal with it def apply(self) -> None: dest_x = self.entity.x + self.xdir dest_y = self.entity.y + self.ydir diff --git a/source/components/base_ai.py b/source/components/base_ai.py new file mode 100644 index 0000000..c599c59 --- /dev/null +++ b/source/components/base_ai.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import Any, List, Tuple + +import numpy as np +import tcod + +from components.base_component import BaseComponent +from actions import BaseAction, MeleeAction, MovementAction, WaitAction + + +class BaseAI(BaseAction, BaseComponent): + entity: Any + + def apply(self) -> None: + raise NotImplementedError() + + def get_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) + + #higher numbers deter path-finding this way + for entity in self.entity.floor_map.entities: + if not entity.walkable and cost[entity.x, entity.y]: + cost[entity.x, entity.y] += 10 + + graph = tcod.path.SimpleGraph(cost=cost, cardinal=2, diagonal=3) + pathfinder = tcod.path.Pathfinder(graph) + + pathfinder.add_root((self.entity.x, self.entity.y)) #start pos + + #make the path, omitting the start pos + path: List[List[int]] = pathfinder.path_to((dest_x, dest_y))[1:].tolist() + + #return the path, after mapping it to a list of tuples + return [(index[0], index[1]) for index in path] + +class AttackOnSight(BaseAI): + def __init__(self, entity: Actor): + super().__init__(entity) + self.path: List[Tuple[int, int]] = [] + + def apply(self) -> None: + target = self.entity.floor_map.player + xdir = target.x - self.entity.x + ydir = target.y - self.entity.y + distance = max(abs(xdir), abs(ydir)) + + #if the player can see me, and I'm close enough, attack + if self.entity.floor_map.visible[self.entity.x, self.entity.y]: + if distance <= 1: + return MeleeAction(self.entity, xdir, ydir).apply() + + self.path = self.get_path_to(target.x, target.y) + + if self.path: + dest_x, dest_y = self.path.pop(0) + return MovementAction( + entity = self.entity, + xdir = dest_x - self.entity.x, + ydir = dest_y - self.entity.y, + ).apply() + + return WaitAction(self.entity).apply() diff --git a/source/components/base_component.py b/source/components/base_component.py new file mode 100644 index 0000000..cd833b6 --- /dev/null +++ b/source/components/base_component.py @@ -0,0 +1,5 @@ +from typing import Any + +class BaseComponent: + entity: Any + diff --git a/source/components/fighter.py b/source/components/fighter.py new file mode 100644 index 0000000..22968e8 --- /dev/null +++ b/source/components/fighter.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import Any + +from components.base_component import BaseComponent + +class Fighter(BaseComponent): + entity: Any + + def __init__(self, hp: int, attack: int, defense: int): + self._maximum_hp = hp + self._current_hp = hp + self.attack = attack + self.defense = defense + + @property + def current_hp(self) -> int: + return self._current_hp + + @current_hp.setter + def current_hp(self, value: int) -> None: + self._current_hp = max(0, min(value, self._maximum_hp)) + diff --git a/source/engine.py b/source/engine.py index e584dfe..7b10cf9 100644 --- a/source/engine.py +++ b/source/engine.py @@ -21,9 +21,10 @@ class Engine: def handle_entities(self) -> None: self.update_fov() #knowing the FOV lets entities mess with it - #all entities in the level - for entity in self.floor_map.entities - {self.player}: - pass #TODO: run entity AI + #all *actors* in the level + for actor in set(self.floor_map.actors) - {self.player}: + if actor.ai: + actor.ai.apply() def handle_rendering(self, context: Context, console: Console) -> None: #map and all entities within diff --git a/source/entity.py b/source/entity.py index 1d9ee2c..45a306f 100644 --- a/source/entity.py +++ b/source/entity.py @@ -1,7 +1,10 @@ from __future__ import annotations import copy -from typing import Tuple +from typing import Optional, Tuple, Type + +from components.base_ai import BaseAI +from components.fighter import Fighter class Entity: def __init__( @@ -33,3 +36,37 @@ class Entity: def set_pos(self, x: int, y: int) -> None: self.x = x self.y = y + + +#actors are entities that can act on their own +class Actor(Entity): + def __init__( #yep, this is ugly + self, + x: int = 0, + y: int = 0, + char: str = "?", + color: Tuple[int, int, int] = (255, 255, 255), + name: str = "", + walkable: bool = True, + floor_map = None, + + #actor-specific stuff + ai_class: Type[BaseAI] = None, + fighter: Fighter = None, + ): + super().__init__( + x = x, + y = y, + char = char, + color = color, + name = name, + walkable = walkable, + floor_map = floor_map, + ) + + self.ai: Optional[BaseAI] = ai_class(self) + self.fighter = fighter + self.fighter.entity = self + + def is_alive(self) -> bool: + return bool(self.ai) diff --git a/source/entity_types.py b/source/entity_types.py index ebaf557..f942eba 100644 --- a/source/entity_types.py +++ b/source/entity_types.py @@ -1,8 +1,31 @@ -from entity import Entity +from entity import Entity, Actor +from components.base_ai import BaseAI, AttackOnSight +from components.fighter import Fighter -player = Entity(char="@", color=(255, 255, 255), name="Player", walkable=False) +player = Actor( + char = "@", + color = (255, 255, 255), + name = "Player", + walkable = False, + ai_class = BaseAI, + fighter = Fighter(hp = 10, attack = 2, defense = 2), +) #gobbos -gobbo = Entity(char="g", color=(30, 168, 41), name="Gobbo", walkable=False) -gobbo_red = Entity(char="g", color=(168, 41, 30), name="Red Gobbo", walkable=False) +gobbo = Actor( + char = "g", + color = (30, 168, 41), + name = "Gobbo", + walkable = False, + ai_class = AttackOnSight, + fighter = Fighter(hp = 5, attack = 2, defense = 0), +) +gobbo_red = Actor( + char = "g", + color = (168, 41, 30), + name = "Red Gobbo", + walkable = False, + ai_class = AttackOnSight, + fighter = Fighter(hp = 5, attack = 2, defense = 1), +) diff --git a/source/event_handler.py b/source/event_handler.py index aaca902..85f1c58 100644 --- a/source/event_handler.py +++ b/source/event_handler.py @@ -2,11 +2,11 @@ from typing import Optional import tcod -from actions import Action, QuitAction, MoveAction +from actions import BaseAction, QuitAction, BumpAction from engine import Engine #event handler is one part of the engine -class EventHandler(tcod.event.EventDispatch[Action]): +class EventHandler(tcod.event.EventDispatch[BaseAction]): def __init__(self, engine: Engine): super().__init__() self.engine = engine @@ -21,10 +21,10 @@ class EventHandler(tcod.event.EventDispatch[Action]): action.apply() #entity references the engine #callbacks - def ev_quit(self, event: tcod.event.Quit) -> Optional[Action]: + def ev_quit(self, event: tcod.event.Quit) -> Optional[BaseAction]: return QuitAction() - def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]: + def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[BaseAction]: key = event.sym #SDL stuff, neat. player = self.engine.player @@ -35,13 +35,13 @@ class EventHandler(tcod.event.EventDispatch[Action]): return QuitAction() case tcod.event.KeySym.UP: - return MoveAction(player, xdir = 0, ydir = -1) + return BumpAction(player, xdir = 0, ydir = -1) case tcod.event.KeySym.DOWN: - return MoveAction(player, xdir = 0, ydir = 1) + return BumpAction(player, xdir = 0, ydir = 1) case tcod.event.KeySym.LEFT: - return MoveAction(player, xdir = -1, ydir = 0) + return BumpAction(player, xdir = -1, ydir = 0) case tcod.event.KeySym.RIGHT: - return MoveAction(player, xdir = 1, ydir = 0) + return BumpAction(player, xdir = 1, ydir = 0) case _: return None \ No newline at end of file diff --git a/source/floor_map.py b/source/floor_map.py index d148297..052a958 100644 --- a/source/floor_map.py +++ b/source/floor_map.py @@ -1,11 +1,11 @@ from __future__ import annotations -from typing import Iterable, Optional +from typing import Iterable, Iterator, Optional import numpy as np from tcod.console import Console import tile_types -from entity import Entity +from entity import Entity, Actor class FloorMap: def __init__(self, width: int, height: int, entities: Iterable[Entity] = ()): @@ -24,6 +24,14 @@ class FloorMap: self.engine = None self.player = None + @property + def actors(self) -> Iterator[Actor]: + yield from ( + entity + for entity in self.entities + if isinstance(entity, Actor) and entity.is_alive() + ) + def in_bounds(self, x: int, y: int) -> bool: return 0 <= x < self.width and 0 <= y < self.height @@ -38,6 +46,13 @@ class FloorMap: return None + def get_actor_at(self, x: int, y: int) -> Optional[Actor]: + for actor in self.actors: + if actor.x == x and actor.y == y: + return actor + + return None + def render(self, console: Console) -> None: console.rgb[0:self.width, 0:self.height] = np.select( condlist = [self.visible, self.explored],