Added confusion, fireball, and various utils

While the scrolls themselves won't be around forever, the tools used to
make them work will be.
This commit is contained in:
Kayne Ruse 2025-04-06 17:20:08 +10:00
parent 1722681823
commit 6a42bb8c59
10 changed files with 216 additions and 63 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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