Allow worlds to provide item and location descriptions (#2409)
These are displayed in the weighted options page as hoverable tooltips.
This commit is contained in:
parent
64159a6d0f
commit
2dd904e758
|
@ -136,16 +136,20 @@ def create():
|
|||
option["defaultValue"] = "random"
|
||||
|
||||
weighted_options["baseOptions"]["game"][game_name] = 0
|
||||
weighted_options["games"][game_name] = {}
|
||||
weighted_options["games"][game_name]["gameSettings"] = game_options
|
||||
weighted_options["games"][game_name]["gameItems"] = tuple(world.item_names)
|
||||
weighted_options["games"][game_name]["gameItemGroups"] = [
|
||||
group for group in world.item_name_groups.keys() if group != "Everything"
|
||||
]
|
||||
weighted_options["games"][game_name]["gameLocations"] = tuple(world.location_names)
|
||||
weighted_options["games"][game_name]["gameLocationGroups"] = [
|
||||
group for group in world.location_name_groups.keys() if group != "Everywhere"
|
||||
]
|
||||
weighted_options["games"][game_name] = {
|
||||
"gameSettings": game_options,
|
||||
"gameItems": tuple(world.item_names),
|
||||
"gameItemGroups": [
|
||||
group for group in world.item_name_groups.keys() if group != "Everything"
|
||||
],
|
||||
"gameItemDescriptions": world.item_descriptions,
|
||||
"gameLocations": tuple(world.location_names),
|
||||
"gameLocationGroups": [
|
||||
group for group in world.location_name_groups.keys() if group != "Everywhere"
|
||||
],
|
||||
"gameLocationDescriptions": world.location_descriptions,
|
||||
}
|
||||
|
||||
with open(os.path.join(target_folder, 'weighted-options.json'), "w") as f:
|
||||
json.dump(weighted_options, f, indent=2, separators=(',', ': '))
|
||||
|
||||
|
|
|
@ -1024,12 +1024,18 @@ class GameSettings {
|
|||
|
||||
// Builds a div for a setting whose value is a list of locations.
|
||||
#buildLocationsDiv(setting) {
|
||||
return this.#buildListDiv(setting, this.data.gameLocations, this.data.gameLocationGroups);
|
||||
return this.#buildListDiv(setting, this.data.gameLocations, {
|
||||
groups: this.data.gameLocationGroups,
|
||||
descriptions: this.data.gameLocationDescriptions,
|
||||
});
|
||||
}
|
||||
|
||||
// Builds a div for a setting whose value is a list of items.
|
||||
#buildItemsDiv(setting) {
|
||||
return this.#buildListDiv(setting, this.data.gameItems, this.data.gameItemGroups);
|
||||
return this.#buildListDiv(setting, this.data.gameItems, {
|
||||
groups: this.data.gameItemGroups,
|
||||
descriptions: this.data.gameItemDescriptions
|
||||
});
|
||||
}
|
||||
|
||||
// Builds a div for a setting named `setting` with a list value that can
|
||||
|
@ -1038,12 +1044,15 @@ class GameSettings {
|
|||
// The `groups` option can be a list of additional options for this list
|
||||
// (usually `item_name_groups` or `location_name_groups`) that are displayed
|
||||
// in a special section at the top of the list.
|
||||
#buildListDiv(setting, items, groups = []) {
|
||||
//
|
||||
// The `descriptions` option can be a map from item names or group names to
|
||||
// descriptions for the user's benefit.
|
||||
#buildListDiv(setting, items, {groups = [], descriptions = {}} = {}) {
|
||||
const div = document.createElement('div');
|
||||
div.classList.add('simple-list');
|
||||
|
||||
groups.forEach((group) => {
|
||||
const row = this.#addListRow(setting, group);
|
||||
const row = this.#addListRow(setting, group, descriptions[group]);
|
||||
div.appendChild(row);
|
||||
});
|
||||
|
||||
|
@ -1052,7 +1061,7 @@ class GameSettings {
|
|||
}
|
||||
|
||||
items.forEach((item) => {
|
||||
const row = this.#addListRow(setting, item);
|
||||
const row = this.#addListRow(setting, item, descriptions[item]);
|
||||
div.appendChild(row);
|
||||
});
|
||||
|
||||
|
@ -1060,7 +1069,9 @@ class GameSettings {
|
|||
}
|
||||
|
||||
// Builds and returns a row for a list of checkboxes.
|
||||
#addListRow(setting, item) {
|
||||
//
|
||||
// If `help` is passed, it's displayed as a help tooltip for this list item.
|
||||
#addListRow(setting, item, help = undefined) {
|
||||
const row = document.createElement('div');
|
||||
row.classList.add('list-row');
|
||||
|
||||
|
@ -1081,6 +1092,23 @@ class GameSettings {
|
|||
|
||||
const name = document.createElement('span');
|
||||
name.innerText = item;
|
||||
|
||||
if (help) {
|
||||
const helpSpan = document.createElement('span');
|
||||
helpSpan.classList.add('interactive');
|
||||
helpSpan.setAttribute('data-tooltip', help);
|
||||
helpSpan.innerText = '(?)';
|
||||
name.innerText += ' ';
|
||||
name.appendChild(helpSpan);
|
||||
|
||||
// Put the first 7 tooltips below their rows. CSS tooltips in scrolling
|
||||
// containers can't be visible outside those containers, so this helps
|
||||
// ensure they won't be pushed out the top.
|
||||
if (helpSpan.parentNode.childNodes.length < 7) {
|
||||
helpSpan.classList.add('tooltip-bottom');
|
||||
}
|
||||
}
|
||||
|
||||
label.appendChild(name);
|
||||
|
||||
row.appendChild(label);
|
||||
|
|
|
@ -121,6 +121,38 @@ Classification is one of `LocationProgressType.DEFAULT`, `PRIORITY` or `EXCLUDED
|
|||
The Fill algorithm will force progression items to be placed at priority locations, giving a higher chance of them being
|
||||
required, and will prevent progression and useful items from being placed at excluded locations.
|
||||
|
||||
#### Documenting Locations
|
||||
|
||||
Worlds can optionally provide a `location_descriptions` map which contains
|
||||
human-friendly descriptions of locations or location groups. These descriptions
|
||||
will show up in location-selection options in the Weighted Options page. Extra
|
||||
indentation and single newlines will be collapsed into spaces.
|
||||
|
||||
```python
|
||||
# Locations.py
|
||||
|
||||
location_descriptions = {
|
||||
"Red Potion #6": "In a secret destructible block under the second stairway",
|
||||
"L2 Spaceship": """
|
||||
The group of all items in the spaceship in Level 2.
|
||||
|
||||
This doesn't include the item on the spaceship door, since it can be
|
||||
accessed without the Spaeship Key.
|
||||
"""
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
# __init__.py
|
||||
|
||||
from worlds.AutoWorld import World
|
||||
from .Locations import location_descriptions
|
||||
|
||||
|
||||
class MyGameWorld(World):
|
||||
location_descriptions = location_descriptions
|
||||
```
|
||||
|
||||
### Items
|
||||
|
||||
Items are all things that can "drop" for your game. This may be RPG items like
|
||||
|
@ -147,6 +179,37 @@ Other classifications include
|
|||
* `progression_skip_balancing`: the combination of `progression` and `skip_balancing`, i.e., a progression item that
|
||||
will not be moved around by progression balancing; used, e.g., for currency or tokens
|
||||
|
||||
#### Documenting Items
|
||||
|
||||
Worlds can optionally provide an `item_descriptions` map which contains
|
||||
human-friendly descriptions of items or item groups. These descriptions will
|
||||
show up in item-selection options in the Weighted Options page. Extra
|
||||
indentation and single newlines will be collapsed into spaces.
|
||||
|
||||
```python
|
||||
# Items.py
|
||||
|
||||
item_descriptions = {
|
||||
"Red Potion": "A standard health potion",
|
||||
"Spaceship Key": """
|
||||
The key to the spaceship in Level 2.
|
||||
|
||||
This is necessary to get to the Star Realm.
|
||||
"""
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
# __init__.py
|
||||
|
||||
from worlds.AutoWorld import World
|
||||
from .Items import item_descriptions
|
||||
|
||||
|
||||
class MyGameWorld(World):
|
||||
item_descriptions = item_descriptions
|
||||
```
|
||||
|
||||
### Events
|
||||
|
||||
Events will mark some progress. You define an event location, an
|
||||
|
|
|
@ -333,3 +333,24 @@ class WorldTestBase(unittest.TestCase):
|
|||
placed_items = [loc.item for loc in self.multiworld.get_locations() if loc.item and loc.item.code]
|
||||
self.assertLessEqual(len(self.multiworld.itempool), len(placed_items),
|
||||
"Unplaced Items remaining in itempool")
|
||||
|
||||
def test_descriptions_have_valid_names(self):
|
||||
"""Ensure all item and location descriptions match a name of the corresponding type"""
|
||||
if not (self.run_default_tests and self.constructed):
|
||||
return
|
||||
with self.subTest("Game", game=self.game):
|
||||
with self.subTest("Items"):
|
||||
world = self.multiworld.worlds[1]
|
||||
valid_names = world.item_names.union(world.item_name_groups)
|
||||
for name in world.item_descriptions.keys():
|
||||
with self.subTest("Name should be valid", name=name):
|
||||
self.assertIn(name, valid_names,
|
||||
"""All item descriptions must match defined item names""")
|
||||
|
||||
with self.subTest("Locations"):
|
||||
world = self.multiworld.worlds[1]
|
||||
valid_names = world.location_names.union(world.location_name_groups)
|
||||
for name in world.location_descriptions.keys():
|
||||
with self.subTest("Name should be valid", name=name):
|
||||
self.assertIn(name, valid_names,
|
||||
"""All item descriptions must match defined item names""")
|
||||
|
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
import hashlib
|
||||
import logging
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import make_dataclass
|
||||
|
@ -51,11 +52,17 @@ class AutoWorldRegister(type):
|
|||
dct["item_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set
|
||||
in dct.get("item_name_groups", {}).items()}
|
||||
dct["item_name_groups"]["Everything"] = dct["item_names"]
|
||||
dct["item_descriptions"] = {name: _normalize_description(description) for name, description
|
||||
in dct.get("item_descriptions", {}).items()}
|
||||
dct["item_descriptions"]["Everything"] = "All items in the entire game."
|
||||
dct["location_names"] = frozenset(dct["location_name_to_id"])
|
||||
dct["location_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set
|
||||
in dct.get("location_name_groups", {}).items()}
|
||||
dct["location_name_groups"]["Everywhere"] = dct["location_names"]
|
||||
dct["all_item_and_group_names"] = frozenset(dct["item_names"] | set(dct.get("item_name_groups", {})))
|
||||
dct["location_descriptions"] = {name: _normalize_description(description) for name, description
|
||||
in dct.get("location_descriptions", {}).items()}
|
||||
dct["location_descriptions"]["Everywhere"] = "All locations in the entire game."
|
||||
|
||||
# move away from get_required_client_version function
|
||||
if "game" in dct:
|
||||
|
@ -205,9 +212,23 @@ class World(metaclass=AutoWorldRegister):
|
|||
item_name_groups: ClassVar[Dict[str, Set[str]]] = {}
|
||||
"""maps item group names to sets of items. Example: {"Weapons": {"Sword", "Bow"}}"""
|
||||
|
||||
item_descriptions: ClassVar[Dict[str, str]] = {}
|
||||
"""An optional map from item names (or item group names) to brief descriptions for users.
|
||||
|
||||
Individual newlines and indentation will be collapsed into spaces before these descriptions are
|
||||
displayed. This may cover only a subset of items.
|
||||
"""
|
||||
|
||||
location_name_groups: ClassVar[Dict[str, Set[str]]] = {}
|
||||
"""maps location group names to sets of locations. Example: {"Sewer": {"Sewer Key Drop 1", "Sewer Key Drop 2"}}"""
|
||||
|
||||
location_descriptions: ClassVar[Dict[str, str]] = {}
|
||||
"""An optional map from location names (or location group names) to brief descriptions for users.
|
||||
|
||||
Individual newlines and indentation will be collapsed into spaces before these descriptions are
|
||||
displayed. This may cover only a subset of locations.
|
||||
"""
|
||||
|
||||
data_version: ClassVar[int] = 0
|
||||
"""
|
||||
Increment this every time something in your world's names/id mappings changes.
|
||||
|
@ -462,3 +483,17 @@ def data_package_checksum(data: "GamesPackage") -> str:
|
|||
assert sorted(data) == list(data), "Data not ordered"
|
||||
from NetUtils import encode
|
||||
return hashlib.sha1(encode(data).encode()).hexdigest()
|
||||
|
||||
|
||||
def _normalize_description(description):
|
||||
"""Normalizes a description in item_descriptions or location_descriptions.
|
||||
|
||||
This allows authors to write descritions with nice indentation and line lengths in their world
|
||||
definitions without having it affect the rendered format.
|
||||
"""
|
||||
# First, collapse the whitespace around newlines and the ends of the description.
|
||||
description = re.sub(r' *\n *', '\n', description.strip())
|
||||
# Next, condense individual newlines into spaces.
|
||||
description = re.sub(r'(?<!\n)\n(?!\n)', ' ', description)
|
||||
return description
|
||||
|
||||
|
|
|
@ -1271,6 +1271,14 @@ _cut_content_items = [DS3ItemData(row[0], row[1], False, row[2]) for row in [
|
|||
("Dorris Swarm", 0x40393870, DS3ItemCategory.SKIP),
|
||||
]]
|
||||
|
||||
item_descriptions = {
|
||||
"Cinders": """
|
||||
All four Cinders of a Lord.
|
||||
|
||||
Once you have these four, you can fight Soul of Cinder and win the game.
|
||||
""",
|
||||
}
|
||||
|
||||
_all_items = _vanilla_items + _dlc_items
|
||||
|
||||
item_dictionary = {item_data.name: item_data for item_data in _all_items}
|
||||
|
|
|
@ -7,7 +7,7 @@ from Options import Toggle
|
|||
from worlds.AutoWorld import World, WebWorld
|
||||
from worlds.generic.Rules import set_rule, add_rule, add_item_rule
|
||||
|
||||
from .Items import DarkSouls3Item, DS3ItemCategory, item_dictionary, key_item_names
|
||||
from .Items import DarkSouls3Item, DS3ItemCategory, item_dictionary, key_item_names, item_descriptions
|
||||
from .Locations import DarkSouls3Location, DS3LocationCategory, location_tables, location_dictionary
|
||||
from .Options import RandomizeWeaponLevelOption, PoolTypeOption, dark_souls_options
|
||||
|
||||
|
@ -60,6 +60,7 @@ class DarkSouls3World(World):
|
|||
"Cinders of a Lord - Lothric Prince"
|
||||
}
|
||||
}
|
||||
item_descriptions = item_descriptions
|
||||
|
||||
|
||||
def __init__(self, multiworld: MultiWorld, player: int):
|
||||
|
|
Loading…
Reference in New Issue