diff --git a/worlds/apquest/client/ap_quest_client.kv b/worlds/apquest/client/ap_quest_client.kv index 9f4024e62a..e60136a835 100644 --- a/worlds/apquest/client/ap_quest_client.kv +++ b/worlds/apquest/client/ap_quest_client.kv @@ -30,7 +30,10 @@ C to fire available Confetti Cannons Number Keys + Backspace for Math Trap\n - Rebinding controls might be added in the future :)""" + [b]Click to move also works![/b] + + Click/tap Confetti Cannon to fire it + Submit Math Trap solution in the command line at the bottom""" : orientation: "horizontal" diff --git a/worlds/apquest/client/ap_quest_client.py b/worlds/apquest/client/ap_quest_client.py index c1edc8edb4..9b6b82b75f 100644 --- a/worlds/apquest/client/ap_quest_client.py +++ b/worlds/apquest/client/ap_quest_client.py @@ -4,8 +4,9 @@ from argparse import Namespace from enum import Enum from typing import TYPE_CHECKING, Any -from CommonClient import CommonContext, gui_enabled, logger, server_loop +from CommonClient import ClientCommandProcessor, CommonContext, logger, server_loop from NetUtils import ClientStatus +from Utils import gui_enabled from ..game.events import ConfettiFired, LocationClearedEvent, MathProblemSolved, MathProblemStarted, VictoryEvent from ..game.game import Game @@ -41,6 +42,16 @@ class ConnectionStatus(Enum): GAME_RUNNING = 3 +class APQuestClientCommandProcessor(ClientCommandProcessor): + ctx: "APQuestContext" + + def default(self, raw: str) -> None: + if self.ctx.external_math_trap_input(raw): + return + + super().default(raw) + + class APQuestContext(CommonContext): game = "APQuest" items_handling = 0b111 # full remote @@ -65,6 +76,7 @@ class APQuestContext(CommonContext): delay_intro_song: bool ui: APQuestManager + command_processor = APQuestClientCommandProcessor def __init__( self, server_address: str | None = None, password: str | None = None, delay_intro_song: bool = False @@ -244,6 +256,53 @@ class APQuestContext(CommonContext): self.ap_quest_game.input(input_key) self.render() + def queue_auto_move(self, target_x: int, target_y: int) -> None: + if self.ap_quest_game is None: + return + if not self.ap_quest_game.gameboard.ready: + return + self.ap_quest_game.queue_auto_move(target_x, target_y) + self.ui.start_auto_move() + + def do_auto_move_and_rerender(self) -> None: + if self.ap_quest_game is None: + return + if not self.ap_quest_game.gameboard.ready: + return + changed = self.ap_quest_game.do_auto_move() + if changed: + self.render() + + def confetti_and_rerender(self) -> None: + # Used by tap mode + if self.ap_quest_game is None: + return + if not self.ap_quest_game.gameboard.ready: + return + + if self.ap_quest_game.attempt_fire_confetti_cannon(): + self.render() + + def external_math_trap_input(self, raw: str) -> bool: + if self.ap_quest_game is None: + return False + if not self.ap_quest_game.gameboard.ready: + return False + if not self.ap_quest_game.active_math_problem: + return False + + raw = raw.strip() + + if not raw: + return False + if not raw.isnumeric(): + return False + + self.ap_quest_game.math_problem_replace([int(digit) for digit in raw]) + self.render() + + return True + def make_gui(self) -> "type[kvui.GameManager]": self.load_kv() return APQuestManager diff --git a/worlds/apquest/client/custom_views.py b/worlds/apquest/client/custom_views.py index 026aa1fc8d..cc44f991d6 100644 --- a/worlds/apquest/client/custom_views.py +++ b/worlds/apquest/client/custom_views.py @@ -8,15 +8,17 @@ from kivy.core.window import Keyboard, Window from kivy.graphics import Color, Triangle from kivy.graphics.instructions import Canvas from kivy.input import MotionEvent +from kivy.uix.behaviors import ButtonBehavior from kivy.uix.boxlayout import BoxLayout from kivy.uix.gridlayout import GridLayout +from kivy.uix.image import Image +from kivy.uix.widget import Widget from kivymd.uix.recycleview import MDRecycleView from CommonClient import logger from ..game.inputs import Input - INPUT_MAP = { "up": Input.UP, "w": Input.UP, @@ -51,8 +53,9 @@ class APQuestGameView(MDRecycleView): self.input_function = input_function self.bind_keyboard() - def on_touch_down(self, touch: MotionEvent) -> None: + def on_touch_down(self, touch: MotionEvent) -> bool | None: self.bind_keyboard() + return super().on_touch_down(touch) def bind_keyboard(self) -> None: if self._keyboard is not None: @@ -203,13 +206,23 @@ class Confetti: return True -class ConfettiView(MDRecycleView): +class ConfettiView(Widget): confetti: list[Confetti] def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) self.confetti = [] + # Don't eat tap events for the game grid under the confetti view + def on_touch_down(self, touch) -> bool: + return False + + def on_touch_move(self, touch) -> bool: + return False + + def on_touch_up(self, touch) -> bool: + return False + def check_resize(self, _: int, _1: int) -> None: parent_width, parent_height = self.parent.size @@ -254,3 +267,32 @@ class VolumeSliderView(BoxLayout): class APQuestControlsView(BoxLayout): pass + + +class TapImage(ButtonBehavior, Image): + callback: Callable[[], None] + + def __init__(self, callback: Callable[[], None], **kwargs) -> None: + self.callback = callback + super().__init__(**kwargs) + + def on_release(self) -> bool: + self.callback() + + return True + + +class TapIfConfettiCannonImage(ButtonBehavior, Image): + callback: Callable[[], None] + + is_confetti_cannon: bool = False + + def __init__(self, callback: Callable[[], None], **kwargs: dict[str, Any]) -> None: + self.callback = callback + super().__init__(**kwargs) + + def on_release(self) -> bool: + if self.is_confetti_cannon: + self.callback() + + return True diff --git a/worlds/apquest/client/game_manager.py b/worlds/apquest/client/game_manager.py index 86f4316d12..241fbec0ae 100644 --- a/worlds/apquest/client/game_manager.py +++ b/worlds/apquest/client/game_manager.py @@ -6,6 +6,7 @@ from kvui import GameManager, MDNavigationItemBase # isort: on from typing import TYPE_CHECKING, Any +from kivy._clock import ClockEvent from kivy.clock import Clock from kivy.uix.gridlayout import GridLayout from kivy.uix.image import Image @@ -13,7 +14,16 @@ from kivy.uix.layout import Layout from kivymd.uix.recycleview import MDRecycleView from ..game.game import Game -from .custom_views import APQuestControlsView, APQuestGameView, APQuestGrid, ConfettiView, VolumeSliderView +from ..game.graphics import Graphic +from .custom_views import ( + APQuestControlsView, + APQuestGameView, + APQuestGrid, + ConfettiView, + TapIfConfettiCannonImage, + TapImage, + VolumeSliderView, +) from .graphics import PlayerSprite, get_texture from .sounds import SoundManager @@ -34,9 +44,11 @@ class APQuestManager(GameManager): sound_manager: SoundManager bottom_image_grid: list[list[Image]] - top_image_grid: list[list[Image]] + top_image_grid: list[list[TapImage]] confetti_view: ConfettiView + move_event: ClockEvent | None + bottom_grid_is_grass: bool def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -45,6 +57,7 @@ class APQuestManager(GameManager): self.sound_manager.allow_intro_to_play = not self.ctx.delay_intro_song self.top_image_grid = [] self.bottom_image_grid = [] + self.move_event = None self.bottom_grid_is_grass = False def allow_intro_song(self) -> None: @@ -74,7 +87,7 @@ class APQuestManager(GameManager): self.sound_manager.game_started = True def render(self, game: Game, player_sprite: PlayerSprite) -> None: - self.setup_game_grid_if_not_setup(game.gameboard.size) + self.setup_game_grid_if_not_setup(game) # This calls game.render(), which needs to happen to update the state of math traps self.render_gameboard(game, player_sprite) @@ -104,6 +117,8 @@ class APQuestManager(GameManager): for item_graphic, image_row in zip(rendered_item_column, self.top_image_grid, strict=False): image = image_row[-1] + image.is_confetti_cannon = item_graphic == Graphic.CONFETTI_CANNON + texture = get_texture(item_graphic) if texture is None: image.opacity = 0 @@ -136,23 +151,25 @@ class APQuestManager(GameManager): self.bottom_grid_is_grass = grass - def setup_game_grid_if_not_setup(self, size: tuple[int, int]) -> None: + def setup_game_grid_if_not_setup(self, game: Game) -> None: if self.upper_game_grid.children: return self.top_image_grid = [] self.bottom_image_grid = [] - for _row in range(size[1]): + size = game.gameboard.size + + for row in range(size[1]): self.top_image_grid.append([]) self.bottom_image_grid.append([]) - for _column in range(size[0]): + for column in range(size[0]): bottom_image = Image(fit_mode="fill", color=(0.3, 0.3, 0.3)) self.lower_game_grid.add_widget(bottom_image) self.bottom_image_grid[-1].append(bottom_image) - top_image = Image(fit_mode="fill") + top_image = TapImage(lambda y=row, x=column: self.ctx.queue_auto_move(x, y), fit_mode="fill") self.upper_game_grid.add_widget(top_image) self.top_image_grid[-1].append(top_image) @@ -160,11 +177,19 @@ class APQuestManager(GameManager): image = Image(fit_mode="fill", color=(0.3, 0.3, 0.3)) self.lower_game_grid.add_widget(image) - image2 = Image(fit_mode="fill", opacity=0) + image2 = TapIfConfettiCannonImage(lambda: self.ctx.confetti_and_rerender(), fit_mode="fill", opacity=0) self.upper_game_grid.add_widget(image2) self.top_image_grid[-1].append(image2) + def start_auto_move(self) -> None: + if self.move_event is not None: + self.move_event.cancel() + + self.ctx.do_auto_move_and_rerender() + + self.move_event = Clock.schedule_interval(lambda _: self.ctx.do_auto_move_and_rerender(), 0.10) + def build(self) -> Layout: container = super().build() diff --git a/worlds/apquest/game/entities.py b/worlds/apquest/game/entities.py index 64b89206f6..ae7c7e85bc 100644 --- a/worlds/apquest/game/entities.py +++ b/worlds/apquest/game/entities.py @@ -17,8 +17,10 @@ class Entity: class InteractableMixin: + auto_move_attempt_passing_through = False + @abstractmethod - def interact(self, player: Player) -> None: + def interact(self, player: Player) -> bool: pass @@ -89,15 +91,16 @@ class Chest(Entity, InteractableMixin, LocationMixin): self.is_open = True self.update_solidity() - def interact(self, player: Player) -> None: + def interact(self, player: Player) -> bool: if self.has_given_content: - return + return False if self.is_open: self.give_content(player) - return + return True self.open() + return True def content_success(self) -> None: self.update_solidity() @@ -135,47 +138,59 @@ class Door(Entity): class KeyDoor(Door, InteractableMixin): + auto_move_attempt_passing_through = True + closed_graphic = Graphic.KEY_DOOR - def interact(self, player: Player) -> None: + def interact(self, player: Player) -> bool: if self.is_open: - return + return False if not player.has_item(Item.KEY): - return + return False player.remove_item(Item.KEY) self.open() + return True + class BreakableBlock(Door, InteractableMixin): + auto_move_attempt_passing_through = True + closed_graphic = Graphic.BREAKABLE_BLOCK - def interact(self, player: Player) -> None: + def interact(self, player: Player) -> bool: if self.is_open: - return + return False if not player.has_item(Item.HAMMER): - return + return False player.remove_item(Item.HAMMER) self.open() + return True + class Bush(Door, InteractableMixin): + auto_move_attempt_passing_through = True + closed_graphic = Graphic.BUSH - def interact(self, player: Player) -> None: + def interact(self, player: Player) -> bool: if self.is_open: - return + return False if not player.has_item(Item.SWORD): - return + return False self.open() + return True + class Button(Entity, InteractableMixin): solid = True @@ -186,12 +201,13 @@ class Button(Entity, InteractableMixin): def __init__(self, activates: ActivatableMixin) -> None: self.activates = activates - def interact(self, player: Player) -> None: + def interact(self, player: Player) -> bool: if self.activated: - return + return False self.activated = True self.activates.activate(player) + return True @property def graphic(self) -> Graphic: @@ -240,9 +256,9 @@ class Enemy(Entity, InteractableMixin): return self.current_health = self.max_health - def interact(self, player: Player) -> None: + def interact(self, player: Player) -> bool: if self.dead: - return + return False if player.has_item(Item.SWORD): self.current_health = max(0, self.current_health - 1) @@ -250,9 +266,10 @@ class Enemy(Entity, InteractableMixin): if self.current_health == 0: if not self.dead: self.die() - return + return True player.damage(2) + return True @property def graphic(self) -> Graphic: @@ -270,13 +287,15 @@ class EnemyWithLoot(Enemy, LocationMixin): self.dead = True self.solid = not self.has_given_content - def interact(self, player: Player) -> None: + def interact(self, player: Player) -> bool: if self.dead: if not self.has_given_content: self.give_content(player) - return + return True + return False super().interact(player) + return True @property def graphic(self) -> Graphic: @@ -303,10 +322,12 @@ class FinalBoss(Enemy): } enemy_default_graphic = Graphic.BOSS_1_HEALTH - def interact(self, player: Player) -> None: + def interact(self, player: Player) -> bool: dead_before = self.dead - super().interact(player) + changed = super().interact(player) if not dead_before and self.dead: player.victory() + + return changed diff --git a/worlds/apquest/game/game.py b/worlds/apquest/game/game.py index 709e74850a..21bebca681 100644 --- a/worlds/apquest/game/game.py +++ b/worlds/apquest/game/game.py @@ -23,6 +23,8 @@ class Game: active_math_problem: MathProblem | None active_math_problem_input: list[int] | None + auto_target_path: list[tuple[int, int]] = [] + remotely_received_items: set[tuple[int, int, int]] def __init__( @@ -94,29 +96,40 @@ class Game: return tuple(graphics_array) - def attempt_player_movement(self, direction: Direction) -> None: + def attempt_player_movement(self, direction: Direction, cancel_auto_move: bool = True) -> bool: + if cancel_auto_move: + self.cancel_auto_move() + self.player.facing = direction delta_x, delta_y = direction.value new_x, new_y = self.player.current_x + delta_x, self.player.current_y + delta_y - if not self.gameboard.get_entity_at(new_x, new_y).solid: - self.player.current_x = new_x - self.player.current_y = new_y + if self.gameboard.get_entity_at(new_x, new_y).solid: + return False - def attempt_interact(self) -> None: + self.player.current_x = new_x + self.player.current_y = new_y + return True + + def attempt_interact(self) -> bool: delta_x, delta_y = self.player.facing.value entity_x, entity_y = self.player.current_x + delta_x, self.player.current_y + delta_y entity = self.gameboard.get_entity_at(entity_x, entity_y) if isinstance(entity, InteractableMixin): - entity.interact(self.player) + return entity.interact(self.player) - def attempt_fire_confetti_cannon(self) -> None: - if self.player.has_item(Item.CONFETTI_CANNON): - self.player.remove_item(Item.CONFETTI_CANNON) - self.queued_events.append(ConfettiFired(self.player.current_x, self.player.current_y)) + return False + + def attempt_fire_confetti_cannon(self) -> bool: + if not self.player.has_item(Item.CONFETTI_CANNON): + return False + + self.player.remove_item(Item.CONFETTI_CANNON) + self.queued_events.append(ConfettiFired(self.player.current_x, self.player.current_y)) + return True def math_problem_success(self) -> None: self.active_math_problem = None @@ -154,6 +167,12 @@ class Game: self.active_math_problem_input.pop() self.check_math_problem_result() + def math_problem_replace(self, input: list[int]) -> None: + if self.active_math_problem_input is None: + return + self.active_math_problem_input = input[:2] + self.check_math_problem_result() + def input(self, input_key: Input) -> None: if not self.gameboard.ready: return @@ -201,3 +220,47 @@ class Game: def force_clear_location(self, location_id: int) -> None: location = Location(location_id) self.gameboard.force_clear_location(location) + + def cancel_auto_move(self) -> None: + self.auto_target_path = [] + + def queue_auto_move(self, target_x: int, target_y: int) -> None: + self.cancel_auto_move() + path = self.gameboard.calculate_shortest_path(self.player.current_x, self.player.current_y, target_x, target_y) + self.auto_target_path = path + + def do_auto_move(self) -> bool: + if not self.auto_target_path: + return False + + target_x, target_y = self.auto_target_path.pop(0) + movement = target_x - self.player.current_x, target_y - self.player.current_y + direction = Direction(movement) + moved = self.attempt_player_movement(direction, cancel_auto_move=False) + + if moved: + return True + + # We are attempting to interact with something on the path. + # First, make the player face it. + if self.player.facing != direction: + self.player.facing = direction + self.auto_target_path.insert(0, (target_x, target_y)) + return True + + # If we are facing it, attempt to interact with it. + changed = self.attempt_interact() + + if not changed: + self.cancel_auto_move() + return False + + # If the interaction was successful, and this was the end of the path, stop + # (i.e. don't try to attack the attacked enemy over and over until it's dead) + if not self.auto_target_path: + self.cancel_auto_move() + return True + + # If there is more to go, keep going along the path + self.auto_target_path.insert(0, (target_x, target_y)) + return True diff --git a/worlds/apquest/game/gameboard.py b/worlds/apquest/game/gameboard.py index ec97491c87..77688c2929 100644 --- a/worlds/apquest/game/gameboard.py +++ b/worlds/apquest/game/gameboard.py @@ -15,6 +15,7 @@ from .entities import ( EnemyWithLoot, Entity, FinalBoss, + InteractableMixin, KeyDoor, LocationMixin, Wall, @@ -23,6 +24,7 @@ from .generate_math_problem import MathProblem from .graphics import DIGIT_TO_GRAPHIC, DIGIT_TO_GRAPHIC_ZERO_EMPTY, MATH_PROBLEM_TYPE_TO_GRAPHIC, Graphic from .items import Item from .locations import DEFAULT_CONTENT, Location +from .path_finding import find_path_or_closest if TYPE_CHECKING: from .player import Player @@ -107,6 +109,21 @@ class Gameboard: return tuple(graphics) + def as_traversability_bools(self) -> tuple[tuple[bool, ...], ...]: + traversability = [] + + for y, row in enumerate(self.gameboard): + traversable_row = [] + for x, entity in enumerate(row): + traversable_row.append( + not entity.solid + or (isinstance(entity, InteractableMixin) and entity.auto_move_attempt_passing_through) + ) + + traversability.append(tuple(traversable_row)) + + return tuple(traversability) + def render_math_problem( self, problem: MathProblem, current_input_digits: list[int], current_input_int: int | None ) -> tuple[tuple[Graphic, ...], ...]: @@ -186,6 +203,23 @@ class Gameboard: entity = self.remote_entity_by_location_id[location] entity.force_clear() + def calculate_shortest_path( + self, source_x: int, source_y: int, target_x: int, target_y: int + ) -> list[tuple[int, int]]: + gameboard_traversability = self.as_traversability_bools() + + path = find_path_or_closest(gameboard_traversability, source_x, source_y, target_x, target_y) + + if not path: + return path + + # If the path stops just short of target, attempt interacting with it at the end + if abs(path[-1][0] - target_x) + abs(path[-1][1] - target_y) == 1: + if isinstance(self.gameboard[target_y][target_x], InteractableMixin): + path.append((target_x, target_y)) + + return path[1:] # Cut off starting tile + @property def ready(self) -> bool: return self.content_filled diff --git a/worlds/apquest/game/generate_math_problem.py b/worlds/apquest/game/generate_math_problem.py index eb8ff0f01e..b93375e9ac 100644 --- a/worlds/apquest/game/generate_math_problem.py +++ b/worlds/apquest/game/generate_math_problem.py @@ -6,6 +6,7 @@ from typing import NamedTuple _random = random.Random() + class NumberChoiceConstraints(NamedTuple): num_1_min: int num_1_max: int diff --git a/worlds/apquest/game/path_finding.py b/worlds/apquest/game/path_finding.py new file mode 100644 index 0000000000..8b3e649b1d --- /dev/null +++ b/worlds/apquest/game/path_finding.py @@ -0,0 +1,84 @@ +import heapq +from typing import Generator + +Point = tuple[int, int] + + +def heuristic(a: Point, b: Point) -> int: + # Manhattan distance (good for 4-directional grids) + return abs(a[0] - b[0]) + abs(a[1] - b[1]) + + +def reconstruct_path(came_from: dict[Point, Point], current: Point) -> list[Point]: + path = [current] + while current in came_from: + current = came_from[current] + path.append(current) + path.reverse() + return path + + +def find_path_or_closest( + grid: tuple[tuple[bool, ...], ...], source_x: int, source_y: int, target_x: int, target_y: int +) -> list[Point]: + start = source_x, source_y + goal = target_x, target_y + + rows, cols = len(grid), len(grid[0]) + + def in_bounds(p: Point) -> bool: + return 0 <= p[0] < rows and 0 <= p[1] < cols + + def passable(p: Point) -> bool: + return grid[p[1]][p[0]] + + def neighbors(p: Point) -> Generator[Point, None, None]: + x, y = p + for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]: + np = (x + dx, y + dy) + if in_bounds(np) and passable(np): + yield np + + open_heap: list[tuple[int, tuple[int, int]]] = [] + heapq.heappush(open_heap, (0, start)) + + came_from: dict[Point, Point] = {} + g_score = {start: 0} + + # Track best fallback node + best_node = start + best_dist = heuristic(start, goal) + + visited = set() + + while open_heap: + _, current = heapq.heappop(open_heap) + + if current in visited: + continue + visited.add(current) + + # Check if we reached the goal + if current == goal: + return reconstruct_path(came_from, current) + + # Update "closest node" fallback + dist = heuristic(current, goal) + if dist < best_dist or (dist == best_dist and g_score[current] < g_score.get(best_node, float("inf"))): + best_node = current + best_dist = dist + + for neighbor in neighbors(current): + tentative_g = g_score[current] + 1 # cost is 1 per move + + if tentative_g < g_score.get(neighbor, float("inf")): + came_from[neighbor] = current + g_score[neighbor] = tentative_g + f_score = tentative_g + heuristic(neighbor, goal) + heapq.heappush(open_heap, (f_score, neighbor)) + + # Goal not reachable → return path to closest node + if best_node is not None: + return reconstruct_path(came_from, best_node) + + return []