from __future__ import annotations from typing import List, Optional, Tuple, TYPE_CHECKING import tcod import colors from actions import ( BaseAction, QuitAction, BumpAction, WaitAction, PickupAction, DropAction, DropPartialStackAction, 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 = { #arrow keys tcod.event.KeySym.UP: (0, -1), tcod.event.KeySym.DOWN: (0, 1), tcod.event.KeySym.LEFT: (-1, 0), tcod.event.KeySym.RIGHT: (1, 0), tcod.event.KeySym.HOME: (-1, -1), tcod.event.KeySym.END: (-1, 1), tcod.event.KeySym.PAGEUP: (1, -1), tcod.event.KeySym.PAGEDOWN: (1, 1), #numpad keys tcod.event.KeySym.KP_1: (-1, 1), tcod.event.KeySym.KP_2: (0, 1), tcod.event.KeySym.KP_3: (1, 1), tcod.event.KeySym.KP_4: (-1, 0), tcod.event.KeySym.KP_6: (1, 0), tcod.event.KeySym.KP_7: (-1, -1), tcod.event.KeySym.KP_8: (0, -1), tcod.event.KeySym.KP_9: (1, -1), #vi key mapping tcod.event.KeySym.h: (-1, 0), tcod.event.KeySym.j: (0, 1), tcod.event.KeySym.k: (0, -1), tcod.event.KeySym.l: (1, 0), tcod.event.KeySym.y: (-1, -1), tcod.event.KeySym.u: (1, -1), tcod.event.KeySym.b: (-1, 1), tcod.event.KeySym.n: (1, 1), } WAIT_KEYS = { tcod.event.KeySym.PERIOD, tcod.event.KeySym.KP_5, tcod.event.KeySym.CLEAR, } PICKUP_KEYS = { tcod.event.KeySym.COMMA, tcod.event.KeySym.SPACE, } CURSOR_SCROLL_KEYS = { tcod.event.KeySym.UP: -1, tcod.event.KeySym.DOWN: 1, tcod.event.KeySym.PAGEUP: -10, tcod.event.KeySym.PAGEDOWN: 10, tcod.event.KeySym.KP_2: 1, tcod.event.KeySym.KP_8: -1, } CURSOR_CONFIRM_KEYS = { tcod.event.KeySym.RETURN, 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 parent_handler: EventHandler def __init__(self,engine: Engine, parent_handler: EventHandler): super().__init__() self.engine = engine self.parent_handler = parent_handler def render(self, console: tcod.console.Console) -> None: if self.parent_handler: self.parent_handler.render(console) #callbacks def ev_quit(self, event: tcod.event.Quit) -> Optional[BaseAction]: return QuitAction() def handle_events(self, context: tcod.context.Context) -> bool: """If any Action signals True, then the game state should be progressed after this""" result = False for event in tcod.event.wait(): context.convert_event(event) #adds mouse position info action = self.dispatch(event) if action is None: continue result |= action.perform() return result 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. #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 class GameplayViewer(EventHandler): def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[BaseAction]: key = event.sym #SDL stuff, neat. player = self.engine.player #special keys if key == tcod.event.KeySym.ESCAPE: return QuitAction() #gameplay keys if key in MOVE_KEYS: xdir, ydir = MOVE_KEYS[key] return BumpAction(player, xdir = xdir, ydir = ydir) if key in WAIT_KEYS: return WaitAction(player) if key in PICKUP_KEYS: return PickupAction(player) #menu keys if key == tcod.event.KeySym.BACKQUOTE: #lowercase tilde self.engine.event_handler = LogHistoryViewer(self.engine, self) if key == tcod.event.KeySym.TAB: self.engine.event_handler = InventoryViewer(self.engine, self, player) #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", "Four", "Five", "Six"], 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) def dbg_callback(self, selected: int) -> Optional[BaseAction]: player: Entity = self.engine.player 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 class LogHistoryViewer(EventHandler): def __init__(self, engine: Engine, parent_handler: EventHandler): super().__init__(engine, parent_handler) self.length = len(engine.message_log.messages) self.cursor = self.length - 1 #start at the bottom def render(self, console: tcod.console.Console) -> None: super().render(console) log_console = tcod.console.Console(console.width - 6, console.height - 6) #rendering a nice window log_console.draw_frame( 0,0, log_console.width, log_console.height, # "╔═╗║ ║╚═╝" decoration="\\x/x x/x\\", fg=colors.terminal_dark, bg=colors.black ) log_console.print_box( 0, 0, log_console.width, log_console.height, string = "Message History", alignment=tcod.constants.CENTER, fg=colors.terminal_light, bg=colors.black ) self.engine.message_log.render_messages( log_console, 2, 2, log_console.width - 4, log_console.height - 4, self.engine.message_log.messages[:self.cursor + 1] ) log_console.blit(console, 3, 3) def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[BaseAction]: if event.sym in CURSOR_SCROLL_KEYS: adjust = CURSOR_SCROLL_KEYS[event.sym] if adjust < 0 and self.cursor == 0: pass #do nothing elif adjust > 0 and self.cursor == self.length - 1: pass #do nothing else: self.cursor = max(0, min(self.length - 1, self.cursor + adjust)) elif event.sym == tcod.event.KeySym.HOME: self.cursor = 0 elif event.sym == tcod.event.KeySym.END: self.cursor = self.length - 1 else: #return to the game - where's the any key? self.engine.event_handler = self.parent_handler class InventoryViewer(EventHandler): def __init__(self, engine: Engine, parent_handler: EventHandler, entity: Entity): #this entity's inventory super().__init__(engine, parent_handler) self.entity = entity self.length = len(self.entity.inventory.contents) self.cursor = 0 def render(self, console: tcod.console.Console) -> None: super().render(console) inner_console = tcod.console.Console(console.width - 6, console.height - 6) #rendering a nice window inner_console.draw_frame( 0,0, inner_console.width, inner_console.height, # "╔═╗║ ║╚═╝" decoration="\\x/x x/x\\", fg=colors.terminal_dark, bg=colors.black ) inner_console.print_box( 0, 0, inner_console.width, inner_console.height, string = "Inventory", alignment=tcod.constants.CENTER, fg=colors.terminal_light, bg=colors.black ) #render the cursor & inventory contents offset: int = 0 for item in self.entity.inventory.contents: msg = item.name if item.useable.current_stack > 1: msg = f"{msg} x{item.useable.current_stack}" inner_console.print( 4, 2 + offset, string = msg, fg=colors.terminal_light, bg=colors.black, ) offset += 2 if self.length > 0: inner_console.print(2, 2 + self.cursor * 2, string = ">", fg=colors.terminal_light, bg=colors.black) else: #if inventory is empty, show a default message inner_console.print_box( 0,inner_console.height // 2, inner_console.width, inner_console.height, string = "EMPTY", fg=colors.terminal_dark, bg=colors.black, alignment=tcod.constants.CENTER ) inner_console.blit(console, 3, 3) def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[BaseAction]: if event.sym in CURSOR_SCROLL_KEYS: adjust = CURSOR_SCROLL_KEYS[event.sym] if adjust < 0 and self.cursor == 0: pass #do nothing elif adjust > 0 and self.cursor == self.length - 1: pass #do nothing else: self.cursor = max(0, min(self.length - 1, self.cursor + adjust)) #extra size check, so empty inventories still work elif self.length > 0 and event.sym in CURSOR_CONFIRM_KEYS: #drop an item form this entity's inventory item: Entity = self.entity.inventory.access(self.cursor) #defaults options = ["Use", "Drop", "Back"] callback = lambda x: self.default_selector_callback(x) #different options for different situations if item.useable.current_stack > 1: options = ["Use", "Drop 1", "Drop All", "Back"] callback = lambda x: self.stack_selector_callback(x) self.engine.event_handler = OptionSelector( self.engine, self, title=item.name, options=options, callback=callback, margin_x=20, margin_y=12, ) #TODO: hotkeys via a config elif event.sym == tcod.event.KeySym.HOME: self.cursor = 0 elif event.sym == tcod.event.KeySym.END: self.cursor = self.length - 1 else: #return to the game - where's the any key? self.engine.event_handler = self.parent_handler #utils def default_selector_callback(self, selected: int) -> Optional[BaseAction]: if selected == 0: #Use return self.use() elif selected == 1: #Drop return self.drop() elif selected == 2: #Back return None #the selector does the change def stack_selector_callback(self, selected: int) -> Optional[BaseAction]: if selected == 0: #Use return self.use() elif selected == 1: #Drop 1 return self.drop_partial_stack(1) elif selected == 2: #Drop all return self.drop() elif selected == 3: #Back return None #the selector does the change def use(self) -> Optional[BaseAction]: """Use the item at the cursor's position.""" if self.length > 0: item: Entity = self.entity.inventory.access(self.cursor) useable: BaseUseable = item.useable #for ranged items, delegate to the tile selector 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_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 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: 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: 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: return DropAction(self.entity, self.cursor, lambda x: self.adjust_length(x)) def adjust_length(self, amount: int): self.length += amount if self.cursor >= self.length: self.cursor = self.length - 1 #generic tools class OptionSelector(EventHandler): def __init__( self, engine: Engine, parent_handler: EventHandler, *, options: List[str], callback: function, title: str = "Select Option", margin_x: int = 10, margin_y: int = 8 ): super().__init__(engine, parent_handler) self.options = options self.callback = callback self.length = len(options) self.cursor = 0 #graphical prettiness self.title = title self.margin_x = margin_x self.margin_y = margin_y def render(self, console: tcod.console.Console) -> None: super().render(console) select_console = tcod.console.Console(console.width - self.margin_x*2, console.height - self.margin_y*2) #rendering a nice window select_console.draw_frame( 0,0, select_console.width, select_console.height, # "╔═╗║ ║╚═╝" decoration="\\x/x x/x\\", fg=colors.terminal_dark, bg=colors.black ) select_console.print_box( 0, 0, select_console.width, select_console.height, string = self.title, alignment=tcod.constants.CENTER, fg=colors.terminal_light, bg=colors.black ) #render the cursor & options offset: int = 0 for option in self.options: select_console.print( 4, 2 + offset, string = option, fg=colors.terminal_light, bg=colors.black, ) offset += 2 select_console.print(2, 2 + self.cursor * 2, string = ">", fg=colors.terminal_light, bg=colors.black) select_console.blit(console, self.margin_x, self.margin_y) def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[BaseAction]: if event.sym in CURSOR_SCROLL_KEYS: adjust = CURSOR_SCROLL_KEYS[event.sym] if adjust < 0 and self.cursor == 0: pass #do nothing elif adjust > 0 and self.cursor == self.length - 1: pass #do nothing else: self.cursor = max(0, min(self.length - 1, self.cursor + adjust)) elif event.sym in CURSOR_CONFIRM_KEYS: self.engine.event_handler = self.parent_handler return self.callback(self.cursor) elif event.sym == tcod.event.KeySym.HOME: self.cursor = 0 elif event.sym == tcod.event.KeySym.END: self.cursor = self.length - 1 else: #return to the game self.engine.event_handler = self.parent_handler class TileSelector(EventHandler): floor_map: FloorMap cursor_x: int cursor_y: int cursor_radius: int callback: function def __init__( self, engine, parent_handler, *, floor_map: FloorMap, 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_cursor self.cursor_radius = cursor_radius 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) 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. #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)