Skip to content

Commit

Permalink
TUNIC: Implement New Game (ArchipelagoMW#2172)
Browse files Browse the repository at this point in the history
  • Loading branch information
silent-destroyer authored Jan 12, 2024
1 parent b241644 commit 3933fd3
Show file tree
Hide file tree
Showing 15 changed files with 3,991 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ Currently, the following games are supported:
* Heretic
* Landstalker: The Treasures of King Nole
* Final Fantasy Mystic Quest
* TUNIC

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
Expand Down
3 changes: 3 additions & 0 deletions docs/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@
# The Legend of Zelda (1)
/worlds/tloz/ @Rosalie-A @t3hf1gm3nt

# TUNIC
/worlds/tunic/ @silent-destroyer

# Undertale
/worlds/undertale/ @jonloveslegos

Expand Down
279 changes: 279 additions & 0 deletions worlds/tunic/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
from typing import Dict, List, Any

from BaseClasses import Region, Location, Item, Tutorial, ItemClassification
from .items import item_name_to_id, item_table, item_name_groups, fool_tiers, filler_items, slot_data_item_names
from .locations import location_table, location_name_groups, location_name_to_id, hexagon_locations
from .rules import set_location_rules, set_region_rules, randomize_ability_unlocks, gold_hexagon
from .er_rules import set_er_location_rules
from .regions import tunic_regions
from .er_scripts import create_er_regions
from .options import TunicOptions
from worlds.AutoWorld import WebWorld, World
from decimal import Decimal, ROUND_HALF_UP


class TunicWeb(WebWorld):
tutorials = [
Tutorial(
tutorial_name="Multiworld Setup Guide",
description="A guide to setting up the TUNIC Randomizer for Archipelago multiworld games.",
language="English",
file_name="setup_en.md",
link="setup/en",
authors=["SilentDestroyer"]
)
]
theme = "grassFlowers"
game = "Tunic"


class TunicItem(Item):
game: str = "Tunic"


class TunicLocation(Location):
game: str = "Tunic"


class TunicWorld(World):
"""
Explore a land filled with lost legends, ancient powers, and ferocious monsters in TUNIC, an isometric action game
about a small fox on a big adventure. Stranded on a mysterious beach, armed with only your own curiosity, you will
confront colossal beasts, collect strange and powerful items, and unravel long-lost secrets. Be brave, tiny fox!
"""
game = "Tunic"
web = TunicWeb()

data_version = 2
options: TunicOptions
options_dataclass = TunicOptions
item_name_groups = item_name_groups
location_name_groups = location_name_groups

item_name_to_id = item_name_to_id
location_name_to_id = location_name_to_id

ability_unlocks: Dict[str, int]
slot_data_items: List[TunicItem]
tunic_portal_pairs: Dict[str, str]
er_portal_hints: Dict[int, str]

def generate_early(self) -> None:
if self.options.start_with_sword and "Sword" not in self.options.start_inventory:
self.options.start_inventory.value["Sword"] = 1

def create_item(self, name: str) -> TunicItem:
item_data = item_table[name]
return TunicItem(name, item_data.classification, self.item_name_to_id[name], self.player)

def create_items(self) -> None:
keys_behind_bosses = self.options.keys_behind_bosses
hexagon_quest = self.options.hexagon_quest
sword_progression = self.options.sword_progression

tunic_items: List[TunicItem] = []
self.slot_data_items = []

items_to_create: Dict[str, int] = {item: data.quantity_in_item_pool for item, data in item_table.items()}

for money_fool in fool_tiers[self.options.fool_traps]:
items_to_create["Fool Trap"] += items_to_create[money_fool]
items_to_create[money_fool] = 0

if sword_progression:
items_to_create["Stick"] = 0
items_to_create["Sword"] = 0
else:
items_to_create["Sword Upgrade"] = 0

if self.options.laurels_location:
laurels = self.create_item("Hero's Laurels")
if self.options.laurels_location == "6_coins":
self.multiworld.get_location("Coins in the Well - 6 Coins", self.player).place_locked_item(laurels)
elif self.options.laurels_location == "10_coins":
self.multiworld.get_location("Coins in the Well - 10 Coins", self.player).place_locked_item(laurels)
elif self.options.laurels_location == "10_fairies":
self.multiworld.get_location("Secret Gathering Place - 10 Fairy Reward", self.player).place_locked_item(laurels)
self.slot_data_items.append(laurels)
items_to_create["Hero's Laurels"] = 0

if keys_behind_bosses:
for rgb_hexagon, location in hexagon_locations.items():
hex_item = self.create_item(gold_hexagon if hexagon_quest else rgb_hexagon)
self.multiworld.get_location(location, self.player).place_locked_item(hex_item)
self.slot_data_items.append(hex_item)
items_to_create[rgb_hexagon] = 0
items_to_create[gold_hexagon] -= 3

if hexagon_quest:
# Calculate number of hexagons in item pool
hexagon_goal = self.options.hexagon_goal
extra_hexagons = self.options.extra_hexagon_percentage
items_to_create[gold_hexagon] += int((Decimal(100 + extra_hexagons) / 100 * hexagon_goal).to_integral_value(rounding=ROUND_HALF_UP))

# Replace pages and normal hexagons with filler
for replaced_item in list(filter(lambda item: "Pages" in item or item in hexagon_locations, items_to_create)):
items_to_create[self.get_filler_item_name()] += items_to_create[replaced_item]
items_to_create[replaced_item] = 0

# Filler items that are still in the item pool to swap out
available_filler: List[str] = [filler for filler in items_to_create if items_to_create[filler] > 0 and
item_table[filler].classification == ItemClassification.filler]

# Remove filler to make room for extra hexagons
for i in range(0, items_to_create[gold_hexagon]):
fill = self.random.choice(available_filler)
items_to_create[fill] -= 1
if items_to_create[fill] == 0:
available_filler.remove(fill)

if self.options.maskless:
mask_item = TunicItem("Scavenger Mask", ItemClassification.useful, self.item_name_to_id["Scavenger Mask"], self.player)
tunic_items.append(mask_item)
items_to_create["Scavenger Mask"] = 0

if self.options.lanternless:
mask_item = TunicItem("Lantern", ItemClassification.useful, self.item_name_to_id["Lantern"], self.player)
tunic_items.append(mask_item)
items_to_create["Lantern"] = 0

for item, quantity in items_to_create.items():
for i in range(0, quantity):
tunic_item: TunicItem = self.create_item(item)
if item in slot_data_item_names:
self.slot_data_items.append(tunic_item)
tunic_items.append(tunic_item)

self.multiworld.itempool += tunic_items

def create_regions(self) -> None:
self.tunic_portal_pairs = {}
self.er_portal_hints = {}
self.ability_unlocks = randomize_ability_unlocks(self.random, self.options)
if self.options.entrance_rando:
portal_pairs, portal_hints = create_er_regions(self)
for portal1, portal2 in portal_pairs.items():
self.tunic_portal_pairs[portal1.scene_destination()] = portal2.scene_destination()
self.er_portal_hints = portal_hints

else:
for region_name in tunic_regions:
region = Region(region_name, self.player, self.multiworld)
self.multiworld.regions.append(region)

for region_name, exits in tunic_regions.items():
region = self.multiworld.get_region(region_name, self.player)
region.add_exits(exits)

for location_name, location_id in self.location_name_to_id.items():
region = self.multiworld.get_region(location_table[location_name].region, self.player)
location = TunicLocation(self.player, location_name, location_id, region)
region.locations.append(location)

victory_region = self.multiworld.get_region("Spirit Arena", self.player)
victory_location = TunicLocation(self.player, "The Heir", None, victory_region)
victory_location.place_locked_item(TunicItem("Victory", ItemClassification.progression, None, self.player))
self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player)
victory_region.locations.append(victory_location)

