diff --git a/README.md b/README.md index 50551c10..9e0020e7 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Currently, the following games are supported: * Minecraft * Subnautica * Slay the Spire +* Risk of Rain 2 For setup and instructions check out our [tutorials page](http://archipelago.gg:48484/tutorial). Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py index eb8b4724..38e8afd4 100644 --- a/WebHostLib/__init__.py +++ b/WebHostLib/__init__.py @@ -82,6 +82,7 @@ def page_not_found(err): return render_template('404.html'), 404 + # Player settings pages @app.route('/games//player-settings') def player_settings(game): diff --git a/WebHostLib/static/assets/tutorial/ror2/setup_en.md b/WebHostLib/static/assets/tutorial/ror2/setup_en.md new file mode 100644 index 00000000..a4e317ff --- /dev/null +++ b/WebHostLib/static/assets/tutorial/ror2/setup_en.md @@ -0,0 +1,66 @@ +# Risk of Rain 2 Setup Guide + +## Install using r2modman +### Install r2modman +Head on over to the r2modman page on Thunderstore and follow the installation instructions. + +https://thunderstore.io/package/ebkr/r2modman/ + +### Install Archipelago Mod using r2modman +You can install the Archipelago mod using r2modman in one of two ways. +One, you can use the Thunderstore website and click on the "Install with Mod Manager" link. + +https://thunderstore.io/package/ArchipelagoMW/Archipelago/ + +You can also search for the "Archipelago" mod in the r2modman interface. +The mod manager should automatically install all necessary dependencies as well. + +### Running the Modded Game +Click on the "Start modded" button in the top left in r2modman to start the game with the +Archipelago mod installed. + +## Joining an Archipelago Session +There will be a menu button on the right side of the screen in the character select menu. +Click it in order to bring up the in lobby mod config. +From here you can expand the Archipelago sections and fill in the relevant info. +Keep password blank if there is no password on the server. + +Simply check `Enable Archipelago?` and when you start the run it will automatically connect. + +## Gameplay +The Risk of Rain 2 players send checks by causing items to spawn in-game. That means opening chests or killing bosses, generally. +An item check is only sent out after a certain number of items are picked up. This count is configurable in the player's YAML. + +## YAML Settings +An example YAML would look like this: +```yaml +description: Ijwu-ror2 +name: Ijwu + +game: + Risk of Rain 2: 1 + +Risk of Rain 2: + total_locations: 15 + total_items: 30 + total_revivals: 4 + start_with_revive: true + item_pickup_step: 1 + enable_lunar: true +``` + +| Name | Description | Allowed values | +| ---- | ----------- | -------------- | +| total_locations | The total number of location checks that will be attributed to the Risk of Rain player. | 10 - 50 | +| total_items | The total number of items which are added to the multiworld on behalf of the Risk of Rain player. | 10-50 | +| total_revivals | The total number of items in the Risk of Rain player's item pool (items other players pick up for them) replaced with `Dio's Best Friend`. | 0 - 5 | +| start_with_revive | Starts the player off with a `Dio's Best Friend`. Functionally equivalent to putting a `Dio's Best Friend` in your `starting_inventory`. | true/false | +| item_pickup_step | The number of item pickups which you are allowed to claim before they become an Archipelago location check. | 0 - 5 | +| enable_lunar | Allows for lunar items to be shuffled into the item pool on behalf of the Risk of Rain player. | true/false | + +Using the example YAML above: the Risk of Rain 2 player will have 15 total items which they can pick up for other players. (total_locations = 15) +They will have 30 items which can be granted to them through the multiworld. (total_items = 30) +They will complete a location check every second item. (item_pickup_step = 1) +They will have 4 of the items which other players can grant them replaced with `Dio's Best Friend`. (total_revivals = 4) +The player will also start with a `Dio's Best Friend`. (start_with_revive = true) +The player will have lunar items shuffled into the item pool on their behalf. (enable_lunar = true) \ No newline at end of file diff --git a/WebHostLib/static/assets/tutorial/tutorials.json b/WebHostLib/static/assets/tutorial/tutorials.json index 2457e096..4931e4b2 100644 --- a/WebHostLib/static/assets/tutorial/tutorials.json +++ b/WebHostLib/static/assets/tutorial/tutorials.json @@ -139,5 +139,24 @@ ] } ] + }, + { + "gameTitle": "Risk of Rain 2", + "tutorials": [ + { + "name": "Multiworld Setup Guide", + "description": "A guide to setting up the Risk of Rain 2 integration for Archipelago multiworld games.", + "files": [ + { + "language": "English", + "filename": "ror2/setup_en.md", + "link": "ror2/setup/en", + "authors": [ + "Ijwu" + ] + } + ] + } + ] } ] diff --git a/worlds/ror2/Items.py b/worlds/ror2/Items.py new file mode 100644 index 00000000..35992e1f --- /dev/null +++ b/worlds/ror2/Items.py @@ -0,0 +1,42 @@ +from BaseClasses import Item +import typing + + +class RiskOfRainItem(Item): + game: str = "Risk of Rain 2" + +# 37000 - 38000 +item_table = { + "Dio's Best Friend": 37001, + "Common Item": 37002, + "Uncommon Item": 37003, + "Legendary Item": 37004, + "Boss Item": 37005, + "Lunar Item": 37006, + "Equipment": 37007, + "Item Scrap, White": 37008, + "Item Scrap, Green": 37009, + "Item Scrap, Red": 37010, + "Item Scrap, Yellow": 37011, + "Victory": None, + "Beat Level One": None, + "Beat Level Two": None, + "Beat Level Three": None, + "Beat Level Four": None, + "Beat Level Five": None, +} + +junk_weights = { + "Item Scrap, Green": 16, + "Item Scrap, Red": 4, + "Item Scrap, Yellow": 1, + "Item Scrap, White": 32, + "Common Item": 64, + "Uncommon Item": 32, + "Legendary Item": 8, + "Boss Item": 4, + "Lunar Item": 16, + "Equipment": 32, +} + +lookup_id_to_name: typing.Dict[int, str] = {id: name for name, id in item_table.items() if id} diff --git a/worlds/ror2/Locations.py b/worlds/ror2/Locations.py new file mode 100644 index 00000000..56585f5c --- /dev/null +++ b/worlds/ror2/Locations.py @@ -0,0 +1,23 @@ +from BaseClasses import Location +import typing + +class RiskOfRainLocation(Location): + game: str = "Risk of Rain 2" + +# 37000 - 38000 +base_location_table = { + "Victory": None, + "Level One": None, + "Level Two": None, + "Level Three": None, + "Level Four": None, + "Level Five": None +} + +item_pickups = { + f"ItemPickup{i}": 37005+i for i in range(1, 51) +} + +location_table = {**base_location_table, **item_pickups} + +lookup_id_to_name: typing.Dict[int, str] = {id: name for name, id in location_table.items()} diff --git a/worlds/ror2/Options.py b/worlds/ror2/Options.py new file mode 100644 index 00000000..a77d7c3f --- /dev/null +++ b/worlds/ror2/Options.py @@ -0,0 +1,55 @@ +import typing +from Options import Option, Toggle, Range + + +class TotalLocations(Range): + """Number of location checks which are added to the Risk of Rain playthrough.""" + displayname = "Total Locations" + range_start = 10 + range_end = 50 + default = 15 + +class TotalItems(Range): + """Number of items which are added to the multiworld on behalf of the Risk of Rain player.""" + displayname = "Total Items" + range_start = 10 + range_end = 50 + default = 30 + + +class TotalRevivals(Range): + """Number of `Dio's Best Friend` item put in the item pool.""" + displayname = "Total Revivals Available" + range_start = 0 + range_end = 10 + default = 4 + + +class ItemPickupStep(Range): + """Number of items to pick up before an AP Check is completed. + Setting to 1 means every other pickup. + Setting to 2 means every third pickup. So on...""" + displayname = "Item Pickup Step" + range_start = 0 + range_end = 5 + default = 1 + +class AllowLunarItems(Toggle): + """Allows Lunar items in the item pool.""" + displayname = "Enable Lunar Item Shuffling" + default = True + +class StartWithRevive(Toggle): + """Start the game with a `Dio's Best Friend` item.""" + displayname = "Start with a Revive" + default = True + + +ror2_options: typing.Dict[str, type(Option)] = { + "total_locations": TotalLocations, + "total_revivals": TotalRevivals, + "start_with_revive": StartWithRevive, + "item_pickup_step": ItemPickupStep, + "total_items": TotalItems, + "enable_lunar": AllowLunarItems +} diff --git a/worlds/ror2/Rules.py b/worlds/ror2/Rules.py new file mode 100644 index 00000000..c1992be5 --- /dev/null +++ b/worlds/ror2/Rules.py @@ -0,0 +1,32 @@ +from BaseClasses import MultiWorld +from ..AutoWorld import LogicMixin +from ..generic.Rules import set_rule + + +class RiskOfRainLogic(LogicMixin): + def _ror_has_items(self, player: int, amount: int) -> bool: + count: int = self.item_count("Common Item", player) + self.item_count("Uncommon Item", player) + \ + self.item_count("Legendary Item", player) + self.item_count("Boss Item", player) + return count >= amount + + +def set_rules(world: MultiWorld, player: int): + total_checks = world.total_locations[player] + # divide by 5 since 5 levels (then commencement) + items_per_level = total_checks / 5 + leftover = total_checks % 5 + + set_rule(world.get_location("Level One", player), + lambda state: state._ror_has_items(player, items_per_level + leftover)) + set_rule(world.get_location("Level Two", player), + lambda state: state._ror_has_items(player, items_per_level) and state.has("Beat Level One", player)) + set_rule(world.get_location("Level Three", player), + lambda state: state._ror_has_items(player, items_per_level) and state.has("Beat Level Two", player)) + set_rule(world.get_location("Level Four", player), + lambda state: state._ror_has_items(player, items_per_level) and state.has("Beat Level Three", player)) + set_rule(world.get_location("Level Five", player), + lambda state: state._ror_has_items(player, items_per_level) and state.has("Beat Level Four", player)) + set_rule(world.get_location("Victory", player), + lambda state: state._ror_has_items(player, items_per_level) and state.has("Beat Level Five", player)) + + world.completion_condition[player] = lambda state: state.has("Victory", player) \ No newline at end of file diff --git a/worlds/ror2/__init__.py b/worlds/ror2/__init__.py new file mode 100644 index 00000000..581ef5ed --- /dev/null +++ b/worlds/ror2/__init__.py @@ -0,0 +1,103 @@ +import string +from .Items import RiskOfRainItem, item_table, junk_weights +from .Locations import location_table, RiskOfRainLocation, base_location_table +from .Rules import set_rules + +from BaseClasses import Region, Entrance, Item, MultiWorld +from .Options import ror2_options +from ..AutoWorld import World + +client_version = 1 + + +class RiskOfRainWorld(World): + """ + Escape a chaotic alien planet by fighting through hordes of frenzied monsters – with your friends, or on your own. + Combine loot in surprising ways and master each character until you become the havoc you feared upon your + first crash landing. + """ + game: str = "Risk of Rain 2" + options = ror2_options + topology_present = False + + item_name_to_id = {name: data for name, data in item_table.items()} + location_name_to_id = {name: data for name, data in location_table.items()} + + data_version = 1 + forced_auto_forfeit = True + + def generate_basic(self): + # shortcut for starting_inventory... The start_with_revive option lets you start with a Dio's Best Friend + if self.world.start_with_revive[self.player].value: + self.world.push_precollected(self.world.create_item("Dio's Best Friend", self.player)) + + # Generate item pool + itempool = [] + junk_pool = junk_weights.copy() + + # Add revive items for the player + itempool += ["Dio's Best Friend"] * self.world.total_revivals[self.player] + + if not self.world.enable_lunar[self.player]: + junk_pool.pop("Lunar Item") + + # Fill remaining items with randomly generated junk + itempool += self.world.random.choices(list(junk_pool.keys()), weights=list(junk_pool.values()), + k=self.world.total_items[self.player] - + self.world.total_revivals[self.player]) + + # Convert itempool into real items + itempool = [item for item in map(lambda name: self.create_item(name), itempool)] + + self.world.itempool += itempool + + def set_rules(self): + set_rules(self.world, self.player) + + def create_regions(self): + create_regions(self.world, self.player) + + def fill_slot_data(self): + return { + "itemPickupStep": self.world.item_pickup_step[self.player].value, + "seed": "".join(self.world.slot_seeds[self.player].choice(string.digits) for i in range(16)), + "totalLocations": self.world.total_items[self.player].value, + "totalRevivals": self.world.total_revivals[self.player].value + } + + def create_item(self, name: str) -> Item: + item_id = item_table[name] + item = RiskOfRainItem(name, True, item_id, self.player) + return item + + +def create_regions(world, player: int): + world.regions += [ + create_region(world, player, 'Menu', None, ['Lobby']), + create_region(world, player, 'Petrichor V', + [location for location in base_location_table] + + [f"ItemPickup{i}" for i in range(1, world.total_locations[player])]) + ] + + world.get_entrance("Lobby", player).connect(world.get_region("Petrichor V", player)) + world.get_location("Level One", player).place_locked_item(RiskOfRainItem("Beat Level One", True, None, player)) + world.get_location("Level Two", player).place_locked_item(RiskOfRainItem("Beat Level Two", True, None, player)) + world.get_location("Level Three", player).place_locked_item(RiskOfRainItem("Beat Level Three", True, None, player)) + world.get_location("Level Four", player).place_locked_item(RiskOfRainItem("Beat Level Four", True, None, player)) + world.get_location("Level Five", player).place_locked_item(RiskOfRainItem("Beat Level Five", True, None, player)) + world.get_location("Victory", player).place_locked_item(RiskOfRainItem("Victory", True, None, player)) + + +def create_region(world: MultiWorld, player: int, name: str, locations=None, exits=None): + ret = Region(name, None, name, player) + ret.world = world + if locations: + for location in locations: + loc_id = location_table.get(location, 0) + location = RiskOfRainLocation(player, location, loc_id, ret) + ret.locations.append(location) + if exits: + for exit in exits: + ret.exits.append(Entrance(player, exit, ret)) + + return ret