APQuest: Implement New Game (#5393)

* APQuest

* Add confetti cannon

* ID change on enemy drop

* nevermind

* Write the apworld

* Actually implement hard mode

* split everything into multiple files

* Push out webworld into a file

* Comment

* Enemy health graphics

* more ruff rules

* graphics :)

* heal player when receiving health upgrade

* the dumbest client of all time

* Fix typo

* You can kinda play it now! Now we just need to render the game... :)))

* fix kvui imports again

* It's playable. Kind of

* oops

* Sounds and stuff

* exceptions for audio

* player sprite stuff

* Not attack without sword

* Make sure it plays correctly

* Collect behavior

* ruff

* don't need to clear checked_locations, but do need to still clear finished_game

* Connect calls disconnect, so this is not necessary

* more seemless reconnection

* Ok now I think it's correct

* Bgm

* Bgm

* minor adjustment

* More refactoring of graphics and sound

* add graphics

* Item column

* Fix enemies not regaining their health

* oops

* oops

* oops

* 6 health final boss on hard mode

* boss_6.png

* Display APQuest items correctly

* auto switch tabs

* some mypy stuff

* Intro song

* Confetti Cannon

* a bit more confetti work

* launcher component

* Graphics change

* graphics and cleanup

* fix apworld

* comment out horse and cat for now

* add docs

* copypasta

* ruff made my comment look unhinged

* Move that comment

* Fix typing and don't import kvui in nogui

* lmao that already exists I don't need to do it myself

* Must've just copied this from somewhere

* order change

* Add unit tests

* Notes about the client

* oops

* another intro song case

* Write WebWorld and setup guides

* Yes description provided

* thing

* how to play

* Music and Volume

* Add cat and horse player sprites

* updates

* Add hammer and breakable wall

* TODO

* replace wav with ogg

* Codeowners and readme

* finish unit tests

* lint

* Todid

* Update worlds/apquest/client/ap_quest_client.py

Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>

* Update worlds/apquest/client/custom_views.py

Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>

* Filler pattern

* __future__ annotations

* twebhost

* Allow wasd and arrow keys

* correct wording

* oops

* just say the website

* append instead of +=

* qwint is onto my favoritism

* kitty alias

* Add a comment about preplaced items for assertAccessDependency

* Use classvar_matrix instead of MultiworldTestBase

* actually remove multiworld stuff from those tests

* missed one more

* Refactor a bit more

* Fix getting of the user path

* Actually explain components

* Meh

* Be a bit clearer about what's what

* oops

* More comments in the regions.py file

* Nevermind

* clarify regions further

* I use too many brackets

* Ok I'm done fr

* simplify wording

* missing .

* Add precollected example

* add note about precollected advancements

* missing s

* APQuest sound rework

* Volume slider

* I forgot I made this

* a

* fix volume of jingles

* Add math trap to game (only works in play_in_console mode so far)

* Math trap in apworld and client side

* Fix background during math trap

* fix leading 0

* Sound and further ui improvements for Math Trap

* fix music bug

* rename apquest subfolder to game

* Move comment to where it belongs

* Clear up language around components (hopefully)

* Clear up what CommonClient is

* Reword some more

* Mention Archipelago (the program) explicitly

* Update worlds/apquest/docs/en_APQuest.md

Co-authored-by: Ixrec <ericrhitchcock@gmail.com>

* Explain a bit more why you would use classvar matrix

* reword the assert raises stuff

* the volume slider thing is no longer true

* german game page

* Be more clear about why we're overriding Item and Location

* default item classification

* logically considered -> relevant to logic ()

* Update worlds/apquest/items.py

Co-authored-by: Ixrec <ericrhitchcock@gmail.com>

* a word on the ambiguity of the word 'filler'

* more rewording

* amount -> number

* stress the necessity of appending to the multiworld itempool

* Update worlds/apquest/locations.py

Co-authored-by: Ixrec <ericrhitchcock@gmail.com>

* get_location_names_with_ids

* slight rewording of the new helper method

* add some words about creating known location+item pairs

* Add some more words to worlds/apqeust/options.py

* more words in options.py

* 120 chars (thanks Ixrec >:((( LOL)

* Less confusing wording about rules, hopefully?

* victory -> completion

* remove the immediate creation of the hammer rule on the option region entrance

* access rule performance

* Make all imports module-level in world.py

* formatting

* get rid of noqa RUF012 (and also disable the rule in my local ruff.toml

* move comment for docstring closer to docstring in another place

* advancement????

* Missing function type annotations

* pass mypy again (I don't love this one but all the alternatives are equally bad)

* subclass instead of override

* I forgor to remove these

* Get rid of classvar_matrix and instead talk about some other stuff

* protect people a bit from the assertAccessDependency nonsense

* reword a bit more

* word

* More accessdependency text

* More accessdependency text

* More accessdependency text

* More accessdependency text

* oops

* this is supposed to be absolute

* Add some links to docs

* that's called game now

* Add an archipelago.json and explain what it means

* new line who dis

* reorganize a bit

* ignore instead of skip

* Update archipelago.json

* She new on my line till I

* Update archipelago.json

* add controls tab

* new ruff rule? idk

* WHOOPS

* Pack graphics into fewer files

* annoying ruff format thing

* Cleanup + mypy

* relative import

* Update worlds/apquest/client/custom_views.py

Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>

* Update generate_math_problem.py

* Update worlds/apquest/game/player.py

Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>

---------

Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>
Co-authored-by: Ixrec <ericrhitchcock@gmail.com>
Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
This commit is contained in:
NewSoupVi
2025-11-25 00:38:06 +01:00
committed by GitHub
parent 447f8fba20
commit e0cbf77dae
70 changed files with 4123 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
{
"game": "APQuest",
"minimum_ap_version": "0.6.4",
"world_version": "1.0.0",
"authors": ["NewSoupVi"]
}

View File

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

View File

@@ -0,0 +1,56 @@
<ConfettiView>:
size_hint: None, None
pos_hint: {"center_x": 0.5, "center_y": 0.5}
spacing: 0
padding: 0
<APQuestGrid>:
cols: 12
rows: 11
spacing: 0
padding: 0
size_hint: None, None
pos_hint: {"center_x": 0.5, "center_y": 0.5}
<APQuestGameView>:
RelativeLayout:
id: game_container
<APQuestControlsView>:
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 :)"""
<VolumeSliderView>:
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))

View File

@@ -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:])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

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

203
worlds/apquest/game/game.py Normal file
View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 529 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 971 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 613 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

View File

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

View File

@@ -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,
},
)

View File

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

View File

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

View File

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

166
worlds/apquest/items.py Normal file
View File

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

136
worlds/apquest/locations.py Normal file
View File

@@ -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! :)

152
worlds/apquest/options.py Normal file
View File

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

79
worlds/apquest/regions.py Normal file
View File

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

131
worlds/apquest/rules.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

84
worlds/apquest/world.py Normal file
View File

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