def set_rules(self) -> None:
if self.options.entrance_rando:
set_er_location_rules(self, self.ability_unlocks)
else:
set_region_rules(self, self.ability_unlocks)
set_location_rules(self, self.ability_unlocks)

def get_filler_item_name(self) -> str:
return self.random.choice(filler_items)

def extend_hint_information(self, hint_data: Dict[int, Dict[int, str]]):
if self.options.entrance_rando:
hint_data[self.player] = self.er_portal_hints

def fill_slot_data(self) -> Dict[str, Any]:
slot_data: Dict[str, Any] = {
"seed": self.random.randint(0, 2147483647),
"start_with_sword": self.options.start_with_sword.value,
"keys_behind_bosses": self.options.keys_behind_bosses.value,
"sword_progression": self.options.sword_progression.value,
"ability_shuffling": self.options.ability_shuffling.value,
"hexagon_quest": self.options.hexagon_quest.value,
"fool_traps": self.options.fool_traps.value,
"entrance_rando": self.options.entrance_rando.value,
"Hexagon Quest Prayer": self.ability_unlocks["Pages 24-25 (Prayer)"],
"Hexagon Quest Holy Cross": self.ability_unlocks["Pages 42-43 (Holy Cross)"],
"Hexagon Quest Ice Rod": self.ability_unlocks["Pages 52-53 (Ice Rod)"],
"Hexagon Quest Goal": self.options.hexagon_goal.value,
"Entrance Rando": self.tunic_portal_pairs
}

