mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-31 14:03:23 -07:00
@@ -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"""
|
||||
|
||||
<VolumeSliderView>:
|
||||
orientation: "horizontal"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import NamedTuple
|
||||
|
||||
_random = random.Random()
|
||||
|
||||
|
||||
class NumberChoiceConstraints(NamedTuple):
|
||||
num_1_min: int
|
||||
num_1_max: int
|
||||
|
||||
84
worlds/apquest/game/path_finding.py
Normal file
84
worlds/apquest/game/path_finding.py
Normal file
@@ -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 []
|
||||
Reference in New Issue
Block a user