Files
Archipelago/worlds/apquest/game/game.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

204 lines
7.3 KiB
Python

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)