APQuest: Tap to move (#6082)

* Tap to move

* inputs

* cleanup

* oops
This commit is contained in:
NewSoupVi
2026-03-31 19:55:52 +01:00
committed by GitHub
parent 5360b6bb37
commit 3c4af8f432
9 changed files with 377 additions and 45 deletions

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -6,6 +6,7 @@ from typing import NamedTuple
_random = random.Random()
class NumberChoiceConstraints(NamedTuple):
num_1_min: int
num_1_max: int

View 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 []