for tunic_item in filter(lambda item: item.location is not None and item.code is not None, self.slot_data_items):
if tunic_item.name not in slot_data:
slot_data[tunic_item.name] = []
if tunic_item.name == gold_hexagon and len(slot_data[gold_hexagon]) >= 6:
continue
slot_data[tunic_item.name].extend([tunic_item.location.name, tunic_item.location.player])

for start_item in self.options.start_inventory_from_pool:
if start_item in slot_data_item_names:
if start_item not in slot_data:
slot_data[start_item] = []
for i in range(0, self.options.start_inventory_from_pool[start_item]):
slot_data[start_item].extend(["Your Pocket", self.player])

for plando_item in self.multiworld.plando_items[self.player]:
if plando_item["from_pool"]:
items_to_find = set()
for item_type in [key for key in ["item", "items"] if key in plando_item]:
for item in plando_item[item_type]:
items_to_find.add(item)
for item in items_to_find:
if item in slot_data_item_names:
slot_data[item] = []
for item_location in self.multiworld.find_item_locations(item, self.player):
slot_data[item].extend([item_location.name, item_location.player])

return slot_data

# for the universal tracker, doesn't get called in standard gen
def interpret_slot_data(self, slot_data: Dict[str, Any]) -> None:
# bypassing random yaml settings
self.options.start_with_sword.value = slot_data["start_with_sword"]
self.options.keys_behind_bosses.value = slot_data["keys_behind_bosses"]
self.options.sword_progression.value = slot_data["sword_progression"]
self.options.ability_shuffling.value = slot_data["ability_shuffling"]
self.options.hexagon_quest.value = slot_data["hexagon_quest"]
self.ability_unlocks["Pages 24-25 (Prayer)"] = slot_data["Hexagon Quest Prayer"]
self.ability_unlocks["Pages 42-43 (Holy Cross)"] = slot_data["Hexagon Quest Holy Cross"]
self.ability_unlocks["Pages 52-53 (Ice Rod)"] = slot_data["Hexagon Quest Ice Rod"]

# swapping entrances around so the mapping matches what was generated
if slot_data["entrance_rando"]:
from BaseClasses import Entrance
from .er_data import portal_mapping
entrance_dict: Dict[str, Entrance] = {entrance.name: entrance
for region in self.multiworld.get_regions(self.player)
for entrance in region.entrances}
slot_portals: Dict[str, str] = slot_data["Entrance Rando"]
for portal1, portal2 in slot_portals.items():
portal_name1: str = ""
portal_name2: str = ""
entrance1 = None
entrance2 = None
for portal in portal_mapping:
if portal.scene_destination() == portal1:
portal_name1 = portal.name
if portal.scene_destination() == portal2:
portal_name2 = portal.name

