diff --git a/README.md b/README.md index fa87190565..608af1313c 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ Currently, the following games are supported: * Paint * Celeste (Open World) * Choo-Choo Charles +* APQuest For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled diff --git a/docs/CODEOWNERS b/docs/CODEOWNERS index 7b8e48af14..e4ef3fe73e 100644 --- a/docs/CODEOWNERS +++ b/docs/CODEOWNERS @@ -15,6 +15,10 @@ # A Link to the Past /worlds/alttp/ @Berserker66 +# APQuest +# NewSoupVi is acting maintainer, but world belongs to core with the exception of the music +/worlds/apquest/ @NewSoupVi + # Sudoku (APSudoku) /worlds/apsudoku/ @EmilyV99 diff --git a/worlds/apquest/!READ_FIRST!.txt b/worlds/apquest/!READ_FIRST!.txt new file mode 100644 index 0000000000..ff3d5fe510 --- /dev/null +++ b/worlds/apquest/!READ_FIRST!.txt @@ -0,0 +1,46 @@ +This apworld is meant as a learning tool for new apworld devs. +It is a completely standalone resource, but there will be links to additional resources when appropriate. + +################# +# Prerequisites # +################# + +APQuest will only explain how to write the generation-side code for your game, not how to write a client or mod for it. +For a more zoomed out view of how to add a game to Archipelago, you can read this document: +https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/adding%20games.md + +APQuest assumes you already vaguely know what an apworld is. +If you don't know, read this first: +https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/apworld%20specification.md + +To write an apworld, you need to be running Archipelago from source (Python) instead of using e.g. the .exe build. +Here's an explanation for how to do that. +https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/running%20from%20source.md + +####################### +# How to read APQuest # +####################### + +You'll want to start with __init__.py, then move to world.py. +If you also want to learn how to write unit tests, go to test/__init__.py. + +You can ignore the game/ folder, it contains the actual game code, graphics and music. + +The client/ folder is NOT meant for teaching. +While the client was written to the best of its author's ability, it does not meet the same standard as the world code. +The client code is also lacking the explanatory comments. +Copy from it at your own risk. + +################### +# Further reading # +################### + +APQuest is a very simple game, so not every edge case will be covered. +The world API document goes a lot more in-depth on certain topics: +https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/world%20api.md + +There is also the "APWorld dev FAQ" document with common emergent problems: +https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/apworld_dev_faq.md + +In general, but especially if you want your apworld to be verified by core, you should follow our style guide: +https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/style.md diff --git a/worlds/apquest/__init__.py b/worlds/apquest/__init__.py new file mode 100644 index 0000000000..6ef396ee90 --- /dev/null +++ b/worlds/apquest/__init__.py @@ -0,0 +1,12 @@ +# The first thing you should make for your world is an archipelago.json manifest file. +# You can reference APQuest's, but you should change the "game" field (obviously), +# and you should also change the "minimum_ap_version" - probably to the current value of Utils.__version__. + +# Apart from the regular apworld code that allows generating multiworld seeds with your game, +# your apworld might have other "components" that should be launchable from the Archipelago Launcher. +# You can ignore this for now. If you are specifically interested in components, you can read components.py. +from . import components as components + +# The main thing we do in our __init__.py is importing our world class from our world.py to initialize it. +# Obviously, this world class needs to exist first. For this, read world.py. +from .world import APQuestWorld as APQuestWorld diff --git a/worlds/apquest/archipelago.json b/worlds/apquest/archipelago.json new file mode 100644 index 0000000000..102e2e2558 --- /dev/null +++ b/worlds/apquest/archipelago.json @@ -0,0 +1,6 @@ +{ + "game": "APQuest", + "minimum_ap_version": "0.6.4", + "world_version": "1.0.0", + "authors": ["NewSoupVi"] +} diff --git a/worlds/apquest/client/__init__.py b/worlds/apquest/client/__init__.py new file mode 100644 index 0000000000..0cd1b86a49 --- /dev/null +++ b/worlds/apquest/client/__init__.py @@ -0,0 +1,5 @@ +# !!! IMPORTANT !!! +# The client implementation is *not* meant for teaching. +# Obviously, it is written to the best of its author's abilities, +# but it is not to the same standard as the rest of the apworld. +# Copy things from here at your own risk. diff --git a/worlds/apquest/client/ap_quest_client.kv b/worlds/apquest/client/ap_quest_client.kv new file mode 100644 index 0000000000..9f4024e62a --- /dev/null +++ b/worlds/apquest/client/ap_quest_client.kv @@ -0,0 +1,56 @@ +: + size_hint: None, None + pos_hint: {"center_x": 0.5, "center_y": 0.5} + spacing: 0 + padding: 0 + +: + cols: 12 + rows: 11 + spacing: 0 + padding: 0 + size_hint: None, None + pos_hint: {"center_x": 0.5, "center_y": 0.5} + +: + RelativeLayout: + id: game_container + +: + Label: + markup: True + font_size: "20sp" + valign: "middle" + pos_hint: {"center_x": 0.5, "center_y": 0.5} + text: + """[b]Controls:[/b] + + WASD or Arrow Keys to move + Space to attack or interact + C to fire available Confetti Cannons + Number Keys + Backspace for Math Trap\n + + Rebinding controls might be added in the future :)""" + +: + orientation: "horizontal" + size_hint: 1, None + padding: 0 + height: 50 + + Label: + size_hint: None, 1 + text: "Volume:" + + Slider: + id: volume_slider + size_hint: 1, 1 + min: 0 + max: 100 + step: 1 + value: 50 + orientation: "horizontal" + + Label: + size_hint: None, 1 + text: str(int(volume_slider.value)) diff --git a/worlds/apquest/client/ap_quest_client.py b/worlds/apquest/client/ap_quest_client.py new file mode 100644 index 0000000000..c1edc8edb4 --- /dev/null +++ b/worlds/apquest/client/ap_quest_client.py @@ -0,0 +1,290 @@ +import asyncio +import sys +from argparse import Namespace +from enum import Enum +from typing import TYPE_CHECKING, Any + +from CommonClient import CommonContext, gui_enabled, logger, server_loop +from NetUtils import ClientStatus + +from ..game.events import ConfettiFired, LocationClearedEvent, MathProblemSolved, MathProblemStarted, VictoryEvent +from ..game.game import Game +from ..game.inputs import Input +from ..game.items import Item +from ..game.locations import Location +from .game_manager import APQuestManager +from .graphics import PlayerSprite +from .item_quality import get_quality_for_network_item +from .sounds import ( + CONFETTI_CANNON, + ITEM_JINGLES, + MATH_PROBLEM_SOLVED_JINGLE, + MATH_PROBLEM_STARTED_JINGLE, + VICTORY_JINGLE, +) + +if TYPE_CHECKING: + import kvui + + +# !!! IMPORTANT !!! +# The client implementation is *not* meant for teaching. +# Obviously, it is written to the best of its author's abilities, +# but it is not to the same standard as the rest of the apworld. +# Copy things from here at your own risk. + + +class ConnectionStatus(Enum): + NOT_CONNECTED = 0 + SCOUTS_NOT_SENT = 1 + SCOUTS_SENT = 2 + GAME_RUNNING = 3 + + +class APQuestContext(CommonContext): + game = "APQuest" + items_handling = 0b111 # full remote + + client_loop: asyncio.Task[None] + + last_connected_slot: int | None = None + + slot_data: dict[str, Any] + + ap_quest_game: Game | None = None + hard_mode: bool = False + hammer: bool = False + extra_starting_chest: bool = False + player_sprite: PlayerSprite = PlayerSprite.HUMAN + + connection_status: ConnectionStatus = ConnectionStatus.NOT_CONNECTED + + highest_processed_item_index: int = 0 + queued_locations: list[int] + + delay_intro_song: bool + + ui: APQuestManager + + def __init__( + self, server_address: str | None = None, password: str | None = None, delay_intro_song: bool = False + ) -> None: + super().__init__(server_address, password) + + self.queued_locations = [] + self.slot_data = {} + self.delay_intro_song = delay_intro_song + + async def server_auth(self, password_requested: bool = False) -> None: + if password_requested and not self.password: + self.ui.allow_intro_song() + await super().server_auth(password_requested) + await self.get_username() + await self.send_connect(game=self.game) + + def handle_connection_loss(self, msg: str) -> None: + self.ui.allow_intro_song() + super().handle_connection_loss(msg) + + async def connect(self, address: str | None = None) -> None: + self.ui.switch_to_regular_tab() + await super().connect(address) + + async def apquest_loop(self) -> None: + while not self.exit_event.is_set(): + if self.connection_status != ConnectionStatus.GAME_RUNNING: + if self.connection_status == ConnectionStatus.SCOUTS_NOT_SENT: + await self.send_msgs([{"cmd": "LocationScouts", "locations": self.server_locations}]) + self.connection_status = ConnectionStatus.SCOUTS_SENT + + await asyncio.sleep(0.1) + continue + + if not self.ap_quest_game or not self.ap_quest_game.gameboard or not self.ap_quest_game.gameboard.ready: + await asyncio.sleep(0.1) + continue + + try: + while self.queued_locations: + location = self.queued_locations.pop(0) + self.location_checked_side_effects(location) + self.locations_checked.add(location) + await self.check_locations({location}) + + rerender = False + + new_items = self.items_received[self.highest_processed_item_index :] + for item in new_items: + self.highest_processed_item_index += 1 + self.ap_quest_game.receive_item(item.item, item.location, item.player) + rerender = True + + for new_remotely_cleared_location in self.checked_locations - self.locations_checked: + self.ap_quest_game.force_clear_location(new_remotely_cleared_location) + rerender = True + + if rerender: + self.render() + + if self.ap_quest_game.player.has_won and not self.finished_game: + await self.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) + self.finished_game = True + except Exception as e: + logger.exception(e) + + await asyncio.sleep(0.1) + + def on_package(self, cmd: str, args: dict[str, Any]) -> None: + if cmd == "ConnectionRefused": + self.ui.allow_intro_song() + + if cmd == "Connected": + if self.connection_status == ConnectionStatus.GAME_RUNNING: + # In a connection loss -> auto reconnect scenario, we can seamlessly keep going + return + + self.last_connected_slot = self.slot + + self.connection_status = ConnectionStatus.NOT_CONNECTED # for safety, it will get set again later + + self.slot_data = args["slot_data"] + self.hard_mode = self.slot_data["hard_mode"] + self.hammer = self.slot_data["hammer"] + self.extra_starting_chest = self.slot_data["extra_starting_chest"] + try: + self.player_sprite = PlayerSprite(self.slot_data["player_sprite"]) + except Exception as e: + logger.exception(e) + self.player_sprite = PlayerSprite.UNKNOWN + + self.ap_quest_game = Game(self.hard_mode, self.hammer, self.extra_starting_chest) + self.highest_processed_item_index = 0 + self.render() + + self.connection_status = ConnectionStatus.SCOUTS_NOT_SENT + if cmd == "LocationInfo": + remote_item_graphic_overrides = { + Location(location): Item(network_item.item) + for location, network_item in self.locations_info.items() + if self.slot_info[network_item.player].game == self.game + } + + assert self.ap_quest_game is not None + self.ap_quest_game.gameboard.fill_remote_location_content(remote_item_graphic_overrides) + self.render() + self.ui.game_view.bind_keyboard() + + self.connection_status = ConnectionStatus.GAME_RUNNING + self.ui.game_started() + + async def disconnect(self, *args: Any, **kwargs: Any) -> None: + self.finished_game = False + self.locations_checked = set() + self.connection_status = ConnectionStatus.NOT_CONNECTED + await super().disconnect(*args, **kwargs) + + def render(self) -> None: + if self.ap_quest_game is None: + raise RuntimeError("Tried to render before self.ap_quest_game was initialized.") + + self.ui.render(self.ap_quest_game, self.player_sprite) + self.handle_game_events() + + def location_checked_side_effects(self, location: int) -> None: + network_item = self.locations_info[location] + + if network_item.player == self.slot and network_item.item == Item.MATH_TRAP.value: + # In case of a local math trap, we only play the math trap trigger jingle + return + + item_quality = get_quality_for_network_item(network_item) + self.play_jingle(ITEM_JINGLES[item_quality]) + + def play_jingle(self, audio_filename: str) -> None: + self.ui.play_jingle(audio_filename) + + def handle_game_events(self) -> None: + if self.ap_quest_game is None: + return + + while self.ap_quest_game.queued_events: + event = self.ap_quest_game.queued_events.pop(0) + + if isinstance(event, LocationClearedEvent): + self.queued_locations.append(event.location_id) + continue + + if isinstance(event, VictoryEvent): + self.play_jingle(VICTORY_JINGLE) + continue + + if isinstance(event, ConfettiFired): + gameboard_x, gameboard_y = self.ap_quest_game.gameboard.size + gameboard_x += 1 # vertical item column + x = (event.x + 0.5) / gameboard_x + y = 1 - (event.y + 0.5) / gameboard_y # Kivy's y is bottom to top (ew) + + self.ui.play_jingle(CONFETTI_CANNON) + self.ui.add_confetti((x, y), (self.slot_data["confetti_explosiveness"] + 1) * 5) + continue + + if isinstance(event, MathProblemStarted): + self.play_jingle(MATH_PROBLEM_STARTED_JINGLE) + continue + + if isinstance(event, MathProblemSolved): + self.play_jingle(MATH_PROBLEM_SOLVED_JINGLE) + continue + + def input_and_rerender(self, input_key: Input) -> None: + if self.ap_quest_game is None: + return + if not self.ap_quest_game.gameboard.ready: + return + self.ap_quest_game.input(input_key) + self.render() + + def make_gui(self) -> "type[kvui.GameManager]": + self.load_kv() + return APQuestManager + + def load_kv(self) -> None: + import pkgutil + + from kivy.lang import Builder + + data = pkgutil.get_data(__name__, "ap_quest_client.kv") + if data is None: + raise RuntimeError("ap_quest_client.kv could not be loaded.") + + Builder.load_string(data.decode()) + + +async def main(args: Namespace) -> None: + if not gui_enabled: + raise RuntimeError("APQuest cannot be played without gui.") + + # Assume we shouldn't play the intro song in the auto-connect scenario, because the game will instantly start. + delay_intro_song = args.connect and args.name + + ctx = APQuestContext(args.connect, args.password, delay_intro_song=delay_intro_song) + ctx.auth = args.name + ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") + + ctx.run_gui() + ctx.run_cli() + + ctx.client_loop = asyncio.create_task(ctx.apquest_loop(), name="Client Loop") + + await ctx.exit_event.wait() + await ctx.shutdown() + + +def launch(*args: str) -> None: + from .launch import launch_ap_quest_client + + launch_ap_quest_client(*args) + + +if __name__ == "__main__": + launch(*sys.argv[1:]) diff --git a/worlds/apquest/client/custom_views.py b/worlds/apquest/client/custom_views.py new file mode 100644 index 0000000000..b85584116f --- /dev/null +++ b/worlds/apquest/client/custom_views.py @@ -0,0 +1,256 @@ +from collections.abc import Callable +from dataclasses import dataclass +from math import sqrt +from random import choice, random +from typing import TYPE_CHECKING, Any + +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.boxlayout import BoxLayout +from kivy.uix.gridlayout import GridLayout +from kivymd.uix.recycleview import MDRecycleView + +from CommonClient import logger + +from ..game.inputs import Input + + +INPUT_MAP = { + "up": Input.UP, + "w": Input.UP, + "down": Input.DOWN, + "s": Input.DOWN, + "right": Input.RIGHT, + "d": Input.RIGHT, + "left": Input.LEFT, + "a": Input.LEFT, + "spacebar": Input.ACTION, + "c": Input.CONFETTI, + "0": Input.ZERO, + "1": Input.ONE, + "2": Input.TWO, + "3": Input.THREE, + "4": Input.FOUR, + "5": Input.FIVE, + "6": Input.SIX, + "7": Input.SEVEN, + "8": Input.EIGHT, + "9": Input.NINE, + "backspace": Input.BACKSPACE, +} + + +class APQuestGameView(MDRecycleView): + _keyboard: Keyboard | None = None + input_function: Callable[[Input], None] + + def __init__(self, input_function: Callable[[Input], None], **kwargs: Any) -> None: + super().__init__(**kwargs) + self.input_function = input_function + self.bind_keyboard() + + def on_touch_down(self, touch: MotionEvent) -> None: + self.bind_keyboard() + + def bind_keyboard(self) -> None: + if self._keyboard is not None: + return + self._keyboard = Window.request_keyboard(self._keyboard_closed, self) + self._keyboard.bind(on_key_down=self._on_keyboard_down) + + def _keyboard_closed(self) -> None: + if self._keyboard is None: + return + self._keyboard.unbind(on_key_down=self._on_keyboard_down) + self._keyboard = None + + def _on_keyboard_down(self, _: Any, keycode: tuple[int, str], _1: Any, _2: Any) -> bool: + if keycode[1] in INPUT_MAP: + self.input_function(INPUT_MAP[keycode[1]]) + return True + + +class APQuestGrid(GridLayout): + def check_resize(self, _: int, _1: int) -> None: + parent_width, parent_height = self.parent.size + + self_width_according_to_parent_height = parent_height * 12 / 11 + self_height_according_to_parent_width = parent_height * 11 / 12 + + if self_width_according_to_parent_height > parent_width: + self.size = parent_width, self_height_according_to_parent_width + else: + self.size = self_width_according_to_parent_height, parent_height + + +CONFETTI_COLORS = [ + (220 / 255, 0, 212 / 255), # PINK + (0, 0, 252 / 255), # BLUE + (252 / 255, 220 / 255, 0), # YELLOW + (0, 184 / 255, 0), # GREEN + (252 / 255, 56 / 255, 0), # ORANGE +] + + +@dataclass +class Confetti: + x_pos: float + y_pos: float + x_speed: float + y_speed: float + color: tuple[float, float, float] + life: float = 3 + + triangle1: Triangle | None = None + triangle2: Triangle | None = None + color_instruction: Color | None = None + + def update_speed(self, dt: float) -> None: + if self.x_speed > 0: + self.x_speed -= 2.7 * dt + if self.x_speed < 0: + self.x_speed = 0 + else: + self.x_speed += 2.7 * dt + if self.x_speed > 0: + self.x_speed = 0 + + if self.y_speed > -0.03: + self.y_speed -= 2.7 * dt + if self.y_speed < -0.03: + self.y_speed = -0.03 + else: + self.y_speed += 2.7 * dt + if self.y_speed > -0.03: + self.y_speed = -0.03 + + def move(self, dt: float) -> None: + self.update_speed(dt) + + if self.y_pos > 1: + self.y_pos = 1 + self.y_speed = 0 + if self.x_pos < 0.01: + self.x_pos = 0.01 + self.x_speed = 0 + if self.x_pos > 0.99: + self.x_pos = 0.99 + self.x_speed = 0 + + self.x_pos += self.x_speed * dt + self.y_pos += self.y_speed * dt + + def render(self, offset_x: float, offset_y: float, max_x: int, max_y: int) -> None: + if self.x_speed == 0 and self.y_speed == 0: + x_normalized, y_normalized = 0.0, 1.0 + else: + speed_magnitude = sqrt(self.x_speed**2 + self.y_speed**2) + x_normalized, y_normalized = self.x_speed / speed_magnitude, self.y_speed / speed_magnitude + + half_top_to_bottom = 0.006 + half_left_to_right = 0.018 + + upwards_delta_x = x_normalized * half_top_to_bottom + upwards_delta_y = y_normalized * half_top_to_bottom + sideways_delta_x = y_normalized * half_left_to_right + sideways_delta_y = x_normalized * half_left_to_right + + top_left_x, top_left_y = upwards_delta_x - sideways_delta_x, upwards_delta_y + sideways_delta_y + bottom_left_x, bottom_left_y = -upwards_delta_x - sideways_delta_x, -upwards_delta_y + sideways_delta_y + top_right_x, top_right_y = -bottom_left_x, -bottom_left_y + bottom_right_x, bottom_right_y = -top_left_x, -top_left_y + + top_left_x, top_left_y = top_left_x + self.x_pos, top_left_y + self.y_pos + bottom_left_x, bottom_left_y = bottom_left_x + self.x_pos, bottom_left_y + self.y_pos + top_right_x, top_right_y = top_right_x + self.x_pos, top_right_y + self.y_pos + bottom_right_x, bottom_right_y = bottom_right_x + self.x_pos, bottom_right_y + self.y_pos + + top_left_x, top_left_y = top_left_x * max_x + offset_x, top_left_y * max_y + offset_y + bottom_left_x, bottom_left_y = bottom_left_x * max_x + offset_x, bottom_left_y * max_y + offset_y + top_right_x, top_right_y = top_right_x * max_x + offset_x, top_right_y * max_y + offset_y + bottom_right_x, bottom_right_y = bottom_right_x * max_x + offset_x, bottom_right_y * max_y + offset_y + + points1 = (top_left_x, top_left_y, top_right_x, top_right_y, bottom_left_x, bottom_left_y) + points2 = (bottom_right_x, bottom_right_y, top_right_x, top_right_y, bottom_left_x, bottom_left_y) + + if self.color_instruction is None: + self.color_instruction = Color(*self.color) + + if self.triangle1 is None: + self.triangle1 = Triangle(points=points1) + else: + self.triangle1.points = points1 + + if self.triangle2 is None: + self.triangle2 = Triangle(points=points2) + else: + self.triangle2.points = points2 + + def reduce_life(self, dt: float, canvas: Canvas) -> bool: + self.life -= dt + + if self.life <= 0: + if self.color_instruction is not None: + canvas.remove(self.color_instruction) + if self.triangle1 is not None: + canvas.remove(self.triangle1) + if self.triangle2 is not None: + canvas.remove(self.triangle2) + return False + + return True + + +class ConfettiView(MDRecycleView): + confetti: list[Confetti] + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self.confetti = [] + + def check_resize(self, _: int, _1: int) -> None: + parent_width, parent_height = self.parent.size + + self_width_according_to_parent_height = parent_height * 12 / 11 + self_height_according_to_parent_width = parent_height * 11 / 12 + + if self_width_according_to_parent_height > parent_width: + self.size = parent_width, self_height_according_to_parent_width + else: + self.size = self_width_according_to_parent_height, parent_height + + def redraw_confetti(self, dt: float) -> None: + try: + with self.canvas: + for confetti in self.confetti: + confetti.move(dt) + + self.confetti = [confetti for confetti in self.confetti if confetti.reduce_life(dt, self.canvas)] + + for confetti in self.confetti: + confetti.render(self.pos[0], self.pos[1], self.size[0], self.size[1]) + except Exception as e: + logger.exception(e) + + def add_confetti(self, initial_position: tuple[float, float], amount: int) -> None: + for i in range(amount): + self.confetti.append( + Confetti( + initial_position[0], + initial_position[1], + random() * 3.2 - 1.6 - (initial_position[0] - 0.5) * 1.2, + random() * 3.2 - 1.3 - (initial_position[1] - 0.5) * 1.2, + choice(CONFETTI_COLORS), + 3 + i * 0.05, + ) + ) + + +class VolumeSliderView(BoxLayout): + pass + + +class APQuestControlsView(BoxLayout): + pass diff --git a/worlds/apquest/client/game_manager.py b/worlds/apquest/client/game_manager.py new file mode 100644 index 0000000000..86f4316d12 --- /dev/null +++ b/worlds/apquest/client/game_manager.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +# isort: off +from kvui import GameManager, MDNavigationItemBase + +# isort: on +from typing import TYPE_CHECKING, Any + +from kivy.clock import Clock +from kivy.uix.gridlayout import GridLayout +from kivy.uix.image import Image +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 .graphics import PlayerSprite, get_texture +from .sounds import SoundManager + +if TYPE_CHECKING: + from .ap_quest_client import APQuestContext + + +class APQuestManager(GameManager): + base_title = "APQuest for AP version" + ctx: APQuestContext + + lower_game_grid: GridLayout + upper_game_grid: GridLayout + + game_view: MDRecycleView + game_view_tab: MDNavigationItemBase + + sound_manager: SoundManager + + bottom_image_grid: list[list[Image]] + top_image_grid: list[list[Image]] + confetti_view: ConfettiView + + bottom_grid_is_grass: bool + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.sound_manager = SoundManager() + self.sound_manager.allow_intro_to_play = not self.ctx.delay_intro_song + self.top_image_grid = [] + self.bottom_image_grid = [] + self.bottom_grid_is_grass = False + + def allow_intro_song(self) -> None: + self.sound_manager.allow_intro_to_play = True + + def add_confetti(self, position: tuple[float, float], amount: int) -> None: + self.confetti_view.add_confetti(position, amount) + + def play_jingle(self, audio_filename: str) -> None: + self.sound_manager.play_jingle(audio_filename) + + def switch_to_tab(self, desired_tab: MDNavigationItemBase) -> None: + if self.screens.current_tab == desired_tab: + return + self.screens.current_tab.active = False + self.screens.switch_screens(desired_tab) + desired_tab.active = True + + def switch_to_game_tab(self) -> None: + self.switch_to_tab(self.game_view_tab) + + def switch_to_regular_tab(self) -> None: + self.switch_to_tab(self.tabs.children[-1]) + + def game_started(self) -> None: + self.switch_to_game_tab() + 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) + + # This calls game.render(), which needs to happen to update the state of math traps + self.render_gameboard(game, player_sprite) + # Only now can we check whether a math problem is active + self.render_background_game_grid(game.gameboard.size, game.active_math_problem is None) + self.sound_manager.math_trap_active = game.active_math_problem is not None + + self.render_item_column(game) + + def render_gameboard(self, game: Game, player_sprite: PlayerSprite) -> None: + rendered_gameboard = game.render() + + for gameboard_row, image_row in zip(rendered_gameboard, self.top_image_grid, strict=False): + for graphic, image in zip(gameboard_row, image_row[:11], strict=False): + texture = get_texture(graphic, player_sprite) + + if texture is None: + image.opacity = 0 + image.texture = None + continue + + image.texture = texture + image.opacity = 1 + + def render_item_column(self, game: Game) -> None: + rendered_item_column = game.render_health_and_inventory(vertical=True) + for item_graphic, image_row in zip(rendered_item_column, self.top_image_grid, strict=False): + image = image_row[-1] + + texture = get_texture(item_graphic) + if texture is None: + image.opacity = 0 + image.texture = None + continue + + image.texture = texture + image.opacity = 1 + + def render_background_game_grid(self, size: tuple[int, int], grass: bool) -> None: + if grass == self.bottom_grid_is_grass: + return + + for row in range(size[1]): + for column in range(size[0]): + image = self.bottom_image_grid[row][column] + + if not grass: + image.color = (0.3, 0.3, 0.3) + image.texture = None + continue + + boss_room = (row in (0, 1, 2) and (size[1] - column) in (1, 2, 3)) or (row, column) == (3, size[1] - 2) + if boss_room: + image.color = (0.45, 0.35, 0.1) + image.texture = None + continue + image.texture = get_texture("Grass") + image.color = (1.0, 1.0, 1.0) + + self.bottom_grid_is_grass = grass + + def setup_game_grid_if_not_setup(self, size: tuple[int, int]) -> None: + if self.upper_game_grid.children: + return + + self.top_image_grid = [] + self.bottom_image_grid = [] + + for _row in range(size[1]): + self.top_image_grid.append([]) + self.bottom_image_grid.append([]) + + 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") + self.upper_game_grid.add_widget(top_image) + self.top_image_grid[-1].append(top_image) + + # Right side: Inventory + 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) + self.upper_game_grid.add_widget(image2) + + self.top_image_grid[-1].append(image2) + + def build(self) -> Layout: + container = super().build() + + self.game_view = APQuestGameView(self.ctx.input_and_rerender) + + self.game_view_tab = self.add_client_tab("APQuest", self.game_view) + + controls = APQuestControlsView() + + self.add_client_tab("Controls", controls) + + game_container = self.game_view.ids["game_container"] + self.lower_game_grid = APQuestGrid() + self.upper_game_grid = APQuestGrid() + self.confetti_view = ConfettiView() + game_container.add_widget(self.lower_game_grid) + game_container.add_widget(self.upper_game_grid) + game_container.add_widget(self.confetti_view) + + game_container.bind(size=self.lower_game_grid.check_resize) + game_container.bind(size=self.upper_game_grid.check_resize) + game_container.bind(size=self.confetti_view.check_resize) + + volume_slider_container = VolumeSliderView() + volume_slider = volume_slider_container.ids["volume_slider"] + volume_slider.value = self.sound_manager.volume_percentage + volume_slider.bind(value=lambda _, new_volume: self.sound_manager.set_volume_percentage(new_volume)) + + self.grid.add_widget(volume_slider_container, index=3) + + Clock.schedule_interval(lambda dt: self.confetti_view.redraw_confetti(dt), 1 / 60) + + return container diff --git a/worlds/apquest/client/graphics.py b/worlds/apquest/client/graphics.py new file mode 100644 index 0000000000..f1cd387cf6 --- /dev/null +++ b/worlds/apquest/client/graphics.py @@ -0,0 +1,181 @@ +import pkgutil +from collections.abc import Buffer +from enum import Enum +from io import BytesIO +from typing import Literal, NamedTuple, cast + +from bokeh.protocol import Protocol +from kivy.uix.image import CoreImage + +from CommonClient import logger + +from .. import game +from ..game.graphics import Graphic + + +# The import "from kivy.graphics.texture import Texture" does not work correctly. +# We never need the class directly, so we need to use a protocol. +class Texture(Protocol): + mag_filter: Literal["nearest"] + + def get_region(self, x: int, y: int, w: int, h: int) -> "Texture": ... + + +class RelatedTexture(NamedTuple): + base_texture_file: str + x: int + y: int + width: int + height: int + + +IMAGE_GRAPHICS: dict[Graphic, str | RelatedTexture] = { + Graphic.WALL: RelatedTexture("inanimates.png", 16, 32, 16, 16), + Graphic.BREAKABLE_BLOCK: RelatedTexture("inanimates.png", 32, 32, 16, 16), + Graphic.CHEST: RelatedTexture("inanimates.png", 0, 16, 16, 16), + Graphic.BUSH: RelatedTexture("inanimates.png", 16, 16, 16, 16), + Graphic.KEY_DOOR: RelatedTexture("inanimates.png", 32, 16, 16, 16), + Graphic.BUTTON_NOT_ACTIVATED: RelatedTexture("inanimates.png", 0, 0, 16, 16), + Graphic.BUTTON_ACTIVATED: RelatedTexture("inanimates.png", 16, 0, 16, 16), + Graphic.BUTTON_DOOR: RelatedTexture("inanimates.png", 32, 0, 16, 16), + + Graphic.NORMAL_ENEMY_1_HEALTH: RelatedTexture("normal_enemy.png", 0, 0, 16, 16), + Graphic.NORMAL_ENEMY_2_HEALTH: RelatedTexture("normal_enemy.png", 16, 0, 16, 16), + + Graphic.BOSS_5_HEALTH: RelatedTexture("boss.png", 16, 16, 16, 16), + Graphic.BOSS_4_HEALTH: RelatedTexture("boss.png", 0, 16, 16, 16), + Graphic.BOSS_3_HEALTH: RelatedTexture("boss.png", 32, 32, 16, 16), + Graphic.BOSS_2_HEALTH: RelatedTexture("boss.png", 16, 32, 16, 16), + Graphic.BOSS_1_HEALTH: RelatedTexture("boss.png", 0, 32, 16, 16), + + Graphic.EMPTY_HEART: RelatedTexture("hearts.png", 0, 0, 16, 16), + Graphic.HEART: RelatedTexture("hearts.png", 16, 0, 16, 16), + Graphic.HALF_HEART: RelatedTexture("hearts.png", 32, 0, 16, 16), + + Graphic.REMOTE_ITEM: RelatedTexture("items.png", 0, 16, 16, 16), + Graphic.CONFETTI_CANNON: RelatedTexture("items.png", 16, 16, 16, 16), + Graphic.HAMMER: RelatedTexture("items.png", 32, 16, 16, 16), + Graphic.KEY: RelatedTexture("items.png", 0, 0, 16, 16), + Graphic.SHIELD: RelatedTexture("items.png", 16, 0, 16, 16), + Graphic.SWORD: RelatedTexture("items.png", 32, 0, 16, 16), + + Graphic.ITEMS_TEXT: "items_text.png", + + Graphic.ZERO: RelatedTexture("numbers.png", 0, 16, 16, 16), + Graphic.ONE: RelatedTexture("numbers.png", 16, 16, 16, 16), + Graphic.TWO: RelatedTexture("numbers.png", 32, 16, 16, 16), + Graphic.THREE: RelatedTexture("numbers.png", 48, 16, 16, 16), + Graphic.FOUR: RelatedTexture("numbers.png", 64, 16, 16, 16), + Graphic.FIVE: RelatedTexture("numbers.png", 0, 0, 16, 16), + Graphic.SIX: RelatedTexture("numbers.png", 16, 0, 16, 16), + Graphic.SEVEN: RelatedTexture("numbers.png", 32, 0, 16, 16), + Graphic.EIGHT: RelatedTexture("numbers.png", 48, 0, 16, 16), + Graphic.NINE: RelatedTexture("numbers.png", 64, 0, 16, 16), + + Graphic.LETTER_A: RelatedTexture("letters.png", 0, 16, 16, 16), + Graphic.LETTER_E: RelatedTexture("letters.png", 16, 16, 16, 16), + Graphic.LETTER_H: RelatedTexture("letters.png", 32, 16, 16, 16), + Graphic.LETTER_I: RelatedTexture("letters.png", 0, 0, 16, 16), + Graphic.LETTER_M: RelatedTexture("letters.png", 16, 0, 16, 16), + Graphic.LETTER_T: RelatedTexture("letters.png", 32, 0, 16, 16), + + Graphic.DIVIDE: RelatedTexture("symbols.png", 0, 16, 16, 16), + Graphic.EQUALS: RelatedTexture("symbols.png", 16, 16, 16, 16), + Graphic.MINUS: RelatedTexture("symbols.png", 32, 16, 16, 16), + Graphic.PLUS: RelatedTexture("symbols.png", 0, 0, 16, 16), + Graphic.TIMES: RelatedTexture("symbols.png", 16, 0, 16, 16), + Graphic.NO: RelatedTexture("symbols.png", 32, 0, 16, 16), + + Graphic.UNKNOWN: RelatedTexture("symbols.png", 32, 0, 16, 16), # Same as "No" +} + +BACKGROUND_TILE = RelatedTexture("inanimates.png", 0, 32, 16, 16) + + +class PlayerSprite(Enum): + HUMAN = 0 + DUCK = 1 + HORSE = 2 + CAT = 3 + UNKNOWN = -1 + + +PLAYER_GRAPHICS = { + Graphic.PLAYER_DOWN: { + PlayerSprite.HUMAN: RelatedTexture("human.png", 0, 16, 16, 16), + PlayerSprite.DUCK: RelatedTexture("duck.png", 0, 16, 16, 16), + PlayerSprite.HORSE: RelatedTexture("horse.png", 0, 16, 16, 16), + PlayerSprite.CAT: RelatedTexture("cat.png", 0, 16, 16, 16), + }, + Graphic.PLAYER_UP: { + PlayerSprite.HUMAN: RelatedTexture("human.png", 16, 0, 16, 16), + PlayerSprite.DUCK: RelatedTexture("duck.png", 16, 0, 16, 16), + PlayerSprite.HORSE: RelatedTexture("horse.png", 16, 0, 16, 16), + PlayerSprite.CAT: RelatedTexture("cat.png", 16, 0, 16, 16), + }, + Graphic.PLAYER_LEFT: { + PlayerSprite.HUMAN: RelatedTexture("human.png", 16, 16, 16, 16), + PlayerSprite.DUCK: RelatedTexture("duck.png", 16, 16, 16, 16), + PlayerSprite.HORSE: RelatedTexture("horse.png", 16, 16, 16, 16), + PlayerSprite.CAT: RelatedTexture("cat.png", 16, 16, 16, 16), + }, + Graphic.PLAYER_RIGHT: { + PlayerSprite.HUMAN: RelatedTexture("human.png", 0, 0, 16, 16), + PlayerSprite.DUCK: RelatedTexture("duck.png", 0, 0, 16, 16), + PlayerSprite.HORSE: RelatedTexture("horse.png", 0, 0, 16, 16), + PlayerSprite.CAT: RelatedTexture("cat.png", 0, 0, 16, 16), + }, +} + +ALL_GRAPHICS = [ + BACKGROUND_TILE, + *IMAGE_GRAPHICS.values(), + *[graphic for sub_dict in PLAYER_GRAPHICS.values() for graphic in sub_dict.values()], +] + +_textures: dict[str | RelatedTexture, Texture] = {} + + +def get_texture_by_identifier(texture_identifier: str | RelatedTexture) -> Texture: + if texture_identifier in _textures: + return _textures[texture_identifier] + + if isinstance(texture_identifier, str): + image_data = pkgutil.get_data(game.__name__, f"graphics/{texture_identifier}") + if image_data is None: + raise RuntimeError(f'Could not find file "graphics/{texture_identifier}" for texture {texture_identifier}') + + image_bytes = BytesIO(cast(Buffer, image_data)) + texture = cast(Texture, CoreImage(image_bytes, ext="png").texture) + texture.mag_filter = "nearest" + _textures[texture_identifier] = texture + return texture + + base_texture_filename, x, y, w, h = texture_identifier + + base_texture = get_texture_by_identifier(base_texture_filename) + + sub_texture = base_texture.get_region(x, y, w, h) + sub_texture.mag_filter = "nearest" + _textures[texture_identifier] = sub_texture + return sub_texture + + +def get_texture(graphic: Graphic | Literal["Grass"], player_sprite: PlayerSprite | None = None) -> Texture | None: + if graphic == Graphic.EMPTY: + return None + + if graphic == "Grass": + return get_texture_by_identifier(BACKGROUND_TILE) + + if graphic in IMAGE_GRAPHICS: + return get_texture_by_identifier(IMAGE_GRAPHICS[graphic]) + + if graphic in PLAYER_GRAPHICS: + if player_sprite is None: + raise ValueError("Tried to load a player graphic without specifying a player_sprite") + + return get_texture_by_identifier(PLAYER_GRAPHICS[graphic][player_sprite]) + + logger.exception(f"Tried to load unknown graphic {graphic}.") + return get_texture(Graphic.UNKNOWN) diff --git a/worlds/apquest/client/item_quality.py b/worlds/apquest/client/item_quality.py new file mode 100644 index 0000000000..35a975d886 --- /dev/null +++ b/worlds/apquest/client/item_quality.py @@ -0,0 +1,25 @@ +from enum import Enum + +from BaseClasses import ItemClassification +from NetUtils import NetworkItem + + +class ItemQuality(Enum): + FILLER = 0 + TRAP = 1 + USEFUL = 2 + PROGRESSION = 3 + PROGUSEFUL = 4 + + +def get_quality_for_network_item(network_item: NetworkItem) -> ItemQuality: + flags = ItemClassification(network_item.flags) + if ItemClassification.progression in flags: + if ItemClassification.useful in flags: + return ItemQuality.PROGUSEFUL + return ItemQuality.PROGRESSION + if ItemClassification.useful in flags: + return ItemQuality.USEFUL + if ItemClassification.trap in flags: + return ItemQuality.TRAP + return ItemQuality.FILLER diff --git a/worlds/apquest/client/launch.py b/worlds/apquest/client/launch.py new file mode 100644 index 0000000000..515cf395be --- /dev/null +++ b/worlds/apquest/client/launch.py @@ -0,0 +1,27 @@ +import asyncio +from collections.abc import Sequence + +import colorama + +from CommonClient import get_base_parser, handle_url_arg + +# !!! IMPORTANT !!! +# The client implementation is *not* meant for teaching. +# Obviously, it is written to the best of its author's abilities, +# but it is not to the same standard as the rest of the apworld. +# Copy things from here at your own risk. + + +def launch_ap_quest_client(*args: Sequence[str]) -> None: + from .ap_quest_client import main + + parser = get_base_parser() + parser.add_argument("--name", default=None, help="Slot Name to connect as.") + parser.add_argument("url", nargs="?", help="Archipelago connection url") + + launch_args = handle_url_arg(parser.parse_args(args)) + + colorama.just_fix_windows_console() + + asyncio.run(main(launch_args)) + colorama.deinit() diff --git a/worlds/apquest/client/sounds.py b/worlds/apquest/client/sounds.py new file mode 100644 index 0000000000..f4c4ab769d --- /dev/null +++ b/worlds/apquest/client/sounds.py @@ -0,0 +1,249 @@ +import asyncio +import pkgutil +from asyncio import Task +from collections.abc import Buffer +from pathlib import Path +from typing import cast + +from kivy import Config +from kivy.core.audio import Sound, SoundLoader + +from CommonClient import logger + +from .. import game +from .item_quality import ItemQuality +from .utils import make_data_directory + +ITEM_JINGLES = { + ItemQuality.PROGUSEFUL: "8bit ProgUseful.ogg", + ItemQuality.PROGRESSION: "8bit Progression.ogg", + ItemQuality.USEFUL: "8bit Useful.ogg", + ItemQuality.TRAP: "8bit Trap.ogg", + ItemQuality.FILLER: "8bit Filler.ogg", +} + +CONFETTI_CANNON = "APQuest Confetti Cannon.ogg" +MATH_PROBLEM_STARTED_JINGLE = "APQuest Math Problem Starter Jingle.ogg" +MATH_PROBLEM_SOLVED_JINGLE = "APQuest Math Problem Solved Jingle.ogg" +VICTORY_JINGLE = "8bit Victory.ogg" + +ALL_JINGLES = [ + MATH_PROBLEM_SOLVED_JINGLE, + MATH_PROBLEM_STARTED_JINGLE, + CONFETTI_CANNON, + VICTORY_JINGLE, + *ITEM_JINGLES.values(), +] + +BACKGROUND_MUSIC_INTRO = "APQuest Intro.ogg" +BACKGROUND_MUSIC = "APQuest BGM.ogg" +MATH_TIME_BACKGROUND_MUSIC = "APQuest Math BGM.ogg" + +ALL_BGM = [ + BACKGROUND_MUSIC_INTRO, + BACKGROUND_MUSIC, + MATH_TIME_BACKGROUND_MUSIC, +] + +ALL_SOUNDS = [ + *ALL_JINGLES, + *ALL_BGM, +] + + +class SoundManager: + sound_paths: dict[str, Path] + + jingles: dict[str, Sound] + bgm_songs: dict[str, Sound] + + active_bgm_song: str = BACKGROUND_MUSIC_INTRO + + current_background_music_volume: float = 1.0 + background_music_target_volume: float = 0.0 + + background_music_task: Task[None] | None = None + background_music_last_position: int = 0 + + volume_percentage: int = 0 + + game_started: bool + math_trap_active: bool + allow_intro_to_play: bool + + def __init__(self) -> None: + self.extract_sounds() + self.populate_sounds() + + self.game_started = False + self.allow_intro_to_play = False + self.math_trap_active = False + + self.ensure_config() + + self.background_music_task = asyncio.create_task(self.sound_manager_loop()) + + def ensure_config(self) -> None: + Config.adddefaultsection("APQuest") + Config.setdefault("APQuest", "volume", 50) + self.set_volume_percentage(Config.getint("APQuest", "volume")) + + async def sound_manager_loop(self) -> None: + while True: + self.update_background_music() + self.do_fade() + await asyncio.sleep(0.02) + + def extract_sounds(self) -> None: + # Kivy appears to have no good way of loading audio from bytes. + # So, we have to extract it out of the .apworld first + + sound_paths = {} + + sound_directory = make_data_directory("sounds") + + for sound in ALL_SOUNDS: + sound_file_location = sound_directory / sound + + sound_paths[sound] = sound_file_location + + if sound_file_location.exists(): + continue + + with open(sound_file_location, "wb") as sound_file: + data = pkgutil.get_data(game.__name__, f"audio/{sound}") + if data is None: + logger.exception(f"Unable to extract sound {sound} to Archipelago/data") + continue + sound_file.write(cast(Buffer, data)) + + self.sound_paths = sound_paths + + def load_audio(self, sound_filename: str) -> Sound: + audio_path = self.sound_paths[sound_filename] + + sound_object = SoundLoader.load(str(audio_path.absolute())) + sound_object.seek(0) + return sound_object + + def populate_sounds(self) -> None: + try: + self.jingles = {sound_filename: self.load_audio(sound_filename) for sound_filename in ALL_JINGLES} + except Exception as e: + logger.exception(e) + + try: + self.bgm_songs = {sound_filename: self.load_audio(sound_filename) for sound_filename in ALL_BGM} + for bgm_song in self.bgm_songs.values(): + bgm_song.loop = True + bgm_song.seek(0) + except Exception as e: + logger.exception(e) + + def play_jingle(self, audio_filename: str) -> None: + higher_priority_sound_is_playing = False + + for sound_name, sound in self.jingles.items(): + if higher_priority_sound_is_playing: # jingles are ordered by priority, lower priority gets eaten + sound.stop() + continue + + if sound_name == audio_filename: + sound.play() + self.update_background_music() + higher_priority_sound_is_playing = True + + elif sound.state == "play": + higher_priority_sound_is_playing = True + + def update_background_music(self) -> None: + self.update_active_song() + if any(sound.state == "play" for sound in self.jingles.values()): + self.play_background_music(False) + else: + if self.math_trap_active: + # Don't fade math trap song, it ends up feeling better + self.play_background_music(True) + else: + self.fade_background_music(True) + + def play_background_music(self, play: bool = True) -> None: + if play: + self.background_music_target_volume = 1 + self.set_background_music_volume(1) + else: + self.background_music_target_volume = 0 + self.set_background_music_volume(0) + + def set_background_music_volume(self, volume: float) -> None: + self.current_background_music_volume = volume + + for song_filename, song in self.bgm_songs.items(): + if song_filename != self.active_bgm_song: + song.volume = 0 + continue + song.volume = volume * self.volume_percentage / 100 + + def fade_background_music(self, fade_in: bool = True) -> None: + if fade_in: + self.background_music_target_volume = 1 + else: + self.background_music_target_volume = 0 + + def set_volume_percentage(self, volume_percentage: float) -> None: + volume_percentage_int = int(volume_percentage) + if self.volume_percentage != volume_percentage: + self.volume_percentage = volume_percentage_int + Config.set("APQuest", "volume", volume_percentage_int) + Config.write() + self.set_background_music_volume(self.current_background_music_volume) + + for jingle in self.jingles.values(): + jingle.volume = self.volume_percentage / 100 + + def do_fade(self) -> None: + if self.current_background_music_volume > self.background_music_target_volume: + self.set_background_music_volume(max(0.0, self.current_background_music_volume - 0.02)) + if self.current_background_music_volume < self.background_music_target_volume: + self.set_background_music_volume(min(1.0, self.current_background_music_volume + 0.02)) + + for song_filename, song in self.bgm_songs.items(): + if song_filename != self.active_bgm_song: + if song_filename == BACKGROUND_MUSIC: + # It ends up feeling better if this just always continues playing quietly after being started. + # Even "fading in at a random spot" is better than restarting the song after a jingle / math trap. + if self.game_started and song.state == "stop": + song.play() + song.seek(0) + continue + + song.stop() + song.seek(0) + continue + + if self.active_bgm_song == BACKGROUND_MUSIC_INTRO and not self.allow_intro_to_play: + song.stop() + song.seek(0) + continue + + if self.current_background_music_volume != 0: + if song.state == "stop": + song.play() + song.seek(0) + + def update_active_song(self) -> None: + new_active_song = self.determine_correct_song() + if new_active_song == self.active_bgm_song: + return + self.active_bgm_song = new_active_song + # reevaluate song volumes + self.set_background_music_volume(self.current_background_music_volume) + + def determine_correct_song(self) -> str: + if not self.game_started: + return BACKGROUND_MUSIC_INTRO + + if self.math_trap_active: + return MATH_TIME_BACKGROUND_MUSIC + + return BACKGROUND_MUSIC diff --git a/worlds/apquest/client/utils.py b/worlds/apquest/client/utils.py new file mode 100644 index 0000000000..362e5fed11 --- /dev/null +++ b/worlds/apquest/client/utils.py @@ -0,0 +1,25 @@ +from pathlib import Path + +from Utils import user_path + + +def make_data_directory(dir_name: str) -> Path: + root_directory = Path(user_path()) + if not root_directory.exists(): + raise FileNotFoundError(f"Unable to find AP directory {root_directory.absolute()}.") + + data_directory = root_directory / "data" + + specific_data_directory = data_directory / "apquest" / dir_name + specific_data_directory.mkdir(parents=True, exist_ok=True) + + gitignore = specific_data_directory / ".gitignore" + + with open(gitignore, "w") as f: + f.write( + """* +!.gitignore +""" + ) + + return specific_data_directory diff --git a/worlds/apquest/components.py b/worlds/apquest/components.py new file mode 100644 index 0000000000..eb25450d8c --- /dev/null +++ b/worlds/apquest/components.py @@ -0,0 +1,33 @@ +from worlds.LauncherComponents import Component, Type, components, launch + + +# The most common type of component is a client, but there are other components, such as sprite/palette adjusters. +# (Note: Some worlds distribute their clients as separate, standalone programs, +# while others include them in the apworld itself. Standalone clients are not an apworld component, +# although you could make a component that e.g. auto-installs and launches the standalone client for the user.) +# APQuest has a Python client inside the apworld that contains the entire game. This is a component. +# APQuest will not teach you how to make a client or any other type of component. +# However, let's quickly talk about how you register a component to be launchable from the Archipelago Launcher. +# First, you'll need a function that takes a list of args (e.g. from the command line) that launches your component. +def run_client(*args: str) -> None: + # Ideally, you should lazily import your component code so that it doesn't have to be loaded until necessary. + from .client.launch import launch_ap_quest_client + + # Also, if your component has its own lifecycle, like if it is its own window that can be interacted with, + # you should use the LauncherComponents.launch helper (which itself calls launch_subprocess). + # This will create a subprocess for your component, launching it in a separate window from the Archipelago Launcher. + launch(launch_ap_quest_client, name="APQuest Client", args=args) + + +# You then add this function as a component by appending a Component instance to LauncherComponents.components. +# Now, it will show up in the Launcher with its display name, +# and when the user clicks on the "Open" button, your function will be run. +components.append( + Component( + "APQuest Client", + func=run_client, + game_name="APQuest", + component_type=Type.CLIENT, + supports_uri=True, + ) +) diff --git a/worlds/apquest/docs/de_APQuest.md b/worlds/apquest/docs/de_APQuest.md new file mode 100644 index 0000000000..82e383fa16 --- /dev/null +++ b/worlds/apquest/docs/de_APQuest.md @@ -0,0 +1,78 @@ +# APQuest + +## Wo ist die Seite für die Einstellungen? + +Die [Seite für die Spielereinstellungen dieses Spiels](../player-options) enthält alle Optionen die man benötigt, um +eine YAML-Datei zu konfigurieren und zu exportieren. + +## Was ist APQuest? + +APQuest ist ein Spiel, welches von NewSoupVi für Archipelago entwickelt wurde. +Es ist ein minimalistisches 8bit-inspiriertes Abenteuerspiel mit gitterförmiger Bewegungssteuerung. +APQuest ist ungefähr 20 Sekunden lang. Der Client kann aber nahtlos zwischen mehreren APQuest-Slots wechseln. +Wenn du 10 APQuest-Slots in einer Multiworld haben willst, sollte das also problemlos möglich sein.if you want to have 10 of them, that should work pretty well. + +Ausschlaggebend ist bei APQuest, dass das gesamte Spiel in der .apworld enthalten ist. +Wenn du also die .apworld in deine +[Archipelago-Installation](https://github.com/ArchipelagoMW/Archipelago/releases/latest) installiert hast, +kannst du APQuest spielen. + +## Warum existiert APQuest? + +APQuest ist als Beispiel-.apworld geschrieben, mit welchem neue .apworld-Entwickler lernen können, wie man eine +.apworld schreibt. +Der [APQuest-Quellcode](https://github.com/NewSoupVi/Archipelago/tree/apquest/worlds/apquest) enthält unzählige Kommentare und Beispiele, die erklären, +wie jeder Teil der World-API funktioniert. +Dabei nutzt er nur die modernsten API-Funktionen (Stand: 2025-08-24). + +Das sekundäre Ziel von APQuest ist, eine semi-minimale, generische .apworld zu sein, die Archipelago selbst gehört. +Damit kann sie für Archipelagos Unit-Tests benutzt werden, +ohne dass sich die Archipelago-Entwickler davor fürchten müssen, dass APQuest irgendwann gelöscht wird. + +Das dritte Ziel von APQuest ist, das erste "Spiel in einer .apworld" zu sein, +wobei das ganze Spiel in Python und Kivy programmiert ist +und innerhalb seines CommonClient-basierten Clients spielbar ist. +Ich bin mir nicht ganz sicher, dass es wirklich das erste Spiel dieser Art ist, aber ich kenne bis jetzt keine anderen. + +## Wenn ich mich im APQuest-Client angemeldet habe, wie spiele ich dann das Spiel? + +WASD oder Pfeiltasten zum Bewegen. +Leertaste, um dein Schwert zu schwingen (wenn du es hast) und um mit Objekten zu interagieren. +C, um die Konfettikanone zu feuern. + +Öffne Kisten, zerhacke Büsche, öffne Türen, aktiviere Knöpfe, besiege Gegner. +Sobald du den Drachen im oberen rechten Raum bezwingst, gewinnst du das Spiel. +Das ist alles! Viel Spaß! + +## Ein Statement zum Besitz von APQuest + +APQuest ist mit der [MIT-Lizenz](https://opensource.org/license/mit) lizenziert, +was heißt, dass es von jedem für jeden Zweck modifiziert und verbreitet werden kann. +Archipelago hat jedoch seine eigenen Besitztumsstrukturen, die über der MIT-Lizenz stehen. +Diese Strukturen machen es unklar, +ob eine .apworld-Implementierung überhaupt permanent verlässlich in Archipelago bleibt. + +Im Zusammenhang mit diesen unverbindlichen, nicht gesetzlich verpflichtenden Besitztumsstrukturen +mache ich die folgende Aussage. + +Ich, NewSoupVi, verzichte hiermit auf alle Rechte, APQuest aus Archipelago zu entfernen. +Dies bezieht sich auf alle Teile von APQuest mit der Ausnahme der Musik und der Soundeffekte. +Wenn ich die Töne entfernt haben möchte, muss ich dafür selbst einen PR öffnen. +Dieser PR darf nur die Töne entfernen und muss APQuest intakt und spielbar halten. + +Solang ich der Maintainer von APQuest bin, möchte ich als solcher agieren. +Das heißt, dass jegliche Änderungen an APQuest zuerst von mir genehmigt werden müssen. + +Wenn ich jedoch aufhöre, der Maintainer von APQuest zu sein, +egal ob es mein eigener Wunsch war oder ich meinen Maintainer-Verantwortungen nicht mehr nachkomme, +dann wird APQuest automatisch Eigentum der Core-Maintainer von Archipelago, +die dann frei entscheiden können, was mit APQuest passieren soll. +Es wäre mein Wunsch, dass wenn APQuest an eine andere Einzelperson übergeben wird, +diese Person sich an ähnliche Eigentumsregelungen hält wie ich. + +Hoffentlich stellt dieses Statement sicher, dass APQuest für immer eine .apworld sein kann, +auf die Archipelago sich verlassen kann. +Wenn die Besitztumsstrukturen von Archipelago geändert werden, +vertraue ich den Core-Maintainern (bzw. den Eigentümern von Archipelago generell) damit, +angemessene Entscheidungen darüber zu treffen, +wie dieses Statement im Kontext der neuen Regeln interpretiert werden sollte. diff --git a/worlds/apquest/docs/en_APQuest.md b/worlds/apquest/docs/en_APQuest.md new file mode 100644 index 0000000000..64eb1fa35a --- /dev/null +++ b/worlds/apquest/docs/en_APQuest.md @@ -0,0 +1,69 @@ +# APQuest + +## Where is the options page? + +The [player options page for this game](../player-options) contains all the options you need to configure and export a +config file. + +## What is APQuest? + +APQuest is an original game made entirely by NewSoupVi. +It is a minimal 8bit-era inspired adventure game with grid-like movement. +It is about 20 seconds long. However, the client can seamlessly switch between different slots, +so if you want to have 10 of them, that should work pretty well. + +Crucially, this game is entirely integrated into the client sitting inside its .apworld. +If you have the .apworld installed into your [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest) +install, you can play APQuest. + +## Why does APQuest exist? + +APQuest is implemented to be an example .apworld that can be used as a learning tool for new .apworld developers. +Its [source code](https://github.com/NewSoupVi/Archipelago/tree/apquest/worlds/apquest) +contains countless comments explaining how each part of the World API works. +Also, as of the writing of this setup guide (2025-08-24), it is up to date with all the modern Archipelago APIs. + +The secondary goal of APQuest is to be a semi-minimal generic world that is owned by Archipelago. +This means it can be used for Archipelago's unit tests without fear of eventual removal. + +Finally, APQuest was designed to be the first ever "game inside an .apworld", +where the entire game is coded in Python and Kivy and is playable from within its CommonClient-based Client. +I'm not actually sure if it's the first, but I'm not aware of any others. + +## Once I'm inside the APQuest client, how do I actually play APQuest? + +WASD or Arrow Keys for movement. +Space to swing your sword (if you have it) or interact with objects. +C to fire the Confetti Cannon. + +Open chests, slash bushes, open doors, press buttons, defeat enemies. +Once you beat the dragon in the top right room, you win. +That's all there is! Have fun! + +## A statement on the ownership over APQuest + +APQuest is licensed using the [MIT license](https://opensource.org/license/mit), +meaning it can be modified and redistributed by anyone for any purpose. +However, Archipelago has its own ownership structures built ontop of the license. +These ownership structures call into question whether any world implementation can permanently be relied on. + +In terms of these non-binding, non-legal Archipelago ownership structures, I will make the following statement. + +I, NewSoupVi, hereby relinquish any and all rights to remove APQuest from Archipelago. +This applies to all parts of APQuest with the sole exception of the music and sounds. +If I want the sounds to be removed, I must do so via a PR to the Archipelago repository myself. +Said PR must keep APQuest intact and playable, just with the music removed. + +As long as I am the maintainer of APQuest, I wish to act as such. +This means that any updates to APQuest must go through me. + +However, if I ever cease to be the maintainer of APQuest, +due to my own wishes or because I fail to uphold the maintainership "contract", +the maintainership of APQuest will go to the Core Maintainers of Archipelago, who may then decide what to do with it. +They can decide freely, but if the maintainership goes to another singular person, +it is my wish that this person adheres to a similar set of rules that I've laid out here for myself. + +Hopefully, this set of commitments should ensure that APQuest will forever be an apworld that can be relied on in Core. +If the ownership structures of Archipelago change, +I trust the Core Maintainers (or the owners in general) of Archipelago to make reasonable assumptions +about how this statement should be reinterpreted to fit the new rules. diff --git a/worlds/apquest/docs/setup_de.md b/worlds/apquest/docs/setup_de.md new file mode 100644 index 0000000000..3bdb3f690d --- /dev/null +++ b/worlds/apquest/docs/setup_de.md @@ -0,0 +1,43 @@ +# APQuest Randomizer Setup-Anleitung + +## Benötigte Software + +- [Archipelago](github.com/ArchipelagoMW/Archipelago/releases/latest) +- Die [APQuest-apworld](https://github.com/NewSoupVi/Archipelago/releases), + falls diese nicht mit deiner Version von Archipelago gebündelt ist. + +## Wie man spielt + +Zuerst brauchst du einen Raum, mit dem du dich verbinden kannst. +Dafür musst du oder jemand den du kennst ein Spiel generieren. +Dieser Schritt wird hier nicht erklärt, aber du kannst den +[Archipelago Setup Guide](https://archipelago.gg/tutorial/Archipelago/setup_en#generating-a-game) lesen. + +Du musst außerdem [Archipelago](github.com/ArchipelagoMW/Archipelago/releases/latest) installiert haben +und die [APQuest apworld](https://github.com/NewSoupVi/Archipelago/releases) darin installieren. + +Von hier ist es einfach, dich mit deinem Slot zu verbinden. + +### Webhost-Raum + +Wenn dein Raum auf einem WebHost läuft (z.B. [archipelago.gg](archipelago.gg)) +kannst du einfach auf deinen Namen in der Spielerliste klicken. +Dies öffnet den Archipelago Launcher, welcher dich dann fragt, +ob du den Text Client oder den APQuest Client öffnen willst. +Wähle hier den APQuest Client. Der Rest sollte automatisch passieren, sodass du APQuest direkt spielen kannst. + +### Lokaler Server + +Falls für deinen Raum keine WebHost-Raumseite verfügbar ist, kannst du APQuest manuell starten. + +Öffne den Archipelago Launcher und finde den APQuest Client in der Komponentenliste. Klicke auf "Open". +Nach einer kurzen Wartezeit sollte sich der APQuest Client öffnen. +Tippe in der oberen Zeile die Server-Adresse ein und klicke dann auf "Connect". +Gib deinen Spielernamen ein. Wenn ein Passwort existiert, tippe dieses auch ein. +Du solltest jetzt verbunden sein und kannst APQuest spielen. + +## Slotwechsel + +Der APQuest Client kann zwischen verschiedenen Slots wechseln, ohne neugestartet werden zu müssen, + +Klicke einfach den "Disconnect"-Knopf. Dann verbinde dich mit dem anderen Raum / Slot. diff --git a/worlds/apquest/docs/setup_en.md b/worlds/apquest/docs/setup_en.md new file mode 100644 index 0000000000..d322e2a7e3 --- /dev/null +++ b/worlds/apquest/docs/setup_en.md @@ -0,0 +1,42 @@ +# APQuest Randomizer Setup Guide + +## Required Software + +- [Archipelago](github.com/ArchipelagoMW/Archipelago/releases/latest) +- [The APQuest apworld](https://github.com/NewSoupVi/Archipelago/releases), + if not bundled with your version of Archipelago + +## How to play + +First, you need a room to connect to. For this, you or someone you know has to generate a game. +This will not be explained here, +but you can check the [Archipelago Setup Guide](https://archipelago.gg/tutorial/Archipelago/setup_en#generating-a-game). + +You also need to have [Archipelago](github.com/ArchipelagoMW/Archipelago/releases/latest) installed +and the [The APQuest apworld](https://github.com/NewSoupVi/Archipelago/releases) installed into Archipelago. + +From here, connecting to your APQuest slot is easy. There are two scenarios. + +### Webhost Room + +If your room is hosted on a WebHost (e.g. [archipelago.gg](archipelago.gg)), +you should be able to simply click on your name in the player list. +This will open the Archipelago Launcher +and ask you whether you want to connect with the Text Client or the APQuest Client. +Choose "APQuest Client". The rest should happen completely automatically and you should be able to play APQuest. + +### Locally hosted room + +If your room does not have a WebHost room page available, you can launch APQuest manually. + +Open the Archipelago Launcher, and then select the APQuest Client from the list. +After a short while, the APQuest client should open. +Enter the server address at the top and click "Connect". +Then, enter your name. If a password exists, enter the password. +You should now be connected and able to play APQuest. + +## Switching Rooms + +The APQuest Client can seamlessly switch rooms without restarting. + +Simply click the "Disconnect" button, then connect to a different slot/room. diff --git a/worlds/apquest/game/__init__.py b/worlds/apquest/game/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/worlds/apquest/game/audio/8bit Filler.ogg b/worlds/apquest/game/audio/8bit Filler.ogg new file mode 100644 index 0000000000..d16cfc7aba Binary files /dev/null and b/worlds/apquest/game/audio/8bit Filler.ogg differ diff --git a/worlds/apquest/game/audio/8bit ProgUseful.ogg b/worlds/apquest/game/audio/8bit ProgUseful.ogg new file mode 100644 index 0000000000..bc092f771a Binary files /dev/null and b/worlds/apquest/game/audio/8bit ProgUseful.ogg differ diff --git a/worlds/apquest/game/audio/8bit Progression.ogg b/worlds/apquest/game/audio/8bit Progression.ogg new file mode 100644 index 0000000000..a4d7ba8711 Binary files /dev/null and b/worlds/apquest/game/audio/8bit Progression.ogg differ diff --git a/worlds/apquest/game/audio/8bit Trap.ogg b/worlds/apquest/game/audio/8bit Trap.ogg new file mode 100644 index 0000000000..783f5fd3d3 Binary files /dev/null and b/worlds/apquest/game/audio/8bit Trap.ogg differ diff --git a/worlds/apquest/game/audio/8bit Useful.ogg b/worlds/apquest/game/audio/8bit Useful.ogg new file mode 100644 index 0000000000..ed333e9d93 Binary files /dev/null and b/worlds/apquest/game/audio/8bit Useful.ogg differ diff --git a/worlds/apquest/game/audio/8bit Victory.ogg b/worlds/apquest/game/audio/8bit Victory.ogg new file mode 100644 index 0000000000..5153af11ce Binary files /dev/null and b/worlds/apquest/game/audio/8bit Victory.ogg differ diff --git a/worlds/apquest/game/audio/APQuest BGM.ogg b/worlds/apquest/game/audio/APQuest BGM.ogg new file mode 100644 index 0000000000..8a6e542891 Binary files /dev/null and b/worlds/apquest/game/audio/APQuest BGM.ogg differ diff --git a/worlds/apquest/game/audio/APQuest Confetti Cannon.ogg b/worlds/apquest/game/audio/APQuest Confetti Cannon.ogg new file mode 100644 index 0000000000..4eafe62a73 Binary files /dev/null and b/worlds/apquest/game/audio/APQuest Confetti Cannon.ogg differ diff --git a/worlds/apquest/game/audio/APQuest Intro.ogg b/worlds/apquest/game/audio/APQuest Intro.ogg new file mode 100644 index 0000000000..52c1fa89ad Binary files /dev/null and b/worlds/apquest/game/audio/APQuest Intro.ogg differ diff --git a/worlds/apquest/game/audio/APQuest Math BGM.ogg b/worlds/apquest/game/audio/APQuest Math BGM.ogg new file mode 100644 index 0000000000..5708f9cafb Binary files /dev/null and b/worlds/apquest/game/audio/APQuest Math BGM.ogg differ diff --git a/worlds/apquest/game/audio/APQuest Math Problem Solved Jingle.ogg b/worlds/apquest/game/audio/APQuest Math Problem Solved Jingle.ogg new file mode 100644 index 0000000000..f20e7a909e Binary files /dev/null and b/worlds/apquest/game/audio/APQuest Math Problem Solved Jingle.ogg differ diff --git a/worlds/apquest/game/audio/APQuest Math Problem Starter Jingle.ogg b/worlds/apquest/game/audio/APQuest Math Problem Starter Jingle.ogg new file mode 100644 index 0000000000..7af05ee2a8 Binary files /dev/null and b/worlds/apquest/game/audio/APQuest Math Problem Starter Jingle.ogg differ diff --git a/worlds/apquest/game/entities.py b/worlds/apquest/game/entities.py new file mode 100644 index 0000000000..64b89206f6 --- /dev/null +++ b/worlds/apquest/game/entities.py @@ -0,0 +1,312 @@ +from __future__ import annotations + +from abc import abstractmethod +from typing import TYPE_CHECKING, ClassVar + +from .graphics import Graphic +from .items import ITEM_TO_GRAPHIC, Item +from .locations import Location + +if TYPE_CHECKING: + from .player import Player + + +class Entity: + solid: bool + graphic: Graphic + + +class InteractableMixin: + @abstractmethod + def interact(self, player: Player) -> None: + pass + + +class ActivatableMixin: + @abstractmethod + def activate(self, player: Player) -> None: + pass + + +class LocationMixin: + location: Location + content: Item | None = None + remote: bool = False + has_given_content: bool = False + + def force_clear(self) -> None: + if self.has_given_content: + return + + self.has_given_content = True + self.content_success() + + def give_content(self, player: Player) -> None: + if self.has_given_content: + return + + if self.content is None: + self.content_failure() + return + + if self.remote: + player.location_cleared(self.location.value) + else: + player.receive_item(self.content) + + self.has_given_content = True + self.content_success() + + def content_success(self) -> None: + pass + + def content_failure(self) -> None: + pass + + +class Empty(Entity): + solid = False + graphic = Graphic.EMPTY + + +class Wall(Entity): + solid = True + graphic = Graphic.WALL + + +class Chest(Entity, InteractableMixin, LocationMixin): + solid = True + + is_open: bool = False + + def __init__(self, location: Location) -> None: + self.location = location + + def update_solidity(self) -> None: + self.solid = not self.has_given_content + + def open(self) -> None: + self.is_open = True + self.update_solidity() + + def interact(self, player: Player) -> None: + if self.has_given_content: + return + + if self.is_open: + self.give_content(player) + return + + self.open() + + def content_success(self) -> None: + self.update_solidity() + + def content_failure(self) -> None: + self.update_solidity() + + @property + def graphic(self) -> Graphic: + if self.has_given_content: + return Graphic.EMPTY + if self.is_open: + if self.content is None: + return Graphic.EMPTY + return ITEM_TO_GRAPHIC[self.content] + return Graphic.CHEST + + +class Door(Entity): + solid = True + + is_open: bool = False + + closed_graphic: ClassVar[Graphic] + + def open(self) -> None: + self.is_open = True + self.solid = False + + @property + def graphic(self) -> Graphic: + if self.is_open: + return Graphic.EMPTY + return self.closed_graphic + + +class KeyDoor(Door, InteractableMixin): + closed_graphic = Graphic.KEY_DOOR + + def interact(self, player: Player) -> None: + if self.is_open: + return + + if not player.has_item(Item.KEY): + return + + player.remove_item(Item.KEY) + + self.open() + + +class BreakableBlock(Door, InteractableMixin): + closed_graphic = Graphic.BREAKABLE_BLOCK + + def interact(self, player: Player) -> None: + if self.is_open: + return + + if not player.has_item(Item.HAMMER): + return + + player.remove_item(Item.HAMMER) + + self.open() + + +class Bush(Door, InteractableMixin): + closed_graphic = Graphic.BUSH + + def interact(self, player: Player) -> None: + if self.is_open: + return + + if not player.has_item(Item.SWORD): + return + + self.open() + + +class Button(Entity, InteractableMixin): + solid = True + + activates: ActivatableMixin + activated = False + + def __init__(self, activates: ActivatableMixin) -> None: + self.activates = activates + + def interact(self, player: Player) -> None: + if self.activated: + return + + self.activated = True + self.activates.activate(player) + + @property + def graphic(self) -> Graphic: + if self.activated: + return Graphic.BUTTON_ACTIVATED + return Graphic.BUTTON_NOT_ACTIVATED + + +class ButtonDoor(Door, ActivatableMixin): + closed_graphic = Graphic.BUTTON_DOOR + + def activate(self, player: Player) -> None: + self.is_open = True + self.solid = False + + +class Enemy(Entity, InteractableMixin): + solid = True + + current_health: int + max_health: int + + dead: bool = False + + enemy_graphic_by_health: ClassVar[dict[int, Graphic]] = { + 2: Graphic.NORMAL_ENEMY_2_HEALTH, + 1: Graphic.NORMAL_ENEMY_1_HEALTH, + } + enemy_default_graphic = Graphic.NORMAL_ENEMY_1_HEALTH + + def __init__(self, max_health: int) -> None: + self.max_health = max_health + self.respawn() + + def die(self) -> None: + self.dead = True + self.solid = False + + def respawn(self) -> None: + self.dead = False + self.solid = True + self.heal_if_not_dead() + + def heal_if_not_dead(self) -> None: + if self.dead: + return + self.current_health = self.max_health + + def interact(self, player: Player) -> None: + if self.dead: + return + + if player.has_item(Item.SWORD): + self.current_health = max(0, self.current_health - 1) + + if self.current_health == 0: + if not self.dead: + self.die() + return + + player.damage(2) + + @property + def graphic(self) -> Graphic: + if self.dead: + return Graphic.EMPTY + return self.enemy_graphic_by_health.get(self.current_health, self.enemy_default_graphic) + + +class EnemyWithLoot(Enemy, LocationMixin): + def __init__(self, max_health: int, location: Location) -> None: + super().__init__(max_health) + self.location = location + + def die(self) -> None: + self.dead = True + self.solid = not self.has_given_content + + def interact(self, player: Player) -> None: + if self.dead: + if not self.has_given_content: + self.give_content(player) + return + + super().interact(player) + + @property + def graphic(self) -> Graphic: + if self.dead and not self.has_given_content: + if self.content is None: + return Graphic.EMPTY + return ITEM_TO_GRAPHIC[self.content] + return super().graphic + + def content_success(self) -> None: + self.die() + + def content_failure(self) -> None: + self.die() + + +class FinalBoss(Enemy): + enemy_graphic_by_health: ClassVar[dict[int, Graphic]] = { + 5: Graphic.BOSS_5_HEALTH, + 4: Graphic.BOSS_4_HEALTH, + 3: Graphic.BOSS_3_HEALTH, + 2: Graphic.BOSS_2_HEALTH, + 1: Graphic.BOSS_1_HEALTH, + } + enemy_default_graphic = Graphic.BOSS_1_HEALTH + + def interact(self, player: Player) -> None: + dead_before = self.dead + + super().interact(player) + + if not dead_before and self.dead: + player.victory() diff --git a/worlds/apquest/game/events.py b/worlds/apquest/game/events.py new file mode 100644 index 0000000000..0517abc69b --- /dev/null +++ b/worlds/apquest/game/events.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass + + +@dataclass +class Event: + pass + + +@dataclass +class LocationClearedEvent(Event): + location_id: int + + +@dataclass +class MathProblemStarted(Event): + pass + + +@dataclass +class MathProblemSolved(Event): + pass + + +@dataclass +class VictoryEvent(Event): + pass + + +@dataclass +class LocationalEvent(Event): + x: int + y: int + + +@dataclass +class ConfettiFired(LocationalEvent): + pass diff --git a/worlds/apquest/game/game.py b/worlds/apquest/game/game.py new file mode 100644 index 0000000000..47dfc8cb37 --- /dev/null +++ b/worlds/apquest/game/game.py @@ -0,0 +1,203 @@ +from math import ceil +from random import Random + +from .entities import InteractableMixin +from .events import ConfettiFired, Event, MathProblemSolved, MathProblemStarted +from .gameboard import Gameboard, create_gameboard +from .generate_math_problem import MathProblem, generate_math_problem +from .graphics import Graphic +from .inputs import DIGIT_INPUTS_TO_DIGITS, Direction, Input +from .items import ITEM_TO_GRAPHIC, Item, RemotelyReceivedItem +from .locations import Location +from .player import Player + + +class Game: + player: Player + gameboard: Gameboard + + random: Random + + queued_events: list[Event] + + active_math_problem: MathProblem | None + active_math_problem_input: list[int] | None + + remotely_received_items: set[tuple[int, int, int]] + + def __init__( + self, hard_mode: bool, hammer_exists: bool, extra_chest: bool, random_object: Random | None = None + ) -> None: + self.queued_events = [] + self.gameboard = create_gameboard(hard_mode, hammer_exists, extra_chest) + self.player = Player(self.gameboard, self.queued_events.append) + self.active_math_problem = None + self.remotely_received_items = set() + + if random_object is None: + self.random = Random() + else: + self.random = random_object + + def render(self) -> tuple[tuple[Graphic, ...], ...]: + if self.active_math_problem is None and self.player.inventory[Item.MATH_TRAP]: + self.active_math_problem = generate_math_problem(self.random) + self.active_math_problem_input = [] + self.player.remove_item(Item.MATH_TRAP) + self.queued_events.append(MathProblemStarted()) + return self.gameboard.render_math_problem( + self.active_math_problem, + self.active_math_problem_input, + self.currently_typed_in_math_result, + ) + + if self.active_math_problem is not None and self.active_math_problem_input is not None: + return self.gameboard.render_math_problem( + self.active_math_problem, self.active_math_problem_input, self.currently_typed_in_math_result + ) + + return self.gameboard.render(self.player) + + def render_health_and_inventory(self, vertical: bool = False) -> tuple[Graphic, ...]: + size = self.gameboard.size[1] if vertical else self.gameboard.size[0] + + graphics_array = [Graphic.EMPTY] * size + + item_back_index = size - 1 + for item, amount in sorted(self.player.inventory.items(), key=lambda sort_item: sort_item[0].value): + for _ in range(amount): + if item_back_index == 3: + break + if item == Item.HEALTH_UPGRADE: + continue + if item == Item.MATH_TRAP: + continue + + graphics_array[item_back_index] = ITEM_TO_GRAPHIC[item] + item_back_index -= 1 + else: + continue + break + + remaining_health = self.player.current_health + for i in range(min(item_back_index, ceil(self.player.max_health / 2))): + new_remaining_health = max(0, remaining_health - 2) + change = remaining_health - new_remaining_health + remaining_health = new_remaining_health + + if change == 2: + graphics_array[i] = Graphic.HEART + elif change == 1: + graphics_array[i] = Graphic.HALF_HEART + elif change == 0: + graphics_array[i] = Graphic.EMPTY_HEART + + return tuple(graphics_array) + + def attempt_player_movement(self, direction: Direction) -> None: + 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 + + def attempt_interact(self) -> None: + 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) + + 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)) + + def math_problem_success(self) -> None: + self.active_math_problem = None + self.active_math_problem_input = None + self.queued_events.append(MathProblemSolved()) + + @property + def currently_typed_in_math_result(self) -> int | None: + if not self.active_math_problem_input: + return None + + number = self.active_math_problem_input[-1] + if len(self.active_math_problem_input) == 2: + number += self.active_math_problem_input[0] * 10 + + return number + + def check_math_problem_result(self) -> None: + if self.active_math_problem is None: + return + + if self.currently_typed_in_math_result == self.active_math_problem.result: + self.math_problem_success() + + def math_problem_input(self, input: int) -> None: + if self.active_math_problem_input is None or len(self.active_math_problem_input) >= 2: + return + + self.active_math_problem_input.append(input) + self.check_math_problem_result() + + def math_problem_delete(self) -> None: + if self.active_math_problem_input is None or len(self.active_math_problem_input) == 0: + return + self.active_math_problem_input.pop() + self.check_math_problem_result() + + def input(self, input_key: Input) -> None: + if not self.gameboard.ready: + return + + if self.active_math_problem is not None: + if input_key in DIGIT_INPUTS_TO_DIGITS: + self.math_problem_input(DIGIT_INPUTS_TO_DIGITS[input_key]) + if input_key == Input.BACKSPACE: + self.math_problem_delete() + return + + if input_key == Input.LEFT: + self.attempt_player_movement(Direction.LEFT) + return + + if input_key == Input.UP: + self.attempt_player_movement(Direction.UP) + return + + if input_key == Input.RIGHT: + self.attempt_player_movement(Direction.RIGHT) + return + + if input_key == Input.DOWN: + self.attempt_player_movement(Direction.DOWN) + return + + if input_key == Input.ACTION: + self.attempt_interact() + return + + if input_key == Input.CONFETTI: + self.attempt_fire_confetti_cannon() + return + + raise ValueError(f"Don't know input {input_key}") + + def receive_item(self, remote_item_id: int, remote_location_id: int, remote_location_player: int) -> None: + remotely_received_item = RemotelyReceivedItem(remote_item_id, remote_location_id, remote_location_player) + if remotely_received_item in self.remotely_received_items: + return + + self.player.receive_item(Item(remote_item_id)) + + def force_clear_location(self, location_id: int) -> None: + location = Location(location_id) + self.gameboard.force_clear_location(location) diff --git a/worlds/apquest/game/gameboard.py b/worlds/apquest/game/gameboard.py new file mode 100644 index 0000000000..ec97491c87 --- /dev/null +++ b/worlds/apquest/game/gameboard.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import random +from collections.abc import Iterable +from typing import TYPE_CHECKING + +from .entities import ( + BreakableBlock, + Bush, + Button, + ButtonDoor, + Chest, + Empty, + Enemy, + EnemyWithLoot, + Entity, + FinalBoss, + KeyDoor, + LocationMixin, + Wall, +) +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 + +if TYPE_CHECKING: + from .player import Player + + +class Gameboard: + gameboard: tuple[tuple[Entity, ...], ...] + + hammer_exists: bool + content_filled: bool + + remote_entity_by_location_id: dict[Location, LocationMixin] + + def __init__(self, gameboard: tuple[tuple[Entity, ...], ...], hammer_exists: bool) -> None: + assert gameboard, "Gameboard is empty" + assert all(len(row) == len(gameboard[0]) for row in gameboard), "Not all rows have the same size" + + self.gameboard = gameboard + self.hammer_exists = hammer_exists + self.content_filled = False + self.remote_entity_by_location_id = {} + + def fill_default_location_content(self, trap_percentage: int = 0) -> None: + for entity in self.iterate_entities(): + if isinstance(entity, LocationMixin): + if entity.location in DEFAULT_CONTENT: + content = DEFAULT_CONTENT[entity.location] + if content == Item.HAMMER and not self.hammer_exists: + content = Item.CONFETTI_CANNON + + if content == Item.CONFETTI_CANNON: + if random.randrange(100) < trap_percentage: + content = Item.MATH_TRAP + + entity.content = content + + self.content_filled = True + + def fill_remote_location_content(self, graphic_overrides: dict[Location, Item]) -> None: + for entity in self.iterate_entities(): + if isinstance(entity, LocationMixin): + entity.content = graphic_overrides.get(entity.location, Item.REMOTE_ITEM) + entity.remote = True + self.remote_entity_by_location_id[entity.location] = entity + + self.content_filled = True + + def get_entity_at(self, x: int, y: int) -> Entity: + if x < 0 or x >= len(self.gameboard[0]): + return Wall() + if y < 0 or y >= len(self.gameboard): + return Wall() + + return self.gameboard[y][x] + + def iterate_entities(self) -> Iterable[Entity]: + for row in self.gameboard: + yield from row + + def respawn_final_boss(self) -> None: + for entity in self.iterate_entities(): + if isinstance(entity, FinalBoss): + entity.respawn() + + def heal_alive_enemies(self) -> None: + for entity in self.iterate_entities(): + if isinstance(entity, Enemy): + entity.heal_if_not_dead() + + def render(self, player: Player) -> tuple[tuple[Graphic, ...], ...]: + graphics = [] + + for y, row in enumerate(self.gameboard): + graphics_row = [] + for x, entity in enumerate(row): + if player.current_x == x and player.current_y == y: + graphics_row.append(player.render()) + else: + graphics_row.append(entity.graphic) + + graphics.append(tuple(graphics_row)) + + return tuple(graphics) + + def render_math_problem( + self, problem: MathProblem, current_input_digits: list[int], current_input_int: int | None + ) -> tuple[tuple[Graphic, ...], ...]: + rows = len(self.gameboard) + columns = len(self.gameboard[0]) + + def pad_row(row: list[Graphic]) -> tuple[Graphic, ...]: + row = row.copy() + while len(row) < columns: + row = [Graphic.EMPTY, *row, Graphic.EMPTY] + while len(row) > columns: + row.pop() + + return tuple(row) + + empty_row = tuple([Graphic.EMPTY] * columns) + + math_time_row = pad_row( + [ + Graphic.LETTER_M, + Graphic.LETTER_A, + Graphic.LETTER_T, + Graphic.LETTER_H, + Graphic.EMPTY, + Graphic.LETTER_T, + Graphic.LETTER_I, + Graphic.LETTER_M, + Graphic.LETTER_E, + ] + ) + + num_1_first_digit = problem.num_1 // 10 + num_1_second_digit = problem.num_1 % 10 + num_2_first_digit = problem.num_2 // 10 + num_2_second_digit = problem.num_2 % 10 + + math_problem_row = pad_row( + [ + DIGIT_TO_GRAPHIC_ZERO_EMPTY[num_1_first_digit], + DIGIT_TO_GRAPHIC[num_1_second_digit], + Graphic.EMPTY, + MATH_PROBLEM_TYPE_TO_GRAPHIC[problem.problem_type], + Graphic.EMPTY, + DIGIT_TO_GRAPHIC_ZERO_EMPTY[num_2_first_digit], + DIGIT_TO_GRAPHIC[num_2_second_digit], + ] + ) + + display_digit_1 = None + display_digit_2 = None + if current_input_digits: + display_digit_1 = current_input_digits[0] + if len(current_input_digits) == 2: + display_digit_2 = current_input_digits[1] + + result_row = pad_row( + [ + Graphic.EQUALS, + Graphic.EMPTY, + DIGIT_TO_GRAPHIC[display_digit_1], + DIGIT_TO_GRAPHIC[display_digit_2], + Graphic.EMPTY, + Graphic.NO if len(current_input_digits) == 2 and current_input_int != problem.result else Graphic.EMPTY, + ] + ) + + output = [math_time_row, empty_row, math_problem_row, result_row] + + while len(output) < rows: + output = [empty_row, *output, empty_row] + while len(output) > columns: + output.pop(0) + + return tuple(output) + + def force_clear_location(self, location: Location) -> None: + entity = self.remote_entity_by_location_id[location] + entity.force_clear() + + @property + def ready(self) -> bool: + return self.content_filled + + @property + def size(self) -> tuple[int, int]: + return len(self.gameboard[0]), len(self.gameboard) + + +def create_gameboard(hard_mode: bool, hammer_exists: bool, extra_chest: bool) -> Gameboard: + boss_door = ButtonDoor() + boss_door_button = Button(boss_door) + + key_door = KeyDoor() + + top_middle_chest = Chest(Location.TOP_MIDDLE_CHEST) + left_room_chest = Chest(Location.TOP_LEFT_CHEST) + bottom_left_chest = Chest(Location.BOTTOM_LEFT_CHEST) + bottom_right_room_left_chest = Chest(Location.BOTTOM_RIGHT_ROOM_LEFT_CHEST) + bottom_right_room_right_chest = Chest(Location.BOTTOM_RIGHT_ROOM_RIGHT_CHEST) + + bottom_left_extra_chest = Chest(Location.BOTTOM_LEFT_EXTRA_CHEST) if extra_chest else Empty() + wall_if_hammer = Wall() if hammer_exists else Empty() + breakable_block = BreakableBlock() if hammer_exists else Empty() + + normal_enemy = EnemyWithLoot(2 if hard_mode else 1, Location.ENEMY_DROP) + boss = FinalBoss(5 if hard_mode else 3) + + gameboard = ( + (Empty(), Empty(), Empty(), Wall(), Empty(), Empty(), Empty(), Wall(), Empty(), Empty(), Empty()), + ( + Empty(), + boss_door_button, + Empty(), + Wall(), + Empty(), + top_middle_chest, + Empty(), + Wall(), + Empty(), + boss, + Empty(), + ), + (Empty(), Empty(), Empty(), Wall(), Empty(), Empty(), Empty(), Wall(), Empty(), Empty(), Empty()), + ( + Empty(), + left_room_chest, + Empty(), + Wall(), + wall_if_hammer, + breakable_block, + wall_if_hammer, + Wall(), + Wall(), + boss_door, + Wall(), + ), + (Empty(), Empty(), Empty(), Wall(), Empty(), Empty(), Empty(), Wall(), Empty(), Empty(), Empty()), + (Wall(), key_door, Wall(), Wall(), Empty(), Empty(), Empty(), Empty(), Empty(), normal_enemy, Empty()), + (Empty(), Empty(), Empty(), Empty(), Empty(), Empty(), Empty(), Wall(), Empty(), Empty(), Empty()), + (Empty(), bottom_left_extra_chest, Empty(), Empty(), Empty(), Empty(), Wall(), Wall(), Wall(), Wall(), Wall()), + (Empty(), Empty(), Empty(), Empty(), Empty(), Empty(), Wall(), Empty(), Empty(), Empty(), Empty()), + ( + Empty(), + bottom_left_chest, + Empty(), + Empty(), + Empty(), + Empty(), + Bush(), + Empty(), + bottom_right_room_left_chest, + bottom_right_room_right_chest, + Empty(), + ), + (Empty(), Empty(), Empty(), Empty(), Empty(), Empty(), Wall(), Empty(), Empty(), Empty(), Empty()), + ) + + return Gameboard(gameboard, hammer_exists) diff --git a/worlds/apquest/game/generate_math_problem.py b/worlds/apquest/game/generate_math_problem.py new file mode 100644 index 0000000000..eb8ff0f01e --- /dev/null +++ b/worlds/apquest/game/generate_math_problem.py @@ -0,0 +1,63 @@ +import random +from collections.abc import Callable +from enum import Enum +from operator import add, mul, sub, truediv +from typing import NamedTuple + +_random = random.Random() + +class NumberChoiceConstraints(NamedTuple): + num_1_min: int + num_1_max: int + num_2_min: int + num_2_max: int + commutative: bool + operator: Callable[[int, int], int | float] + + +class MathProblemType(Enum): + PLUS = 1 + MINUS = 2 + TIMES = 3 + DIVIDE = 4 + + +MATH_PROBLEM_CONSTRAINTS = { + MathProblemType.PLUS: NumberChoiceConstraints(1, 99, 1, 99, True, add), + MathProblemType.MINUS: NumberChoiceConstraints(2, 99, 1, 99, False, sub), + MathProblemType.TIMES: NumberChoiceConstraints(2, 10, 2, 50, True, mul), + MathProblemType.DIVIDE: NumberChoiceConstraints(4, 99, 2, 50, False, truediv), +} + + +class MathProblem(NamedTuple): + problem_type: MathProblemType + num_1: int + num_2: int + result: int + + +def generate_math_problem(random_object: random.Random = _random) -> MathProblem: + problem_type: MathProblemType = random_object.choice(list(MathProblemType)) + number_choice_constraints = MATH_PROBLEM_CONSTRAINTS[problem_type] + + for _ in range(10000): + num_1 = random.randint(number_choice_constraints.num_1_min, number_choice_constraints.num_1_max) + num_2 = random.randint(number_choice_constraints.num_2_min, number_choice_constraints.num_2_max) + + result = number_choice_constraints.operator(num_1, num_2) + + result_int = int(result) + if not result_int == result: + continue + + if result_int < 2 or result_int > 99: + continue + + if number_choice_constraints.commutative: + if random.randint(0, 1): + num_1, num_2 = num_2, num_1 + + return MathProblem(problem_type, num_1, num_2, result_int) + + return MathProblem(MathProblemType.PLUS, 1, 1, 2) diff --git a/worlds/apquest/game/graphics.py b/worlds/apquest/game/graphics.py new file mode 100644 index 0000000000..9279c6c7b4 --- /dev/null +++ b/worlds/apquest/game/graphics.py @@ -0,0 +1,99 @@ +from enum import Enum + +from .generate_math_problem import MathProblemType + + +class Graphic(Enum): + EMPTY = 0 + WALL = 1 + BUTTON_NOT_ACTIVATED = 2 + BUTTON_ACTIVATED = 3 + KEY_DOOR = 4 + BUTTON_DOOR = 5 + CHEST = 6 + BUSH = 7 + BREAKABLE_BLOCK = 8 + + NORMAL_ENEMY_1_HEALTH = 10 + NORMAL_ENEMY_2_HEALTH = 11 + + BOSS_1_HEALTH = 20 + BOSS_2_HEALTH = 21 + BOSS_3_HEALTH = 22 + BOSS_4_HEALTH = 23 + BOSS_5_HEALTH = 24 + + PLAYER_DOWN = 30 + PLAYER_UP = 31 + PLAYER_LEFT = 32 + PLAYER_RIGHT = 33 + + KEY = 41 + SWORD = 42 + SHIELD = 43 + HAMMER = 44 + + HEART = 50 + HALF_HEART = 51 + EMPTY_HEART = 52 + + CONFETTI_CANNON = 60 + + REMOTE_ITEM = 70 + + ITEMS_TEXT = 80 + + MATH_TRAP = CONFETTI_CANNON + + ZERO = 1000 + ONE = 1001 + TWO = 1002 + THREE = 1003 + FOUR = 1004 + FIVE = 1005 + SIX = 1006 + SEVEN = 1007 + EIGHT = 1008 + NINE = 1009 + + PLUS = 1100 + MINUS = 1101 + TIMES = 1102 + DIVIDE = 1103 + + LETTER_A = 2000 + LETTER_E = 2005 + LETTER_H = 2008 + LETTER_I = 2009 + LETTER_M = 2013 + LETTER_T = 2019 + + EQUALS = 2050 + NO = 2060 + + UNKNOWN = -1 + + +DIGIT_TO_GRAPHIC = { + None: Graphic.EMPTY, + 0: Graphic.ZERO, + 1: Graphic.ONE, + 2: Graphic.TWO, + 3: Graphic.THREE, + 4: Graphic.FOUR, + 5: Graphic.FIVE, + 6: Graphic.SIX, + 7: Graphic.SEVEN, + 8: Graphic.EIGHT, + 9: Graphic.NINE, +} + +DIGIT_TO_GRAPHIC_ZERO_EMPTY = DIGIT_TO_GRAPHIC.copy() +DIGIT_TO_GRAPHIC_ZERO_EMPTY[0] = Graphic.EMPTY + +MATH_PROBLEM_TYPE_TO_GRAPHIC = { + MathProblemType.PLUS: Graphic.PLUS, + MathProblemType.MINUS: Graphic.MINUS, + MathProblemType.TIMES: Graphic.TIMES, + MathProblemType.DIVIDE: Graphic.DIVIDE, +} diff --git a/worlds/apquest/game/graphics/boss.png b/worlds/apquest/game/graphics/boss.png new file mode 100644 index 0000000000..dbcda31048 Binary files /dev/null and b/worlds/apquest/game/graphics/boss.png differ diff --git a/worlds/apquest/game/graphics/cat.png b/worlds/apquest/game/graphics/cat.png new file mode 100644 index 0000000000..aa5e854753 Binary files /dev/null and b/worlds/apquest/game/graphics/cat.png differ diff --git a/worlds/apquest/game/graphics/duck.png b/worlds/apquest/game/graphics/duck.png new file mode 100644 index 0000000000..5e07b70dfc Binary files /dev/null and b/worlds/apquest/game/graphics/duck.png differ diff --git a/worlds/apquest/game/graphics/hearts.png b/worlds/apquest/game/graphics/hearts.png new file mode 100644 index 0000000000..0ab344898f Binary files /dev/null and b/worlds/apquest/game/graphics/hearts.png differ diff --git a/worlds/apquest/game/graphics/horse.png b/worlds/apquest/game/graphics/horse.png new file mode 100644 index 0000000000..349c256688 Binary files /dev/null and b/worlds/apquest/game/graphics/horse.png differ diff --git a/worlds/apquest/game/graphics/human.png b/worlds/apquest/game/graphics/human.png new file mode 100644 index 0000000000..6978bf0ac5 Binary files /dev/null and b/worlds/apquest/game/graphics/human.png differ diff --git a/worlds/apquest/game/graphics/inanimates.png b/worlds/apquest/game/graphics/inanimates.png new file mode 100644 index 0000000000..ba7c143b98 Binary files /dev/null and b/worlds/apquest/game/graphics/inanimates.png differ diff --git a/worlds/apquest/game/graphics/items.png b/worlds/apquest/game/graphics/items.png new file mode 100644 index 0000000000..ce66feda77 Binary files /dev/null and b/worlds/apquest/game/graphics/items.png differ diff --git a/worlds/apquest/game/graphics/items_text.png b/worlds/apquest/game/graphics/items_text.png new file mode 100644 index 0000000000..4b5a4f30de Binary files /dev/null and b/worlds/apquest/game/graphics/items_text.png differ diff --git a/worlds/apquest/game/graphics/letters.png b/worlds/apquest/game/graphics/letters.png new file mode 100644 index 0000000000..8ec3bae065 Binary files /dev/null and b/worlds/apquest/game/graphics/letters.png differ diff --git a/worlds/apquest/game/graphics/normal_enemy.png b/worlds/apquest/game/graphics/normal_enemy.png new file mode 100644 index 0000000000..baed65d8cc Binary files /dev/null and b/worlds/apquest/game/graphics/normal_enemy.png differ diff --git a/worlds/apquest/game/graphics/numbers.png b/worlds/apquest/game/graphics/numbers.png new file mode 100644 index 0000000000..97e19bea85 Binary files /dev/null and b/worlds/apquest/game/graphics/numbers.png differ diff --git a/worlds/apquest/game/graphics/symbols.png b/worlds/apquest/game/graphics/symbols.png new file mode 100644 index 0000000000..6f9e043699 Binary files /dev/null and b/worlds/apquest/game/graphics/symbols.png differ diff --git a/worlds/apquest/game/inputs.py b/worlds/apquest/game/inputs.py new file mode 100644 index 0000000000..eb736a606f --- /dev/null +++ b/worlds/apquest/game/inputs.py @@ -0,0 +1,44 @@ +from enum import Enum + + +class Direction(Enum): + LEFT = (-1, 0) + UP = (0, -1) + RIGHT = (1, 0) + DOWN = (0, 1) + + +class Input(Enum): + LEFT = 1 + UP = 2 + RIGHT = 3 + DOWN = 4 + ACTION = 5 + CONFETTI = 6 + + ZERO = 1000 + ONE = 1001 + TWO = 1002 + THREE = 1003 + FOUR = 1004 + FIVE = 1005 + SIX = 1006 + SEVEN = 1007 + EIGHT = 1008 + NINE = 1009 + + BACKSPACE = 2000 + + +DIGIT_INPUTS_TO_DIGITS = { + Input.ZERO: 0, + Input.ONE: 1, + Input.TWO: 2, + Input.THREE: 3, + Input.FOUR: 4, + Input.FIVE: 5, + Input.SIX: 6, + Input.SEVEN: 7, + Input.EIGHT: 8, + Input.NINE: 9, +} diff --git a/worlds/apquest/game/items.py b/worlds/apquest/game/items.py new file mode 100644 index 0000000000..a37d0e654f --- /dev/null +++ b/worlds/apquest/game/items.py @@ -0,0 +1,38 @@ +from collections import defaultdict +from enum import Enum +from typing import NamedTuple + +from ..items import ITEM_NAME_TO_ID +from .graphics import Graphic + + +class Item(Enum): + KEY = ITEM_NAME_TO_ID["Key"] + SWORD = ITEM_NAME_TO_ID["Sword"] + SHIELD = ITEM_NAME_TO_ID["Shield"] + HAMMER = ITEM_NAME_TO_ID["Hammer"] + HEALTH_UPGRADE = ITEM_NAME_TO_ID["Health Upgrade"] + CONFETTI_CANNON = ITEM_NAME_TO_ID["Confetti Cannon"] + MATH_TRAP = ITEM_NAME_TO_ID["Math Trap"] + REMOTE_ITEM = -1 + + +class RemotelyReceivedItem(NamedTuple): + remote_item_id: int + remote_location_id: int + remote_location_player: int + + +ITEM_TO_GRAPHIC = defaultdict( + lambda: Graphic.UNKNOWN, + { + Item.KEY: Graphic.KEY, + Item.SWORD: Graphic.SWORD, + Item.SHIELD: Graphic.SHIELD, + Item.HAMMER: Graphic.HAMMER, + Item.HEALTH_UPGRADE: Graphic.HEART, + Item.CONFETTI_CANNON: Graphic.CONFETTI_CANNON, + Item.REMOTE_ITEM: Graphic.REMOTE_ITEM, + Item.MATH_TRAP: Graphic.MATH_TRAP, + }, +) diff --git a/worlds/apquest/game/locations.py b/worlds/apquest/game/locations.py new file mode 100644 index 0000000000..b8aaa77e7c --- /dev/null +++ b/worlds/apquest/game/locations.py @@ -0,0 +1,25 @@ +from enum import Enum + +from ..locations import LOCATION_NAME_TO_ID +from .items import Item + + +class Location(Enum): + TOP_LEFT_CHEST = LOCATION_NAME_TO_ID["Top Left Room Chest"] + TOP_MIDDLE_CHEST = LOCATION_NAME_TO_ID["Top Middle Chest"] + BOTTOM_LEFT_CHEST = LOCATION_NAME_TO_ID["Bottom Left Chest"] + BOTTOM_LEFT_EXTRA_CHEST = LOCATION_NAME_TO_ID["Bottom Left Extra Chest"] + BOTTOM_RIGHT_ROOM_LEFT_CHEST = LOCATION_NAME_TO_ID["Bottom Right Room Left Chest"] + BOTTOM_RIGHT_ROOM_RIGHT_CHEST = LOCATION_NAME_TO_ID["Bottom Right Room Right Chest"] + ENEMY_DROP = LOCATION_NAME_TO_ID["Right Room Enemy Drop"] + + +DEFAULT_CONTENT = { + Location.TOP_LEFT_CHEST: Item.HEALTH_UPGRADE, + Location.TOP_MIDDLE_CHEST: Item.HEALTH_UPGRADE, + Location.BOTTOM_LEFT_CHEST: Item.SWORD, + Location.BOTTOM_LEFT_EXTRA_CHEST: Item.CONFETTI_CANNON, + Location.BOTTOM_RIGHT_ROOM_LEFT_CHEST: Item.SHIELD, + Location.BOTTOM_RIGHT_ROOM_RIGHT_CHEST: Item.HAMMER, + Location.ENEMY_DROP: Item.KEY, +} diff --git a/worlds/apquest/game/play_in_console.py b/worlds/apquest/game/play_in_console.py new file mode 100644 index 0000000000..9e07896f65 --- /dev/null +++ b/worlds/apquest/game/play_in_console.py @@ -0,0 +1,143 @@ +from .events import ConfettiFired, MathProblemSolved + +try: + from pynput import keyboard + from pynput.keyboard import Key, KeyCode +except ImportError as e: + raise ImportError("In order to play APQuest from console, you have to install pynput.") from e + +from .game import Game +from .graphics import Graphic +from .inputs import Input +from .items import ITEM_TO_GRAPHIC + +graphic_to_char = { + Graphic.EMPTY: " ", + Graphic.WALL: "W", + Graphic.BUTTON_NOT_ACTIVATED: "B", + Graphic.BUTTON_ACTIVATED: "A", + Graphic.KEY_DOOR: "D", + Graphic.BUTTON_DOOR: "?", + Graphic.CHEST: "C", + Graphic.BUSH: "T", + Graphic.BREAKABLE_BLOCK: "~", + Graphic.NORMAL_ENEMY_2_HEALTH: "2", + Graphic.NORMAL_ENEMY_1_HEALTH: "1", + Graphic.BOSS_5_HEALTH: "5", + Graphic.BOSS_4_HEALTH: "4", + Graphic.BOSS_3_HEALTH: "3", + Graphic.BOSS_2_HEALTH: "2", + Graphic.BOSS_1_HEALTH: "1", + Graphic.PLAYER_DOWN: "v", + Graphic.PLAYER_UP: "^", + Graphic.PLAYER_LEFT: "<", + Graphic.PLAYER_RIGHT: ">", + Graphic.KEY: "K", + Graphic.SHIELD: "X", + Graphic.SWORD: "S", + Graphic.HAMMER: "H", + Graphic.HEART: "♡", + Graphic.CONFETTI_CANNON: "?", + Graphic.REMOTE_ITEM: "I", + Graphic.UNKNOWN: "ß", + Graphic.ZERO: "0", + Graphic.ONE: "1", + Graphic.TWO: "2", + Graphic.THREE: "3", + Graphic.FOUR: "4", + Graphic.FIVE: "5", + Graphic.SIX: "6", + Graphic.SEVEN: "7", + Graphic.EIGHT: "8", + Graphic.NINE: "9", + Graphic.PLUS: "+", + Graphic.MINUS: "-", + Graphic.TIMES: "x", + Graphic.DIVIDE: "/", + Graphic.LETTER_A: "A", + Graphic.LETTER_E: "E", + Graphic.LETTER_H: "H", + Graphic.LETTER_I: "I", + Graphic.LETTER_M: "M", + Graphic.LETTER_T: "T", + Graphic.EQUALS: "=", + Graphic.NO: "X", +} + +KEY_CONVERSION = { + keyboard.KeyCode.from_char("w"): Input.UP, + Key.up: Input.UP, + keyboard.KeyCode.from_char("s"): Input.DOWN, + Key.down: Input.DOWN, + keyboard.KeyCode.from_char("a"): Input.LEFT, + Key.left: Input.LEFT, + keyboard.KeyCode.from_char("d"): Input.RIGHT, + Key.right: Input.RIGHT, + Key.space: Input.ACTION, + keyboard.KeyCode.from_char("c"): Input.CONFETTI, + keyboard.KeyCode.from_char("0"): Input.ZERO, + keyboard.KeyCode.from_char("1"): Input.ONE, + keyboard.KeyCode.from_char("2"): Input.TWO, + keyboard.KeyCode.from_char("3"): Input.THREE, + keyboard.KeyCode.from_char("4"): Input.FOUR, + keyboard.KeyCode.from_char("5"): Input.FIVE, + keyboard.KeyCode.from_char("6"): Input.SIX, + keyboard.KeyCode.from_char("7"): Input.SEVEN, + keyboard.KeyCode.from_char("8"): Input.EIGHT, + keyboard.KeyCode.from_char("9"): Input.NINE, + Key.backspace: Input.BACKSPACE, +} + + +def render_to_text(game: Game) -> str: + player = game.player + rendered_graphics = game.render() + + output_string = f"Health: {player.current_health}/{player.max_health}\n" + + inventory = [] + for item, count in player.inventory.items(): + inventory += [graphic_to_char[ITEM_TO_GRAPHIC[item]] for _ in range(count)] + inventory.sort() + + output_string += f"Inventory: {', '.join(inventory)}\n" + + if player.has_won: + output_string += "VICTORY!!!\n" + + while game.queued_events: + next_event = game.queued_events.pop(0) + if isinstance(next_event, ConfettiFired): + output_string += "Confetti fired! You feel motivated :)\n" + if isinstance(next_event, MathProblemSolved): + output_string += "Math problem solved!\n" + + for row in rendered_graphics: + output_string += " ".join(graphic_to_char[graphic] for graphic in row) + output_string += "\n" + + return output_string + + +if __name__ == "__main__": + hard_mode = input("Do you want to play hard mode? (Y/N)").lower().strip() in ("y", "yes") + hammer_exists = input("Do you want the hammer to exist in the game? (Y/N)").lower().strip() in ("y", "yes") + extra_chest = input("Do you want the extra starting chest to exist in the game?").lower().strip() in ("y", "yes") + math_trap_percentage = int(input("What should the percentage of math traps be?")) + + game = Game(hard_mode, hammer_exists, extra_chest) + game.gameboard.fill_default_location_content(math_trap_percentage) + + def input_and_rerender(input_key: Input) -> None: + game.input(input_key) + print(render_to_text(game)) + + def on_press(key: Key | KeyCode | None) -> None: + if key in KEY_CONVERSION: + input_and_rerender(KEY_CONVERSION[key]) + + print(render_to_text(game)) + + with keyboard.Listener(on_press=on_press) as listener: + while True: + listener.join() diff --git a/worlds/apquest/game/player.py b/worlds/apquest/game/player.py new file mode 100644 index 0000000000..1703532f5c --- /dev/null +++ b/worlds/apquest/game/player.py @@ -0,0 +1,88 @@ +from collections import Counter +from collections.abc import Callable +from typing import TYPE_CHECKING + +from .events import Event, LocationClearedEvent, VictoryEvent +from .gameboard import Gameboard +from .graphics import Graphic +from .inputs import Direction +from .items import Item + + +class Player: + current_x: int + current_y: int + current_health: int + + has_won: bool = False + + facing: Direction + + inventory: Counter[Item] + + gameboard: Gameboard + push_event: Callable[[Event], None] + + def __init__(self, gameboard: Gameboard, push_event: Callable[[Event], None]) -> None: + self.gameboard = gameboard + self.inventory = Counter() + self.push_event = push_event + self.respawn() + + def respawn(self) -> None: + self.current_x = 4 + self.current_y = 9 + self.current_health = self.max_health + self.facing = Direction.DOWN + + @property + def max_health(self) -> int: + return 2 + 2 * self.inventory[Item.HEALTH_UPGRADE] + + def render(self) -> Graphic: + if not self.gameboard.ready: + return Graphic.EMPTY + + if self.facing == Direction.LEFT: + return Graphic.PLAYER_LEFT + if self.facing == Direction.UP: + return Graphic.PLAYER_UP + if self.facing == Direction.RIGHT: + return Graphic.PLAYER_RIGHT + return Graphic.PLAYER_DOWN + + def receive_item(self, item: Item) -> None: + assert item != Item.REMOTE_ITEM, "Player should not directly receive the remote item" + + self.inventory[item] += 1 + if item == Item.HEALTH_UPGRADE: + self.current_health += 2 + + def has_item(self, item: Item) -> bool: + return self.inventory[item] > 0 + + def remove_item(self, item: Item) -> None: + self.inventory[item] -= 1 + + def damage(self, damage: int) -> None: + if self.has_item(Item.SHIELD): + damage = damage // 2 + + self.current_health = max(0, self.current_health - damage) + + if self.current_health <= 0: + self.die() + + def die(self) -> None: + self.respawn() + self.gameboard.respawn_final_boss() + self.gameboard.heal_alive_enemies() + + def location_cleared(self, location_id: int) -> None: + event = LocationClearedEvent(location_id) + self.push_event(event) + + def victory(self) -> None: + self.has_won = True + event = VictoryEvent() + self.push_event(event) diff --git a/worlds/apquest/items.py b/worlds/apquest/items.py new file mode 100644 index 0000000000..abe3dda70b --- /dev/null +++ b/worlds/apquest/items.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from BaseClasses import Item, ItemClassification + +if TYPE_CHECKING: + from .world import APQuestWorld + +# Every item must have a unique integer ID associated with it. +# We will have a lookup from item name to ID here that, in world.py, we will import and bind to the world class. +# Even if an item doesn't exist on specific options, it must be present in this lookup. +ITEM_NAME_TO_ID = { + "Key": 1, + "Sword": 2, + "Shield": 3, + "Hammer": 4, + "Health Upgrade": 5, + "Confetti Cannon": 6, + "Math Trap": 7, +} + +# Items should have a defined default classification. +# In our case, we will make a dictionary from item name to classification. +DEFAULT_ITEM_CLASSIFICATIONS = { + "Key": ItemClassification.progression, + "Sword": ItemClassification.progression | ItemClassification.useful, # Items can have multiple classifications. + "Shield": ItemClassification.progression, + "Hammer": ItemClassification.progression, + "Health Upgrade": ItemClassification.useful, + "Confetti Cannon": ItemClassification.filler, + "Math Trap": ItemClassification.trap, +} + + +# Each Item instance must correctly report the "game" it belongs to. +# To make this simple, it is common practice to subclass the basic Item class and override the "game" field. +class APQuestItem(Item): + game = "APQuest" + + +# Ontop of our regular itempool, our world must be able to create arbitrary amounts of filler as requested by core. +# To do this, it must define a function called world.get_filler_item_name(), which we will define in world.py later. +# For now, let's make a function that returns the name of a random filler item here in items.py. +def get_random_filler_item_name(world: APQuestWorld) -> str: + # APQuest has an option called "trap_chance". + # This is the percentage chance that each filler item is a Math Trap instead of a Confetti Cannon. + # For this purpose, we need to use a random generator. + + # IMPORTANT: Whenever you need to use a random generator, you must use world.random. + # This ensures that generating with the same generator seed twice yields the same output. + # DO NOT use a bare random object from Python's built-in random module. + if world.random.randint(0, 99) < world.options.trap_chance: + return "Math Trap" + return "Confetti Cannon" + + +def create_item_with_correct_classification(world: APQuestWorld, name: str) -> APQuestItem: + # Our world class must have a create_item() function that can create any of our items by name at any time. + # So, we make this helper function that creates the item by name with the correct classification. + # Note: This function's content could just be the contents of world.create_item in world.py directly, + # but it seemed nicer to have it in its own function over here in items.py. + classification = DEFAULT_ITEM_CLASSIFICATIONS[name] + + # It is perfectly normal and valid for an item's classification to differ based on the player's options. + # In our case, Health Upgrades are only relevant to logic (and thus labeled as "progression") in hard mode. + if name == "Health Upgrade" and world.options.hard_mode: + classification = ItemClassification.progression + + return APQuestItem(name, classification, ITEM_NAME_TO_ID[name], world.player) + + +# With those two helper functions defined, let's now get to actually creating and submitting our itempool. +def create_all_items(world: APQuestWorld) -> None: + # This is the function in which we will create all the items that this world submits to the multiworld item pool. + # There must be exactly as many items as there are locations. + # In our case, there are either six or seven locations. + # We must make sure that when there are six locations, there are six items, + # and when there are seven locations, there are seven items. + + # Creating items should generally be done via the world's create_item method. + # First, we create a list containing all the items that always exist. + + itempool: list[Item] = [ + world.create_item("Key"), + world.create_item("Sword"), + world.create_item("Shield"), + world.create_item("Health Upgrade"), + world.create_item("Health Upgrade"), + ] + + # Some items may only exist if the player enables certain options. + # In our case, If the hammer option is enabled, the sixth item is the Hammer. + # Otherwise, we add a filler Confetti Cannon. + if world.options.hammer: + # Once again, it is important to stress that even though the Hammer doesn't always exist, + # it must be present in the worlds item_name_to_id. + # Whether it is actually in the itempool is determined purely by whether we create and add the item here. + itempool.append(world.create_item("Hammer")) + + # Archipelago requires that each world submits as many locations as it submits items. + # This is where we can use our filler and trap items. + # APQuest has two of these: The Confetti Cannon and the Math Trap. + # (Unfortunately, Archipelago is a bit ambiguous about its terminology here: + # "filler" is an ItemClassification separate from "trap", but in a lot of its functions, + # Archipelago will use "filler" to just mean "an additional item created to fill out the itempool". + # "Filler" in this sense can technically have any ItemClassification, + # but most commonly ItemClassification.filler or ItemClassification.trap. + # Starting here, the word "filler" will be used to collectively refer to APQuest's Confetti Cannon and Math Trap, + # which are ItemClassification.filler and ItemClassification.trap respectively.) + # Creating filler items works the same as any other item. But there is a question: + # How many filler items do we actually need to create? + # In regions.py, we created either six or seven locations depending on the "extra_starting_chest" option. + # In this function, we have created five or six items depending on whether the "hammer" option is enabled. + # We *could* have a really complicated if-else tree checking the options again, but there is a better way. + # We can compare the size of our itempool so far to the number of locations in our world. + + # The length of our itempool is easy to determine, since we have it as a list. + number_of_items = len(itempool) + + # The number of locations is also easy to determine, but we have to be careful. + # Just calling len(world.get_locations()) would report an incorrect number, because of our *event locations*. + # What we actually want is the number of *unfilled* locations. Luckily, there is a helper method for this: + number_of_unfilled_locations = len(world.multiworld.get_unfilled_locations(world.player)) + + # Now, we just subtract the number of items from the number of locations to get the number of empty item slots. + needed_number_of_filler_items = number_of_unfilled_locations - number_of_items + + # Finally, we create that many filler items and add them to the itempool. + # To create our filler, we could just use world.create_item("Confetti Cannon"). + # But there is an alternative that works even better for most worlds, including APQuest. + # As discussed above, our world must have a get_filler_item_name() function defined, + # which must return the name of an infinitely repeatable filler item. + # Defining this function enables the use of a helper function called world.create_filler(). + # You can just use this function directly to create as many filler items as you need to complete your itempool. + itempool += [world.create_filler() for _ in range(needed_number_of_filler_items)] + + # But... is that the right option for your game? Let's explore that. + # For some games, the concepts of "regular itempool filler" and "additionally created filler" are different. + # These games might want / require specific amounts of specific filler items in their regular pool. + # To achieve this, they will have to intentionally create the correct quantities using world.create_item(). + # They may still use world.create_filler() to fill up the rest of their itempool with "repeatable filler", + # after creating their "specific quantity" filler and still having room left over. + + # But there are many other games which *only* have infinitely repeatable filler items. + # They don't care about specific amounts of specific filler items, instead only caring about the proportions. + # In this case, world.create_filler() can just be used for the entire filler itempool. + # APQuest is one of these games: + # Regardless of whether it's filler for the regular itempool or additional filler for item links / etc., + # we always just want a Confetti Cannon or a Math Trap depending on the "trap_chance" option. + # We defined this behavior in our get_random_filler_item_name() function, which in world.py, + # we'll bind to world.get_filler_item_name(). So, we can just use world.create_filler() for all of our filler. + + # Anyway. With our world's itempool finalized, we now need to submit it to the multiworld itempool. + # This is how the generator actually knows about the existence of our items. + world.multiworld.itempool += itempool + + # Sometimes, you might want the player to start with certain items already in their inventory. + # These items are called "precollected items". + # They will be sent as soon as they connect for the first time (depending on your client's item handling flag). + # Players can add precollected items themselves via the generic "start_inventory" option. + # If you want to add your own precollected items, you can do so via world.push_precollected(). + if world.options.start_with_one_confetti_cannon: + # We're adding a filler item, but you can also add progression items to the player's precollected inventory. + starting_confetti_cannon = world.create_item("Confetti Cannon") + world.push_precollected(starting_confetti_cannon) diff --git a/worlds/apquest/locations.py b/worlds/apquest/locations.py new file mode 100644 index 0000000000..553519fa6c --- /dev/null +++ b/worlds/apquest/locations.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from BaseClasses import ItemClassification, Location + +from . import items + +if TYPE_CHECKING: + from .world import APQuestWorld + +# Every location must have a unique integer ID associated with it. +# We will have a lookup from location name to ID here that, in world.py, we will import and bind to the world class. +# Even if a location doesn't exist on specific options, it must be present in this lookup. +LOCATION_NAME_TO_ID = { + "Top Left Room Chest": 1, + "Top Middle Chest": 2, + "Bottom Left Chest": 3, + "Bottom Left Extra Chest": 4, + "Bottom Right Room Left Chest": 5, + "Bottom Right Room Right Chest": 6, + # Location IDs don't need to be sequential, as long as they're unique and greater than 0. + "Right Room Enemy Drop": 10, +} + + +# Each Location instance must correctly report the "game" it belongs to. +# To make this simple, it is common practice to subclass the basic Location class and override the "game" field. +class APQuestLocation(Location): + game = "APQuest" + + +# Let's make one more helper method before we begin actually creating locations. +# Later on in the code, we'll want specific subsections of LOCATION_NAME_TO_ID. +# To reduce the chance of copy-paste errors writing something like {"Chest": LOCATION_NAME_TO_ID["Chest"]}, +# let's make a helper method that takes a list of location names and returns them as a dict with their IDs. +# Note: There is a minor typing quirk here. Some functions want location addresses to be an "int | None", +# so while our function here only ever returns dict[str, int], we annotate it as dict[str, int | None]. +def get_location_names_with_ids(location_names: list[str]) -> dict[str, int | None]: + return {location_name: LOCATION_NAME_TO_ID[location_name] for location_name in location_names} + + +def create_all_locations(world: APQuestWorld) -> None: + create_regular_locations(world) + create_events(world) + + +def create_regular_locations(world: APQuestWorld) -> None: + # Finally, we need to put the Locations ("checks") into their regions. + # Once again, before we do anything, we can grab our regions we created by using world.get_region() + overworld = world.get_region("Overworld") + top_left_room = world.get_region("Top Left Room") + bottom_right_room = world.get_region("Bottom Right Room") + right_room = world.get_region("Right Room") + + # One way to create locations is by just creating them directly via their constructor. + bottom_left_chest = APQuestLocation( + world.player, "Bottom Left Chest", world.location_name_to_id["Bottom Left Chest"], overworld + ) + + # You can then add them to the region. + overworld.locations.append(bottom_left_chest) + + # A simpler way to do this is by using the region.add_locations helper. + # For this, you need to have a dict of location names to their IDs (i.e. a subset of location_name_to_id) + # Aha! So that's why we made that "get_location_names_with_ids" helper method earlier. + # You also need to pass your overridden Location class. + bottom_right_room_locations = get_location_names_with_ids( + ["Bottom Right Room Left Chest", "Bottom Right Room Right Chest"] + ) + bottom_right_room.add_locations(bottom_right_room_locations, APQuestLocation) + + top_left_room_locations = get_location_names_with_ids(["Top Left Room Chest"]) + top_left_room.add_locations(top_left_room_locations, APQuestLocation) + + right_room_locations = get_location_names_with_ids(["Right Room Enemy Drop"]) + right_room.add_locations(right_room_locations, APQuestLocation) + + # Locations may be in different regions depending on the player's options. + # In our case, the hammer option puts the Top Middle Chest into its own room called Top Middle Room. + top_middle_room_locations = get_location_names_with_ids(["Top Middle Chest"]) + if world.options.hammer: + top_middle_room = world.get_region("Top Middle Room") + top_middle_room.add_locations(top_middle_room_locations, APQuestLocation) + else: + overworld.add_locations(top_middle_room_locations, APQuestLocation) + + # Locations may exist only if the player enables certain options. + # In our case, the extra_starting_chest option adds the Bottom Left Extra Chest location. + if world.options.extra_starting_chest: + # Once again, it is important to stress that even though the Bottom Left Extra Chest location doesn't always + # exist, it must still always be present in the world's location_name_to_id. + # Whether the location actually exists in the seed is purely determined by whether we create and add it here. + bottom_left_extra_chest = get_location_names_with_ids(["Bottom Left Extra Chest"]) + overworld.add_locations(bottom_left_extra_chest, APQuestLocation) + + +def create_events(world: APQuestWorld) -> None: + # Sometimes, the player may perform in-game actions that allow them to progress which are not related to Items. + # In our case, the player must press a button in the top left room to open the final boss door. + # AP has something for this purpose: "Event locations" and "Event items". + # An event location is no different than a regular location, except it has the address "None". + # It is treated during generation like any other location, but then it is discarded. + # This location cannot be "sent" and its item cannot be "received", but the item can be used in logic rules. + # Since we are creating more locations and adding them to regions, we need to grab those regions again first. + top_left_room = world.get_region("Top Left Room") + final_boss_room = world.get_region("Final Boss Room") + + # One way to create an event is simply to use one of the normal methods of creating a location. + button_in_top_left_room = APQuestLocation(world.player, "Top Left Room Button", None, top_left_room) + top_left_room.locations.append(button_in_top_left_room) + + # We then need to put an event item onto the location. + # An event item is an item whose code is "None" (same as the event location's address), + # and whose classification is "progression". Item creation will be discussed more in items.py. + # Note: Usually, items are created in world.create_items(), which for us happens in items.py. + # However, when the location of an item is known ahead of time (as is the case with an event location/item pair), + # it is common practice to create the item when creating the location. + # Since locations also have to be finalized after world.create_regions(), which runs before world.create_items(), + # we'll create both the event location and the event item in our locations.py code. + button_item = items.APQuestItem("Top Left Room Button Pressed", ItemClassification.progression, None, world.player) + button_in_top_left_room.place_locked_item(button_item) + + # A way simpler way to do create an event location/item pair is by using the region.create_event helper. + # Luckily, we have another event we want to create: The Victory event. + # We will use this event to track whether the player can win the game. + # The Victory event is a completely optional abstraction - This will be discussed more in set_rules(). + final_boss_room.add_event( + "Final Boss Defeated", "Victory", location_type=APQuestLocation, item_type=items.APQuestItem + ) + + # If you create all your regions and locations line-by-line like this, + # the length of your create_regions might get out of hand. + # Many worlds use more data-driven approaches using dataclasses or NamedTuples. + # However, it is worth understanding how the actual creation of regions and locations works, + # That way, we're not just mindlessly copy-pasting! :) diff --git a/worlds/apquest/options.py b/worlds/apquest/options.py new file mode 100644 index 0000000000..00fe9ed34e --- /dev/null +++ b/worlds/apquest/options.py @@ -0,0 +1,152 @@ +from dataclasses import dataclass + +from Options import Choice, OptionGroup, PerGameCommonOptions, Range, Toggle + +# In this file, we define the options the player can pick. +# The most common types of options are Toggle, Range and Choice. + +# Options will be in the game's template yaml. +# They will be represented by checkboxes, sliders etc. on the game's options page on the website. +# (Note: Options can also be made invisible from either of these places by overriding Option.visibility. +# APQuest doesn't have an example of this, but this can be used for secret / hidden / advanced options.) + +# For further reading on options, you can also read the Options API Document: +# https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/options%20api.md + + +# The first type of Option we'll discuss is the Toggle. +# A toggle is an option that can either be on or off. This will be represented by a checkbox on the website. +# The default for a toggle is "off". +# If you want a toggle to be on by default, you can use the "DefaultOnToggle" class instead of the "Toggle" class. +class HardMode(Toggle): + """ + In hard mode, the basic enemy and the final boss will have more health. + The Health Upgrades become progression, as they are now required to beat the final boss. + """ + + # The docstring of an option is used as the description on the website and in the template yaml. + + # You'll also want to set a display name, which will determine what the option is called on the website. + display_name = "Hard Mode" + + +class Hammer(Toggle): + """ + Adds another item to the itempool: The Hammer. + The top middle chest will now be locked behind a breakable wall, requiring the Hammer. + """ + + display_name = "Hammer" + + +class ExtraStartingChest(Toggle): + """ + Adds an extra chest in the bottom left, making room for an extra Confetti Cannon. + """ + + display_name = "Extra Starting Chest" + + +class TrapChance(Range): + """ + Percentage chance that any given Confetti Cannon will be replaced by a Math Trap. + """ + + display_name = "Trap Chance" + + range_start = 0 + range_end = 100 + default = 0 + + +class StartWithOneConfettiCannon(Toggle): + """ + Start with a confetti cannon already in your inventory. + Why? Because you deserve it. You get to celebrate yourself without doing any work first. + """ + + display_name = "Start With One Confetti Cannon" + + +# A Range is a numeric option with a min and max value. This will be represented by a slider on the website. +class ConfettiExplosiveness(Range): + """ + How much confetti each use of a confetti cannon will fire. + """ + + display_name = "Confetti Explosiveness" + + range_start = 0 + range_end = 10 + + # Range options must define an explicit default value. + default = 3 + + +# A Choice is an option with multiple discrete choices. This will be represented by a dropdown on the website. +class PlayerSprite(Choice): + """ + The sprite that the player will have. + """ + + display_name = "Player Sprite" + + option_human = 0 + option_duck = 1 + option_horse = 2 + option_cat = 3 + + # Choice options must define an explicit default value. + default = option_human + + # For choices, you can also define aliases. + # For example, we could make it so "player_sprite: kitty" resolves to "player_sprite: cat" like this: + alias_kitty = option_cat + + +# We must now define a dataclass inheriting from PerGameCommonOptions that we put all our options in. +# This is in the format "option_name_in_snake_case: OptionClassName". +@dataclass +class APQuestOptions(PerGameCommonOptions): + hard_mode: HardMode + hammer: Hammer + extra_starting_chest: ExtraStartingChest + start_with_one_confetti_cannon: StartWithOneConfettiCannon + trap_chance: TrapChance + confetti_explosiveness: ConfettiExplosiveness + player_sprite: PlayerSprite + + +# If we want to group our options by similar type, we can do so as well. This looks nice on the website. +option_groups = [ + OptionGroup( + "Gameplay Options", + [HardMode, Hammer, ExtraStartingChest, StartWithOneConfettiCannon, TrapChance], + ), + OptionGroup( + "Aesthetic Options", + [ConfettiExplosiveness, PlayerSprite], + ), +] + +# Finally, we can define some option presets if we want the player to be able to quickly choose a specific "mode". +option_presets = { + "boring": { + "hard_mode": False, + "hammer": False, + "extra_starting_chest": False, + "start_with_one_confetti_cannon": False, + "trap_chance": 0, + "confetti_explosiveness": ConfettiExplosiveness.range_start, + "player_sprite": PlayerSprite.option_human, + }, + "the true way to play": { + "hard_mode": True, + "hammer": True, + "extra_starting_chest": True, + "start_with_one_confetti_cannon": True, + "trap_chance": 50, + "confetti_explosiveness": ConfettiExplosiveness.range_end, + "player_sprite": PlayerSprite.option_duck, + }, +} diff --git a/worlds/apquest/regions.py b/worlds/apquest/regions.py new file mode 100644 index 0000000000..b002f551ff --- /dev/null +++ b/worlds/apquest/regions.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from BaseClasses import Entrance, Region + +if TYPE_CHECKING: + from .world import APQuestWorld + +# A region is a container for locations ("checks"), which connects to other regions via "Entrance" objects. +# Many games will model their Regions after physical in-game places, but you can also have more abstract regions. +# For a location to be in logic, its containing region must be reachable. +# The Entrances connecting regions can have rules - more on that in rules.py. +# This makes regions especially useful for traversal logic ("Can the player reach this part of the map?") + +# Every location must be inside a region, and you must have at least one region. +# This is why we create regions first, and then later we create the locations (in locations.py). + + +def create_and_connect_regions(world: APQuestWorld) -> None: + create_all_regions(world) + connect_regions(world) + + +def create_all_regions(world: APQuestWorld) -> None: + # Creating a region is as simple as calling the constructor of the Region class. + overworld = Region("Overworld", world.player, world.multiworld) + top_left_room = Region("Top Left Room", world.player, world.multiworld) + bottom_right_room = Region("Bottom Right Room", world.player, world.multiworld) + right_room = Region("Right Room", world.player, world.multiworld) + final_boss_room = Region("Final Boss Room", world.player, world.multiworld) + + # Let's put all these regions in a list. + regions = [overworld, top_left_room, bottom_right_room, right_room, final_boss_room] + + # Some regions may only exist if the player enables certain options. + # In our case, the Hammer locks the top middle chest in its own room if the hammer option is enabled. + if world.options.hammer: + top_middle_room = Region("Top Middle Room", world.player, world.multiworld) + regions.append(top_middle_room) + + # We now need to add these regions to multiworld.regions so that AP knows about their existence. + world.multiworld.regions += regions + + +def connect_regions(world: APQuestWorld) -> None: + # We have regions now, but still need to connect them to each other. + # But wait, we no longer have access to the region variables we created in create_all_regions()! + # Luckily, once you've submitted your regions to multiworld.regions, + # you can get them at any time using world.get_region(...). + overworld = world.get_region("Overworld") + top_left_room = world.get_region("Top Left Room") + bottom_right_room = world.get_region("Bottom Right Room") + right_room = world.get_region("Right Room") + final_boss_room = world.get_region("Final Boss Room") + + # Okay, now we can get connecting. For this, we need to create Entrances. + # Entrances are inherently one-way, but crucially, AP assumes you can always return to the origin region. + # One way to create an Entrance is by calling the Entrance constructor. + overworld_to_bottom_right_room = Entrance(world.player, "Overworld to Bottom Right Room", parent=overworld) + overworld.exits.append(overworld_to_bottom_right_room) + + # You can then connect the Entrance to the target region. + overworld_to_bottom_right_room.connect(bottom_right_room) + + # An even easier way is to use the region.connect helper. + overworld.connect(right_room, "Overworld to Right Room") + right_room.connect(final_boss_room, "Right Room to Final Boss Room") + + # The region.connect helper even allows adding a rule immediately. + # We'll talk more about rule creation in the set_all_rules() function in rules.py. + overworld.connect(top_left_room, "Overworld to Top Left Room", lambda state: state.has("Key", world.player)) + + # Some Entrances may only exist if the player enables certain options. + # In our case, the Hammer locks the top middle chest in its own room if the hammer option is enabled. + # In this case, we previously created an extra "Top Middle Room" region that we now need to connect to Overworld. + if world.options.hammer: + top_middle_room = world.get_region("Top Middle Room") + overworld.connect(top_middle_room, "Overworld to Top Middle Room") diff --git a/worlds/apquest/rules.py b/worlds/apquest/rules.py new file mode 100644 index 0000000000..533c33d5ea --- /dev/null +++ b/worlds/apquest/rules.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from BaseClasses import CollectionState +from worlds.generic.Rules import add_rule, set_rule + +if TYPE_CHECKING: + from .world import APQuestWorld + + +def set_all_rules(world: APQuestWorld) -> None: + # In order for AP to generate an item layout that is actually possible for the player to complete, + # we need to define rules for our Entrances and Locations. + # Note: Regions do not have rules, the Entrances connecting them do! + # We'll do entrances first, then locations, and then finally we set our victory condition. + + set_all_entrance_rules(world) + set_all_location_rules(world) + set_completion_condition(world) + + +def set_all_entrance_rules(world: APQuestWorld) -> None: + # First, we need to actually grab our entrances. Luckily, there is a helper method for this. + overworld_to_bottom_right_room = world.get_entrance("Overworld to Bottom Right Room") + overworld_to_top_left_room = world.get_entrance("Overworld to Top Left Room") + right_room_to_final_boss_room = world.get_entrance("Right Room to Final Boss Room") + + # An access rule is a function. We can define this function like any other function. + # This function must accept exactly one parameter: A "CollectionState". + # A CollectionState describes the current progress of the players in the multiworld, i.e. what items they have, + # which regions they've reached, etc. + # In an access rule, we can ask whether the player has a collected a certain item. + # We can do this via the state.has(...) function. + # This function takes an item name, a player number, and an optional count parameter (more on that below) + # Since a rule only takes a CollectionState parameter, but we also need the player number in the state.has call, + # our function needs to be locally defined so that it has access to the player number from the outer scope. + # In our case, we are inside a function that has access to the "world" parameter, so we can use world.player. + def can_destroy_bush(state: CollectionState) -> bool: + return state.has("Sword", world.player) + + # Now we can set our "can_destroy_bush" rule to our entrance which requires slashing a bush to clear the path. + # One way to set rules is via the set_rule() function, which works on both Entrances and Locations. + set_rule(overworld_to_bottom_right_room, can_destroy_bush) + + # Because the function has to be defined locally, most worlds prefer the lambda syntax. + set_rule(overworld_to_top_left_room, lambda state: state.has("Key", world.player)) + + # Conditions can depend on event items. + set_rule(right_room_to_final_boss_room, lambda state: state.has("Top Left Room Button Pressed", world.player)) + + # Some entrance rules may only apply if the player enabled certain options. + # In our case, if the hammer option is enabled, we need to add the Hammer requirement to the Entrance from + # Overworld to the Top Middle Room. + if world.options.hammer: + overworld_to_top_middle_room = world.get_entrance("Overworld to Top Middle Room") + set_rule(overworld_to_top_middle_room, lambda state: state.has("Hammer", world.player)) + + +def set_all_location_rules(world: APQuestWorld) -> None: + # Location rules work no differently from Entrance rules. + # Most of our locations are chests that can simply be opened by walking up to them. + # Thus, their logical requirements are covered by the Entrance rules of the Entrances that were required to + # reach the region that the chest sits in. + # However, our two enemies work differently. + # Entering the room with the enemy is not enough, you also need to have enough combat items to be able to defeat it. + # So, we need to set requirements on the Locations themselves. + # Since combat is a bit more complicated, we'll use this chance to cover some advanced access rule concepts. + + # Sometimes, you may want to have different rules depending on the player's chosen options. + # There is a wrong way to do this, and a right way to do this. Let's do the wrong way first. + right_room_enemy = world.get_location("Right Room Enemy Drop") + + # DON'T DO THIS!!!! + set_rule( + right_room_enemy, + lambda state: ( + state.has("Sword", world.player) + and (not world.options.hard_mode or state.has_any(("Shield", "Health Upgrade"), world.player)) + ), + ) + # DON'T DO THIS!!!! + + # Now, what's actually wrong with this? It works perfectly fine, right? + # If hard mode disabled, Sword is enough. If hard mode is enabled, we also need a Shield or a Health Upgrade. + # The access rule we just wrote does this correctly, so what's the problem? + # The problem is performance. + # Most of your world code doesn't need to be perfectly performant, since it just runs once per slot. + # However, access rules in particular are by far the hottest code path in Archipelago. + # An access rule will potentially be called thousands or even millions of times over the course of one generation. + # As a result, access rules are the one place where it's really worth putting in some effort to optimize. + # What's the performance problem here? + # Every time our access rule is called, it has to evaluate whether world.options.hard_mode is True or False. + # Wouldn't it be better if in easy mode, the access rule only checked for Sword to begin with? + # Wouldn't it also be better if in hard mode, it already knew it had to check Shield and Health Upgrade as well? + # Well, we can achieve this by doing the "if world.options.hard_mode" check outside the set_rule call, + # and instead having two *different* set_rule calls depending on which case we're in. + + if world.options.hard_mode: + # If you have multiple conditions, you can obviously chain them via "or" or "and". + # However, there are also the nice helper functions "state.has_any" and "state.has_all". + set_rule( + right_room_enemy, + lambda state: ( + state.has("Sword", world.player) and state.has_any(("Shield", "Health Upgrade"), world.player) + ), + ) + else: + set_rule(right_room_enemy, lambda state: state.has("Sword", world.player)) + + # Another way to chain multiple conditions is via the add_rule function. + # This makes the access rules a bit slower though, so it should only be used if your structure justifies it. + # In our case, it's pretty useful because hard mode and easy mode have different requirements. + final_boss = world.get_location("Final Boss Defeated") + + # For the "known" requirements, it's still better to chain them using a normal "and" condition. + add_rule(final_boss, lambda state: state.has_all(("Sword", "Shield"), world.player)) + + if world.options.hard_mode: + # You can check for multiple copies of an item by using the optional count parameter of state.has(). + add_rule(final_boss, lambda state: state.has("Health Upgrade", world.player, 2)) + + +def set_completion_condition(world: APQuestWorld) -> None: + # Finally, we need to set a completion condition for our world, defining what the player needs to win the game. + # You can just set a completion condition directly like any other condition, referencing items the player receives: + world.multiworld.completion_condition[world.player] = lambda state: state.has_all(("Sword", "Shield"), world.player) + + # In our case, we went for the Victory event design pattern (see create_events() in locations.py). + # So lets undo what we just did, and instead set the completion condition to: + world.multiworld.completion_condition[world.player] = lambda state: state.has("Victory", world.player) diff --git a/worlds/apquest/test/__init__.py b/worlds/apquest/test/__init__.py new file mode 100644 index 0000000000..b2e5d0d383 --- /dev/null +++ b/worlds/apquest/test/__init__.py @@ -0,0 +1,7 @@ +# The __init__.py file of the test directory should be empty. +# (Before you say it: Comments are fine, smart*ss ;D) + +# You'll want to start with reading bases.py. + +# If you want to read more about tests, there is also the "Tests" section of the World API document: +# https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/world%20api.md#tests diff --git a/worlds/apquest/test/bases.py b/worlds/apquest/test/bases.py new file mode 100644 index 0000000000..641a85e2d9 --- /dev/null +++ b/worlds/apquest/test/bases.py @@ -0,0 +1,26 @@ +from test.bases import WorldTestBase + +from ..world import APQuestWorld + +# Tests are a big topic. +# The testing API and the core code in general empower you to test all kinds of complicated custom behavior. +# However, for APQuest, we'll stick to some of the more basic tests. + + +# Most of your testing will probably be done using the generic WorldTestBase. +# WorldTestBase is a class that performs a set of generic tests on your world using a given set of options. +# It also enables you to write custom tests with a slew of generic helper functions. +# The first thing you'll want to do is subclass it. You'll want to override "game" And "world" like this. +class APQuestTestBase(WorldTestBase): + game = "APQuest" + world: APQuestWorld + + +# The actual tests you write should be in files whose names start with "test_". +# Ideally, you should group similar tests together in one file, where each file has some overarching significance. + +# The best order to read these tests in is: +# 1. test_easy_mode.py +# 2. test_hard_mode.py +# 3. test_extra_starting_chest.py +# 4. test_hammer.py diff --git a/worlds/apquest/test/test_easy_mode.py b/worlds/apquest/test/test_easy_mode.py new file mode 100644 index 0000000000..baf3055bbc --- /dev/null +++ b/worlds/apquest/test/test_easy_mode.py @@ -0,0 +1,106 @@ +from .bases import APQuestTestBase + + +# When writing a test, you'll first need to subclass unittest.TestCase. +# In our case, we'll subclass the APQuestTestBase we defined in bases.py. +class TestEasyModeLogic(APQuestTestBase): + # Our test base is a subclass of WorldTestBase. + # WorldTestBase takes a dict of options and sets up a multiworld for you with a single world of your game. + # The world will have the options you specified. + options = { + "hard_mode": False, + # Options you don't specify will use their default values. + # It is good practice to specify every option that has an impact on your test, even when it's the default value. + # As such, we'll spell out that hard_mode is meant to be False. + # All other options in APQuest are cosmetic, so we don't need to list them. + } + + # At this point, we could stop, and a few default tests would be run on our world. + # At the time of writing (2025-09-04), this includes the following tests: + # - If you have every item, every location can be reached + # - If you have no items, you can still reach something ("Sphere 1" is not empty) + # - The world successfully generates (Fill does not crash) + + # This is already useful, but we also want to do our own tests. + # A test is a function whose name starts with "test". + def test_easy_mode_access(self) -> None: + # Inside a test, we can manually collect items, check access rules, etc. + # For example, we could check that the two early chests are already accessible despite us having no items. + # For the sake of structure, let's have every test item in its own subtest. + with self.subTest("Test checks accessible with nothing"): + bottom_left_chest = self.world.get_location("Bottom Left Chest") + top_middle_chest = self.world.get_location("Top Middle Chest") + + # Since access rules have a "state" argument, we must pass our current CollectionState. + # Helpfully, since we're in a WorldTestBase, we can just use "self.multiworld.state". + self.assertTrue(bottom_left_chest.can_reach(self.multiworld.state)) + self.assertTrue(top_middle_chest.can_reach(self.multiworld.state)) + + # Next, let's test that the top left room location requires the key to unlock the door. + with self.subTest("Test key is required to get top left chest"): + top_left_room_chest = self.world.get_location("Top Left Room Chest") + + # Right now, this location should *not* be accessible, as we don't have the key yet. + self.assertFalse(top_left_room_chest.can_reach(self.multiworld.state)) + + # Now, let's collect the Key. + # For this, there is a handy helper function to collect items from the itempool. + # Keep in mind that while test functions are sectioned off from one another, subtests are not. + # Collecting this here means that the state will have the Key for all future subtests in this function. + self.collect_by_name("Key") + + # The top left room chest should now be accessible. + self.assertTrue(top_left_room_chest.can_reach(self.multiworld.state)) + + # Next, let's test that you need the sword to access locations that require it (bush room and enemies). + with self.subTest("Test sword is required for enemy and bush locations"): + # Manually checking the dependency in the previous function was a bit of a hassle, wasn't it? + # Now we are checking four locations. It would be even longer as a result. + # Well, there is another option. It's the assertAccessDependency function of WorldTestBase. + self.assertAccessDependency( + [ + "Bottom Right Room Right Chest", + "Bottom Right Room Left Chest", + "Right Room Enemy Drop", + "Final Boss Defeated", # Reminder: APQuest's victory condition uses this event location + ], + [["Sword"]], + ) + + # The assertAccessDependency function is a bit complicated, so let's discuss what it does. + # By default, the locations argument must contain *every* location that *hard-depends* on the items. + # So, in our case: If every item except Sword is collected, *exactly* these four locations are unreachable. + + # The possible_items argument is initially more intuitive, but has some complexity as well. + # In our case, we only care about one item. But sometimes, we care about multiple items at once. + # This is why we pass a list of lists. We'll discuss this more when we test hard mode logic. + + # Let's do one more test: That the key is required for the Button. + with self.subTest("Test that the Key is required to activate the Button"): + # The Button is not the only thing that depends on the Key. + # As explained above, the locations list must be exhaustive. + # Thus, we would have to add the "Top Left Room Chest" as well. + # However, we can set "only_check_listed" if we only want the Top Left Room Button location to be checked. + self.assertAccessDependency( + ["Top Left Room Button"], + [["Key"]], + only_check_listed=True, + ) + + def test_easy_mode_health_upgrades(self) -> None: + # For our second test, let's make sure that we have two Health Upgrades with the correct classification. + + # We can find the Health Upgrades in the itempool like this: + health_upgrades = self.get_items_by_name("Health Upgrade") + + # First, let's verify there's two of them. + with self.subTest("Test that there are two Health Upgrades in the pool"): + self.assertEqual(len(health_upgrades), 2) + + # Then, let's verify that they have the useful classification and NOT the progression classification. + with self.subTest("Test that the Health Upgrades in the pool are useful, but not progression."): + # To check whether an item has a certain classification, you can use the following helper properties: + # item.filler, item.trap, item.useful and... item.advancement. No, not item.progression... + # (Just go with it, AP is old and has had many name changes over the years :D) + self.assertTrue(all(health_upgrade.useful for health_upgrade in health_upgrades)) + self.assertFalse(any(health_upgrade.advancement for health_upgrade in health_upgrades)) diff --git a/worlds/apquest/test/test_extra_starting_chest.py b/worlds/apquest/test/test_extra_starting_chest.py new file mode 100644 index 0000000000..22c01620b0 --- /dev/null +++ b/worlds/apquest/test/test_extra_starting_chest.py @@ -0,0 +1,39 @@ +from .bases import APQuestTestBase + + +# Sometimes, you might want to test something with a specific option disabled, then with it enabled. +# For this purpose, we'll just have two different TestCase classes. +class TestExtraStartingChestOff(APQuestTestBase): + options = { + "extra_starting_chest": False, + } + + # Hmm... This is just default options again. + # This would run all the default WorldTestBase tests a second time on default options. That's a bit wasteful. + # Luckily, there is a way to turn off the default tests for a WorldTestBase subclass: + run_default_tests = False + + # Since the extra_starting_chest option is False, we'll verify that the Extra Starting Chest location doesn't exist. + def test_extra_starting_chest_doesnt_exit(self) -> None: + # Currently, the best way to check for the existence of a location is to try using get_location, + # then watch for the KeyError that is raised if the location doesn't exist. + # In a testing context, we can do this with TestCase.assertRaises. + self.assertRaises(KeyError, self.world.get_location, "Bottom Left Extra Chest") + + +class TestExtraStartingChestOn(APQuestTestBase): + options = { + "extra_starting_chest": True, + } + + # In this case, running the default tests is acceptable, since this is a unique options combination. + + # Since the extra_starting_chest option is True, we'll verify that the Extra Starting Chest location exists. + def test_extra_starting_chest_exists(self) -> None: + # In this case, the location *should* exist, so world.get_location() should *not* KeyError. + # This is a bit awkward, because unittest.TestCase doesn't have an "assertNotRaises". + # So, we'll catch the KeyError ourselves, and then fail in the catch block with a custom message. + try: + self.world.get_location("Bottom Left Extra Chest") + except KeyError: + self.fail("Bottom Left Extra Chest should exist, but it doesn't.") diff --git a/worlds/apquest/test/test_hammer.py b/worlds/apquest/test/test_hammer.py new file mode 100644 index 0000000000..4bf55523b6 --- /dev/null +++ b/worlds/apquest/test/test_hammer.py @@ -0,0 +1,64 @@ +from .bases import APQuestTestBase + + +class TestHammerOff(APQuestTestBase): + options = { + "hammer": False, + } + + # Once again, this is just default settings, so running the default tests would be wasteful. + run_default_tests = False + + # The hammer option adds the Hammer item to the itempool. + # Since the hammer option is off in this TestCase, we have to verify that the Hammer is *not* in the itempool. + def test_hammer_doesnt_exist(self) -> None: + # An easy way to verify that an item is or is not in the itempool is by using WorldTestBase.get_items_by_name(). + # This will return a list of all matching items, which we can check for its length. + hammers_in_itempool = self.get_items_by_name("Hammer") + self.assertEqual(len(hammers_in_itempool), 0) + + # If the hammer option is not enabled, the Top Middle Chest should just be accessible with nothing. + def test_hammer_is_not_required_for_top_middle_chest(self) -> None: + # To check whether an item is required for a location, we would use self.assertAccessDependency. + # However, in this case, we want to check that the Hammer *isn't* required for the Top Middle Chest location. + # The robust way to do this is to collect every item into the state except for the Hammer, + # then assert that the location is reachable. + # Luckily, there is a helper for this: "collect_all_but". + self.collect_all_but("Hammer") + + # Now, we manually check that the location is accessible using location.can_reach(state): + top_middle_chest_player_one = self.world.get_location("Top Middle Chest") + self.assertTrue(top_middle_chest_player_one.can_reach(self.multiworld.state)) + + +class TestHammerOn(APQuestTestBase): + options = { + "hammer": True, + } + + # When the hammer option is on, the Hammer should exist in the itempool. Let's verify that. + def test_hammer_exists(self) -> None: + # Nothing new to say here, but I do want to take this opportunity to teach you some Python magic. :D + # In Python, when you check for the truth value of something that isn't a bool, + # it will be implicitly converted to a bool automatically. + # Which instances of a class convert to "False" and which convert to "True" is class-specific. + # In the case of lists (or containers in general), empty means False, and not-empty means True. + # bool([]) -> False + # bool([1, 2, 3]) -> True + # So, after grabbing all instances of the Hammer item from the itempool as a list ... + hammers_in_itempool = self.get_items_by_name("Hammer") + + # ... instead of checking that the len() is 1, we can run this absolutely beautiful statement instead: + self.assertTrue(hammers_in_itempool) + + # I love Python <3 + + # When the hammer option is on, the Hammer is required for the Top Middle Chest. + def test_hammer_is_required_for_top_middle_chest(self) -> None: + # This case is simple again: Just run self.assertAccessDependency() + self.assertAccessDependency(["Top Middle Chest"], [["Hammer"]]) + + # This unit test genuinely found an error in the world code when it was first written! + # The Hammer logic was not actually being correctly applied even if the hammer option was enabled, + # and the generator thought Top Middle Chest was considered accessible without the Hammer. + # This is why testing can be extremely valuable. diff --git a/worlds/apquest/test/test_hard_mode.py b/worlds/apquest/test/test_hard_mode.py new file mode 100644 index 0000000000..fc2f7acdf2 --- /dev/null +++ b/worlds/apquest/test/test_hard_mode.py @@ -0,0 +1,117 @@ +from .bases import APQuestTestBase + + +class TestHardMode(APQuestTestBase): + options = { + "hard_mode": True, + } + + def test_hard_mode_access(self) -> None: + # For the sake of brevity, we won't repeat anything we tested in easy mode. + # Instead, let's use this opportunity to talk a bit more about assertAccessDependency. + + # Let's take the Enemy Drop location. + # In hard mode, the Enemy has two health. One swipe of the Sword does not kill it. + # This means that the Enemy has a chance to attack you back. + # If you only have the Sword, this attack kills you. After respawning, the Enemy has full health again. + # However, if you have a Shield, you can block the attack (resulting in half damage). + # Alternatively, if you have found a Health Upgrade, you can tank an extra hit. + + # Why is this important? + # If we called assertAccessDependency with ["Right Room Enemy Drop"] and [["Shield"]], it would actually *fail*. + # This is because "Right Room Enemy Drop" is beatable without "Shield" - You can use "Health Upgrade" instead. + # However, we can call assertAccessDependency with *both* items like this: + + with self.subTest("Test that you need either Shield or Health Upgrade to beat the Right Room Enemy"): + self.assertAccessDependency( + ["Right Room Enemy Drop"], + [["Shield"], ["Health Upgrade"]], + only_check_listed=True, + ) + + # This tests that: + # 1. No Shield & No Health Upgrades -> Right Room Enemy Drop is not reachable. + # 2. Shield & No Health Upgrades -> Right Room Enemy Drop is reachable. + # 3. No Shield & All Health Upgrades -> Right Room Enemy Drop is reachable. + + # Note: Every other item that isn't the Shield nor a Health Upgrade is collected into state. + # This even includes pre-placed items, which notably includes any event location/item pairs you created. + # In our case, it means we don't have to mention the Sword. By omitting it, it's assumed that we have it. + + # This explains why the possible_items parameter is a list, but not why it's a list of lists. + # Let's look at the Final Boss Location. This location requires Sword, Shield, and both Health Upgrades. + # We could implement it like this: + with self.subTest("Test that the final boss isn't beatable without Sword, Shield, and both Health Upgrades"): + self.assertAccessDependency( + ["Final Boss Defeated"], + [["Sword", "Shield", "Health Upgrade"]], + only_check_listed=True, + ) + + # This would now test the following: + # 1. Without Sword, nor Shield, nor any Health Upgrades, the final boss is not beatable. + # 2. With Sword, Shield, and all Health Upgrades, the final boss is beatable. + + # But, it's not really advisable to do this. + # Think about it: If we implemented our logic incorrectly and forgot to add the Shield requirement, + # this call would still pass. We'd rather make sure that each item individually is required: + for item in ["Sword", "Shield", "Health Upgrade"]: + with self.subTest(f"Test that the final boss requires {item}"): + self.assertAccessDependency( + ["Final Boss Defeated"], + [[item]], + only_check_listed=True, + ) + + # This now tests that: + # 1. Without Sword, you can't beat the Final Boss + # 2. With Sword, you can beat the Final Boss (if you have everything else) + # 3. Without Shield, you can't beat the Final Boss + # 4. With Shield, you can beat the Final Boss (if you have everything else) + # 5. Without Health Upgrades, you can't beat the Final Boss + # 6. With all Health Upgrades, you can beat the Final Boss (if you have everything else) + + # 2., 4., and 6. are the exact same check, so it is a bit redundant. + # But crucially, we are ensuring that all three items are actually required. + + # So that's not really why the inner elements are lists. + # So we ask again: Why are they lists? When is it ever useful? + # Fair warning: This is probably where you should stop reading this and skip to test_hard_mode_health_upgrades. + # But if you really want to know why: + + # Having multiple elements in the inner lists is something that only comes up in more complex scenarios. + # APQuest doesn't have any of these scenarios, but let's imagine one for completeness' sake. + # Currently, the Enemy can be beaten with these item combinations: + # 1. Sword and Shield + # 2. Sword and Health Upgrade + # Let's say there was also a "Shield Bash". When using the Shield Bash, you cannot use the Shield to defend. + # This would mean there is a third valid combination: + # 3. Shield + Health Upgrade + # We have set up a scenario where none of the three items are enough on their own, + # but any combination of two items works. + # The best way to test this would be to call assertAccessDependency with: + # [["Sword", "Shield"], ["Sword", "Health Upgrade"], ["Shield", "Health Upgrade"]] + # If we omitted any item from any of the three sub-lists, the check would fail. + # This is because the item is still *mentioned* in one of the other two conditions, + # meaning it is not collected into state. + # Thus, this term cannot be simplified any further without testing something different to what we want to test. + + # You can kinda think of assertAccessDependency as an OR(AND(item_list_1), AND(item_list_2), ...). + # Except this "AND" is a special "AND" which allows reducing each list to a single representative item. + # And also, the "OR" is special as well in that has to be exhaustive, + # where the set of completely unmentioned items must *not* be able to reach the location collectively. + # And *also*, each "AND" must be enough to access the location *out of the mentioned items*. + # ... I'm not sure this explanation helps anyone, but most of the time, you really don't have to think about it. + + def test_hard_mode_health_upgrades(self) -> None: + # We'll also repeat our Health Upgrade test from the Easy Mode test case, but modified for Hard Mode. + # This will not be explained again here. + + health_upgrades = self.get_items_by_name("Health Upgrade") + + with self.subTest("Test that there are two Health Upgrades in the pool"): + self.assertEqual(len(health_upgrades), 2) + + with self.subTest("Test that the Health Upgrades in the pool are progression, but not useful."): + self.assertFalse(any(health_upgrade.useful for health_upgrade in health_upgrades)) + self.assertTrue(all(health_upgrade.advancement for health_upgrade in health_upgrades)) diff --git a/worlds/apquest/web_world.py b/worlds/apquest/web_world.py new file mode 100644 index 0000000000..20a9a9ff18 --- /dev/null +++ b/worlds/apquest/web_world.py @@ -0,0 +1,49 @@ +from BaseClasses import Tutorial +from worlds.AutoWorld import WebWorld + +from .options import option_groups, option_presets + + +# For our game to display correctly on the website, we need to define a WebWorld subclass. +class APQuestWebWorld(WebWorld): + # We need to override the "game" field of the WebWorld superclass. + # This must be the same string as the regular World class. + game = "APQuest" + + # Your game pages will have a visual theme (affecting e.g. the background image). + # You can choose between dirt, grass, grassFlowers, ice, jungle, ocean, partyTime, and stone. + theme = "grassFlowers" + + # A WebWorld can have any number of tutorials, but should always have at least an English setup guide. + # Many WebWorlds just have one setup guide, but some have multiple, e.g. for different languages. + # We need to create a Tutorial object for every setup guide. + # In order, we need to provide a title, a description, a language, a filepath, a link, and authors. + # The filepath is relative to a "/docs/" directory in the root folder of your apworld. + # The "link" parameter is unused, but we still need to provide it. + setup_en = Tutorial( + "Multiworld Setup Guide", + "A guide to setting up APQuest for MultiWorld.", + "English", + "setup_en.md", + "setup/en", + ["NewSoupVi"], + ) + # Let's have our setup guide in German as well. + # Do not translate the title and description! + # WebHost needs them to be the same to identify that it is the same tutorial. + # This lets it display the tutorials more compactly. + setup_de = Tutorial( + "Multiworld Setup Guide", + "A guide to setting up APQuest for MultiWorld.", + "German", + "setup_de.md", + "setup/de", + ["NewSoupVi"], + ) + + # We add these tutorials to our WebWorld by overriding the "tutorials" field. + tutorials = [setup_en, setup_de] + + # If we have option groups and/or option presets, we need to specify these here as well. + option_groups = option_groups + options_presets = option_presets diff --git a/worlds/apquest/world.py b/worlds/apquest/world.py new file mode 100644 index 0000000000..b38cb0913a --- /dev/null +++ b/worlds/apquest/world.py @@ -0,0 +1,84 @@ +from collections.abc import Mapping +from typing import Any + +# Imports of base Archipelago modules must be absolute. +from worlds.AutoWorld import World + +# Imports of your world's files must be relative. +from . import items, locations, options, regions, rules, web_world + +# APQuest will go through all the parts of the world api one step at a time, +# with many examples and comments across multiple files. +# If you'd rather read one continuous document, or just like reading multiple sources, +# we also have this document specifying the entire world api: +# https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/world%20api.md + + +# The world class is the heart and soul of an apworld implementation. +# It holds all the data and functions required to build the world and submit it to the multiworld generator. +# You could have all your world code in just this one class, but for readability and better structure, +# it is common to split up world functionality into multiple files. +# This implementation in particular has the following additional files, each covering one topic: +# regions.py, locations.py, rules.py, items.py, options.py and web_world.py. +# It is recommended that you read these in that specific order, then come back to the world class. +class APQuestWorld(World): + """ + APQuest is a minimal 8bit-era inspired adventure game with grid-like movement. + Good games don't need more than six checks. + """ + + # The docstring should contain a description of the game, to be displayed on the WebHost. + + # You must override the "game" field to say the name of the game. + game = "APQuest" + + # The WebWorld is a definition class that governs how this world will be displayed on the website. + web = web_world.APQuestWebWorld() + + # This is how we associate the options defined in our options.py with our world. + options_dataclass = options.APQuestOptions + options: options.APQuestOptions # Common mistake: This has to be a colon (:), not an equals sign (=). + + # Our world class must have a static location_name_to_id and item_name_to_id defined. + # We define these in regions.py and items.py respectively, so we just set them here. + location_name_to_id = locations.LOCATION_NAME_TO_ID + item_name_to_id = items.ITEM_NAME_TO_ID + + # There is always one region that the generator starts from & assumes you can always go back to. + # This defaults to "Menu", but you can change it by overriding origin_region_name. + origin_region_name = "Overworld" + + # Our world class must have certain functions ("steps") that get called during generation. + # The main ones are: create_regions, set_rules, create_items. + # For better structure and readability, we put each of these in their own file. + def create_regions(self) -> None: + regions.create_and_connect_regions(self) + locations.create_all_locations(self) + + def set_rules(self) -> None: + rules.set_all_rules(self) + + def create_items(self) -> None: + items.create_all_items(self) + + # Our world class must also have a create_item function that can create any one of our items by name at any time. + # We also put this in a different file, the same one that create_items is in. + def create_item(self, name: str) -> items.APQuestItem: + return items.create_item_with_correct_classification(self, name) + + # For features such as item links and panic-method start inventory, AP may ask your world to create extra filler. + # The way it does this is by calling get_filler_item_name. + # For this purpose, your world *must* have at least one infinitely repeatable item (usually filler). + # You must override this function and return this infinitely repeatable item's name. + # In our case, we defined a function called get_random_filler_item_name for this purpose in our items.py. + def get_filler_item_name(self) -> str: + return items.get_random_filler_item_name(self) + + # There may be data that the game client will need to modify the behavior of the game. + # This is what slot_data exists for. Upon every client connection, the slot's slot_data is sent to the client. + # slot_data is just a dictionary using basic types, that will be converted to json when sent to the client. + def fill_slot_data(self) -> Mapping[str, Any]: + # If you need access to the player's chosen options on the client side, there is a helper for that. + return self.options.as_dict( + "hard_mode", "hammer", "extra_starting_chest", "confetti_explosiveness", "player_sprite" + )