Added ranged Useables, TileSelector

Added ScrollOfLightning as an example.

Not finished yet, confusion & fireball are still in part 9.
This commit is contained in:
Kayne Ruse 2025-04-05 18:59:22 +11:00
parent b8e5777729
commit a7064e3db0
15 changed files with 248 additions and 73 deletions

View File

@ -33,7 +33,7 @@ Gobbo Swarm
Merchant Ship
* A group of merchants capable of travelling between dimensions, will sell items from other worlds, including unique items not available elsewhere.
* For each run (perhaps, with a minimum level needed, so it can't be cheesed), a counter ticks down until zero, at which point the merchant ship is guaranteed to generate. The counter only resets when the player finds it. In essence, this guarantees the ship will be encountered on a semi-regular basis.
* For each run (perhaps, with a minimum level needed, so it can't be cheesed), a counter ticks down until zero, at which point the merchant ship is guaranteed to generate. The counter only resets when the player finds it. In essence, this guarantees the ship will be encountered on a semiregular basis.
Contagion
* A strange affliction, that turns people into zombies. Could also prevent the spawning of normally present monsters, to invoke a 28-days-later vibe.

View File

@ -6,6 +6,7 @@ This file is a kind of scratch-pad for a multitude of ideas, that I can't implem
https://rogueliketutorials.com/
https://python-tcod.readthedocs.io/en/latest/
https://pyinstaller.org/en/stable/
```bash
#make the virtual environment

BIN
promo/screenshot_05.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
promo/screenshot_06.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
promo/screenshot_07.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,5 +1,5 @@
from __future__ import annotations
from typing import List, Optional, TYPE_CHECKING
from typing import List, TYPE_CHECKING
import colors
from floor_map import FloorMap
@ -8,6 +8,7 @@ from useable import BaseUseable
if TYPE_CHECKING:
from engine import Engine
from event_handlers import OptionSelector, TileSelector
from entity import Entity
class BaseAction:
@ -143,8 +144,6 @@ class PickupAction(BaseAction):
self.entity.inventory.insert(item)
engine.message_log.add_message(msg, color=colors.terminal_light)
else:
from event_handlers import OptionSelector #circular imports are a pain
#build an options list
options: List[str] = []
for item in item_pile:
@ -268,17 +267,29 @@ class UsageAction(BaseAction):
item: Entity = inventory.access(self.index)
usable: BaseUseable = item.useable
if usable.apply(self.target.stats) and usable.is_stack_empty():
#remove the item from the inventory
inventory.discard(self.index)
#TODO: also check visibility?
if self.display_callback:
self.display_callback(-1)
#check range
distance = self.entity.get_distance_to(self.target.x, self.target.y)
if usable.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:
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():
#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)
if not msg:
msg = f"you used a(n) {item.name}"
msg = f"What is a(n) {item.name}?"
engine.message_log.add_message(msg, color=colors.terminal_light)
return True
return True

View File

@ -1,16 +1,31 @@
#Standard colors
white = (0xFF, 0xFF, 0xFF)
black = (0x0, 0x0, 0x0)
#url: https://en.wikipedia.org/wiki/Web_colors
red = (0xFF, 0, 0)
green = (0, 0xFF, 0)
blue = (0, 0, 0xFF)
#CSS standard colors
white = (0xFF, 0xFF, 0xFF)
silver = (0xC0, 0xC0, 0xC0)
gray = (0x80, 0x80, 0x80)
black = (0x00, 0x00, 0x00)
red = (0xFF, 0x00, 0x00)
maroon = (0x80, 0x00, 0x00)
yellow = (0xFF, 0xFF, 0x00)
olive = (0x80, 0x80, 0x00)
lime = (0x00, 0xFF, 0x00)
green = (0x00, 0x80, 0x00)
aqua = (0x00, 0xFF, 0xFF) #identical to cyan
teal = (0x00, 0x80, 0x80)
blue = (0x00, 0x00, 0xFF)
navy = (0x00, 0x00, 0x80)
fuchsia = (0xFF, 0x00, 0xFF)
purple = (0x80, 0x00, 0x80) #identical to magenta
yellow = (0xFF, 0xFF, 0)
magenta = (0xFF, 0, 0xFF)
cyan = (0, 0xFF, 0xFF)
#CSS extended colors (incomplete selection, may be expanded later)
pink = (0xFF, 0xC0, 0xCB)
orange = (0xFF, 0xA5, 0x00)
deep_sky_blue = (0x00, 0xBF, 0xFF)
navajo_white = (0xFF, 0xDE, 0xAD) #misnomer
goldenrod = (0xDA, 0xA5, 0x20)
#gameboy DMG-01, according to Wikipedia's CSS
#gameboy DMG-01
gameboy_00 = (0x29, 0x41, 0x39)
gameboy_01 = (0x39, 0x59, 0x4a)
gameboy_02 = (0x5a, 0x79, 0x42)
@ -19,6 +34,3 @@ gameboy_03 = (0x7b, 0x82, 0x10)
#terminal-like
terminal_light = (200, 200, 200)
terminal_dark = (100, 100, 100)
#extended colors
orange = (0xFF, 0xA5, 0x00)

View File

@ -19,8 +19,8 @@ class Engine:
def __init__(self, *, floor_map: FloorMap, initial_log: List[Message] = None, ui_width: int = None, ui_height: int = None):
#events
from event_handlers import GameplayHandler
self.event_handler = GameplayHandler(self, None)
from event_handlers import GameplayViewer
self.event_handler = GameplayViewer(self, None)
self.mouse_position = (0, 0)
#map
@ -47,7 +47,7 @@ class Engine:
self.update_fov()
if self.event_handler.handle_events(context):
self.handle_entities() #TODO: what 'game state'?
self.handle_entities()
self.handle_rendering(context, console)

View File

@ -1,6 +1,7 @@
from __future__ import annotations
import copy
import math
from typing import Optional, Tuple, Type, TYPE_CHECKING
from ai import BaseAI
@ -72,6 +73,9 @@ class Entity:
self.x = x
self.y = y
def get_distance_to(self, x: int, y: int) -> float:
return math.sqrt((x - self.x) ** 2 + (y - self.y) ** 2)
#monster-specific stuff
def is_alive(self) -> bool:
return bool(self.ai)

View File

@ -2,12 +2,13 @@ from entity import Entity
from ai import BaseAI, AggressiveWhenSeen
from stats import Stats
from inventory import Inventory
from useable import PotionOfHealing
import useable
import colors
#player and utils
player = Entity(
char = "@",
color = (255, 255, 255),
color = colors.white,
name = "Player",
walkable = False,
ai_class = BaseAI, #TODO: remove this or dummy it out
@ -18,7 +19,7 @@ player = Entity(
#monsters - gobbos
gobbo = Entity(
char = "g",
color = (30, 168, 41),
color = colors.lime,
name = "Gobbo",
walkable = False,
ai_class = AggressiveWhenSeen,
@ -27,7 +28,7 @@ gobbo = Entity(
gobbo_red = Entity(
char = "g",
color = (168, 41, 30),
color = colors.red,
name = "Red Gobbo",
walkable = False,
ai_class = AggressiveWhenSeen,
@ -37,9 +38,19 @@ gobbo_red = Entity(
#items - conumables
potion_of_healing = Entity(
char = "!",
color = (0, 0, 255),
color = colors.deep_sky_blue,
name = "Potion of Healing",
walkable = True,
useable=PotionOfHealing(current_stack=1, maximum_stack=255, consumable=True),
useable=useable.PotionOfHealing(consumable=True, current_stack=1, maximum_stack=255),
)
scroll_of_lightning = Entity(
char = "!",
color = colors.goldenrod,
name = "Scroll of Lightning",
walkable = True,
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

View File

@ -1,5 +1,5 @@
from __future__ import annotations
from typing import List, Optional, TYPE_CHECKING
from typing import List, Optional, Tuple, TYPE_CHECKING
import tcod
@ -15,9 +15,14 @@ from actions import (
UsageAction,
)
from useable import (
BaseUseable,
)
if TYPE_CHECKING:
from engine import Engine
from entity import Entity
from floor_map import FloorMap
#input options
MOVE_KEYS = {
@ -63,6 +68,7 @@ WAIT_KEYS = {
PICKUP_KEYS = {
tcod.event.KeySym.COMMA,
tcod.event.KeySym.SPACE,
}
CURSOR_SCROLL_KEYS = {
@ -80,6 +86,12 @@ CURSOR_CONFIRM_KEYS = {
tcod.event.KeySym.SPACE,
}
TILE_SCROLL_KEYS = MOVE_KEYS #copied
# TILE_SELECTOR_KEYS
TILE_CONFIRM_KEYS = CURSOR_CONFIRM_KEYS #copied
#the event handlers are a big part of the engine
class EventHandler(tcod.event.EventDispatch[BaseAction]):
engine: Engine
@ -114,7 +126,22 @@ class EventHandler(tcod.event.EventDispatch[BaseAction]):
return result
class GameplayHandler(EventHandler):
class GameoverViewer(EventHandler):
"""Game over, man, GAME OVER!"""
def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[BaseAction]:
key = event.sym #SDL stuff, neat.
#player input
if key == tcod.event.KeySym.ESCAPE:
return QuitAction()
if key == tcod.event.KeySym.BACKQUOTE: #lowercase tilde
self.engine.event_handler = LogHistoryViewer(self.engine, self)
return None
class GameplayViewer(EventHandler):
def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[BaseAction]:
key = event.sym #SDL stuff, neat.
@ -142,34 +169,33 @@ class GameplayHandler(EventHandler):
if key == tcod.event.KeySym.TAB:
self.engine.event_handler = InventoryViewer(self.engine, self, player)
#debugging - can hook this up to more later
#debugging - for various controls and testing needs
if (event.mod & tcod.event.Modifier.CTRL) and key == tcod.event.KeySym.d:
self.engine.event_handler = OptionSelector(
self.engine,
self,
title="Debug Selector",
options=["Zero", "One", "Two", "Three"],
callback=lambda x: self.engine.message_log.add_message(f"DBG: You selected {x}", colors.orange),
margin_x=20,
margin_y=12,
title = "Debug Selector",
options = ["Tile Selector"],
callback = lambda x: self.dbg_callback(x),
margin_x = 20,
margin_y = 12,
)
def ev_mousemotion(self, event: tcod.event.MouseMotion) -> None:
if self.engine.floor_map.in_bounds(event.tile.x, event.tile.y):
self.engine.mouse_location = event.tile.x, event.tile.y
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
class GameOverHandler(EventHandler):
"""Game over, man, GAME OVER!"""
def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[BaseAction]:
key = event.sym #SDL stuff, neat.
#player input
if key == tcod.event.KeySym.ESCAPE:
return QuitAction()
if key == tcod.event.KeySym.BACKQUOTE: #lowercase tilde
self.engine.event_handler = LogHistoryViewer(self.engine, self)
self.engine.event_handler = TileSelector(
self.engine,
self,
floor_map = self.engine.floor_map,
initial_pointer = (player.x, player.y),
callback=lambda: None
)
return None
@ -342,25 +368,44 @@ class InventoryViewer(EventHandler):
return None #the selector does the change
def use(self) -> Optional[BaseAction]:
"""Drop the item at the cursor's position, and adjust the cursor if needed."""
"""Use the item at the cursor's position."""
if self.length > 0:
index = self.cursor
item: Entity = self.entity.inventory.access(self.cursor)
usable: BaseUseable = item.useable
return UsageAction(self.entity, index, self.entity, lambda x: self.adjust_length(x))
#for ranged items, delegate to the tile selector
if usable.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)
)
else:
#non-ranged items target the entity
return UsageAction(self.entity, self.cursor, self.entity, lambda x: self.adjust_length(x))
def use_at_range(self, target_x: int, target_y: int) -> Optional[BaseAction]:
#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))
else:
self.engine.message_log.add_message("No target found.", colors.yellow)
def drop_partial_stack(self, amount: int) -> Optional[BaseAction]:
"""Drop part of an item stack at the cursor's position, and adjust the cursor if needed."""
if self.length > 0:
index = self.cursor
return DropPartialStackAction(self.entity, index, amount, lambda x: self.adjust_length(x))
return DropPartialStackAction(self.entity, self.cursor, amount, lambda x: self.adjust_length(x))
def drop(self) -> Optional[BaseAction]:
"""Drop the item at the cursor's position, and adjust the cursor if needed."""
if self.length > 0:
index = self.cursor
return DropAction(self.entity, index, lambda x: self.adjust_length(x))
return DropAction(self.entity, self.cursor, lambda x: self.adjust_length(x))
def adjust_length(self, amount: int):
self.length += amount
@ -446,3 +491,56 @@ class OptionSelector(EventHandler):
else:
#return to the game
self.engine.event_handler = self.parent_handler
class TileSelector(EventHandler):
floor_map: FloorMap
cursor_x: int
cursor_y: int
callback: function
def __init__(
self,
engine,
parent_handler,
*,
floor_map: FloorMap,
initial_pointer: Tuple[int, int],
callback: function,
):
super().__init__(engine, parent_handler)
self.floor_map = floor_map
self.cursor_x, self.cursor_y = initial_pointer
self.callback = callback
def render(self, console: tcod.console.Console) -> None:
#DON'T render the parent, instead, find and render the gameplay handler
parent: EventHandler = self.parent_handler
while parent and parent is not GameplayViewer:
parent = parent.parent_handler
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
def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[BaseAction]:
key = event.sym #SDL stuff, neat.
#special keys
if key == tcod.event.KeySym.ESCAPE:
#return to the game
self.engine.event_handler = self.parent_handler
#selection keys
elif key in TILE_SCROLL_KEYS:
xdir, ydir = TILE_SCROLL_KEYS[key]
#because actions "only" change the game state, rather than selecting something, just move the cursor from here
self.cursor_x += xdir
self.cursor_y += ydir
elif key in TILE_CONFIRM_KEYS:
self.engine.event_handler = self.parent_handler
return self.callback(self.cursor_x, self.cursor_y)

View File

@ -38,7 +38,7 @@ def main() -> None:
Message(" Text Log: Backtick Items: Tab", colors.terminal_dark),
Message(" Quit: Esc", colors.terminal_dark),
Message(" ~ ~ ~", colors.terminal_dark),
Message("Welcome to the Cave of Gobbos!", colors.cyan),
Message("Welcome to the Cave of Gobbos!", colors.teal),
]
)

View File

@ -95,7 +95,10 @@ 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):
entity_types.potion_of_healing.spawn(x, y, floor_map)
if random.random() < 0.8:
entity_types.scroll_of_lightning.spawn(x, y, floor_map)
else:
entity_types.potion_of_healing.spawn(x, y, floor_map)
floor_map.procgen_cache["item_count"] += 1
#generators

View File

@ -3,7 +3,7 @@ from typing import TYPE_CHECKING
import colors
from event_handlers import GameOverHandler
from event_handlers import GameoverViewer
from useable import Unuseable
if TYPE_CHECKING:
@ -40,11 +40,11 @@ class Stats:
engine: Engine = self.entity.floor_map.engine
if self.entity is engine.player and self.entity.ai: #handle game-over states
engine.event_handler = GameOverHandler(engine)
engine.event_handler = GameoverViewer(engine, engine.event_handler) #TODO: more elegant way to handle GameOver than effectively freezing
engine.message_log.add_message("You died.", colors.red)
else:
engine.message_log.add_message(f"The {self.entity.name} died", colors.yellow)
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

View File

@ -7,15 +7,29 @@ if TYPE_CHECKING:
from stats import Stats
class BaseUseable:
"""Base type for useable items, with various utilities"""
"""
Base type for useable items, with various utilities.
Please note that distances are calculated with the Pythagorean theorem, so a maximum range of `1` won't work in diagonally adjacent tiles.
"""
consumable: bool
current_stack: int
maximum_stack: int
consumable: bool
minimum_range: int
maximum_range: int
def __init__(self, *, current_stack: int = 1, maximum_stack: int = -1, consumable: bool = False):
def __init__(self, *,
consumable: bool = False,
current_stack: int = 1,
maximum_stack: int = -1,
minimum_range: int = 0,
maximum_range: int = -1,
):
self.consumable = consumable
self.current_stack = current_stack
self.maximum_stack = maximum_stack
self.consumable = consumable
self.minimum_range = minimum_range
self.maximum_range = maximum_range
def apply(self, stats: Stats) -> bool:
"""
@ -87,4 +101,25 @@ class PotionOfHealing(BaseUseable):
return self.reduce_stack()
def get_used_msg(self, appearance: str) -> Optional[str]:
return f"You restored {self.__last_roll} health."
if self.__last_roll >= 0:
return f"The {appearance} restored {self.__last_roll} health."
else:
return None
class ScrollOfLightning(BaseUseable):
"""Deals 2d4 damage when applied."""
__last_roll: int = -1
def apply(self, stats: Stats) -> bool:
self.__last_roll = roll_dice(2, 4)
stats.current_hp -= self.__last_roll
return self.reduce_stack()
def get_used_msg(self, appearance: str) -> Optional[str]:
if self.__last_roll >= 0:
return f"The {appearance} dealt {self.__last_roll} damage."
else:
return None
#TODO: "The gobbo died" and "You dealt X damage" are in the wrong order in the log