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>
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
46
worlds/apquest/!READ_FIRST!.txt
Normal 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
|
||||
12
worlds/apquest/__init__.py
Normal 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
|
||||
6
worlds/apquest/archipelago.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"game": "APQuest",
|
||||
"minimum_ap_version": "0.6.4",
|
||||
"world_version": "1.0.0",
|
||||
"authors": ["NewSoupVi"]
|
||||
}
|
||||
5
worlds/apquest/client/__init__.py
Normal 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.
|
||||
56
worlds/apquest/client/ap_quest_client.kv
Normal 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))
|
||||
290
worlds/apquest/client/ap_quest_client.py
Normal 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:])
|
||||
256
worlds/apquest/client/custom_views.py
Normal 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
|
||||
200
worlds/apquest/client/game_manager.py
Normal 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
|
||||
181
worlds/apquest/client/graphics.py
Normal 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)
|
||||
25
worlds/apquest/client/item_quality.py
Normal 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
|
||||
27
worlds/apquest/client/launch.py
Normal 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()
|
||||
249
worlds/apquest/client/sounds.py
Normal 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
|
||||
25
worlds/apquest/client/utils.py
Normal 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
|
||||
33
worlds/apquest/components.py
Normal 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,
|
||||
)
|
||||
)
|
||||
78
worlds/apquest/docs/de_APQuest.md
Normal 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.
|
||||
69
worlds/apquest/docs/en_APQuest.md
Normal 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.
|
||||
43
worlds/apquest/docs/setup_de.md
Normal 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.
|
||||
42
worlds/apquest/docs/setup_en.md
Normal 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.
|
||||
0
worlds/apquest/game/__init__.py
Normal file
BIN
worlds/apquest/game/audio/8bit Filler.ogg
Normal file
BIN
worlds/apquest/game/audio/8bit ProgUseful.ogg
Normal file
BIN
worlds/apquest/game/audio/8bit Progression.ogg
Normal file
BIN
worlds/apquest/game/audio/8bit Trap.ogg
Normal file
BIN
worlds/apquest/game/audio/8bit Useful.ogg
Normal file
BIN
worlds/apquest/game/audio/8bit Victory.ogg
Normal file
BIN
worlds/apquest/game/audio/APQuest BGM.ogg
Normal file
BIN
worlds/apquest/game/audio/APQuest Confetti Cannon.ogg
Normal file
BIN
worlds/apquest/game/audio/APQuest Intro.ogg
Normal file
BIN
worlds/apquest/game/audio/APQuest Math BGM.ogg
Normal file
BIN
worlds/apquest/game/audio/APQuest Math Problem Solved Jingle.ogg
Normal file
312
worlds/apquest/game/entities.py
Normal 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()
|
||||
37
worlds/apquest/game/events.py
Normal 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
@@ -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)
|
||||
267
worlds/apquest/game/gameboard.py
Normal 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)
|
||||
63
worlds/apquest/game/generate_math_problem.py
Normal 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)
|
||||
99
worlds/apquest/game/graphics.py
Normal 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,
|
||||
}
|
||||
BIN
worlds/apquest/game/graphics/boss.png
Normal file
|
After Width: | Height: | Size: 580 B |
BIN
worlds/apquest/game/graphics/cat.png
Normal file
|
After Width: | Height: | Size: 529 B |
BIN
worlds/apquest/game/graphics/duck.png
Normal file
|
After Width: | Height: | Size: 473 B |
BIN
worlds/apquest/game/graphics/hearts.png
Normal file
|
After Width: | Height: | Size: 257 B |
BIN
worlds/apquest/game/graphics/horse.png
Normal file
|
After Width: | Height: | Size: 531 B |
BIN
worlds/apquest/game/graphics/human.png
Normal file
|
After Width: | Height: | Size: 354 B |
BIN
worlds/apquest/game/graphics/inanimates.png
Normal file
|
After Width: | Height: | Size: 971 B |
BIN
worlds/apquest/game/graphics/items.png
Normal file
|
After Width: | Height: | Size: 613 B |
BIN
worlds/apquest/game/graphics/items_text.png
Normal file
|
After Width: | Height: | Size: 329 B |
BIN
worlds/apquest/game/graphics/letters.png
Normal file
|
After Width: | Height: | Size: 358 B |
BIN
worlds/apquest/game/graphics/normal_enemy.png
Normal file
|
After Width: | Height: | Size: 286 B |
BIN
worlds/apquest/game/graphics/numbers.png
Normal file
|
After Width: | Height: | Size: 633 B |
BIN
worlds/apquest/game/graphics/symbols.png
Normal file
|
After Width: | Height: | Size: 318 B |
44
worlds/apquest/game/inputs.py
Normal 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,
|
||||
}
|
||||
38
worlds/apquest/game/items.py
Normal 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,
|
||||
},
|
||||
)
|
||||
25
worlds/apquest/game/locations.py
Normal 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,
|
||||
}
|
||||
143
worlds/apquest/game/play_in_console.py
Normal 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()
|
||||
88
worlds/apquest/game/player.py
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
7
worlds/apquest/test/__init__.py
Normal 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
|
||||
26
worlds/apquest/test/bases.py
Normal 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
|
||||
106
worlds/apquest/test/test_easy_mode.py
Normal 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))
|
||||
39
worlds/apquest/test/test_extra_starting_chest.py
Normal 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.")
|
||||
64
worlds/apquest/test/test_hammer.py
Normal 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.
|
||||
117
worlds/apquest/test/test_hard_mode.py
Normal 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))
|
||||
49
worlds/apquest/web_world.py
Normal 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
@@ -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"
|
||||
)
|
||||