Files
Archipelago/worlds/apquest/game/gameboard.py
NewSoupVi e0cbf77dae 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>
2025-11-25 00:38:06 +01:00

268 lines
8.8 KiB
Python

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)