627 lines
19 KiB
Python
627 lines
19 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
from typing import overload, Callable, Optional, Tuple, TYPE_CHECKING, Union
|
|
|
|
import tcod.event
|
|
|
|
import actions
|
|
from actions import Action, BumpAction, PickupAction, WaitAction
|
|
import color
|
|
import exceptions
|
|
|
|
if TYPE_CHECKING:
|
|
from engine import Engine
|
|
from entity import Item
|
|
|
|
MOVE_KEYS = {
|
|
# Arrow keys
|
|
tcod.event.K_UP: (0, -1),
|
|
tcod.event.K_DOWN: (0, 1),
|
|
tcod.event.K_LEFT: (-1, 0),
|
|
tcod.event.K_RIGHT: (1, 0),
|
|
tcod.event.K_HOME: (-1, -1),
|
|
tcod.event.K_END: (-1, 1),
|
|
tcod.event.K_PAGEUP: (1, -1),
|
|
tcod.event.K_PAGEDOWN: (1, 1),
|
|
# Numpad keys
|
|
tcod.event.K_KP_1: (-1, 1),
|
|
tcod.event.K_KP_2: (0, 1),
|
|
tcod.event.K_KP_3: (1, 1),
|
|
tcod.event.K_KP_4: (-1, 0),
|
|
tcod.event.K_KP_6: (1, 0),
|
|
tcod.event.K_KP_7: (-1, -1),
|
|
tcod.event.K_KP_8: (0, -1),
|
|
tcod.event.K_KP_9: (1, -1),
|
|
# Vi keys
|
|
tcod.event.K_h: (-1, 0),
|
|
tcod.event.K_j: (0, 1),
|
|
tcod.event.K_k: (0, -1),
|
|
tcod.event.K_l: (1, 0),
|
|
tcod.event.K_y: (-1, -1),
|
|
tcod.event.K_u: (1, -1),
|
|
tcod.event.K_b: (-1, 1),
|
|
tcod.event.K_n: (1, 1),
|
|
}
|
|
|
|
WAIT_KEYS = {
|
|
tcod.event.K_PERIOD,
|
|
tcod.event.K_KP_5,
|
|
tcod.event.K_CLEAR,
|
|
}
|
|
|
|
CONFIRM_KEYS = {
|
|
tcod.event.K_RETURN,
|
|
tcod.event.K_KP_ENTER,
|
|
}
|
|
|
|
ActionOrHandler = Union[Action, "BaseEventHandler"]
|
|
"""
|
|
An event handler return value which can trigger an action or switch active handlers.
|
|
|
|
If a handler is returned than it will become the active handler for future events.
|
|
if an action is returned it will be attempted and if it's valid then
|
|
MainGameEventHandler will become the active handler.
|
|
"""
|
|
|
|
|
|
class BaseEventHandler(tcod.event.EventDispatch[ActionOrHandler]):
|
|
def handle_events(self, event: tcod.event.Event) -> BaseEventHandler:
|
|
"""Handle an event and return the next active event handler."""
|
|
state = self.dispatch(event)
|
|
if isinstance(state, BaseEventHandler):
|
|
return state
|
|
assert not isinstance(state, Action), f"{self!r} can not handle actions."
|
|
|
|
return self
|
|
|
|
@overload
|
|
def on_render(self, console: tcod.Console) -> None:
|
|
"""Must be implemented"""
|
|
|
|
def ev_quit(self, event: tcod.event.Quit) -> Optional[Action]:
|
|
raise SystemExit()
|
|
|
|
|
|
class PopupMessage(BaseEventHandler):
|
|
"""Display a popup text window."""
|
|
|
|
def __init__(self, parent_handler: BaseEventHandler, text: str):
|
|
self.parent = parent_handler
|
|
self.text = text
|
|
|
|
def on_render(self, console: tcod.Console) -> None:
|
|
"""Render the parent and dim the result, then print the message on top."""
|
|
self.parent.on_render(console)
|
|
console.tiles_rgb["fg"] //= 8
|
|
console.tiles_rgb["bg"] //= 8
|
|
|
|
console.print(
|
|
console.width // 2,
|
|
console.height // 2,
|
|
self.text,
|
|
fg=color.white,
|
|
bg=color.black,
|
|
alignment=tcod.CENTER,
|
|
)
|
|
|
|
def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[BaseEventHandler]:
|
|
"""Any key returns to the parent handler."""
|
|
return self.parent
|
|
|
|
|
|
class EventHandler(BaseEventHandler):
|
|
def __init__(self, engine: Engine):
|
|
self.engine = engine
|
|
|
|
def handle_events(self, event: tcod.event.Event) -> BaseEventHandler:
|
|
"""Handle events for input handlers with an engine."""
|
|
action_or_state = self.dispatch(event)
|
|
if isinstance(action_or_state, BaseEventHandler):
|
|
return action_or_state
|
|
if self.handle_action(action_or_state):
|
|
# A valid action was performed.
|
|
if not self.engine.player.is_alive:
|
|
# The player was killed sometime during or after the action.
|
|
return GameOverEventHandler(self.engine)
|
|
elif self.engine.player.level.requires_level_up:
|
|
return LevelUpEventHandler(self.engine)
|
|
return MainGameEventHandler(self.engine) # Return to the main handler.
|
|
return self
|
|
|
|
def handle_action(self, action: Optional[Action]) -> bool:
|
|
"""Handle actions returned from event methods.
|
|
|
|
Returns True if the action will advance a turn.
|
|
"""
|
|
if action is None:
|
|
return False
|
|
|
|
try:
|
|
action.perform()
|
|
except exceptions.Impossible as exc:
|
|
self.engine.message_log.add_message(exc.args[0], color.impossible)
|
|
return False # Skip enemy turn on exceptions.
|
|
|
|
self.engine.handle_enemy_turns()
|
|
|
|
self.engine.update_fov()
|
|
|
|
return True
|
|
|
|
def ev_mousemotion(self, event: tcod.event.MouseMotion) -> None:
|
|
if self.engine.game_map.in_bounds(event.tile.x, event.tile.y):
|
|
self.engine.mouse_location = event.tile.x, event.tile.y
|
|
|
|
def on_render(self, console: tcod.Console) -> None:
|
|
self.engine.render(console)
|
|
|
|
|
|
class AskUserEventHandler(EventHandler):
|
|
"""Handles user input for actions which require special input."""
|
|
|
|
def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]:
|
|
"""By default any key exits this input handler."""
|
|
# Ignore modifier keys.
|
|
if event.sym in {
|
|
tcod.event.K_LSHIFT,
|
|
tcod.event.K_RSHIFT,
|
|
tcod.event.K_LCTRL,
|
|
tcod.event.K_RCTRL,
|
|
tcod.event.K_LALT,
|
|
tcod.event.K_RALT,
|
|
}:
|
|
return None
|
|
|
|
return self.on_exit()
|
|
|
|
def ev_mousebuttondown(
|
|
self, event: tcod.event.MouseButtonDown
|
|
) -> Optional[ActionOrHandler]:
|
|
"""By default any mouse click exits this input handler."""
|
|
return self.on_exit()
|
|
|
|
def on_exit(self) -> Optional[ActionOrHandler]:
|
|
"""Called when the user is trying to exit or cancel an action.
|
|
|
|
By default this returns to the main event handler.
|
|
"""
|
|
return MainGameEventHandler(self.engine)
|
|
|
|
|
|
class CharacterScreenEventHandler(AskUserEventHandler):
|
|
TITLE = "Character Information"
|
|
|
|
def on_render(self, console: tcod.Console) -> None:
|
|
super().on_render(console)
|
|
|
|
if self.engine.player.x <= 30:
|
|
x = 40
|
|
else:
|
|
x = 0
|
|
|
|
y = 0
|
|
|
|
width = len(self.TITLE) + 4
|
|
|
|
console.draw_frame(
|
|
x,
|
|
y,
|
|
width,
|
|
height=7,
|
|
title=self.TITLE,
|
|
clear=True,
|
|
fg=(255, 255, 255),
|
|
bg=(0, 0, 0),
|
|
)
|
|
|
|
console.print(x + 1, y + 1, f"Level: {self.engine.player.level.current_level}")
|
|
console.print(x + 1, y + 2, f"XP: {self.engine.player.level.current_xp}")
|
|
console.print(
|
|
x + 1,
|
|
y + 3,
|
|
f"XP for next Level: {self.engine.player.level.experience_to_next_level}",
|
|
)
|
|
console.print(
|
|
x=x + 1, y=y + 4, string=f"Attack: {self.engine.player.fighter.power}"
|
|
)
|
|
console.print(
|
|
x=x + 1, y=y + 5, string=f"Defense: {self.engine.player.fighter.defense}"
|
|
)
|
|
|
|
|
|
class LevelUpEventHandler(AskUserEventHandler):
|
|
TITLE = "Level Up"
|
|
|
|
def on_render(self, console: tcod.Console) -> None:
|
|
super().on_render(console)
|
|
|
|
if self.engine.player.x <= 30:
|
|
x = 40
|
|
else:
|
|
x = 0
|
|
|
|
console.draw_frame(
|
|
x=x,
|
|
y=0,
|
|
width=35,
|
|
height=8,
|
|
title=self.TITLE,
|
|
clear=True,
|
|
fg=(255, 255, 255),
|
|
bg=(0, 0, 0),
|
|
)
|
|
|
|
console.print(x + 1, 1, "Congratulations! You level up!")
|
|
console.print(x + 1, 2, "Select an attribut to increase.")
|
|
|
|
console.print(
|
|
x=x + 1,
|
|
y=4,
|
|
string=f"a) Constitution (+20 HP, from {self.engine.player.fighter.max_hp})",
|
|
)
|
|
console.print(
|
|
x=x + 1,
|
|
y=5,
|
|
string=f"b) Strength (+1 attack, from {self.engine.player.fighter.power})",
|
|
)
|
|
console.print(
|
|
x=x + 1,
|
|
y=6,
|
|
string=f"c) Agility (+1 defense, from {self.engine.player.fighter.defense})",
|
|
)
|
|
|
|
def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]:
|
|
player = self.engine.player
|
|
key = event.sym
|
|
index = key - tcod.event.K_a
|
|
|
|
if 0 <= index <= 2:
|
|
if index == 0:
|
|
player.level.increase_max_hp()
|
|
elif index == 1:
|
|
player.level.increase_power()
|
|
else:
|
|
player.level.increase_defense()
|
|
else:
|
|
self.engine.message_log.add_message("Invalid entry.", color.invalid)
|
|
|
|
return None
|
|
|
|
return super().ev_keydown(event)
|
|
|
|
def ev_mousebuttondown(
|
|
self, event: tcod.event.MouseButtonDown
|
|
) -> Optional[ActionOrHandler]:
|
|
"""
|
|
Don't allow the player to click to exit the menu, like normal
|
|
"""
|
|
return None
|
|
|
|
|
|
class InventoryEventHandler(AskUserEventHandler):
|
|
"""This handler lets the user select an item.
|
|
|
|
What happens then depends on the subclass.
|
|
"""
|
|
|
|
TITLE = "<missing title>"
|
|
|
|
def on_render(self, console: tcod.Console) -> None:
|
|
"""Render an inventory menu, which displays the items in the inventory, and the letter
|
|
to select them. Will move to a different position based on where the player is located, so
|
|
the player can always see where they are.
|
|
"""
|
|
super().on_render(console)
|
|
number_of_items_in_inventory = len(self.engine.player.inventory.items)
|
|
|
|
height = number_of_items_in_inventory + 2
|
|
|
|
if height <= 3:
|
|
height = 3
|
|
|
|
if self.engine.player.x <= 30:
|
|
x = 40
|
|
else:
|
|
x = 0
|
|
|
|
y = 0
|
|
|
|
width = len(self.TITLE) + 4
|
|
|
|
console.draw_frame(
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
title=self.TITLE,
|
|
clear=True,
|
|
fg=(255, 255, 255),
|
|
bg=(0, 0, 0),
|
|
)
|
|
|
|
if number_of_items_in_inventory > 0:
|
|
for i, item in enumerate(self.engine.player.inventory.items):
|
|
item_key = chr(ord("a") + i)
|
|
console.print(x + 1, y + i + 1, f"({item_key}) {item.name}")
|
|
else:
|
|
console.print(x + 1, y + 1, "(Empty)")
|
|
|
|
def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]:
|
|
player = self.engine.player
|
|
key = event.sym
|
|
index = key - tcod.event.K_a
|
|
|
|
if 0 <= index <= 26:
|
|
try:
|
|
selected_item = player.inventory.items[index]
|
|
except IndexError:
|
|
self.engine.message_log.add_message("Invalid entry.", color.invalid)
|
|
return None
|
|
|
|
return self.on_item_selected(selected_item)
|
|
|
|
return super().ev_keydown(event)
|
|
|
|
@overload
|
|
def on_item_selected(self, item: Item) -> Optional[ActionOrHandler]:
|
|
"""Called when the user selects a valid item."""
|
|
|
|
|
|
class InventoryActivateHandler(InventoryEventHandler):
|
|
"""Handle using an inventory item."""
|
|
|
|
TITLE = "Select an item to use"
|
|
|
|
def on_item_selected(self, item: Item) -> Optional[ActionOrHandler]:
|
|
"""Return the action for the selected item."""
|
|
return item.consumable.get_action(self.engine.player)
|
|
|
|
|
|
class InventoryDropHandler(InventoryEventHandler):
|
|
"""Handle dropping an inventory item."""
|
|
|
|
TITLE = "Select an item to drop"
|
|
|
|
def on_item_selected(self, item: Item) -> Optional[ActionOrHandler]:
|
|
"""Drop this item."""
|
|
return actions.DropItem(self.engine.player, item)
|
|
|
|
|
|
class SelectIndexHandler(AskUserEventHandler):
|
|
"""Handles asking the user for an index on the map."""
|
|
|
|
def __init__(self, engine: Engine):
|
|
"""Sets the cursor to the player when this handler is constructed."""
|
|
super().__init__(engine)
|
|
player = self.engine.player
|
|
engine.mouse_location = player.x, player.y
|
|
|
|
def on_render(self, console: tcod.Console) -> None:
|
|
"""Highlight the tile under the cursor."""
|
|
super().on_render(console)
|
|
x, y = self.engine.mouse_location
|
|
console.tiles_rgb["bg"][x, y] = color.white
|
|
console.tiles_rgb["fg"][x, y] = color.black
|
|
|
|
def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]:
|
|
"""Check for key movement or confirmation keys."""
|
|
key = event.sym
|
|
if key in MOVE_KEYS:
|
|
modifier = 1 # Holding modifier keys will speed up key movement
|
|
if event.mod & (tcod.event.KMOD_LSHIFT | tcod.event.KMOD_RSHIFT):
|
|
modifier *= 5
|
|
if event.mod & (tcod.event.KMOD_LCTRL | tcod.event.KMOD_RCTRL):
|
|
modifier *= 10
|
|
if event.mod & (tcod.event.KMOD_LALT | tcod.event.KMOD_RALT):
|
|
modifier *= 20
|
|
|
|
x, y = self.engine.mouse_location
|
|
dx, dy = MOVE_KEYS[key]
|
|
x += dx * modifier
|
|
y += dy * modifier
|
|
# Clamp the cursor index to the map size.
|
|
x = max(0, min(x, self.engine.game_map.width - 1))
|
|
y = max(0, min(y, self.engine.game_map.height - 1))
|
|
self.engine.mouse_location = x, y
|
|
|
|
return None
|
|
elif key in CONFIRM_KEYS:
|
|
return self.on_index_selected(*self.engine.mouse_location)
|
|
|
|
return super().ev_keydown(event)
|
|
|
|
def ev_mousebuttondown(
|
|
self, event: tcod.event.MouseButtonDown
|
|
) -> Optional[ActionOrHandler]:
|
|
"""Left click confirms a selection."""
|
|
if self.engine.game_map.in_bounds(*event.tile):
|
|
if event.button == 1:
|
|
return self.on_index_selected(*event.tile)
|
|
|
|
return super().ev_mousebuttondown(event)
|
|
|
|
@overload
|
|
def on_index_selected(self, x: int, y: int) -> Optional[ActionOrHandler]:
|
|
"""Called when an index is selected."""
|
|
|
|
|
|
class LookHandler(SelectIndexHandler):
|
|
"""Lets the player look around using the keyboard."""
|
|
|
|
def on_index_selected(self, x: int, y: int) -> MainGameEventHandler:
|
|
"""Return to main handler."""
|
|
return MainGameEventHandler(self.engine)
|
|
|
|
|
|
class SingleRangedAttackHandler(SelectIndexHandler):
|
|
"""Handles targeting a single enemy. Only the enemy selected will be affected."""
|
|
|
|
def __init__(
|
|
self,
|
|
engine: Engine,
|
|
callback: Callable[[Tuple[int, int]], Optional[ActionOrHandler]],
|
|
):
|
|
super().__init__(engine)
|
|
|
|
self.callback = callback
|
|
|
|
def on_index_selected(self, x: int, y: int) -> Optional[ActionOrHandler]:
|
|
return self.callback((x, y))
|
|
|
|
|
|
class AreaRangedAttackHandler(SelectIndexHandler):
|
|
"""
|
|
Handles targeting an area within a given radius.
|
|
Any entity within the area will be affected.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
engine: Engine,
|
|
radius: int,
|
|
callback: Callable[[Tuple[int, int]], Optional[ActionOrHandler]],
|
|
):
|
|
super().__init__(engine)
|
|
|
|
self.radius = radius
|
|
self.callback = callback
|
|
|
|
def on_render(self, console: tcod.Console) -> None:
|
|
"""Highlight the tile under the cursor."""
|
|
super().on_render(console)
|
|
|
|
x, y = self.engine.mouse_location
|
|
|
|
# Draw a rectangle around the targeted area, so the player can see the affected tiles.
|
|
console.draw_frame(
|
|
x=x - self.radius - 1,
|
|
y=y - self.radius - 1,
|
|
width=self.radius**2,
|
|
height=self.radius**2,
|
|
fg=color.red,
|
|
clear=False,
|
|
)
|
|
|
|
def on_index_selected(self, x: int, y: int) -> Optional[ActionOrHandler]:
|
|
return self.callback((x, y))
|
|
|
|
|
|
class MainGameEventHandler(EventHandler):
|
|
def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]:
|
|
action: Optional[Action] = None
|
|
|
|
key = event.sym
|
|
modifier = event.mod
|
|
|
|
player = self.engine.player
|
|
|
|
if key == tcod.event.K_PERIOD and modifier & (
|
|
tcod.event.KMOD_LSHIFT | tcod.event.KMOD_RSHIFT
|
|
):
|
|
return actions.TakeStairsAction(player)
|
|
|
|
if key in MOVE_KEYS:
|
|
dx, dy = MOVE_KEYS[key]
|
|
action = BumpAction(player, dx, dy)
|
|
elif key in WAIT_KEYS:
|
|
action = WaitAction(player)
|
|
|
|
elif key == tcod.event.K_ESCAPE:
|
|
raise SystemExit()
|
|
|
|
elif key == tcod.event.K_v:
|
|
return HistoryViewer(self.engine)
|
|
|
|
elif key == tcod.event.K_g:
|
|
action = PickupAction(player)
|
|
|
|
elif key == tcod.event.K_i:
|
|
return InventoryActivateHandler(self.engine)
|
|
elif key == tcod.event.K_d:
|
|
return InventoryDropHandler(self.engine)
|
|
elif key == tcod.event.K_c:
|
|
return CharacterScreenEventHandler(self.engine)
|
|
elif key == tcod.event.K_SLASH:
|
|
return LookHandler(self.engine)
|
|
|
|
# No valid key was pressed
|
|
return action
|
|
|
|
|
|
class GameOverEventHandler(EventHandler):
|
|
def on_quit(self) -> None:
|
|
"""Handle exiting out of a finished game."""
|
|
if os.path.exists("savegame.sav"):
|
|
os.remove("savegame.sav") # Deletes the active save file.
|
|
raise exceptions.QuitWithoutSaving() # Avoid saving a finished game.
|
|
|
|
def ev_quit(self, event: tcod.event.Quit) -> None:
|
|
self.on_quit()
|
|
|
|
def ev_keydown(self, event: tcod.event.KeyDown) -> None:
|
|
if event.sym == tcod.event.K_ESCAPE:
|
|
self.on_quit()
|
|
|
|
|
|
CURSOR_Y_KEYS = {
|
|
tcod.event.K_UP: -1,
|
|
tcod.event.K_DOWN: 1,
|
|
tcod.event.K_PAGEUP: -10,
|
|
tcod.event.K_PAGEDOWN: 10,
|
|
}
|
|
|
|
|
|
class HistoryViewer(EventHandler):
|
|
"""Print the history on a larger window which can be navigated."""
|
|
|
|
def __init__(self, engine: Engine):
|
|
super().__init__(engine)
|
|
self.log_length = len(engine.message_log.messages)
|
|
self.cursor = self.log_length - 1
|
|
|
|
def on_render(self, console: tcod.Console) -> None:
|
|
super().on_render(console) # Draw the main state as the background.
|
|
|
|
log_console = tcod.Console(console.width - 6, console.height - 6)
|
|
|
|
# Draw a frame with a custom banner title.
|
|
log_console.draw_frame(0, 0, log_console.width, log_console.height)
|
|
log_console.print_box(
|
|
0, 0, log_console.width, 1, "┤Message history├", alignment=tcod.CENTER
|
|
)
|
|
|
|
# Render the message log using the cursor parameter.
|
|
self.engine.message_log.render_messages(
|
|
log_console,
|
|
1,
|
|
1,
|
|
log_console.width - 2,
|
|
log_console.height - 2,
|
|
self.engine.message_log.messages[: self.cursor + 1],
|
|
)
|
|
log_console.blit(console, 3, 3)
|
|
|
|
def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[MainGameEventHandler]:
|
|
# Fancy conditional movement to make it feel right.
|
|
if event.sym in CURSOR_Y_KEYS:
|
|
adjust = CURSOR_Y_KEYS[event.sym]
|
|
if adjust < 0 and self.cursor == 0:
|
|
# Only move from the top to the bottom when you're on the edge.
|
|
self.cursor = self.log_length - 1
|
|
elif adjust > 0 and self.cursor == self.log_length - 1:
|
|
# Same with bottom to top movement.
|
|
self.cursor = 0
|
|
else:
|
|
# Otherwise move while staying clamped to the bounds of the history log.
|
|
self.cursor = max(0, min(self.cursor + adjust, self.log_length - 1))
|
|
elif event.sym == tcod.event.K_HOME:
|
|
self.cursor = 0 # Move directly to the top message.
|
|
elif event.sym == tcod.event.K_END:
|
|
self.cursor = self.log_length - 1 # Move directly to the last message.
|
|
else: # Any other key moves back to the main game state.
|
|
return MainGameEventHandler(self.engine)
|
|
|
|
return None
|