for entrance_name, entrance in entrance_dict.items():
if entrance_name.startswith(portal_name1):
entrance1 = entrance
if entrance_name.startswith(portal_name2):
entrance2 = entrance
if entrance1 is None:
raise Exception("entrance1 not found, portal1 is " + portal1)
if entrance2 is None:
raise Exception("entrance2 not found, portal2 is " + portal2)
entrance1.connected_region = entrance2.parent_region
entrance2.connected_region = entrance1.parent_region
59 changes: 59 additions & 0 deletions worlds/tunic/docs/en_Tunic.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# TUNIC

## Where is the settings page?

The [player settings page for this game](../player-settings) contains all the options you need to configure and export a config file.

## What does randomization do to this game?

In the TUNIC Randomizer, every item in the game is randomized. All chests, key item pickups, instruction manual pages, hero relics,
and other unique items are shuffled.<br>

Ability shuffling is an option available from the settings page to shuffle certain abilities (prayer, holy cross, and the ice rod combo),
preventing them from being used until they are unlocked.<br>

Enemy randomization and other options are also available and can be turned on in the client mod.

## What is the goal of TUNIC when randomized?
The standard goal is the same as the vanilla game, which is to find the three hexagon keys, at which point you may either Take Your
Rightful Place or seek another path and Share Your Wisdom.

Alternatively, Hexagon Quest is a mode that shuffles a certain number of Gold Questagons into the item pool, with the goal
being to find the required amount of them and then Share Your Wisdom.

## What items from TUNIC can appear in another player's world?
Every item has a chance to appear in another player's world.

## How many checks are in TUNIC?
There are 302 checks located across the world of TUNIC.

## What do items from other worlds look like in TUNIC?
Items belonging to other TUNIC players will either appear as that item directly (if in a freestanding location) or in a
chest with the original chest texture for that item.

Items belonging to non-TUNIC players will either appear as a question-mark block (if in a freestanding location) or in a chest with
a question mark symbol on it. Additionally, non-TUNIC items are color-coded by classification, with green for filler, blue for useful, and gold for progression.

## Is there a tracker pack?
There is a [tracker pack](https://github.com/SapphireSapphic/TunicTracker/releases/latest). It is compatible with both Poptracker and Emotracker. Using Poptracker, it will automatically track checked locations and important items received. It can also automatically tab between maps as you traverse the world. This tracker was originally created by SapphireSapphic and ScoutJD, and has been extensively updated by Br00ty.

There is also a [standalone item tracker](https://github.com/radicoon/tunic-rando-tracker/releases/latest), which tracks what items you have received. It is great for adding an item overlay to streaming setups. This item tracker was created by Radicoon.

## What should I know regarding logic?
- Nighttime is not considered in logic. Every check in the game is obtainable during the day.
- The Cathedral is accessible during the day by using the Hero's Laurels to reach the Overworld fuse near the Swamp entrance.
- The Secret Legend chest at the Cathedral can be obtained during the day by opening the Holy Cross door from the outside.

For Entrance Rando specifically:
- Activating a fuse to turn on a yellow teleporter pad also activates its counterpart in the Far Shore.
- The West Garden fuse can be activated from below.
- You can pray at the tree at the exterior of the Library.
- The elevators in the Rooted Ziggurat only go down.
- The portal in the trophy room of the Old House is active from the start.
- The elevator in Cathedral is immediately usable without activating the fuse. Activating the fuse does nothing.

## What item groups are there?
Bombs, consumables (non-bomb ones), weapons, melee weapons (stick and sword), keys, hexagons, offerings, hero relics, cards, golden treasures, money, pages, and abilities (the three ability pages). There are also a few groups being used for singular items: laurels, orb, dagger, magic rod, holy cross, prayer, ice rod, and progressive sword.

## What location groups are there?
Holy cross (for all holy cross checks), fairies (for the two fairy checks), well (for the coin well checks), and shop. Additionally, for checks that do not fall into the above categories, the name of the region is the name of the location group.
Loading

0 comments on commit 3933fd3

Please sign in to comment.