317 lines
15 KiB
Python
317 lines
15 KiB
Python
import os
|
|
import typing
|
|
import settings
|
|
import base64
|
|
import logging
|
|
|
|
from BaseClasses import Item, Region, Tutorial, ItemClassification
|
|
from .items import CV64Item, filler_item_names, get_item_info, get_item_names_to_ids, get_item_counts
|
|
from .locations import CV64Location, get_location_info, verify_locations, get_location_names_to_ids, base_id
|
|
from .entrances import verify_entrances, get_warp_entrances
|
|
from .options import CV64Options, CharacterStages, DraculasCondition, SubWeaponShuffle
|
|
from .stages import get_locations_from_stage, get_normal_stage_exits, vanilla_stage_order, \
|
|
shuffle_stages, generate_warps, get_region_names
|
|
from .regions import get_region_info
|
|
from .rules import CV64Rules
|
|
from .data import iname, rname, ename
|
|
from ..AutoWorld import WebWorld, World
|
|
from .aesthetics import randomize_lighting, shuffle_sub_weapons, rom_empty_breakables_flags, rom_sub_weapon_flags, \
|
|
randomize_music, get_start_inventory_data, get_location_data, randomize_shop_prices, get_loading_zone_bytes, \
|
|
get_countdown_numbers
|
|
from .rom import RomData, write_patch, get_base_rom_path, CV64ProcedurePatch, CV64_US_10_HASH
|
|
from .client import Castlevania64Client
|
|
|
|
|
|
class CV64Settings(settings.Group):
|
|
class RomFile(settings.UserFilePath):
|
|
"""File name of the CV64 US 1.0 rom"""
|
|
copy_to = "Castlevania (USA).z64"
|
|
description = "CV64 (US 1.0) ROM File"
|
|
md5s = [CV64_US_10_HASH]
|
|
|
|
rom_file: RomFile = RomFile(RomFile.copy_to)
|
|
|
|
|
|
class CV64Web(WebWorld):
|
|
theme = "stone"
|
|
|
|
tutorials = [Tutorial(
|
|
"Multiworld Setup Guide",
|
|
"A guide to setting up the Archipleago Castlevania 64 randomizer on your computer and connecting it to a "
|
|
"multiworld.",
|
|
"English",
|
|
"setup_en.md",
|
|
"setup/en",
|
|
["Liquid Cat"]
|
|
)]
|
|
|
|
|
|
class CV64World(World):
|
|
"""
|
|
Castlevania for the Nintendo 64 is the first 3D game in the Castlevania franchise. As either whip-wielding Belmont
|
|
descendant Reinhardt Schneider or powerful sorceress Carrie Fernandez, brave many terrifying traps and foes as you
|
|
make your way to Dracula's chamber and stop his rule of terror!
|
|
"""
|
|
game = "Castlevania 64"
|
|
item_name_groups = {
|
|
"Bomb": {iname.magical_nitro, iname.mandragora},
|
|
"Ingredient": {iname.magical_nitro, iname.mandragora},
|
|
}
|
|
location_name_groups = {stage: set(get_locations_from_stage(stage)) for stage in vanilla_stage_order}
|
|
options_dataclass = CV64Options
|
|
options: CV64Options
|
|
settings: typing.ClassVar[CV64Settings]
|
|
topology_present = True
|
|
data_version = 1
|
|
|
|
item_name_to_id = get_item_names_to_ids()
|
|
location_name_to_id = get_location_names_to_ids()
|
|
|
|
active_stage_exits: typing.Dict[str, typing.Dict]
|
|
active_stage_list: typing.List[str]
|
|
active_warp_list: typing.List[str]
|
|
|
|
# Default values to possibly be updated in generate_early
|
|
reinhardt_stages: bool = True
|
|
carrie_stages: bool = True
|
|
branching_stages: bool = False
|
|
starting_stage: str = rname.forest_of_silence
|
|
total_s1s: int = 7
|
|
s1s_per_warp: int = 1
|
|
total_s2s: int = 0
|
|
required_s2s: int = 0
|
|
drac_condition: int = 0
|
|
|
|
auth: bytearray
|
|
|
|
web = CV64Web()
|
|
|
|
def generate_early(self) -> None:
|
|
# Generate the player's unique authentication
|
|
self.auth = bytearray(self.multiworld.random.getrandbits(8) for _ in range(16))
|
|
|
|
self.total_s1s = self.options.total_special1s.value
|
|
self.s1s_per_warp = self.options.special1s_per_warp.value
|
|
self.drac_condition = self.options.draculas_condition.value
|
|
|
|
# If there are more S1s needed to unlock the whole warp menu than there are S1s in total, drop S1s per warp to
|
|
# something manageable.
|
|
if self.s1s_per_warp * 7 > self.total_s1s:
|
|
self.s1s_per_warp = self.total_s1s // 7
|
|
logging.warning(f"[{self.multiworld.player_name[self.player]}] Too many required Special1s "
|
|
f"({self.options.special1s_per_warp.value * 7}) for Special1s Per Warp setting: "
|
|
f"{self.options.special1s_per_warp.value} with Total Special1s setting: "
|
|
f"{self.options.total_special1s.value}. Lowering Special1s Per Warp to: "
|
|
f"{self.s1s_per_warp}")
|
|
self.options.special1s_per_warp.value = self.s1s_per_warp
|
|
|
|
# Set the total and required Special2s to 1 if the drac condition is the Crystal, to the specified YAML numbers
|
|
# if it's Specials, or to 0 if it's None or Bosses. The boss totals will be figured out later.
|
|
if self.drac_condition == DraculasCondition.option_crystal:
|
|
self.total_s2s = 1
|
|
self.required_s2s = 1
|
|
elif self.drac_condition == DraculasCondition.option_specials:
|
|
self.total_s2s = self.options.total_special2s.value
|
|
self.required_s2s = int(self.options.percent_special2s_required.value / 100 * self.total_s2s)
|
|
|
|
# Enable/disable character stages and branching paths accordingly
|
|
if self.options.character_stages == CharacterStages.option_reinhardt_only:
|
|
self.carrie_stages = False
|
|
elif self.options.character_stages == CharacterStages.option_carrie_only:
|
|
self.reinhardt_stages = False
|
|
elif self.options.character_stages == CharacterStages.option_both:
|
|
self.branching_stages = True
|
|
|
|
self.active_stage_exits = get_normal_stage_exits(self)
|
|
|
|
stage_1_blacklist = []
|
|
|
|
# Prevent Clock Tower from being Stage 1 if more than 4 S1s are needed to warp out of it.
|
|
if self.s1s_per_warp > 4 and not self.options.multi_hit_breakables:
|
|
stage_1_blacklist.append(rname.clock_tower)
|
|
|
|
# Shuffle the stages if the option is on.
|
|
if self.options.stage_shuffle:
|
|
self.active_stage_exits, self.starting_stage, self.active_stage_list = \
|
|
shuffle_stages(self, stage_1_blacklist)
|
|
else:
|
|
self.active_stage_list = [stage for stage in vanilla_stage_order if stage in self.active_stage_exits]
|
|
|
|
# Create a list of warps from the active stage list. They are in a random order by default and will never
|
|
# include the starting stage.
|
|
self.active_warp_list = generate_warps(self)
|
|
|
|
def create_regions(self) -> None:
|
|
# Add the Menu Region.
|
|
created_regions = [Region("Menu", self.player, self.multiworld)]
|
|
|
|
# Add every stage Region by checking to see if that stage is active.
|
|
created_regions.extend([Region(name, self.player, self.multiworld)
|
|
for name in get_region_names(self.active_stage_exits)])
|
|
|
|
# Add the Renon's shop Region if shopsanity is on.
|
|
if self.options.shopsanity:
|
|
created_regions.append(Region(rname.renon, self.player, self.multiworld))
|
|
|
|
# Add the Dracula's chamber (the end) Region.
|
|
created_regions.append(Region(rname.ck_drac_chamber, self.player, self.multiworld))
|
|
|
|
# Set up the Regions correctly.
|
|
self.multiworld.regions.extend(created_regions)
|
|
|
|
# Add the warp Entrances to the Menu Region (the one always at the start of the Region list).
|
|
created_regions[0].add_exits(get_warp_entrances(self.active_warp_list))
|
|
|
|
for reg in created_regions:
|
|
|
|
# Add the Entrances to all the Regions.
|
|
ent_names = get_region_info(reg.name, "entrances")
|
|
if ent_names is not None:
|
|
reg.add_exits(verify_entrances(self.options, ent_names, self.active_stage_exits))
|
|
|
|
# Add the Locations to all the Regions.
|
|
loc_names = get_region_info(reg.name, "locations")
|
|
if loc_names is None:
|
|
continue
|
|
verified_locs, events = verify_locations(self.options, loc_names)
|
|
reg.add_locations(verified_locs, CV64Location)
|
|
|
|
# Place event Items on all of their associated Locations.
|
|
for event_loc, event_item in events.items():
|
|
self.get_location(event_loc).place_locked_item(self.create_item(event_item, "progression"))
|
|
# If we're looking at a boss kill trophy, increment the total S2s and, if we're not already at the
|
|
# set number of required bosses, the total required number. This way, we can prevent gen failures
|
|
# should the player set more bosses required than there are total.
|
|
if event_item == iname.trophy:
|
|
self.total_s2s += 1
|
|
if self.required_s2s < self.options.bosses_required.value:
|
|
self.required_s2s += 1
|
|
|
|
# If Dracula's Condition is Bosses and there are less calculated required S2s than the value specified by the
|
|
# player (meaning there weren't enough bosses to reach the player's setting), throw a warning and lower the
|
|
# option value.
|
|
if self.options.draculas_condition == DraculasCondition.option_bosses and self.required_s2s < \
|
|
self.options.bosses_required.value:
|
|
logging.warning(f"[{self.multiworld.player_name[self.player]}] Not enough bosses for Bosses Required "
|
|
f"setting: {self.options.bosses_required.value}. Lowering to: {self.required_s2s}")
|
|
self.options.bosses_required.value = self.required_s2s
|
|
|
|
def create_item(self, name: str, force_classification: typing.Optional[str] = None) -> Item:
|
|
if force_classification is not None:
|
|
classification = getattr(ItemClassification, force_classification)
|
|
else:
|
|
classification = getattr(ItemClassification, get_item_info(name, "default classification"))
|
|
|
|
code = get_item_info(name, "code")
|
|
if code is not None:
|
|
code += base_id
|
|
|
|
created_item = CV64Item(name, classification, code, self.player)
|
|
|
|
return created_item
|
|
|
|
def create_items(self) -> None:
|
|
item_counts = get_item_counts(self)
|
|
|
|
# Set up the items correctly
|
|
self.multiworld.itempool += [self.create_item(item, classification) for classification in item_counts for item
|
|
in item_counts[classification] for _ in range(item_counts[classification][item])]
|
|
|
|
def set_rules(self) -> None:
|
|
# Set all the Entrance rules properly.
|
|
CV64Rules(self).set_cv64_rules()
|
|
|
|
def pre_fill(self) -> None:
|
|
# If we need more Special1s to warp out of Sphere 1 than there are locations available, then AP's fill
|
|
# algorithm may try placing the Special1s anyway despite placing the stage's single key always being an option.
|
|
# To get around this problem in the fill algorithm, the keys will be forced early in these situations to ensure
|
|
# the algorithm will pick them over the Special1s.
|
|
if self.starting_stage == rname.tower_of_science:
|
|
if self.s1s_per_warp > 3:
|
|
self.multiworld.local_early_items[self.player][iname.science_key2] = 1
|
|
elif self.starting_stage == rname.clock_tower:
|
|
if (self.s1s_per_warp > 2 and not self.options.multi_hit_breakables) or \
|
|
(self.s1s_per_warp > 8 and self.options.multi_hit_breakables):
|
|
self.multiworld.local_early_items[self.player][iname.clocktower_key1] = 1
|
|
elif self.starting_stage == rname.castle_wall:
|
|
if self.s1s_per_warp > 5 and not self.options.hard_logic and \
|
|
not self.options.multi_hit_breakables:
|
|
self.multiworld.local_early_items[self.player][iname.left_tower_key] = 1
|
|
|
|
def generate_output(self, output_directory: str) -> None:
|
|
active_locations = self.multiworld.get_locations(self.player)
|
|
|
|
# Location data and shop names, descriptions, and colors
|
|
offset_data, shop_name_list, shop_colors_list, shop_desc_list = \
|
|
get_location_data(self, active_locations)
|
|
# Shop prices
|
|
if self.options.shop_prices:
|
|
offset_data.update(randomize_shop_prices(self))
|
|
# Map lighting
|
|
if self.options.map_lighting:
|
|
offset_data.update(randomize_lighting(self))
|
|
# Sub-weapons
|
|
if self.options.sub_weapon_shuffle == SubWeaponShuffle.option_own_pool:
|
|
offset_data.update(shuffle_sub_weapons(self))
|
|
elif self.options.sub_weapon_shuffle == SubWeaponShuffle.option_anywhere:
|
|
offset_data.update(rom_sub_weapon_flags)
|
|
# Empty breakables
|
|
if self.options.empty_breakables:
|
|
offset_data.update(rom_empty_breakables_flags)
|
|
# Music
|
|
if self.options.background_music:
|
|
offset_data.update(randomize_music(self))
|
|
# Loading zones
|
|
offset_data.update(get_loading_zone_bytes(self.options, self.starting_stage, self.active_stage_exits))
|
|
# Countdown
|
|
if self.options.countdown:
|
|
offset_data.update(get_countdown_numbers(self.options, active_locations))
|
|
# Start Inventory
|
|
offset_data.update(get_start_inventory_data(self.player, self.options,
|
|
self.multiworld.precollected_items[self.player]))
|
|
|
|
patch = CV64ProcedurePatch(player=self.player, player_name=self.multiworld.player_name[self.player])
|
|
write_patch(self, patch, offset_data, shop_name_list, shop_desc_list, shop_colors_list, active_locations)
|
|
|
|
rom_path = os.path.join(output_directory, f"{self.multiworld.get_out_file_name_base(self.player)}"
|
|
f"{patch.patch_file_ending}")
|
|
|
|
patch.write(rom_path)
|
|
|
|
def get_filler_item_name(self) -> str:
|
|
return self.random.choice(filler_item_names)
|
|
|
|
def extend_hint_information(self, hint_data: typing.Dict[int, typing.Dict[int, str]]):
|
|
# Attach each location's stage's position to its hint information if Stage Shuffle is on.
|
|
if not self.options.stage_shuffle:
|
|
return
|
|
|
|
stage_pos_data = {}
|
|
for loc in list(self.multiworld.get_locations(self.player)):
|
|
stage = get_region_info(loc.parent_region.name, "stage")
|
|
if stage is not None and loc.address is not None:
|
|
num = str(self.active_stage_exits[stage]["position"]).zfill(2)
|
|
path = self.active_stage_exits[stage]["path"]
|
|
stage_pos_data[loc.address] = f"Stage {num}"
|
|
if path != " ":
|
|
stage_pos_data[loc.address] += path
|
|
hint_data[self.player] = stage_pos_data
|
|
|
|
def modify_multidata(self, multidata: typing.Dict[str, typing.Any]):
|
|
# Put the player's unique authentication in connect_names.
|
|
multidata["connect_names"][base64.b64encode(self.auth).decode("ascii")] = \
|
|
multidata["connect_names"][self.multiworld.player_name[self.player]]
|
|
|
|
def write_spoiler(self, spoiler_handle: typing.TextIO) -> None:
|
|
# Write the stage order to the spoiler log
|
|
spoiler_handle.write(f"\nCastlevania 64 stage & warp orders for {self.multiworld.player_name[self.player]}:\n")
|
|
for stage in self.active_stage_list:
|
|
num = str(self.active_stage_exits[stage]["position"]).zfill(2)
|
|
path = self.active_stage_exits[stage]["path"]
|
|
spoiler_handle.writelines(f"Stage {num}{path}:\t{stage}\n")
|
|
|
|
# Write the warp order to the spoiler log
|
|
spoiler_handle.writelines(f"\nStart :\t{self.active_stage_list[0]}\n")
|
|
for i in range(1, len(self.active_warp_list)):
|
|
spoiler_handle.writelines(f"Warp {i}:\t{self.active_warp_list[i]}\n")
|