about summary refs log tree commit diff stats
path: root/apworld
diff options
context:
space:
mode:
Diffstat (limited to 'apworld')
-rw-r--r--apworld/CHANGELOG.md54
-rw-r--r--apworld/README.md48
-rw-r--r--apworld/__init__.py105
-rw-r--r--apworld/client/allowNumbers.gd10
-rw-r--r--apworld/client/animationListener.gd38
-rw-r--r--apworld/client/apworld_runtime.gd49
-rw-r--r--apworld/client/assets/goal.pngbin0 -> 215 bytes
-rw-r--r--apworld/client/assets/location.pngbin0 -> 311 bytes
-rw-r--r--apworld/client/assets/worldport.pngbin0 -> 219 bytes
-rw-r--r--apworld/client/client.gd318
-rw-r--r--apworld/client/collectable.gd16
-rw-r--r--apworld/client/compass.gd66
-rw-r--r--apworld/client/compass_overlay.gd17
-rw-r--r--apworld/client/door.gd75
-rw-r--r--apworld/client/effects.gd32
-rw-r--r--apworld/client/gamedata.gd310
-rw-r--r--apworld/client/keyHolder.gd38
-rw-r--r--apworld/client/keyHolderChecker.gd24
-rw-r--r--apworld/client/keyHolderResetterListener.gd10
-rw-r--r--apworld/client/keyboard.gd228
-rw-r--r--apworld/client/locationListener.gd20
-rw-r--r--apworld/client/main.gd314
-rw-r--r--apworld/client/manager.gd773
-rw-r--r--apworld/client/maps/control_center.gd139
-rw-r--r--apworld/client/maps/daedalus.gd85
-rw-r--r--apworld/client/maps/icarus.gd38
-rw-r--r--apworld/client/maps/the_advanced.gd36
-rw-r--r--apworld/client/maps/the_charismatic.gd26
-rw-r--r--apworld/client/maps/the_crystalline.gd34
-rw-r--r--apworld/client/maps/the_entry.gd156
-rw-r--r--apworld/client/maps/the_fuzzy.gd25
-rw-r--r--apworld/client/maps/the_gallery.gd7
-rw-r--r--apworld/client/maps/the_parthenon.gd51
-rw-r--r--apworld/client/maps/the_plaza.gd4
-rw-r--r--apworld/client/maps/the_stellar.gd30
-rw-r--r--apworld/client/maps/the_sun_temple.gd56
-rw-r--r--apworld/client/maps/the_unkempt.gd4
-rw-r--r--apworld/client/maps/the_unyielding.gd5
-rw-r--r--apworld/client/messages.gd74
-rw-r--r--apworld/client/minimap.gd178
-rw-r--r--apworld/client/painting.gd38
-rw-r--r--apworld/client/paintingAuto.gd43
-rw-r--r--apworld/client/panel.gd101
-rw-r--r--apworld/client/pauseMenu.gd91
-rw-r--r--apworld/client/player.gd219
-rw-r--r--apworld/client/rainbowText.gd10
-rw-r--r--apworld/client/rteMenu.gd67
-rw-r--r--apworld/client/run_from_apworld.tscn30
-rw-r--r--apworld/client/run_from_source.tscn22
-rw-r--r--apworld/client/saver.gd23
-rw-r--r--apworld/client/settings_screen.gd149
-rw-r--r--apworld/client/source_runtime.gd33
-rw-r--r--apworld/client/teleport.gd38
-rw-r--r--apworld/client/teleportListener.gd49
-rw-r--r--apworld/client/textclient.gd508
-rw-r--r--apworld/client/unlockReaderListener.gd46
-rw-r--r--apworld/client/vendor/LICENSE21
-rw-r--r--apworld/client/vendor/WebSocketServer.gd173
-rw-r--r--apworld/client/victoryListener.gd20
-rw-r--r--apworld/client/visibilityListener.gd38
-rw-r--r--apworld/client/worldport.gd61
-rw-r--r--apworld/client/worldportListener.gd8
-rw-r--r--apworld/context.py800
-rw-r--r--apworld/items.py5
-rw-r--r--apworld/locations.py109
-rw-r--r--apworld/logo.pngbin0 -> 9429 bytes
-rw-r--r--apworld/options.py135
-rw-r--r--apworld/player_logic.py410
-rw-r--r--apworld/regions.py189
-rw-r--r--apworld/requirements.txt2
-rw-r--r--apworld/rules.py238
-rw-r--r--apworld/static_logic.py35
-rw-r--r--apworld/tracker.py151
-rw-r--r--apworld/version.py1
74 files changed, 6977 insertions, 379 deletions
diff --git a/apworld/CHANGELOG.md b/apworld/CHANGELOG.md deleted file mode 100644 index af45992..0000000 --- a/apworld/CHANGELOG.md +++ /dev/null
@@ -1,54 +0,0 @@
1# lingo2-archipelago Apworld Releases
2
3## v5.5 - 2025-09-16
4
5- Fixed a panel in The Ancient that was missing a symbol.
6- Fixed an issue where you could be expected to get S1 in The Darkroom without
7 having U.
8- Renamed a few locations.
9
10Download:
11[lingo2.apworld](https://files.fourisland.com/releases/lingo2-archipelago/apworld/v5.5/lingo2.apworld)<br/>
12Template YAML:
13[Lingo 2.yaml](https://files.fourisland.com/releases/lingo2-archipelago/apworld/v5.5/Lingo%202.yaml)<br/>
14Source:
15[v5.5](https://code.fourisland.com/lingo2-archipelago/tag/?h=apworld-v5.5)
16
17## v4.4 - 2025-09-14
18
19- Fixed panel set location names.
20
21Download:
22[lingo2.apworld](https://files.fourisland.com/releases/lingo2-archipelago/apworld/v4.4/lingo2.apworld)<br/>
23Template YAML:
24[Lingo 2.yaml](https://files.fourisland.com/releases/lingo2-archipelago/apworld/v4.4/Lingo%202.yaml)<br/>
25Source:
26[v4.4](https://code.fourisland.com/lingo2-archipelago/tag/?h=apworld-v4.4)
27
28## v4.3 - 2025-09-13
29
30- Added a location for the anti-collectable in The Repetitive.
31- Added trap items. These remove letters from your keyboard until you use the
32 Key Return in The Entry, similar to the anti-collectable in The Repetitive.
33 This can be controlled using the `trap_percentage` option, which defaults to
34 zero.
35- Fixed crash on load when using Python 3.11.
36
37Download:
38[lingo2.apworld](https://files.fourisland.com/releases/lingo2-archipelago/apworld/v4.3/lingo2.apworld)<br/>
39Template YAML:
40[Lingo 2.yaml](https://files.fourisland.com/releases/lingo2-archipelago/apworld/v4.3/Lingo%202.yaml)<br/>
41Source:
42[v4.3](https://code.fourisland.com/lingo2-archipelago/tag/?h=apworld-v4.3)
43
44## v3.2 - 2025-09-12
45
46- Initial release for testing. Features include door shuffle, letter shuffle,
47 and symbol shuffle.
48
49Download:
50[lingo2.apworld](https://files.fourisland.com/releases/lingo2-archipelago/apworld/v3.2/lingo2.apworld)<br/>
51Template YAML:
52[Lingo 2.yaml](https://files.fourisland.com/releases/lingo2-archipelago/apworld/v3.2/Lingo%202.yaml)<br/>
53Source:
54[v3.2](https://code.fourisland.com/lingo2-archipelago/tag/?h=apworld-v3.2)
diff --git a/apworld/README.md b/apworld/README.md deleted file mode 100644 index 13374b2..0000000 --- a/apworld/README.md +++ /dev/null
@@ -1,48 +0,0 @@
1# Lingo 2 Apworld
2
3The Lingo 2 Apworld allows you to generate Archipelago Multiworlds containing
4Lingo 2.
5
6## Installation
7
81. Download the Lingo 2 Apworld from
9 [the releases page](https://code.fourisland.com/lingo2-archipelago/about/apworld/CHANGELOG.md).
102. If you do not already have it, download and install the
11 [Archipelago software](https://github.com/ArchipelagoMW/Archipelago/releases/).
123. Double click on `lingo2.apworld` to install it, or copy it manually to the
13 `custom_worlds` folder of your Archipelago installation.
14
15## Running from source
16
17The apworld is mostly written in Python, which does not need to be compiled.
18However, there are two files that need to be generated before the apworld can be
19used.
20
21The first file is `data.binpb`, the datafile containing the randomizer logic.
22You can read about how to generate it on
23[its own README page](https://code.fourisland.com/lingo2-archipelago/about/data/README.md).
24Once you have it, put it in a subfolder of `apworld` called `generated`.
25
26The second generated file is `data_pb2.py`. This file allows Archipelago to read
27the datafile. We use `protoc`, the Protocol Buffer compiler, to generate it. As
28of 0.6.3, Archipelago has protobuf 3.20.3 packaged with it, which means we need
29to compile our proto file with a similar version.
30
31If you followed the steps to generate `data.binpb` and compiled the `datapacker`
32tool yourself, you will already have protobuf version 3.21.12 installed through
33vcpkg. You can then run a command similar to this in order to generate the
34python file.
35
36```shell
37.\out\build\x64-Debug\vcpkg_installed\x64-windows\tools\protobuf\protoc.exe -Iproto\ ^
38 --python_out=apworld\generated\ .\proto\data.proto
39```
40
41The exact path to `protoc.exe` is going to depend on where vcpkg installed its
42packages. The above location is where Visual Studio will probably put it.
43
44After generating those two files, the apworld should be functional. You can copy
45it into an Archipelago source tree (rename the folder `apworld` to `lingo2` if
46you do so) if you want to edit/debug the code. Otherwise, you can zip up the
47folder and rename it to `lingo2.apworld` in order to package it for
48distribution.
diff --git a/apworld/__init__.py b/apworld/__init__.py index f1de503..6b5338e 100644 --- a/apworld/__init__.py +++ b/apworld/__init__.py
@@ -1,14 +1,18 @@
1""" 1"""
2Archipelago init file for Lingo 2 2Archipelago init file for Lingo 2
3""" 3"""
4from typing import ClassVar
5
4from BaseClasses import ItemClassification, Item, Tutorial 6from BaseClasses import ItemClassification, Item, Tutorial
7from Options import OptionError
8from settings import Group, UserFilePath
5from worlds.AutoWorld import WebWorld, World 9from worlds.AutoWorld import WebWorld, World
6from .items import Lingo2Item, ANTI_COLLECTABLE_TRAPS 10from .items import Lingo2Item, ANTI_COLLECTABLE_TRAPS, ALL_LETTERS_UPPER
7from .options import Lingo2Options 11from .options import Lingo2Options
8from .player_logic import Lingo2PlayerLogic 12from .player_logic import Lingo2PlayerLogic
9from .regions import create_regions 13from .regions import create_regions, shuffle_entrances, connect_ports_from_ut
10from .static_logic import Lingo2StaticLogic 14from .static_logic import Lingo2StaticLogic
11from .version import APWORLD_VERSION 15from worlds.LauncherComponents import Component, Type, components, launch as launch_component, icon_paths
12 16
13 17
14class Lingo2WebWorld(WebWorld): 18class Lingo2WebWorld(WebWorld):
@@ -24,6 +28,15 @@ class Lingo2WebWorld(WebWorld):
24 )] 28 )]
25 29
26 30
31class Lingo2Settings(Group):
32 class ExecutableFile(UserFilePath):
33 """Path to the Lingo 2 executable"""
34 is_exe = True
35
36 exe_file: ExecutableFile = ExecutableFile()
37 start_game: bool = True
38
39
27class Lingo2World(World): 40class Lingo2World(World):
28 """ 41 """
29 Lingo 2 is a first person indie puzzle game where you solve word puzzles in a labyrinthe world. Compared to its 42 Lingo 2 is a first person indie puzzle game where you solve word puzzles in a labyrinthe world. Compared to its
@@ -33,6 +46,9 @@ class Lingo2World(World):
33 game = "Lingo 2" 46 game = "Lingo 2"
34 web = Lingo2WebWorld() 47 web = Lingo2WebWorld()
35 48
49 settings: ClassVar[Lingo2Settings]
50 settings_key = "lingo2_options"
51
36 topology_present = True 52 topology_present = True
37 53
38 options_dataclass = Lingo2Options 54 options_dataclass = Lingo2Options
@@ -44,17 +60,42 @@ class Lingo2World(World):
44 item_name_groups = static_logic.item_name_groups 60 item_name_groups = static_logic.item_name_groups
45 location_name_groups = static_logic.location_name_groups 61 location_name_groups = static_logic.location_name_groups
46 62
63 for_tracker: ClassVar[bool] = False
64
47 player_logic: Lingo2PlayerLogic 65 player_logic: Lingo2PlayerLogic
48 66
67 port_pairings: dict[int, int]
68
49 def generate_early(self): 69 def generate_early(self):
50 self.player_logic = Lingo2PlayerLogic(self) 70 self.player_logic = Lingo2PlayerLogic(self)
71 self.port_pairings = {}
72
73 if self.options.restrict_letter_placements:
74 self.options.local_items.value |= set(ALL_LETTERS_UPPER)
51 75
52 def create_regions(self): 76 def create_regions(self):
77 if hasattr(self.multiworld, "re_gen_passthrough") and "Lingo 2" in self.multiworld.re_gen_passthrough:
78 self.player_logic.rte_mapping = [self.world.static_logic.map_id_by_name[map_name]
79 for map_name in self.multiworld.re_gen_passthrough["Lingo 2"]["rte"]]
80
53 create_regions(self) 81 create_regions(self)
54 82
55 from Utils import visualize_regions 83 def connect_entrances(self):
84 if self.options.shuffle_worldports:
85 if hasattr(self.multiworld, "re_gen_passthrough") and "Lingo 2" in self.multiworld.re_gen_passthrough:
86 slot_value = self.multiworld.re_gen_passthrough["Lingo 2"]["port_pairings"]
87 self.port_pairings = {
88 self.static_logic.port_id_by_ap_id[int(fp)]: self.static_logic.port_id_by_ap_id[int(tp)]
89 for fp, tp in slot_value.items()
90 }
91
92 connect_ports_from_ut(self.port_pairings, self)
93 else:
94 shuffle_entrances(self)
95
96 #from Utils import visualize_regions
56 97
57 visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml") 98 #visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml")
58 99
59 def create_items(self): 100 def create_items(self):
60 pool = [self.create_item(name) for name in self.player_logic.real_items] 101 pool = [self.create_item(name) for name in self.player_logic.real_items]
@@ -79,38 +120,86 @@ class Lingo2World(World):
79 for i in range(0, item_difference): 120 for i in range(0, item_difference):
80 pool.append(self.create_item(self.get_filler_item_name())) 121 pool.append(self.create_item(self.get_filler_item_name()))
81 122
123 if not any(ItemClassification.progression in item.classification for item in pool):
124 raise OptionError(f"Lingo 2 player {self.player} has no progression items. Please enable at least one "
125 f"option that would add progression gating to your world, such as Shuffle Doors or "
126 f"Shuffle Letters.")
127
82 self.multiworld.itempool += pool 128 self.multiworld.itempool += pool
83 129
130 for name in self.player_logic.starting_items:
131 self.push_precollected(self.create_item(name))
132
84 def create_item(self, name: str) -> Item: 133 def create_item(self, name: str) -> Item:
85 return Lingo2Item(name, ItemClassification.filler if name == self.get_filler_item_name() else 134 item = Lingo2Item(name, ItemClassification.filler if name == self.get_filler_item_name() else
86 ItemClassification.trap if name in ANTI_COLLECTABLE_TRAPS else 135 ItemClassification.trap if name in ANTI_COLLECTABLE_TRAPS else
87 ItemClassification.progression, 136 ItemClassification.progression,
88 self.item_name_to_id.get(name), self.player) 137 self.item_name_to_id.get(name), self.player)
89 138
139 item.is_letter = (name in ALL_LETTERS_UPPER)
140 return item
141
90 def set_rules(self): 142 def set_rules(self):
91 self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player) 143 self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player)
92 144
93 def fill_slot_data(self): 145 def fill_slot_data(self):
94 slot_options = [ 146 slot_options = [
95 "cyan_door_behavior", 147 "cyan_door_behavior",
148 "daedalus_only",
96 "daedalus_roof_access", 149 "daedalus_roof_access",
150 "enable_gift_maps",
151 "enable_icarus",
152 "endings_requirement",
153 "fast_travel_access",
97 "keyholder_sanity", 154 "keyholder_sanity",
155 "masteries_requirement",
98 "shuffle_control_center_colors", 156 "shuffle_control_center_colors",
99 "shuffle_doors", 157 "shuffle_doors",
100 "shuffle_gallery_paintings", 158 "shuffle_gallery_paintings",
101 "shuffle_letters", 159 "shuffle_letters",
160 "shuffle_music",
102 "shuffle_symbols", 161 "shuffle_symbols",
162 "shuffle_worldports",
103 "strict_cyan_ending", 163 "strict_cyan_ending",
104 "strict_purple_ending", 164 "strict_purple_ending",
105 "victory_condition", 165 "victory_condition",
106 ] 166 ]
107 167
108 slot_data = { 168 slot_data: dict[str, object] = {
109 **self.options.as_dict(*slot_options), 169 **self.options.as_dict(*slot_options),
110 "version": [self.static_logic.get_data_version(), APWORLD_VERSION], 170 "custom_mint_ending": self.player_logic.custom_mint_ending or "",
171 "rte": [self.static_logic.objects.maps[map_id].name for map_id in self.player_logic.rte_mapping],
172 "seed": self.random.randint(0, 1000000),
173 "version": self.static_logic.get_data_version(),
111 } 174 }
112 175
176 if self.options.shuffle_worldports:
177 def get_port_ap_id(port_id):
178 return self.static_logic.objects.ports[port_id].ap_id
179
180 slot_data["port_pairings"] = {get_port_ap_id(from_id): get_port_ap_id(to_id)
181 for from_id, to_id in self.port_pairings.items()}
182
113 return slot_data 183 return slot_data
114 184
115 def get_filler_item_name(self) -> str: 185 def get_filler_item_name(self) -> str:
116 return "A Job Well Done" 186 return "A Job Well Done"
187
188 # for the universal tracker, doesn't get called in standard gen
189 # docs: https://github.com/FarisTheAncient/Archipelago/blob/tracker/worlds/tracker/docs/re-gen-passthrough.md
190 @staticmethod
191 def interpret_slot_data(slot_data: dict[str, object]) -> dict[str, object]:
192 # returning slot_data so it regens, giving it back in multiworld.re_gen_passthrough
193 # we are using re_gen_passthrough over modifying the world here due to complexities with ER
194 return slot_data
195
196
197def launch_client(*args):
198 from .context import client_main
199 launch_component(client_main, name="Lingo2Client", args=args)
200
201
202icon_paths["lingo2_ico"] = f"ap:{__name__}/logo.png"
203component = Component("Lingo 2 Client", component_type=Type.CLIENT, func=launch_client,
204 description="Open Lingo 2.", supports_uri=True, game_name="Lingo 2", icon="lingo2_ico")
205components.append(component)
diff --git a/apworld/client/allowNumbers.gd b/apworld/client/allowNumbers.gd new file mode 100644 index 0000000..d958b50 --- /dev/null +++ b/apworld/client/allowNumbers.gd
@@ -0,0 +1,10 @@
1extends "res://scripts/nodes/allowNumbers.gd"
2
3
4func _readier():
5 var ap = global.get_node("Archipelago")
6 var gamedata = global.get_node("Gamedata")
7
8 var item_id = gamedata.objects.get_special_ids()["Numbers"]
9 if ap.client.getItemAmount(item_id) >= 1:
10 global.allow_numbers = true
diff --git a/apworld/client/animationListener.gd b/apworld/client/animationListener.gd new file mode 100644 index 0000000..c3b26db --- /dev/null +++ b/apworld/client/animationListener.gd
@@ -0,0 +1,38 @@
1extends "res://scripts/nodes/listeners/animationListener.gd"
2
3var item_id
4var item_amount
5
6
7func _ready():
8 var node_path = String(
9 get_tree().get_root().get_node("scene").get_path_to(self).get_concatenated_names()
10 )
11
12 var gamedata = global.get_node("Gamedata")
13 var door_id = gamedata.get_door_for_map_node_path(global.map, node_path)
14 if door_id != null:
15 var ap = global.get_node("Archipelago")
16 var item_lock = ap.get_item_id_for_door(door_id)
17
18 if item_lock != null:
19 item_id = item_lock[0]
20 item_amount = item_lock[1]
21
22 self.senders = []
23 self.senderGroup = []
24 self.nested = false
25 self.complete_at = 0
26 self.max_length = 0
27 self.excludeSenders = []
28
29 call_deferred("_readier")
30
31 super._ready()
32
33
34func _readier():
35 var ap = global.get_node("Archipelago")
36
37 if ap.client.getItemAmount(item_id) >= item_amount:
38 handleTriggered()
diff --git a/apworld/client/apworld_runtime.gd b/apworld/client/apworld_runtime.gd new file mode 100644 index 0000000..03568bf --- /dev/null +++ b/apworld/client/apworld_runtime.gd
@@ -0,0 +1,49 @@
1extends Node
2
3var apworld_reader
4
5
6func _init(path):
7 apworld_reader = ZIPReader.new()
8 apworld_reader.open(path)
9
10
11func _get_true_path(path):
12 if path.begins_with("../"):
13 return "lingo2/%s" % path.substr(3)
14 else:
15 return "lingo2/client/%s" % path
16
17
18func path_exists(path):
19 var true_path = _get_true_path(path)
20 return apworld_reader.file_exists(true_path)
21
22
23func load_script(path):
24 var true_path = _get_true_path(path)
25
26 var script = GDScript.new()
27 script.source_code = apworld_reader.read_file(true_path).get_string_from_utf8()
28 script.reload()
29
30 return script
31
32
33func read_path(path):
34 var true_path = _get_true_path(path)
35 return apworld_reader.read_file(true_path)
36
37
38func load_script_as_scene(path, scene_name):
39 var script = load_script(path)
40 var instance = script.new()
41 instance.name = scene_name
42
43 get_tree().unload_current_scene()
44 _load_scene.call_deferred(instance)
45
46
47func _load_scene(instance):
48 get_tree().get_root().add_child(instance)
49 get_tree().current_scene = instance
diff --git a/apworld/client/assets/goal.png b/apworld/client/assets/goal.png new file mode 100644 index 0000000..bd1650d --- /dev/null +++ b/apworld/client/assets/goal.png
Binary files differ
diff --git a/apworld/client/assets/location.png b/apworld/client/assets/location.png new file mode 100644 index 0000000..5304deb --- /dev/null +++ b/apworld/client/assets/location.png
Binary files differ
diff --git a/apworld/client/assets/worldport.png b/apworld/client/assets/worldport.png new file mode 100644 index 0000000..19dfdc3 --- /dev/null +++ b/apworld/client/assets/worldport.png
Binary files differ
diff --git a/apworld/client/client.gd b/apworld/client/client.gd new file mode 100644 index 0000000..c149482 --- /dev/null +++ b/apworld/client/client.gd
@@ -0,0 +1,318 @@
1extends Node
2
3const ap_version = {"major": 0, "minor": 6, "build": 3, "class": "Version"}
4
5var SCRIPT_websocketserver
6
7var _server
8var _should_process = false
9
10var _remote_version = {"major": 0, "minor": 0, "build": 0}
11var _gen_version = {"major": 0, "minor": 0, "build": 0}
12
13var ap_server = ""
14var ap_user = ""
15var ap_pass = ""
16
17var _seed = ""
18var _team = 0
19var _slot = 0
20var _checked_locations = []
21var _checked_worldports = []
22var _received_indexes = []
23var _received_items = {}
24var _slot_data = {}
25var _accessible_locations = []
26var _accessible_worldports = []
27var _goal_accessible = false
28var _latched_doors = []
29var _hinted_locations = []
30
31signal could_not_connect
32signal connect_status
33signal client_connected(slot_data)
34signal item_received(item, amount)
35signal location_scout_received(location_id, item_name, player_name, flags, for_self)
36signal text_message_received(message)
37signal item_sent_notification(message)
38signal hint_received(message)
39signal door_latched(id)
40signal accessible_locations_updated
41signal checked_locations_updated
42signal ignored_locations_updated(locations)
43signal checked_worldports_updated
44signal keyboard_update_received
45signal hinted_locations_updated
46
47
48func _init():
49 set_process_mode(Node.PROCESS_MODE_ALWAYS)
50
51 global._print("Instantiated APClient")
52
53
54func _ready():
55 _server = SCRIPT_websocketserver.new()
56 _server.client_connected.connect(_on_web_socket_server_client_connected)
57 _server.client_disconnected.connect(_on_web_socket_server_client_disconnected)
58 _server.message_received.connect(_on_web_socket_server_message_received)
59 add_child(_server)
60 _server.listen(43182)
61
62
63func _reset_state():
64 _should_process = false
65 _received_items = {}
66 _received_indexes = []
67 _checked_worldports = []
68 _accessible_locations = []
69 _accessible_worldports = []
70 _goal_accessible = false
71
72
73func disconnect_from_ap():
74 sendMessage([{"cmd": "Disconnect"}])
75
76
77func _on_web_socket_server_client_connected(peer_id: int) -> void:
78 var peer: WebSocketPeer = _server.peers[peer_id]
79 print("Remote client connected: %d. Protocol: %s" % [peer_id, peer.get_selected_protocol()])
80 _server.send(-peer_id, "[%d] connected" % peer_id)
81
82
83func _on_web_socket_server_client_disconnected(peer_id: int) -> void:
84 var peer: WebSocketPeer = _server.peers[peer_id]
85 print(
86 (
87 "Remote client disconnected: %d. Code: %d, Reason: %s"
88 % [peer_id, peer.get_close_code(), peer.get_close_reason()]
89 )
90 )
91 _server.send(-peer_id, "[%d] disconnected" % peer_id)
92
93
94func _on_web_socket_server_message_received(_peer_id: int, packet: String) -> void:
95 global._print("Got data from server: " + packet)
96 var json = JSON.new()
97 var jserror = json.parse(packet)
98 if jserror != OK:
99 global._print("Error parsing packet from AP: " + jserror.error_string)
100 return
101
102 for message in json.data:
103 var cmd = message["cmd"]
104 global._print("Received command: " + cmd)
105
106 if cmd == "Connected":
107 _seed = message["seed_name"]
108 _remote_version = message["version"]
109 _gen_version = message["generator_version"]
110 _team = message["team"]
111 _slot = message["slot"]
112 _slot_data = message["slot_data"]
113
114 _checked_locations = []
115 for location in message["checked_locations"]:
116 _checked_locations.append(int(location))
117
118 client_connected.emit(_slot_data)
119
120 elif cmd == "ConnectionRefused":
121 could_not_connect.emit(message["text"])
122 global._print("Connection to AP refused")
123
124 elif cmd == "UpdateLocations":
125 for location in message["locations"]:
126 var lint = int(location)
127 if not _checked_locations.has(lint):
128 _checked_locations.append(lint)
129
130 checked_locations_updated.emit()
131
132 elif cmd == "UpdateWorldports":
133 for port_id in message["worldports"]:
134 var lint = int(port_id)
135 if not _checked_worldports.has(lint):
136 _checked_worldports.append(lint)
137
138 checked_worldports_updated.emit()
139
140 elif cmd == "ItemReceived":
141 for item in message["items"]:
142 var index = int(item["index"])
143 if _received_indexes.has(index):
144 # Do not re-process items.
145 continue
146
147 _received_indexes.append(index)
148
149 var item_id = int(item["id"])
150 _received_items[item_id] = _received_items.get(item_id, 0) + 1
151
152 item_received.emit(item, _received_items[item_id])
153
154 elif cmd == "TextMessage":
155 text_message_received.emit(message["data"])
156
157 elif cmd == "ItemSentNotif":
158 item_sent_notification.emit(message)
159
160 elif cmd == "HintReceived":
161 hint_received.emit(message)
162
163 elif cmd == "LocationInfo":
164 for loc in message["locations"]:
165 location_scout_received.emit(
166 int(loc["id"]), loc["item"], loc["player"], int(loc["flags"]), int(loc["self"])
167 )
168
169 elif cmd == "AccessibleLocations":
170 _accessible_locations.clear()
171 _accessible_worldports.clear()
172
173 for loc in message["locations"]:
174 _accessible_locations.append(int(loc))
175
176 if "worldports" in message:
177 for port_id in message["worldports"]:
178 _accessible_worldports.append(int(port_id))
179
180 _goal_accessible = bool(message.get("goal", false))
181
182 accessible_locations_updated.emit()
183
184 elif cmd == "UpdateKeyboard":
185 var updates = {}
186 for k in message["updates"]:
187 updates[k] = int(message["updates"][k])
188
189 keyboard_update_received.emit(updates)
190
191 elif cmd == "PathReply":
192 var textclient = global.get_node("Textclient")
193 textclient.display_logical_path(
194 message["type"], int(message.get("id", null)), message["path"]
195 )
196
197 elif cmd == "UpdateLatches":
198 for id in message["latches"]:
199 var iid = int(id)
200 if not _latched_doors.has(iid):
201 _latched_doors.append(iid)
202
203 door_latched.emit(iid)
204
205 elif cmd == "SetIgnoredLocations":
206 var locs = []
207 for id in message["locations"]:
208 locs.append(int(id))
209
210 ignored_locations_updated.emit(locs)
211
212 elif cmd == "UpdateHintedLocations":
213 for id in message["locations"]:
214 var iid = int(id)
215 if !_hinted_locations.has(iid):
216 _hinted_locations.append(iid)
217
218 hinted_locations_updated.emit()
219
220
221func connectToServer(server, un, pw):
222 sendMessage([{"cmd": "Connect", "server": server, "player": un, "password": pw}])
223
224 ap_server = server
225 ap_user = un
226 ap_pass = pw
227
228 _should_process = true
229
230 connect_status.emit("Connecting...")
231
232
233func sendMessage(msg):
234 var payload = JSON.stringify(msg)
235 _server.send(0, payload)
236
237
238func connectToRoom():
239 connect_status.emit("Authenticating...")
240
241 sendMessage(
242 [
243 {
244 "cmd": "Connect",
245 "password": ap_pass,
246 "game": "Lingo 2",
247 "name": ap_user,
248 }
249 ]
250 )
251
252
253func requestSync():
254 sendMessage([{"cmd": "Sync"}])
255
256
257func sendLocation(loc_id):
258 sendMessage([{"cmd": "LocationChecks", "locations": [loc_id]}])
259
260
261func sendLocations(loc_ids):
262 sendMessage([{"cmd": "LocationChecks", "locations": loc_ids}])
263
264
265func say(textdata):
266 sendMessage([{"cmd": "Say", "text": textdata}])
267
268
269func completedGoal():
270 sendMessage([{"cmd": "StatusUpdate", "status": 30}]) # CLIENT_GOAL
271
272
273func scoutLocations(loc_ids):
274 sendMessage([{"cmd": "LocationScouts", "locations": loc_ids}])
275
276
277func updateKeyboard(updates):
278 sendMessage([{"cmd": "UpdateKeyboard", "keyboard": updates}])
279
280
281func checkWorldport(port_id):
282 if not _checked_worldports.has(port_id):
283 sendMessage([{"cmd": "CheckWorldport", "port_id": port_id}])
284
285
286func latchDoor(id):
287 if not _latched_doors.has(id):
288 _latched_doors.append(id)
289
290 sendMessage([{"cmd": "LatchDoor", "door": id}])
291
292
293func getLogicalPath(object_type, object_id):
294 var msg = {"cmd": "GetPath", "type": object_type}
295 if object_id != null:
296 msg["id"] = object_id
297
298 sendMessage([msg])
299
300
301func addIgnoredLocation(loc_id):
302 sendMessage([{"cmd": "IgnoreLocation", "id": loc_id}])
303
304
305func removeIgnoredLocation(loc_id):
306 sendMessage([{"cmd": "UnignoreLocation", "id": loc_id}])
307
308
309func sendQuit():
310 sendMessage([{"cmd": "Quit"}])
311
312
313func hasItem(item_id):
314 return _received_items.has(item_id)
315
316
317func getItemAmount(item_id):
318 return _received_items.get(item_id, 0)
diff --git a/apworld/client/collectable.gd b/apworld/client/collectable.gd new file mode 100644 index 0000000..4a17a2a --- /dev/null +++ b/apworld/client/collectable.gd
@@ -0,0 +1,16 @@
1extends "res://scripts/nodes/collectable.gd"
2
3
4func pickedUp():
5 if unlock_type == "key":
6 var ap = global.get_node("Archipelago")
7 if ap.get_letter_behavior(unlock_key, level == 2) == ap.kLETTER_BEHAVIOR_VANILLA:
8 ap.keyboard.collect_local_letter(unlock_key, level)
9 else:
10 ap.keyboard.update_unlocks()
11
12 super.pickedUp()
13
14
15func setScoutedText(text):
16 get_node("MeshInstance3D").mesh.text = text.replace(" ", "\n")
diff --git a/apworld/client/compass.gd b/apworld/client/compass.gd new file mode 100644 index 0000000..c90475a --- /dev/null +++ b/apworld/client/compass.gd
@@ -0,0 +1,66 @@
1extends Node2D
2
3const RADIUS = 48
4
5var _font
6
7
8func _ready():
9 _font = load("res://assets/fonts/Lingo2.ttf")
10
11
12func _draw():
13 draw_circle(Vector2.ZERO, RADIUS, Color(1.0, 1.0, 1.0, 0.8), true)
14 draw_circle(Vector2.ZERO, RADIUS, Color.BLACK, false)
15 draw_string(
16 _font,
17 Vector2(-4, -RADIUS * 3.0 / 4.0),
18 "N",
19 HorizontalAlignment.HORIZONTAL_ALIGNMENT_LEFT,
20 -1,
21 16,
22 Color.BLACK
23 )
24 draw_set_transform(Vector2.ZERO, PI / 2)
25 draw_string(
26 _font,
27 Vector2(-4, -RADIUS * 3.0 / 4.0),
28 "E",
29 HorizontalAlignment.HORIZONTAL_ALIGNMENT_LEFT,
30 -1,
31 16,
32 Color.BLACK
33 )
34 draw_set_transform(Vector2.ZERO, PI)
35 draw_string(
36 _font,
37 Vector2(-4, -RADIUS * 3.0 / 4.0),
38 "S",
39 HorizontalAlignment.HORIZONTAL_ALIGNMENT_LEFT,
40 -1,
41 16,
42 Color.BLACK
43 )
44 draw_set_transform(Vector2.ZERO, PI * 3.0 / 2.0)
45 draw_string(
46 _font,
47 Vector2(-4, -RADIUS * 3.0 / 4.0),
48 "W",
49 HorizontalAlignment.HORIZONTAL_ALIGNMENT_LEFT,
50 -1,
51 16,
52 Color.BLACK
53 )
54 draw_set_transform(Vector2.ZERO)
55 draw_colored_polygon(
56 PackedVector2Array(
57 [Vector2(0, -RADIUS * 5.0 / 8.0), Vector2(-RADIUS / 6.0, 0), Vector2(RADIUS / 6.0, 0)]
58 ),
59 Color.RED
60 )
61 draw_colored_polygon(
62 PackedVector2Array(
63 [Vector2(0, RADIUS * 5.0 / 8.0), Vector2(-RADIUS / 6.0, 0), Vector2(RADIUS / 6.0, 0)]
64 ),
65 Color.GRAY
66 )
diff --git a/apworld/client/compass_overlay.gd b/apworld/client/compass_overlay.gd new file mode 100644 index 0000000..56e81ff --- /dev/null +++ b/apworld/client/compass_overlay.gd
@@ -0,0 +1,17 @@
1extends CanvasLayer
2
3var SCRIPT_compass
4
5var compass
6
7
8func _ready():
9 compass = SCRIPT_compass.new()
10 compass.position = Vector2(1840, 80)
11 add_child(compass)
12
13 visible = false
14
15
16func update_rotation(ry):
17 compass.rotation = ry
diff --git a/apworld/client/door.gd b/apworld/client/door.gd new file mode 100644 index 0000000..63cfa99 --- /dev/null +++ b/apworld/client/door.gd
@@ -0,0 +1,75 @@
1extends "res://scripts/nodes/door.gd"
2
3var door_id
4var item_id
5var item_amount
6var latched = false
7
8
9func _ready():
10 var node_path = String(
11 get_tree().get_root().get_node("scene").get_path_to(self).get_concatenated_names()
12 )
13
14 var gamedata = global.get_node("Gamedata")
15 door_id = gamedata.get_door_for_map_node_path(global.map, node_path)
16 if door_id != null:
17 var ap = global.get_node("Archipelago")
18 var item_lock = ap.get_item_id_for_door(door_id)
19
20 if item_lock != null:
21 item_id = item_lock[0]
22 item_amount = item_lock[1]
23
24 self.senders = []
25 self.senderGroup = []
26 self.nested = false
27 self.complete_at = 0
28 self.max_length = 0
29 self.excludeSenders = []
30
31 call_deferred("_readier")
32 else:
33 var door_data = gamedata.objects.get_doors()[door_id]
34 if door_data.has_latch() and door_data.get_latch():
35 _check_latched.call_deferred(door_id)
36
37 latched = true
38
39 if global.map == "the_sun_temple":
40 if name == "spe_EndPlatform" or name == "spe_entry_2":
41 senders = [NodePath("/root/scene/Panels/EndCheck_dog")]
42
43 if global.map == "the_parthenon":
44 if name == "spe_entry_1":
45 senders = [NodePath("/root/scene/Panels/EndCheck_dog")]
46
47 super._ready()
48
49
50func _readier():
51 var ap = global.get_node("Archipelago")
52
53 if ap.client.getItemAmount(item_id) >= item_amount:
54 handleTriggered()
55
56
57func _check_latched(door_id):
58 var ap = global.get_node("Archipelago")
59
60 if ap.client._latched_doors.has(door_id):
61 triggered = total
62 handleTriggered()
63
64
65func handleTriggered():
66 super.handleTriggered()
67
68 if latched and ran:
69 var ap = global.get_node("Archipelago")
70 ap.client.latchDoor(door_id)
71
72
73func handleUntriggered():
74 if not latched or not ran:
75 super.handleUntriggered()
diff --git a/apworld/client/effects.gd b/apworld/client/effects.gd new file mode 100644 index 0000000..9dc1dd8 --- /dev/null +++ b/apworld/client/effects.gd
@@ -0,0 +1,32 @@
1extends CanvasLayer
2
3var _label
4
5var _disconnected = false
6
7
8func _ready():
9 _label = Label.new()
10 _label.name = "Label"
11 _label.offset_left = 20
12 _label.offset_top = 20
13 _label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
14 _label.vertical_alignment = VERTICAL_ALIGNMENT_TOP
15 _label.theme = preload("res://assets/themes/baseUI.tres")
16 _label.add_theme_font_size_override("font_size", 36)
17 add_child(_label)
18
19
20func set_connection_lost(arg):
21 _disconnected = arg
22
23 _update_label()
24
25
26func _update_label():
27 var text = []
28
29 if _disconnected:
30 text.append("Disconnected from multiworld.")
31
32 _label.text = "\n".join(text)
diff --git a/apworld/client/gamedata.gd b/apworld/client/gamedata.gd new file mode 100644 index 0000000..373f981 --- /dev/null +++ b/apworld/client/gamedata.gd
@@ -0,0 +1,310 @@
1extends Node
2
3var SCRIPT_proto
4
5var objects
6var door_id_by_map_node_path = {}
7var painting_id_by_map_node_path = {}
8var panel_id_by_map_node_path = {}
9var port_id_by_map_node_path = {}
10var door_id_by_ap_id = {}
11var map_id_by_name = {}
12var progressive_id_by_ap_id = {}
13var letter_id_by_ap_id = {}
14var symbol_item_ids = []
15var anti_trap_ids = {}
16var location_name_by_id = {}
17var ending_display_name_by_name = {}
18var port_id_by_ap_id = {}
19var map_id_by_rte_ap_id = {}
20
21var kSYMBOL_ITEMS
22
23
24func _init(proto_script):
25 SCRIPT_proto = proto_script
26
27 kSYMBOL_ITEMS = {
28 SCRIPT_proto.PuzzleSymbol.SUN: "Sun Symbol",
29 SCRIPT_proto.PuzzleSymbol.SPARKLES: "Sparkles Symbol",
30 SCRIPT_proto.PuzzleSymbol.ZERO: "Zero Symbol",
31 SCRIPT_proto.PuzzleSymbol.EXAMPLE: "Example Symbol",
32 SCRIPT_proto.PuzzleSymbol.BOXES: "Boxes Symbol",
33 SCRIPT_proto.PuzzleSymbol.PLANET: "Planet Symbol",
34 SCRIPT_proto.PuzzleSymbol.PYRAMID: "Pyramid Symbol",
35 SCRIPT_proto.PuzzleSymbol.CROSS: "Cross Symbol",
36 SCRIPT_proto.PuzzleSymbol.SWEET: "Sweet Symbol",
37 SCRIPT_proto.PuzzleSymbol.GENDER: "Gender Symbol",
38 SCRIPT_proto.PuzzleSymbol.AGE: "Age Symbol",
39 SCRIPT_proto.PuzzleSymbol.SOUND: "Sound Symbol",
40 SCRIPT_proto.PuzzleSymbol.ANAGRAM: "Anagram Symbol",
41 SCRIPT_proto.PuzzleSymbol.JOB: "Job Symbol",
42 SCRIPT_proto.PuzzleSymbol.STARS: "Stars Symbol",
43 SCRIPT_proto.PuzzleSymbol.NULL: "Null Symbol",
44 SCRIPT_proto.PuzzleSymbol.EVAL: "Eval Symbol",
45 SCRIPT_proto.PuzzleSymbol.LINGO: "Lingo Symbol",
46 SCRIPT_proto.PuzzleSymbol.QUESTION: "Question Symbol",
47 }
48
49
50func load(data_bytes):
51 objects = SCRIPT_proto.AllObjects.new()
52
53 var result_code = objects.from_bytes(data_bytes)
54 if result_code != SCRIPT_proto.PB_ERR.NO_ERRORS:
55 print("Could not load generated data: %d" % result_code)
56 return
57
58 for map in objects.get_maps():
59 map_id_by_name[map.get_name()] = map.get_id()
60
61 if map.has_rte_ap_id():
62 map_id_by_rte_ap_id[map.get_rte_ap_id()] = map.get_id()
63
64 for door in objects.get_doors():
65 var map = objects.get_maps()[door.get_map_id()]
66
67 if not map.get_name() in door_id_by_map_node_path:
68 door_id_by_map_node_path[map.get_name()] = {}
69
70 var map_data = door_id_by_map_node_path[map.get_name()]
71 for receiver in door.get_receivers():
72 map_data[receiver] = door.get_id()
73
74 for painting_id in door.get_move_paintings():
75 var painting = objects.get_paintings()[painting_id]
76 map_data[painting.get_path()] = door.get_id()
77
78 if door.has_ap_id():
79 door_id_by_ap_id[door.get_ap_id()] = door.get_id()
80
81 if (
82 door.get_type() == SCRIPT_proto.DoorType.STANDARD
83 or door.get_type() == SCRIPT_proto.DoorType.LOCATION_ONLY
84 or door.get_type() == SCRIPT_proto.DoorType.GRAVESTONE
85 ):
86 location_name_by_id[door.get_ap_id()] = _get_door_location_name(door)
87
88 for painting in objects.get_paintings():
89 var room = objects.get_rooms()[painting.get_room_id()]
90 var map = objects.get_maps()[room.get_map_id()]
91
92 if not map.get_name() in painting_id_by_map_node_path:
93 painting_id_by_map_node_path[map.get_name()] = {}
94
95 var _map_data = painting_id_by_map_node_path[map.get_name()]
96
97 for port in objects.get_ports():
98 var room = objects.get_rooms()[port.get_room_id()]
99 var map = objects.get_maps()[room.get_map_id()]
100
101 if not map.get_name() in port_id_by_map_node_path:
102 port_id_by_map_node_path[map.get_name()] = {}
103
104 var map_data = port_id_by_map_node_path[map.get_name()]
105 map_data[port.get_path()] = port.get_id()
106
107 if port.has_ap_id():
108 port_id_by_ap_id[port.get_ap_id()] = port.get_id()
109
110 for progressive in objects.get_progressives():
111 progressive_id_by_ap_id[progressive.get_ap_id()] = progressive.get_id()
112
113 for letter in objects.get_letters():
114 letter_id_by_ap_id[letter.get_ap_id()] = letter.get_id()
115 location_name_by_id[letter.get_ap_id()] = _get_letter_location_name(letter)
116
117 for mastery in objects.get_masteries():
118 location_name_by_id[mastery.get_ap_id()] = _get_mastery_location_name(mastery)
119
120 for ending in objects.get_endings():
121 var location_name = _get_ending_location_name(ending)
122 location_name_by_id[ending.get_ap_id()] = location_name
123 ending_display_name_by_name[ending.get_name()] = location_name
124
125 for keyholder in objects.get_keyholders():
126 if keyholder.has_key():
127 location_name_by_id[keyholder.get_ap_id()] = _get_keyholder_location_name(keyholder)
128
129 for panel in objects.get_panels():
130 var room = objects.get_rooms()[panel.get_room_id()]
131 var map = objects.get_maps()[room.get_map_id()]
132
133 if not map.get_name() in panel_id_by_map_node_path:
134 panel_id_by_map_node_path[map.get_name()] = {}
135
136 var map_data = panel_id_by_map_node_path[map.get_name()]
137 map_data[panel.get_path()] = panel.get_id()
138
139 for symbol_name in kSYMBOL_ITEMS.values():
140 symbol_item_ids.append(objects.get_special_ids()[symbol_name])
141
142 for special_name in objects.get_special_ids().keys():
143 if special_name.begins_with("Anti "):
144 anti_trap_ids[objects.get_special_ids()[special_name]] = (
145 special_name.substr(5).to_lower()
146 )
147
148
149func get_door_for_map_node_path(map_name, node_path):
150 if not door_id_by_map_node_path.has(map_name):
151 return null
152
153 var map_data = door_id_by_map_node_path[map_name]
154 return map_data.get(node_path, null)
155
156
157func get_panel_for_map_node_path(map_name, node_path):
158 if not panel_id_by_map_node_path.has(map_name):
159 return null
160
161 var map_data = panel_id_by_map_node_path[map_name]
162 return map_data.get(node_path, null)
163
164
165func get_port_for_map_node_path(map_name, node_path):
166 if not port_id_by_map_node_path.has(map_name):
167 return null
168
169 var map_data = port_id_by_map_node_path[map_name]
170 return map_data.get(node_path, null)
171
172
173func get_door_ap_id(door_id):
174 var door = objects.get_doors()[door_id]
175 if door.has_ap_id():
176 return door.get_ap_id()
177 else:
178 return null
179
180
181func get_door_map_name(door_id):
182 var door = objects.get_doors()[door_id]
183 var map = objects.get_maps()[door.get_map_id()]
184 return map.get_name()
185
186
187func get_door_receivers(door_id):
188 var door = objects.get_doors()[door_id]
189 return door.get_receivers()
190
191
192func get_worldport_display_name(port_id):
193 var port = objects.get_ports()[port_id]
194 return "%s - %s" % [_get_room_object_map_name(port), port.get_display_name()]
195
196
197func _get_map_object_map_name(obj):
198 return objects.get_maps()[obj.get_map_id()].get_display_name()
199
200
201func _get_room_object_map_name(obj):
202 return _get_map_object_map_name(objects.get_rooms()[obj.get_room_id()])
203
204
205func _get_room_object_location_prefix(obj):
206 var room = objects.get_rooms()[obj.get_room_id()]
207 var game_map = objects.get_maps()[room.get_map_id()]
208
209 if room.has_panel_display_name():
210 return "%s (%s)" % [game_map.get_display_name(), room.get_panel_display_name()]
211 else:
212 return game_map.get_display_name()
213
214
215func _get_door_location_name(door):
216 var map_part = _get_room_object_location_prefix(door)
217
218 if door.has_location_name():
219 return "%s - %s" % [map_part, door.get_location_name()]
220
221 var generated_location_name = _get_generated_door_location_name(door)
222 if generated_location_name != null:
223 return generated_location_name
224
225 return "%s - %s" % [map_part, door.get_name()]
226
227
228func _get_generated_door_location_name(door):
229 if door.get_type() != SCRIPT_proto.DoorType.STANDARD:
230 return null
231
232 if (
233 door.get_keyholders().size() > 0
234 or (door.has_white_ending() and door.get_white_ending())
235 or door.has_complete_at()
236 ):
237 return null
238
239 if door.get_panels().size() > 4:
240 return null
241
242 var map_areas = []
243 for panel_id in door.get_panels():
244 var panel = objects.get_panels()[panel_id.get_panel()]
245 var panel_room = objects.get_rooms()[panel.get_room_id()]
246 # It's okay if panel_display_name is not present because then it's coalesced with other unnamed areas.
247 var panel_display_name = ""
248 if panel_room.has_panel_display_name():
249 panel_display_name = panel_room.get_panel_display_name()
250 if not map_areas.has(panel_display_name):
251 map_areas.append(panel_display_name)
252
253 if map_areas.size() > 1:
254 return null
255
256 var game_map = objects.get_maps()[door.get_map_id()]
257 var map_area = map_areas[0]
258 var map_part
259 if map_area == "":
260 map_part = game_map.get_display_name()
261 else:
262 map_part = "%s (%s)" % [game_map.get_display_name(), map_area]
263
264 var panel_names = []
265 for panel_id in door.get_panels():
266 var panel_data = objects.get_panels()[panel_id.get_panel()]
267 var panel_name
268 if panel_data.has_display_name():
269 panel_name = panel_data.get_display_name()
270 else:
271 panel_name = panel_data.get_name()
272
273 var location_part
274 if panel_id.has_answer():
275 location_part = "%s/%s" % [panel_name, panel_id.get_answer().to_upper()]
276 else:
277 location_part = panel_name
278
279 panel_names.append(location_part)
280
281 panel_names.sort()
282
283 return map_part + " - " + ", ".join(panel_names)
284
285
286func _get_letter_location_name(letter):
287 var letter_level = 2 if (letter.has_level2() and letter.get_level2()) else 1
288 var letter_name = "%s%d" % [letter.get_key().to_upper(), letter_level]
289 return "%s - %s" % [_get_room_object_map_name(letter), letter_name]
290
291
292func _get_mastery_location_name(mastery):
293 return "%s - Mastery" % _get_room_object_map_name(mastery)
294
295
296func _get_ending_location_name(ending):
297 return (
298 "%s - %s Ending" % [_get_room_object_map_name(ending), ending.get_name().to_pascal_case()]
299 )
300
301
302func _get_keyholder_location_name(keyholder):
303 return (
304 "%s - %s Keyholder"
305 % [_get_room_object_location_prefix(keyholder), keyholder.get_key().to_upper()]
306 )
307
308
309func vec3d_to_vector3(input) -> Vector3:
310 return Vector3(input.get_x(), input.get_y(), input.get_z())
diff --git a/apworld/client/keyHolder.gd b/apworld/client/keyHolder.gd new file mode 100644 index 0000000..3c037ff --- /dev/null +++ b/apworld/client/keyHolder.gd
@@ -0,0 +1,38 @@
1extends "res://scripts/nodes/keyHolder.gd"
2
3
4func setFromAp(key, level):
5 if level > 0:
6 has_key = true
7 is_complete = "%s%d" % [key, level]
8 held_key = key
9 held_level = level
10 get_node("Hinge/Letter").mesh.text = held_key
11 get_node("Hinge/Letter2").mesh.text = held_key
12 setMaterial()
13 emit_signal("trigger")
14 else:
15 has_key = false
16 held_key = ""
17 held_level = 0
18 setMaterial()
19 get_node("Hinge/Letter").mesh.text = "-"
20 get_node("Hinge/Letter2").mesh.text = "-"
21 is_complete = ""
22 emit_signal("untrigger")
23
24
25func addKey(key):
26 var node_path = String(
27 get_tree().get_root().get_node("scene").get_path_to(self).get_concatenated_names()
28 )
29 var ap = global.get_node("Archipelago")
30 ap.keyboard.put_in_keyholder(key, global.map, node_path)
31
32
33func removeKey():
34 var node_path = String(
35 get_tree().get_root().get_node("scene").get_path_to(self).get_concatenated_names()
36 )
37 var ap = global.get_node("Archipelago")
38 ap.keyboard.remove_from_keyholder(held_key, global.map, node_path)
diff --git a/apworld/client/keyHolderChecker.gd b/apworld/client/keyHolderChecker.gd new file mode 100644 index 0000000..a75a9e4 --- /dev/null +++ b/apworld/client/keyHolderChecker.gd
@@ -0,0 +1,24 @@
1extends "res://scripts/nodes/listeners/keyHolderChecker.gd"
2
3
4func check():
5 var ap = global.get_node("Archipelago")
6 var matches = []
7 for map in ap.keyboard.keyholder_state.keys():
8 var nodes = ap.keyboard.keyholder_state[map]
9 for node in nodes.keys():
10 matches.append([nodes[node], 1, map, "/root/scene/%s" % node])
11
12 var count = 0
13 for key_match in matches:
14 var active = (
15 key_match[2] + String(key_match[3]).replace("/root/scene/Components/KeyHolders/", ".")
16 )
17 if map[active] == key_match[0]:
18 emit_signal("trigger_letter", key_match[0], true)
19 count += 1
20 else:
21 emit_signal("trigger_letter", key_match[0], false)
22
23 if count > 25:
24 emit_signal("trigger")
diff --git a/apworld/client/keyHolderResetterListener.gd b/apworld/client/keyHolderResetterListener.gd new file mode 100644 index 0000000..9ab45f9 --- /dev/null +++ b/apworld/client/keyHolderResetterListener.gd
@@ -0,0 +1,10 @@
1extends "res://scripts/nodes/listeners/keyHolderResetterListener.gd"
2
3
4func reset():
5 var ap = global.get_node("Archipelago")
6 var was_removed = ap.keyboard.reset_keyholders()
7 if was_removed:
8 sfxPlayer.sfx_play("pickup")
9
10 ap.client.requestSync()
diff --git a/apworld/client/keyboard.gd b/apworld/client/keyboard.gd new file mode 100644 index 0000000..9026c06 --- /dev/null +++ b/apworld/client/keyboard.gd
@@ -0,0 +1,228 @@
1extends Node
2
3const kALL_LETTERS = "abcdefghjiklmnopqrstuvwxyz"
4
5var letters_saved = {}
6var letters_in_keyholders = []
7var letters_blocked = []
8var letters_dynamic = {}
9var keyholder_state = {}
10
11var filename = ""
12
13
14func _init():
15 reset()
16
17
18func reset():
19 letters_saved.clear()
20 letters_in_keyholders.clear()
21 letters_blocked.clear()
22 letters_dynamic.clear()
23 keyholder_state.clear()
24
25
26func load_seed():
27 var ap = global.get_node("Archipelago")
28
29 reset()
30
31 filename = "user://archipelago_keys/%s_%d" % [ap.client._seed, ap.client._slot]
32
33 if FileAccess.file_exists(filename):
34 var ap_file = FileAccess.open(filename, FileAccess.READ)
35 var localdata = []
36 if ap_file != null:
37 localdata = ap_file.get_var(true)
38 ap_file.close()
39
40 if typeof(localdata) != TYPE_ARRAY:
41 print("AP keyboard file is corrupted")
42 localdata = []
43
44 if localdata.size() > 0:
45 letters_saved = localdata[0]
46 if localdata.size() > 1:
47 letters_in_keyholders = localdata[1]
48 if localdata.size() > 2:
49 keyholder_state = localdata[2]
50
51 if not letters_saved.is_empty():
52 ap.client.updateKeyboard(letters_saved)
53
54 for k in kALL_LETTERS:
55 var level = 0
56
57 if ap.get_letter_behavior(k, false) == ap.kLETTER_BEHAVIOR_UNLOCKED:
58 level += 1
59 if ap.get_letter_behavior(k, true) == ap.kLETTER_BEHAVIOR_UNLOCKED:
60 level += 1
61
62 letters_dynamic[k] = level
63
64 update_unlocks()
65
66
67func save():
68 var dir = DirAccess.open("user://")
69 var folder = "archipelago_keys"
70 if not dir.dir_exists(folder):
71 dir.make_dir(folder)
72
73 var file = FileAccess.open(filename, FileAccess.WRITE)
74
75 var data = [
76 letters_saved,
77 letters_in_keyholders,
78 keyholder_state,
79 ]
80 file.store_var(data, true)
81 file.close()
82
83
84func update_unlocks():
85 unlocks.resetKeys()
86
87 var has_doubles = false
88
89 for k in kALL_LETTERS:
90 var level = 0
91
92 if not letters_in_keyholders.has(k):
93 level = letters_saved.get(k, 0) + letters_dynamic.get(k, 0)
94
95 if level >= 2:
96 level = 2
97 has_doubles = true
98
99 if letters_blocked.has(k):
100 level = 0
101
102 unlocks.unlockKey(k, level)
103
104 if has_doubles and unlocks.data["double_letters"] != "unlocked":
105 var ap = global.get_node("Archipelago")
106 if ap.cyan_door_behavior == ap.kCYAN_DOOR_BEHAVIOR_DOUBLE_LETTER:
107 unlocks.setData("double_letters", "unlocked")
108
109
110func collect_local_letter(key, level):
111 var ap = global.get_node("Archipelago")
112 var true_level = 0
113
114 if ap.get_letter_behavior(key, false) == ap.kLETTER_BEHAVIOR_VANILLA:
115 true_level += 1
116 if level == 2 and ap.get_letter_behavior(key, true) == ap.kLETTER_BEHAVIOR_VANILLA:
117 true_level += 1
118
119 if true_level < letters_saved.get(key, 0):
120 return
121
122 letters_saved[key] = true_level
123
124 ap.client.updateKeyboard({key: true_level})
125
126 if letters_blocked.has(key):
127 letters_blocked.erase(key)
128
129 update_unlocks()
130 save()
131
132
133func collect_remote_letter(key, level):
134 if level < 0 or level > 2 or level < letters_dynamic.get(key, 0):
135 return
136
137 letters_dynamic[key] = level
138
139 if letters_blocked.has(key):
140 letters_blocked.erase(key)
141
142 update_unlocks()
143 save()
144
145
146func put_in_keyholder(key, map, kh_path):
147 if not keyholder_state.has(map):
148 keyholder_state[map] = {}
149
150 keyholder_state[map][kh_path] = key
151 letters_in_keyholders.append(key)
152
153 get_tree().get_root().get_node("scene").get_node(kh_path).setFromAp(
154 key, min(letters_saved.get(key, 0) + letters_dynamic.get(key, 0), 2)
155 )
156
157 update_unlocks()
158 save()
159
160
161func remove_from_keyholder(key, map, kh_path):
162 if not keyholder_state.has(map):
163 # This... shouldn't happen.
164 keyholder_state[map] = {}
165
166 keyholder_state[map].erase(kh_path)
167 letters_in_keyholders.erase(key)
168
169 get_tree().get_root().get_node("scene").get_node(kh_path).setFromAp(key, 0)
170
171 update_unlocks()
172 save()
173
174
175func block_letter(key):
176 if not letters_blocked.has(key):
177 letters_blocked.append(key)
178
179 update_unlocks()
180
181
182func load_keyholders(map):
183 if keyholder_state.has(map):
184 var khs = keyholder_state[map]
185
186 for path in khs.keys():
187 var key = khs[path]
188 get_tree().get_root().get_node("scene").get_node(path).setFromAp(
189 key, min(letters_saved.get(key, 0) + letters_dynamic.get(key, 0), 2)
190 )
191
192
193func reset_keyholders():
194 var cleared_anything = not letters_in_keyholders.is_empty() or not letters_blocked.is_empty()
195
196 if keyholder_state.has(global.map):
197 for path in keyholder_state[global.map]:
198 get_tree().get_root().get_node("scene").get_node(path).setFromAp(
199 keyholder_state[global.map][path], 0
200 )
201
202 keyholder_state.clear()
203 letters_in_keyholders.clear()
204 letters_blocked.clear()
205
206 update_unlocks()
207 save()
208
209 return cleared_anything
210
211
212func remote_keyboard_updated(updates):
213 var reverse = {}
214 var should_update = false
215
216 for k in updates:
217 if not letters_saved.has(k) or updates[k] > letters_saved[k]:
218 letters_saved[k] = updates[k]
219 should_update = true
220 elif updates[k] < letters_saved[k]:
221 reverse[k] = letters_saved[k]
222
223 if should_update:
224 update_unlocks()
225
226 if not reverse.is_empty():
227 var ap = global.get_node("Archipelago")
228 ap.client.updateKeyboard(reverse)
diff --git a/apworld/client/locationListener.gd b/apworld/client/locationListener.gd new file mode 100644 index 0000000..71792ed --- /dev/null +++ b/apworld/client/locationListener.gd
@@ -0,0 +1,20 @@
1extends Receiver
2
3var location_id
4
5
6func _ready():
7 super._ready()
8
9
10func handleTriggered():
11 triggered += 1
12 if triggered >= total:
13 var ap = global.get_node("Archipelago")
14 ap.send_location(location_id)
15
16
17func handleUntriggered():
18 triggered -= 1
19 if triggered < total:
20 pass
diff --git a/apworld/client/main.gd b/apworld/client/main.gd new file mode 100644 index 0000000..8cac24c --- /dev/null +++ b/apworld/client/main.gd
@@ -0,0 +1,314 @@
1extends Node
2
3
4func _ready():
5 var runtime = global.get_node("Runtime")
6
7 # Some helpful logging.
8 if Steam.isSubscribed():
9 global._print("Provisioning successful! Build ID: %d" % Steam.getAppBuildId())
10 else:
11 global._print("Provisioning failed.")
12
13 # Undo the load screen removing our cursor
14 get_tree().get_root().set_disable_input(false)
15 Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
16
17 # Increase the WebSocket input buffer size so that we can download large
18 # data packages.
19 ProjectSettings.set_setting("network/limits/websocket_client/max_in_buffer_kb", 8192)
20
21 switcher.layer = 4
22
23 # Create the global AP manager, if it doesn't already exist.
24 if not global.has_node("Archipelago"):
25 var ap_script = runtime.load_script("manager.gd")
26 var ap_instance = ap_script.new()
27 ap_instance.name = "Archipelago"
28
29 ap_instance.SCRIPT_client = runtime.load_script("client.gd")
30 ap_instance.SCRIPT_keyboard = runtime.load_script("keyboard.gd")
31 ap_instance.SCRIPT_locationListener = runtime.load_script("locationListener.gd")
32 ap_instance.SCRIPT_minimap = runtime.load_script("minimap.gd")
33 ap_instance.SCRIPT_victoryListener = runtime.load_script("victoryListener.gd")
34 ap_instance.SCRIPT_websocketserver = runtime.load_script("vendor/WebSocketServer.gd")
35
36 global.add_child(ap_instance)
37
38 # Let's also inject any scripts we need to inject now.
39 installScriptExtension(runtime.load_script("allowNumbers.gd"))
40 installScriptExtension(runtime.load_script("animationListener.gd"))
41 installScriptExtension(runtime.load_script("collectable.gd"))
42 installScriptExtension(runtime.load_script("door.gd"))
43 installScriptExtension(runtime.load_script("keyHolder.gd"))
44 installScriptExtension(runtime.load_script("keyHolderChecker.gd"))
45 installScriptExtension(runtime.load_script("keyHolderResetterListener.gd"))
46 installScriptExtension(runtime.load_script("painting.gd"))
47 installScriptExtension(runtime.load_script("paintingAuto.gd"))
48 installScriptExtension(runtime.load_script("panel.gd"))
49 installScriptExtension(runtime.load_script("pauseMenu.gd"))
50 installScriptExtension(runtime.load_script("player.gd"))
51 installScriptExtension(runtime.load_script("rteMenu.gd"))
52 installScriptExtension(runtime.load_script("saver.gd"))
53 installScriptExtension(runtime.load_script("teleport.gd"))
54 installScriptExtension(runtime.load_script("teleportListener.gd"))
55 installScriptExtension(runtime.load_script("unlockReaderListener.gd"))
56 installScriptExtension(runtime.load_script("visibilityListener.gd"))
57 installScriptExtension(runtime.load_script("worldport.gd"))
58 installScriptExtension(runtime.load_script("worldportListener.gd"))
59
60 var proto_script = runtime.load_script("../generated/proto.gd")
61 var gamedata_script = runtime.load_script("gamedata.gd")
62 var gamedata_instance = gamedata_script.new(proto_script)
63 gamedata_instance.load(runtime.read_path("../generated/data.binpb"))
64 gamedata_instance.name = "Gamedata"
65 global.add_child(gamedata_instance)
66
67 var messages_script = runtime.load_script("messages.gd")
68 var messages_instance = messages_script.new()
69 messages_instance.name = "Messages"
70 messages_instance.SCRIPT_rainbowText = runtime.load_script("rainbowText.gd")
71 global.add_child(messages_instance)
72
73 var effects_script = runtime.load_script("effects.gd")
74 var effects_instance = effects_script.new()
75 effects_instance.name = "Effects"
76 global.add_child(effects_instance)
77
78 var textclient_script = runtime.load_script("textclient.gd")
79 var textclient_instance = textclient_script.new()
80 textclient_instance.name = "Textclient"
81 global.add_child(textclient_instance)
82
83 var compass_overlay_script = runtime.load_script("compass_overlay.gd")
84 var compass_overlay_instance = compass_overlay_script.new()
85 compass_overlay_instance.name = "Compass"
86 compass_overlay_instance.SCRIPT_compass = runtime.load_script("compass.gd")
87 global.add_child(compass_overlay_instance)
88
89 unlocks.data["advanced_mastery"] = ""
90 unlocks.data["charismatic_mastery"] = ""
91 unlocks.data["crystalline_mastery"] = ""
92 unlocks.data["fuzzy_mastery"] = ""
93 unlocks.data["icarus_mastery"] = ""
94 unlocks.data["stellar_mastery"] = ""
95
96 var ap = global.get_node("Archipelago")
97 var gamedata = global.get_node("Gamedata")
98 ap.ap_connected.connect(connectionSuccessful)
99 ap.could_not_connect.connect(connectionUnsuccessful)
100 ap.connect_status.connect(connectionStatus)
101
102 # Populate textboxes with AP settings.
103 get_node("../Panel/server_box").text = ap.ap_server
104 get_node("../Panel/player_box").text = ap.ap_user
105 get_node("../Panel/password_box").text = ap.ap_pass
106
107 var history_box = get_node("../Panel/connection_history")
108 if ap.connection_history.is_empty():
109 history_box.disabled = true
110 else:
111 history_box.disabled = false
112
113 var i = 0
114 for details in ap.connection_history:
115 history_box.get_popup().add_item("%s (%s)" % [details[1], details[0]], i)
116 i += 1
117
118 history_box.get_popup().id_pressed.connect(historySelected)
119
120 # Show client version.
121 var version = gamedata.objects.get_version()
122 get_node("../Panel/title").text = (
123 "ARCHIPELAGO (%d.%d.%d)" % [version.get_major(), version.get_minor(), version.get_patch()]
124 )
125
126 # Increase font size in text boxes.
127 get_node("../Panel/server_box").add_theme_font_size_override("font_size", 36)
128 get_node("../Panel/player_box").add_theme_font_size_override("font_size", 36)
129 get_node("../Panel/password_box").add_theme_font_size_override("font_size", 36)
130
131 # Set up version mismatch dialog.
132 get_node("../Panel/VersionMismatch").confirmed.connect(startGame)
133 get_node("../Panel/VersionMismatch").get_cancel_button().pressed.connect(
134 versionMismatchDeclined
135 )
136
137 # Set up buttons.
138 get_node("../Panel/connect_button").pressed.connect(_connect_pressed)
139 get_node("../Panel/quit_button").pressed.connect(_back_pressed)
140
141
142func _connect_pressed():
143 get_node("../Panel/connect_button").disabled = true
144
145 var ap = global.get_node("Archipelago")
146 ap.ap_server = get_node("../Panel/server_box").text
147 ap.ap_user = get_node("../Panel/player_box").text
148 ap.ap_pass = get_node("../Panel/password_box").text
149 ap.saveSettings()
150
151 ap.connectToServer()
152
153
154func _back_pressed():
155 var ap = global.get_node("Archipelago")
156 ap.disconnect_from_ap()
157 ap.client.sendQuit()
158
159 get_tree().quit()
160
161
162# Adapted from https://gitlab.com/Delta-V-Modding/Mods/-/blob/main/game/ModLoader.gd
163func installScriptExtension(childScript: Resource):
164 # Force Godot to compile the script now.
165 # We need to do this here to ensure that the inheritance chain is
166 # properly set up, and multiple mods can chain-extend the same
167 # class multiple times.
168 # This is also needed to make Godot instantiate the extended class
169 # when creating singletons.
170 # The actual instance is thrown away.
171 childScript.new()
172
173 var parentScript = childScript.get_base_script()
174 var parentScriptPath = parentScript.resource_path
175 global._print("ModLoader: Installing script extension over %s" % parentScriptPath)
176 childScript.take_over_path(parentScriptPath)
177
178
179func connectionStatus(message):
180 var popup = get_node("../Panel/AcceptDialog")
181 popup.title = "Connecting to Archipelago"
182 popup.dialog_text = message
183 popup.exclusive = true
184 popup.get_ok_button().visible = false
185 popup.popup_centered()
186
187
188func connectionSuccessful():
189 var ap = global.get_node("Archipelago")
190 var gamedata = global.get_node("Gamedata")
191
192 # Check for major version mismatch.
193 if ap.apworld_version[0] != gamedata.objects.get_version().get_major():
194 get_node("../Panel/AcceptDialog").exclusive = false
195
196 var popup = get_node("../Panel/VersionMismatch")
197 popup.title = "Version Mismatch!"
198 popup.dialog_text = (
199 "This slot was generated using v%d.%d.%d of the Lingo 2 apworld,\nwhich has a different major version than this client (v%d.%d.%d).\nIt is highly recommended to play using the correct version of the client.\nYou may experience bugs or logic issues if you continue."
200 % [
201 ap.apworld_version[0],
202 ap.apworld_version[1],
203 ap.apworld_version[2],
204 gamedata.objects.get_version().get_major(),
205 gamedata.objects.get_version().get_minor(),
206 gamedata.objects.get_version().get_patch()
207 ]
208 )
209 popup.exclusive = true
210 popup.popup_centered()
211
212 return
213
214 startGame()
215
216
217func startGame():
218 var ap = global.get_node("Archipelago")
219
220 # Save connection details
221 var connection_details = [ap.ap_server, ap.ap_user, ap.ap_pass]
222 if ap.connection_history.has(connection_details):
223 ap.connection_history.erase(connection_details)
224 ap.connection_history.push_front(connection_details)
225 if ap.connection_history.size() > 10:
226 ap.connection_history.resize(10)
227 ap.saveSettings()
228
229 # Switch to the_entry
230 Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
231 global.user = ap.getSaveFileName()
232 global.universe = "lingo"
233
234 if ap.daedalus_only:
235 global.map = "daedalus"
236 else:
237 global.map = "the_entry"
238
239 unlocks.resetCollectables()
240 unlocks.resetData()
241 unlocks.loadCollectables()
242 unlocks.loadData()
243
244 ap.setup_keys()
245
246 unlocks.unlockKey("capslock", 1)
247
248 if ap.shuffle_worldports:
249 settings.worldport_fades = "default"
250 else:
251 settings.worldport_fades = "never"
252
253 clearResourceCache("res://objects/meshes/gridDoor.tscn")
254 clearResourceCache("res://objects/nodes/allowNumbers.tscn")
255 clearResourceCache("res://objects/nodes/collectable.tscn")
256 clearResourceCache("res://objects/nodes/door.tscn")
257 clearResourceCache("res://objects/nodes/keyHolder.tscn")
258 clearResourceCache("res://objects/nodes/listeners/animationListener.tscn")
259 clearResourceCache("res://objects/nodes/listeners/keyHolderChecker.tscn")
260 clearResourceCache("res://objects/nodes/listeners/keyHolderResetterListener.tscn")
261 clearResourceCache("res://objects/nodes/listeners/teleportListener.tscn")
262 clearResourceCache("res://objects/nodes/listeners/unlockReaderListener.tscn")
263 clearResourceCache("res://objects/nodes/listeners/visibilityListener.tscn")
264 clearResourceCache("res://objects/nodes/listeners/worldportListener.tscn")
265 clearResourceCache("res://objects/nodes/panel.tscn")
266 clearResourceCache("res://objects/nodes/player.tscn")
267 clearResourceCache("res://objects/nodes/saver.tscn")
268 clearResourceCache("res://objects/nodes/teleport.tscn")
269 clearResourceCache("res://objects/nodes/worldport.tscn")
270 clearResourceCache("res://objects/scenes/menus/pause_menu.tscn")
271 clearResourceCache("res://objects/scenes/menus/rte_inner.tscn")
272
273 var paintings_dir = DirAccess.open("res://objects/meshes/paintings")
274 if paintings_dir:
275 paintings_dir.list_dir_begin()
276 var file_name = paintings_dir.get_next()
277 while file_name != "":
278 if not paintings_dir.current_is_dir() and file_name.ends_with(".tscn"):
279 clearResourceCache("res://objects/meshes/paintings/" + file_name)
280 file_name = paintings_dir.get_next()
281
282 switcher.switch_map.call_deferred("res://objects/scenes/%s.tscn" % global.map)
283
284
285func connectionUnsuccessful(error_message):
286 get_node("../Panel/connect_button").disabled = false
287
288 var popup = get_node("../Panel/AcceptDialog")
289 popup.title = "Could not connect to Archipelago"
290 popup.dialog_text = error_message
291 popup.exclusive = true
292 popup.get_ok_button().visible = true
293 popup.popup_centered()
294
295
296func versionMismatchDeclined():
297 get_node("../Panel/AcceptDialog").hide()
298 get_node("../Panel/connect_button").disabled = false
299
300 var ap = global.get_node("Archipelago")
301 ap.disconnect_from_ap()
302
303
304func historySelected(index):
305 var ap = global.get_node("Archipelago")
306 var details = ap.connection_history[index]
307
308 get_node("../Panel/server_box").text = details[0]
309 get_node("../Panel/player_box").text = details[1]
310 get_node("../Panel/password_box").text = details[2]
311
312
313func clearResourceCache(path):
314 ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_REPLACE)
diff --git a/apworld/client/manager.gd b/apworld/client/manager.gd new file mode 100644 index 0000000..f10a0b7 --- /dev/null +++ b/apworld/client/manager.gd
@@ -0,0 +1,773 @@
1extends Node
2
3var SCRIPT_client
4var SCRIPT_keyboard
5var SCRIPT_locationListener
6var SCRIPT_minimap
7var SCRIPT_victoryListener
8var SCRIPT_websocketserver
9
10var ap_server = ""
11var ap_user = ""
12var ap_pass = ""
13var connection_history = []
14var show_compass = false
15var show_locations = false
16var show_minimap = false
17
18var client
19var keyboard
20
21var _localdata_file = ""
22var _last_new_item = -1
23var _batch_locations = false
24var _held_locations = []
25var _held_location_scouts = []
26var _location_scouts = {}
27var _item_locks = {}
28var _inverse_item_locks = {}
29var _held_letters = {}
30var _letters_setup = false
31var _already_connected = false
32var _ignored_locations = []
33var _map_scripts = {}
34
35const kSHUFFLE_LETTERS_VANILLA = 0
36const kSHUFFLE_LETTERS_UNLOCKED = 1
37const kSHUFFLE_LETTERS_PROGRESSIVE = 2
38const kSHUFFLE_LETTERS_VANILLA_CYAN = 3
39const kSHUFFLE_LETTERS_ITEM_CYAN = 4
40
41const kLETTER_BEHAVIOR_VANILLA = 0
42const kLETTER_BEHAVIOR_ITEM = 1
43const kLETTER_BEHAVIOR_UNLOCKED = 2
44
45const kCYAN_DOOR_BEHAVIOR_H2 = 0
46const kCYAN_DOOR_BEHAVIOR_DOUBLE_LETTER = 1
47const kCYAN_DOOR_BEHAVIOR_ITEM = 2
48
49const kFAST_TRAVEL_ACCESS_VANILLA = 0
50const kFAST_TRAVEL_ACCESS_UNLOCKED = 1
51const kFAST_TRAVEL_ACCESS_ITEMS = 2
52
53const kEndingNameByVictoryValue = {
54 0: "GRAY",
55 1: "PURPLE",
56 2: "MINT",
57 3: "BLACK",
58 4: "BLUE",
59 5: "CYAN",
60 6: "RED",
61 7: "PLUM",
62 8: "ORANGE",
63 9: "GOLD",
64 10: "YELLOW",
65 11: "GREEN",
66 12: "WHITE",
67}
68
69var apworld_version = [0, 0, 0]
70var custom_mint_ending = ""
71var cyan_door_behavior = kCYAN_DOOR_BEHAVIOR_H2
72var daedalus_only = false
73var daedalus_roof_access = false
74var enable_gift_maps = []
75var enable_icarus = false
76var endings_requirement = 0
77var fast_travel_access = 0
78var keyholder_sanity = false
79var masteries_requirement = 0
80var music_mapping = {}
81var port_pairings = {}
82var rte_mapping = []
83var shuffle_control_center_colors = false
84var shuffle_doors = false
85var shuffle_gallery_paintings = false
86var shuffle_letters = kSHUFFLE_LETTERS_VANILLA
87var shuffle_symbols = false
88var shuffle_worldports = false
89var slot_rng = null
90var strict_cyan_ending = false
91var strict_purple_ending = false
92var victory_condition = -1
93
94var color_by_material_path = {}
95
96signal could_not_connect
97signal connect_status
98signal ap_connected
99
100
101func _init():
102 # Read AP settings from file, if there are any
103 if FileAccess.file_exists("user://ap_settings"):
104 var file = FileAccess.open("user://ap_settings", FileAccess.READ)
105 var data = file.get_var(true)
106 file.close()
107
108 if typeof(data) != TYPE_ARRAY:
109 global._print("AP settings file is corrupted")
110 data = []
111
112 if data.size() > 0:
113 ap_server = data[0]
114
115 if data.size() > 1:
116 ap_user = data[1]
117
118 if data.size() > 2:
119 ap_pass = data[2]
120
121 if data.size() > 3:
122 connection_history = data[3]
123
124 if data.size() > 4:
125 show_compass = data[4]
126
127 if data.size() > 5:
128 show_locations = data[5]
129
130 if data.size() > 6:
131 show_minimap = data[6]
132
133 # We need to create a mapping from material paths to the original colors of
134 # those materials. We force reload the materials, overwriting any custom
135 # textures, and create the mapping. We then reload the textures in case the
136 # player had a custom one enabled.
137 var directory = DirAccess.open("res://assets/materials")
138 for material_name in directory.get_files():
139 var material = ResourceLoader.load(
140 "res://assets/materials/" + material_name, "", ResourceLoader.CACHE_MODE_REPLACE
141 )
142
143 color_by_material_path[material.resource_path] = Color(material.albedo_color)
144
145 settings.load_user_textures()
146
147
148func _ready():
149 client = SCRIPT_client.new()
150 client.SCRIPT_websocketserver = SCRIPT_websocketserver
151
152 client.item_received.connect(_process_item)
153 client.location_scout_received.connect(_process_location_scout)
154 client.text_message_received.connect(_process_text_message)
155 client.item_sent_notification.connect(_process_item_sent_notification)
156 client.hint_received.connect(_process_hint_received)
157 client.accessible_locations_updated.connect(_on_accessible_locations_updated)
158 client.checked_locations_updated.connect(_on_checked_locations_updated)
159 client.ignored_locations_updated.connect(_on_ignored_locations_updated)
160 client.hinted_locations_updated.connect(_on_hinted_locations_updated)
161 client.checked_worldports_updated.connect(_on_checked_worldports_updated)
162 client.door_latched.connect(_on_door_latched)
163
164 client.could_not_connect.connect(_client_could_not_connect)
165 client.connect_status.connect(_client_connect_status)
166 client.client_connected.connect(_client_connected)
167
168 add_child(client)
169
170 keyboard = SCRIPT_keyboard.new()
171 add_child(keyboard)
172 client.keyboard_update_received.connect(keyboard.remote_keyboard_updated)
173
174
175func saveSettings():
176 # Save the AP settings to disk.
177 var path = "user://ap_settings"
178 var file = FileAccess.open(path, FileAccess.WRITE)
179
180 var data = [
181 ap_server,
182 ap_user,
183 ap_pass,
184 connection_history,
185 show_compass,
186 show_locations,
187 show_minimap,
188 ]
189 file.store_var(data, true)
190 file.close()
191
192
193func saveLocaldata():
194 # Save the MW/slot specific settings to disk.
195 var dir = DirAccess.open("user://")
196 var folder = "archipelago_data"
197 if not dir.dir_exists(folder):
198 dir.make_dir(folder)
199
200 var file = FileAccess.open(_localdata_file, FileAccess.WRITE)
201
202 var data = [
203 _last_new_item,
204 ]
205 file.store_var(data, true)
206 file.close()
207
208
209func connectToServer():
210 _last_new_item = -1
211 _batch_locations = false
212 _held_locations = []
213 _held_location_scouts = []
214 _location_scouts = {}
215 _letters_setup = false
216 _held_letters = {}
217 _already_connected = false
218
219 client.connectToServer(ap_server, ap_user, ap_pass)
220
221
222func getSaveFileName():
223 return "zzAP_%s_%d" % [client._seed, client._slot]
224
225
226func disconnect_from_ap():
227 _already_connected = false
228
229 var effects = global.get_node("Effects")
230 effects.set_connection_lost(false)
231
232 client.disconnect_from_ap()
233
234
235func get_item_id_for_door(door_id):
236 return _item_locks.get(door_id, null)
237
238
239func _process_item(item, amount):
240 var gamedata = global.get_node("Gamedata")
241
242 var item_id = int(item["id"])
243 var prog_id = null
244 if _inverse_item_locks.has(item_id):
245 for lock in _inverse_item_locks.get(item_id):
246 if lock[1] != amount:
247 continue
248
249 if gamedata.progressive_id_by_ap_id.has(item_id):
250 prog_id = lock[0]
251
252 if gamedata.get_door_map_name(lock[0]) != global.map:
253 continue
254
255 # TODO: fix doors opening from door groups
256 var receivers = gamedata.get_door_receivers(lock[0])
257 var scene = get_tree().get_root().get_node_or_null("scene")
258 if scene != null:
259 for receiver in receivers:
260 var rnode = scene.get_node_or_null(receiver)
261 if rnode != null:
262 rnode.handleTriggered()
263
264 var letter_id = gamedata.letter_id_by_ap_id.get(item_id, null)
265 if letter_id != null:
266 var letter = gamedata.objects.get_letters()[letter_id]
267 if not letter.has_level2() or not letter.get_level2():
268 _process_key_item(letter.get_key(), amount)
269
270 if gamedata.symbol_item_ids.has(item_id):
271 var player = get_tree().get_root().get_node_or_null("scene/player")
272 if player != null:
273 player.evaluate_solvability.emit()
274
275 if item_id == gamedata.objects.get_special_ids()["A Job Well Done"]:
276 update_job_well_done_sign()
277
278 if item_id == gamedata.objects.get_special_ids()["Numbers"] and global.map == "the_fuzzy":
279 global.allow_numbers = true
280
281 if gamedata.map_id_by_rte_ap_id.has(item_id):
282 var rteInner = get_tree().get_root().get_node_or_null(
283 "scene/player/pause_menu/menu/return/rteInner"
284 )
285 if rteInner != null:
286 rteInner.refreshButtons()
287
288 # Show a message about the item if it's new.
289 if int(item["index"]) > _last_new_item:
290 _last_new_item = int(item["index"])
291 saveLocaldata()
292
293 var full_item_name = item["text"]
294 if prog_id != null:
295 var door = gamedata.objects.get_doors()[prog_id]
296 full_item_name = "%s (%s)" % [full_item_name, door.get_name()]
297
298 var message
299 if "sender" in item:
300 message = (
301 "Received %s from %s"
302 % [wrapInItemColorTags(full_item_name, item["flags"]), item["sender"]]
303 )
304 else:
305 message = "Found %s" % wrapInItemColorTags(full_item_name, item["flags"])
306
307 if gamedata.anti_trap_ids.has(item):
308 keyboard.block_letter(gamedata.anti_trap_ids[item])
309
310 global._print(message)
311
312 global.get_node("Messages").showMessage(message)
313
314
315func _process_item_sent_notification(message):
316 var sentMsg = (
317 "Sent %s to %s"
318 % [
319 wrapInItemColorTags(message["item_name"], message["item_flags"]),
320 message["receiver_name"]
321 ]
322 )
323 #if _hinted_locations.has(message["item"]["location"]):
324 # sentMsg += " ([color=#fafad2]Hinted![/color])"
325 global.get_node("Messages").showMessage(sentMsg)
326
327
328func _process_hint_received(message):
329 var is_for = ""
330 if message["self"] == 0:
331 is_for = " for %s" % message["receiver_name"]
332
333 global.get_node("Messages").showMessage(
334 (
335 "Hint: %s%s is on %s"
336 % [
337 wrapInItemColorTags(message["item_name"], message["item_flags"]),
338 is_for,
339 message["location_name"]
340 ]
341 )
342 )
343
344
345func _process_text_message(message):
346 var parts = []
347 for message_part in message:
348 if message_part["type"] == "text":
349 parts.append(message_part["text"])
350 elif message_part["type"] == "player":
351 if message_part["self"] == 1:
352 parts.append("[color=#ee00ee]%s[/color]" % message_part["text"])
353 else:
354 parts.append("[color=#fafad2]%s[/color]" % message_part["text"])
355 elif message_part["type"] == "item":
356 parts.append(wrapInItemColorTags(message_part["text"], int(message_part["flags"])))
357 elif message_part["type"] == "location":
358 parts.append("[color=#00ff7f]%s[/color]" % message_part["text"])
359
360 var textclient_node = global.get_node("Textclient")
361 if textclient_node != null:
362 textclient_node.parse_printjson("".join(parts))
363
364
365func _process_location_scout(location_id, item_name, player_name, flags, for_self):
366 _location_scouts[location_id] = {
367 "item": item_name, "player": player_name, "flags": flags, "for_self": for_self
368 }
369
370 if for_self and flags & 4 != 0:
371 # This is a trap for us, so let's not display it.
372 return
373
374 var gamedata = global.get_node("Gamedata")
375 var map_id = gamedata.map_id_by_name.get(global.map)
376
377 var letter_id = gamedata.letter_id_by_ap_id.get(location_id, null)
378 if letter_id != null:
379 var letter = gamedata.objects.get_letters()[letter_id]
380 var room = gamedata.objects.get_rooms()[letter.get_room_id()]
381 if room.get_map_id() == map_id:
382 var collectable = get_tree().get_root().get_node("scene").get_node_or_null(
383 letter.get_path()
384 )
385 if collectable != null:
386 collectable.setScoutedText(item_name)
387
388
389func _on_accessible_locations_updated():
390 var textclient_node = global.get_node("Textclient")
391 if textclient_node != null:
392 textclient_node.update_locations()
393
394
395func _on_checked_locations_updated():
396 var textclient_node = global.get_node("Textclient")
397 if textclient_node != null:
398 textclient_node.update_locations(false)
399
400
401func _on_checked_worldports_updated():
402 var textclient_node = global.get_node("Textclient")
403 if textclient_node != null:
404 textclient_node.update_locations()
405 textclient_node.update_worldports()
406
407
408func _on_ignored_locations_updated(locations):
409 _ignored_locations = locations
410
411 var textclient_node = global.get_node("Textclient")
412 if textclient_node != null:
413 textclient_node.update_locations()
414
415
416func _on_hinted_locations_updated():
417 var textclient_node = global.get_node("Textclient")
418 if textclient_node != null:
419 textclient_node.update_locations()
420
421
422func _on_door_latched(door_id):
423 var gamedata = global.get_node("Gamedata")
424 if gamedata.get_door_map_name(door_id) != global.map:
425 return
426
427 var receivers = gamedata.get_door_receivers(door_id)
428 var scene = get_tree().get_root().get_node_or_null("scene")
429 if scene != null:
430 for receiver in receivers:
431 var rnode = scene.get_node_or_null(receiver)
432 if rnode != null:
433 rnode.handleTriggered()
434
435
436func _client_could_not_connect(message):
437 could_not_connect.emit(message)
438
439 if global.loaded:
440 var effects = global.get_node("Effects")
441 effects.set_connection_lost(true)
442
443 var messages = global.get_node("Messages")
444 messages.showMessage("Connection to multiworld lost.")
445
446
447func _client_connect_status(message):
448 connect_status.emit(message)
449
450
451func _client_connected(slot_data):
452 var effects = global.get_node("Effects")
453 effects.set_connection_lost(false)
454
455 if _already_connected:
456 var messages = global.get_node("Messages")
457 messages.showMessage("Reconnected to multiworld!")
458 return
459
460 _already_connected = true
461
462 var gamedata = global.get_node("Gamedata")
463
464 _localdata_file = "user://archipelago_data/%s_%d" % [client._seed, client._slot]
465 _last_new_item = -1
466
467 if FileAccess.file_exists(_localdata_file):
468 var ap_file = FileAccess.open(_localdata_file, FileAccess.READ)
469 var localdata = []
470 if ap_file != null:
471 localdata = ap_file.get_var(true)
472 ap_file.close()
473
474 if typeof(localdata) != TYPE_ARRAY:
475 print("AP localdata file is corrupted")
476 localdata = []
477
478 if localdata.size() > 0:
479 _last_new_item = localdata[0]
480
481 # Read slot data.
482 custom_mint_ending = slot_data.get("custom_mint_ending", "")
483 cyan_door_behavior = int(slot_data.get("cyan_door_behavior", 0))
484 daedalus_only = bool(slot_data.get("daedalus_only", false))
485 daedalus_roof_access = bool(slot_data.get("daedalus_roof_access", false))
486 enable_gift_maps = slot_data.get("enable_gift_maps", [])
487 enable_icarus = bool(slot_data.get("enable_icarus", false))
488 endings_requirement = int(slot_data.get("endings_requirement", 0))
489 fast_travel_access = int(slot_data.get("fast_travel_access", 0))
490 keyholder_sanity = bool(slot_data.get("keyholder_sanity", false))
491 masteries_requirement = int(slot_data.get("masteries_requirement", 0))
492 shuffle_control_center_colors = bool(slot_data.get("shuffle_control_center_colors", false))
493 shuffle_doors = bool(slot_data.get("shuffle_doors", false))
494 shuffle_gallery_paintings = bool(slot_data.get("shuffle_gallery_paintings", false))
495 shuffle_letters = int(slot_data.get("shuffle_letters", 0))
496 shuffle_symbols = bool(slot_data.get("shuffle_symbols", false))
497 shuffle_worldports = bool(slot_data.get("shuffle_worldports", false))
498 strict_cyan_ending = bool(slot_data.get("strict_cyan_ending", false))
499 strict_purple_ending = bool(slot_data.get("strict_purple_ending", false))
500 victory_condition = int(slot_data.get("victory_condition", 0))
501
502 if slot_data.has("version"):
503 var version_msg = slot_data["version"]
504 apworld_version = [int(version_msg[0]), int(version_msg[1]), 0]
505 if version_msg.size() > 2:
506 apworld_version[2] = int(version_msg[2])
507
508 port_pairings.clear()
509 if slot_data.has("port_pairings"):
510 var raw_pp = slot_data.get("port_pairings")
511
512 for p1 in raw_pp.keys():
513 port_pairings[gamedata.port_id_by_ap_id[int(p1)]] = gamedata.port_id_by_ap_id[int(
514 raw_pp[p1]
515 )]
516
517 rte_mapping.clear()
518 if slot_data.has("rte"):
519 rte_mapping = slot_data.get("rte")
520
521 slot_rng = RandomNumberGenerator.new()
522 slot_rng.seed = int(slot_data.get("seed", 0))
523
524 music_mapping.clear()
525 if bool(slot_data.get("shuffle_music", false)):
526 for map_name in global.reserved_scenes:
527 var track_index = slot_rng.randi_range(0, musicPlayer.all_tracks.size() - 1)
528 music_mapping[map_name] = musicPlayer.all_tracks.keys()[track_index]
529
530 # Set up item locks.
531 _item_locks = {}
532
533 if shuffle_doors or daedalus_only:
534 for door in gamedata.objects.get_doors():
535 if (
536 door.get_type() != gamedata.SCRIPT_proto.DoorType.STANDARD
537 and door.get_type() != gamedata.SCRIPT_proto.DoorType.ITEM_ONLY
538 ):
539 continue
540
541 if (
542 not shuffle_doors
543 and not (
544 daedalus_only
545 and door.has_daedalus_only_always_item()
546 and door.get_daedalus_only_always_item()
547 )
548 ):
549 continue
550
551 _item_locks[door.get_id()] = [door.get_ap_id(), 1]
552
553 if shuffle_doors:
554 for progressive in gamedata.objects.get_progressives():
555 for i in range(0, progressive.get_doors().size()):
556 var door = gamedata.objects.get_doors()[progressive.get_doors()[i]]
557 _item_locks[door.get_id()] = [progressive.get_ap_id(), i + 1]
558
559 for door_group in gamedata.objects.get_door_groups():
560 if door_group.get_type() == gamedata.SCRIPT_proto.DoorGroupType.CONNECTOR:
561 if shuffle_worldports:
562 continue
563 elif door_group.get_type() != gamedata.SCRIPT_proto.DoorGroupType.SHUFFLE_GROUP:
564 continue
565
566 if (
567 not shuffle_doors
568 and not (
569 daedalus_only
570 and door_group.has_daedalus_only_always_item()
571 and door_group.get_daedalus_only_always_item()
572 )
573 ):
574 continue
575
576 for door in door_group.get_doors():
577 _item_locks[door] = [door_group.get_ap_id(), 1]
578
579 if shuffle_control_center_colors:
580 for door in gamedata.objects.get_doors():
581 if door.get_type() == gamedata.SCRIPT_proto.DoorType.CONTROL_CENTER_COLOR:
582 _item_locks[door.get_id()] = [door.get_ap_id(), 1]
583
584 for door_group in gamedata.objects.get_door_groups():
585 if (
586 door_group.get_type() == gamedata.SCRIPT_proto.DoorGroupType.COLOR_CONNECTOR
587 and not shuffle_worldports
588 ):
589 for door in door_group.get_doors():
590 _item_locks[door] = [door_group.get_ap_id(), 1]
591
592 if shuffle_gallery_paintings:
593 for door in gamedata.objects.get_doors():
594 if door.get_type() == gamedata.SCRIPT_proto.DoorType.GALLERY_PAINTING:
595 _item_locks[door.get_id()] = [door.get_ap_id(), 1]
596
597 if cyan_door_behavior == kCYAN_DOOR_BEHAVIOR_ITEM:
598 for door_group in gamedata.objects.get_door_groups():
599 if door_group.get_type() == gamedata.SCRIPT_proto.DoorGroupType.CYAN_DOORS:
600 for door in door_group.get_doors():
601 if not _item_locks.has(door):
602 _item_locks[door] = [door_group.get_ap_id(), 1]
603
604 # Create a reverse item locks map for processing items.
605 _inverse_item_locks = {}
606
607 for door_id in _item_locks.keys():
608 var lock = _item_locks.get(door_id)
609
610 if not _inverse_item_locks.has(lock[0]):
611 _inverse_item_locks[lock[0]] = []
612
613 _inverse_item_locks[lock[0]].append([door_id, lock[1]])
614
615 if shuffle_worldports:
616 var textclient = global.get_node("Textclient")
617 textclient.setup_worldports()
618
619 ap_connected.emit()
620
621
622func start_batching_locations():
623 _batch_locations = true
624
625
626func send_location(loc_id):
627 if client._checked_locations.has(loc_id):
628 return
629
630 if _batch_locations:
631 _held_locations.append(loc_id)
632 else:
633 client.sendLocation(loc_id)
634
635
636func scout_location(loc_id):
637 if _location_scouts.has(loc_id):
638 return _location_scouts.get(loc_id)
639
640 if _batch_locations:
641 _held_location_scouts.append(loc_id)
642 else:
643 client.scoutLocation(loc_id)
644
645 return null
646
647
648func stop_batching_locations():
649 _batch_locations = false
650
651 if not _held_locations.is_empty():
652 client.sendLocations(_held_locations)
653 _held_locations.clear()
654
655 if not _held_location_scouts.is_empty():
656 client.scoutLocations(_held_location_scouts)
657 _held_location_scouts.clear()
658
659
660func colorForItemType(flags):
661 var int_flags = int(flags)
662 if int_flags & 1: # progression
663 if int_flags & 2: # proguseful
664 return "#f0d200"
665 else:
666 return "#bc51e0"
667 elif int_flags & 2: # useful
668 return "#2b67ff"
669 elif int_flags & 4: # trap
670 return "#d63a22"
671 else: # filler
672 return "#14de9e"
673
674
675func wrapInItemColorTags(text, flags):
676 var int_flags = int(flags)
677 if int_flags & 1 and int_flags & 2: # proguseful
678 return "[rainbow]%s[/rainbow]" % text
679 else:
680 return "[color=%s]%s[/color]" % [colorForItemType(flags), text]
681
682
683func get_letter_behavior(key, level2):
684 if shuffle_letters == kSHUFFLE_LETTERS_UNLOCKED:
685 return kLETTER_BEHAVIOR_UNLOCKED
686
687 if [kSHUFFLE_LETTERS_VANILLA_CYAN, kSHUFFLE_LETTERS_ITEM_CYAN].has(shuffle_letters):
688 if level2:
689 if shuffle_letters == kSHUFFLE_LETTERS_VANILLA_CYAN:
690 return kLETTER_BEHAVIOR_VANILLA
691 else:
692 return kLETTER_BEHAVIOR_ITEM
693 else:
694 return kLETTER_BEHAVIOR_UNLOCKED
695
696 if not level2 and ["h", "i", "n", "t"].has(key):
697 # This differs from the equivalent function in the apworld. Logically it is
698 # the same as UNLOCKED since they are in the starting room, but VANILLA
699 # means the player still has to actually pick up the letters.
700 return kLETTER_BEHAVIOR_VANILLA
701
702 if shuffle_letters == kSHUFFLE_LETTERS_PROGRESSIVE:
703 return kLETTER_BEHAVIOR_ITEM
704
705 return kLETTER_BEHAVIOR_VANILLA
706
707
708func setup_keys():
709 keyboard.load_seed()
710
711 _letters_setup = true
712
713 for k in _held_letters.keys():
714 _process_key_item(k, _held_letters[k])
715
716 _held_letters.clear()
717
718
719func _process_key_item(key, level):
720 if not _letters_setup:
721 _held_letters[key] = max(_held_letters.get(key, 0), level)
722 return
723
724 if shuffle_letters == kSHUFFLE_LETTERS_ITEM_CYAN:
725 level += 1
726
727 keyboard.collect_remote_letter(key, level)
728
729
730func update_job_well_done_sign():
731 if global.map != "daedalus":
732 return
733
734 var gamedata = global.get_node("Gamedata")
735 var job_item = gamedata.objects.get_special_ids()["A Job Well Done"]
736 var jobs_done = client.getItemAmount(job_item)
737
738 var sign2 = get_tree().get_root().get_node_or_null("scene/Meshes/Miscellaneous/sign2")
739 var sign3 = get_tree().get_root().get_node_or_null("scene/Meshes/Miscellaneous/sign3")
740
741 if sign2 != null and sign3 != null:
742 if jobs_done == 0:
743 sign2.text = "what are you doing"
744 sign3.text = "?"
745 elif jobs_done == 1:
746 sign2.text = "a job well done"
747 sign3.text = "is its own reward"
748 else:
749 sign2.text = "%d jobs well done" % jobs_done
750 sign3.text = "are their own reward"
751
752 sign2.get_node("MeshInstance3D").mesh.text = sign2.text
753 sign3.get_node("MeshInstance3D").mesh.text = sign3.text
754
755
756func toggle_ignored_location(loc_id):
757 if loc_id in _ignored_locations:
758 client.removeIgnoredLocation(loc_id)
759 else:
760 client.addIgnoredLocation(loc_id)
761
762
763func get_map_script(map_name):
764 if !_map_scripts.has(map_name):
765 var runtime = global.get_node("Runtime")
766 var script_path = "maps/%s.gd" % map_name
767 if runtime.path_exists(script_path):
768 var script = runtime.load_script(script_path)
769 _map_scripts[map_name] = script.new()
770 else:
771 _map_scripts[map_name] = null
772
773 return _map_scripts[map_name]
diff --git a/apworld/client/maps/control_center.gd b/apworld/client/maps/control_center.gd new file mode 100644 index 0000000..8e919ab --- /dev/null +++ b/apworld/client/maps/control_center.gd
@@ -0,0 +1,139 @@
1const kALL_MASTERIES = 19
2
3
4func on_map_load(root):
5 var ap = global.get_node("Archipelago")
6
7 # Remove the door blocking the trophy case.
8 root.get_node("/root/scene/Components/Doors/entry_18").queue_free()
9
10 # Set up mastery listeners for extra maps.
11 _set_up_mastery_listener(root, "advanced")
12 _set_up_mastery_listener(root, "charismatic")
13 _set_up_mastery_listener(root, "crystalline")
14 _set_up_mastery_listener(root, "fuzzy")
15 _set_up_mastery_listener(root, "icarus")
16 _set_up_mastery_listener(root, "stellar")
17
18 if ap.endings_requirement != 12 or ap.masteries_requirement != 0:
19 # Set up listeners for the potential White Ending requirements.
20 var merging_prefab = preload("res://objects/nodes/listeners/mergingListener.tscn")
21
22 var old_door = root.get_node("/root/scene/Components/Doors/entry_19")
23 var new_door = old_door.duplicate()
24 new_door.name = "entry_19_new"
25 new_door.senders.clear()
26 new_door.senderGroup.clear()
27 new_door.excludeSenders.clear()
28
29 if ap.endings_requirement == 12:
30 new_door.senderGroup.append(NodePath("/root/scene/Meshes/Trophies/Listeners"))
31 elif ap.endings_requirement > 0:
32 if ap.masteries_requirement == 0:
33 new_door.senderGroup.append(NodePath("/root/scene/Meshes/Trophies/Listeners"))
34 new_door.complete_at = ap.endings_requirement
35 else:
36 var endings_merge = merging_prefab.instantiate()
37 endings_merge.name = "EndingsMerge"
38 endings_merge.senderGroup.append(NodePath("/root/scene/Meshes/Trophies/Listeners"))
39 endings_merge.complete_at = ap.endings_requirement
40 root.get_node("/root/scene/Components").add_child.call_deferred(endings_merge)
41 new_door.senders.append(NodePath("/root/scene/Components/EndingsMerge"))
42
43 if ap.masteries_requirement == kALL_MASTERIES:
44 new_door.senderGroup.append(NodePath("/root/scene/Meshes/Trophies/MasteryListeners"))
45 new_door.excludeSenders.append(
46 NodePath("/root/scene/Meshes/Trophies/MasteryListeners/unlockReaderListenerWhite")
47 )
48 elif ap.masteries_requirement > 0:
49 if ap.endings_requirement == 0:
50 new_door.senderGroup.append(
51 NodePath("/root/scene/Meshes/Trophies/MasteryListeners")
52 )
53 new_door.excludeSenders.append(
54 NodePath(
55 "/root/scene/Meshes/Trophies/MasteryListeners/unlockReaderListenerWhite"
56 )
57 )
58 new_door.complete_at = ap.masteries_requirement
59 else:
60 var masteries_merge = merging_prefab.instantiate()
61 masteries_merge.name = "MasteriesMerge"
62 masteries_merge.senderGroup.append(
63 NodePath("/root/scene/Meshes/Trophies/MasteryListeners")
64 )
65 masteries_merge.excludeSenders.append(
66 NodePath(
67 "/root/scene/Meshes/Trophies/MasteryListeners/unlockReaderListenerWhite"
68 )
69 )
70 masteries_merge.complete_at = ap.masteries_requirement
71 root.get_node("/root/scene/Components").add_child.call_deferred(masteries_merge)
72 new_door.senders.append(NodePath("/root/scene/Components/MasteriesMerge"))
73
74 old_door.queue_free()
75 root.get_node("/root/scene/Components/Doors").add_child.call_deferred(new_door)
76
77 # Display White Ending requirements.
78 var ending_count = 0
79 var mastery_count = 0
80 for key in unlocks.data:
81 if unlocks.data[key] == "unlocked":
82 if key.ends_with("_ending") and key != "free_ending":
83 ending_count += 1
84 elif key.ends_with("_mastery"):
85 mastery_count += 1
86
87 var sign_prefab = preload("res://objects/nodes/sign.tscn")
88 var sign1 = sign_prefab.instantiate()
89 sign1.position = Vector3(87.5, 5, -42.01)
90 sign1.text = "Endings: %d/%d" % [ending_count, ap.endings_requirement]
91 root.get_node("/root/scene").add_child.call_deferred(sign1)
92
93 var sign2 = sign_prefab.instantiate()
94 sign2.position = Vector3(87.5, 5, -15.99)
95 sign2.rotation_degrees.y = 180
96 sign2.text = "Masteries: %d/%d" % [mastery_count, ap.masteries_requirement]
97 root.get_node("/root/scene").add_child.call_deferred(sign2)
98
99 # Handle custom Mint Ending.
100 if ap.custom_mint_ending != "":
101 var panel_prefab = preload("res://objects/nodes/panel.tscn")
102 var tpl_prefab = preload("res://objects/nodes/listeners/teleportListener.tscn")
103
104 var mint_ending = root.get_node("/root/scene/Components/Endings/mint_ending")
105
106 var mint_panel = panel_prefab.instantiate()
107 mint_panel.name = "mint_panel"
108 mint_panel.clue = ap.custom_mint_ending
109 mint_panel.symbol = ""
110 mint_panel.answer = ap.custom_mint_ending
111 mint_panel.position = Vector3(-63, 3, -29)
112 mint_panel.rotation_degrees = Vector3(-45, 90, 0)
113 root.get_node("/root/scene").add_child.call_deferred(mint_panel)
114
115 var mint_tpl = tpl_prefab.instantiate()
116 mint_tpl.name = "mint_tpl"
117 mint_tpl.teleport_point = mint_ending.position
118 mint_tpl.teleport_rotate = mint_ending.rotation_degrees
119 mint_tpl.target_path = mint_ending
120 mint_tpl.senders.append(NodePath("/root/scene/mint_panel"))
121 root.get_node("/root/scene").add_child.call_deferred(mint_tpl)
122
123 var mint_tpl2 = tpl_prefab.instantiate()
124 mint_tpl2.name = "mint_tpl2"
125 mint_tpl2.teleport_point = Vector3(0, -1000, 0)
126 mint_tpl2.target_path = mint_panel
127 mint_tpl2.senders.append(NodePath("/root/scene/mint_panel"))
128 root.get_node("/root/scene").add_child.call_deferred(mint_tpl2)
129
130 mint_ending.position.y = -1000
131
132
133func _set_up_mastery_listener(root, name):
134 var prefab = preload("res://objects/nodes/listeners/unlockReaderListener.tscn")
135 var url = prefab.instantiate()
136 url.name = "unlockReaderListenerMastery_%s" % name
137 url.key = "%s_mastery" % name
138 url.value = "unlocked"
139 root.get_node("/root/scene/Meshes/Trophies/MasteryListeners").add_child.call_deferred(url)
diff --git a/apworld/client/maps/daedalus.gd b/apworld/client/maps/daedalus.gd new file mode 100644 index 0000000..5fcf7a5 --- /dev/null +++ b/apworld/client/maps/daedalus.gd
@@ -0,0 +1,85 @@
1func on_map_load(root):
2 var ap = global.get_node("Archipelago")
3
4 # Teleport the direction panels when the stairs are there.
5 var tpl_prefab = preload("res://objects/nodes/listeners/teleportListener.tscn")
6
7 var dir1 = root.get_node("/root/scene/Panels/Castle Entrance/castle_direction_1")
8 var dir1_tpl = tpl_prefab.instantiate()
9 dir1_tpl.target_path = dir1
10 dir1_tpl.teleport_point = Vector3(59.5, 8, -6.5)
11 dir1_tpl.teleport_rotate = Vector3(-45, 0, 0)
12 dir1_tpl.senders.append(NodePath("/root/scene/Panels/Castle Entrance/castle_south"))
13 dir1_tpl.senders.append(NodePath("/root/scene/Panels/Castle Entrance/castle_north"))
14 dir1_tpl.senders.append(NodePath("/root/scene/Panels/Castle Entrance/castle_west"))
15 dir1.add_child.call_deferred(dir1_tpl)
16
17 var dir2 = root.get_node("/root/scene/Panels/Castle Entrance/castle_direction_2")
18 var dir2_tpl = tpl_prefab.instantiate()
19 dir2_tpl.target_path = dir2
20 dir2_tpl.teleport_point = Vector3(59.5, 8, 6.5)
21 dir2_tpl.teleport_rotate = Vector3(-45, -180, 0)
22 dir2_tpl.senders.append(NodePath("/root/scene/Panels/Castle Entrance/castle_south"))
23 dir2_tpl.senders.append(NodePath("/root/scene/Panels/Castle Entrance/castle_north"))
24 dir2_tpl.senders.append(NodePath("/root/scene/Panels/Castle Entrance/castle_west"))
25 dir2.add_child.call_deferred(dir2_tpl)
26
27 var dir3 = root.get_node("/root/scene/Panels/Castle Entrance/castle_direction_3")
28 var dir3_tpl = tpl_prefab.instantiate()
29 dir3_tpl.target_path = dir3
30 dir3_tpl.teleport_point = Vector3(54, 8, 0)
31 dir3_tpl.teleport_rotate = Vector3(-45, 90, 0)
32 dir3_tpl.senders.append(NodePath("/root/scene/Panels/Castle Entrance/castle_south"))
33 dir3_tpl.senders.append(NodePath("/root/scene/Panels/Castle Entrance/castle_north"))
34 dir3_tpl.senders.append(NodePath("/root/scene/Panels/Castle Entrance/castle_west"))
35 dir3.add_child.call_deferred(dir3_tpl)
36
37 # Block off roof access in Daedalus.
38 if not ap.daedalus_roof_access:
39 _set_up_invis_wall(root, 75.5, 11, -24.5, 1, 10, 49)
40 _set_up_invis_wall(root, 51.5, 11, -17, 16, 10, 1)
41 _set_up_invis_wall(root, 46, 10, -9.5, 1, 10, 10)
42 _set_up_invis_wall(root, 67.5, 11, 17, 16, 10, 1)
43 _set_up_invis_wall(root, 50.5, 11, 14, 10, 10, 1)
44 _set_up_invis_wall(root, 39, 10, 18.5, 1, 10, 22)
45 _set_up_invis_wall(root, 20, 15, 18.5, 1, 10, 16)
46 _set_up_invis_wall(root, 11.5, 15, 3, 32, 10, 1)
47 _set_up_invis_wall(root, 11.5, 16, -20, 14, 20, 1)
48 _set_up_invis_wall(root, 14, 16, -26.5, 1, 20, 4)
49 _set_up_invis_wall(root, 28.5, 20.5, -26.5, 1, 15, 25)
50 _set_up_invis_wall(root, 40.5, 20.5, -11, 30, 15, 1)
51 _set_up_invis_wall(root, 50.5, 15, 5.5, 7, 10, 1)
52 _set_up_invis_wall(root, 83.5, 33.5, 5.5, 1, 7, 11)
53 _set_up_invis_wall(root, 83.5, 33.5, -5.5, 1, 7, 11)
54
55 var warp_exit_prefab = preload("res://objects/nodes/exit.tscn")
56 var warp_exit = warp_exit_prefab.instantiate()
57 warp_exit.name = "roof_access_blocker_warp_exit"
58 warp_exit.position = Vector3(58, 10, 0)
59 warp_exit.rotation_degrees.y = 90
60 root.get_node("/root/scene").add_child.call_deferred(warp_exit)
61
62 var warp_enter_prefab = preload("res://objects/nodes/teleportAuto.tscn")
63 var warp_enter = warp_enter_prefab.instantiate()
64 warp_enter.target = warp_exit
65 warp_enter.position = Vector3(76.5, 30, 1)
66 warp_enter.scale = Vector3(4, 1.5, 1)
67 warp_enter.rotation_degrees.y = 90
68 root.get_node("/root/scene").add_child.call_deferred(warp_enter)
69
70
71func _set_up_invis_wall(root, x, y, z, sx, sy, sz):
72 var prefab = preload("res://objects/nodes/block.tscn")
73 var newwall = prefab.instantiate()
74 newwall.position.x = x
75 newwall.position.y = y
76 newwall.position.z = z
77 newwall.scale.x = sz
78 newwall.scale.y = sy
79 newwall.scale.z = sx
80 newwall.set_surface_override_material(0, preload("res://assets/materials/blackMatte.material"))
81 newwall.visibility_range_end = 3
82 newwall.visibility_range_end_margin = 1
83 newwall.visibility_range_fade_mode = RenderingServer.VISIBILITY_RANGE_FADE_SELF
84 newwall.skeleton = ".."
85 root.get_node("/root/scene").add_child.call_deferred(newwall)
diff --git a/apworld/client/maps/icarus.gd b/apworld/client/maps/icarus.gd new file mode 100644 index 0000000..ad00741 --- /dev/null +++ b/apworld/client/maps/icarus.gd
@@ -0,0 +1,38 @@
1func on_map_load(root):
2 var ap = global.get_node("Archipelago")
3
4 # Add the mastery to Icarus.
5 if ap.enable_icarus:
6 var collectable_prefab = preload("res://objects/nodes/collectable.tscn")
7 var saver_prefab = preload("res://objects/nodes/saver.tscn")
8 var tpl_prefab = preload("res://objects/nodes/listeners/teleportListener.tscn")
9 var usl_prefab = preload("res://objects/nodes/listeners/unlockSetterListener.tscn")
10
11 var mastery = collectable_prefab.instantiate()
12 mastery.name = "collectable"
13 mastery.position = Vector3(0, -2000, 0)
14 mastery.unlock_type = "smiley"
15 mastery.material_override = load("res://assets/materials/gold.material")
16 root.get_node("/root/scene/Components/Collectables").add_child.call_deferred(mastery)
17
18 var tpl = tpl_prefab.instantiate()
19 tpl.teleport_point = Vector3(56.25, 0, -5.5)
20 tpl.teleport_rotate = Vector3(0, 0, 0)
21 tpl.target_path = mastery
22 tpl.name = "Teleport"
23 tpl.senderGroup.append(NodePath("/root/scene/Panels"))
24 tpl.nested = true
25 mastery.add_child.call_deferred(tpl)
26
27 var usl = usl_prefab.instantiate()
28 usl.name = "unlockSetterListenerMastery"
29 usl.key = "icarus_mastery"
30 usl.value = "unlocked"
31 usl.senders.append(NodePath("/root/scene/Components/Collectables/collectable"))
32 root.get_node("/root/scene/Components").add_child.call_deferred(usl)
33
34 var saver = saver_prefab.instantiate()
35 saver.name = "saver_collectables"
36 saver.type = "collectables"
37 saver.senderGroup.append(NodePath("/root/scene/Components/Collectables"))
38 root.get_node("/root/scene").add_child.call_deferred(saver)
diff --git a/apworld/client/maps/the_advanced.gd b/apworld/client/maps/the_advanced.gd new file mode 100644 index 0000000..b41549c --- /dev/null +++ b/apworld/client/maps/the_advanced.gd
@@ -0,0 +1,36 @@
1func on_map_load(root):
2 # Add the mastery to The Advanced.
3 var collectable_prefab = preload("res://objects/nodes/collectable.tscn")
4 var saver_prefab = preload("res://objects/nodes/saver.tscn")
5 var tpl_prefab = preload("res://objects/nodes/listeners/teleportListener.tscn")
6 var usl_prefab = preload("res://objects/nodes/listeners/unlockSetterListener.tscn")
7
8 var mastery = collectable_prefab.instantiate()
9 mastery.name = "collectable"
10 mastery.position = Vector3(0, -200, -5)
11 mastery.unlock_type = "smiley"
12 mastery.material_override = load("res://assets/materials/gold.material")
13 root.get_node("/root/scene/Components/Collectables").add_child.call_deferred(mastery)
14
15 var tpl = tpl_prefab.instantiate()
16 tpl.teleport_point = Vector3(0, 2, -5)
17 tpl.teleport_rotate = Vector3(0, 0, 0)
18 tpl.target_path = mastery
19 tpl.name = "Teleport"
20 tpl.senders.append(NodePath("/root/scene/Panels/Room_1/panel_29"))
21 tpl.senders.append(NodePath("/root/scene/Panels/Room_1/panel_30"))
22 tpl.senders.append(NodePath("/root/scene/Panels/Room_1/panel_31"))
23 mastery.add_child.call_deferred(tpl)
24
25 var usl = usl_prefab.instantiate()
26 usl.name = "unlockSetterListenerMastery"
27 usl.key = "advanced_mastery"
28 usl.value = "unlocked"
29 usl.senders.append(NodePath("/root/scene/Components/Collectables/collectable"))
30 root.get_node("/root/scene/Components").add_child.call_deferred(usl)
31
32 var saver = saver_prefab.instantiate()
33 saver.name = "saver_collectables"
34 saver.type = "collectables"
35 saver.senderGroup.append(NodePath("/root/scene/Components/Collectables"))
36 root.get_node("/root/scene").add_child.call_deferred(saver)
diff --git a/apworld/client/maps/the_charismatic.gd b/apworld/client/maps/the_charismatic.gd new file mode 100644 index 0000000..734001d --- /dev/null +++ b/apworld/client/maps/the_charismatic.gd
@@ -0,0 +1,26 @@
1func on_map_load(root):
2 # Add the mastery to The Charismatic.
3 var collectable_prefab = preload("res://objects/nodes/collectable.tscn")
4 var saver_prefab = preload("res://objects/nodes/saver.tscn")
5 var usl_prefab = preload("res://objects/nodes/listeners/unlockSetterListener.tscn")
6
7 var mastery = collectable_prefab.instantiate()
8 mastery.name = "collectable"
9 mastery.position = Vector3(-17, 2, -29)
10 mastery.rotation_degrees = Vector3(0, 45, 0)
11 mastery.unlock_type = "smiley"
12 mastery.material_override = load("res://assets/materials/gold.material")
13 root.get_node("/root/scene/Components/Collectables").add_child.call_deferred(mastery)
14
15 var usl = usl_prefab.instantiate()
16 usl.name = "unlockSetterListenerMastery"
17 usl.key = "charismatic_mastery"
18 usl.value = "unlocked"
19 usl.senders.append(NodePath("/root/scene/Components/Collectables/collectable"))
20 root.get_node("/root/scene/Components").add_child.call_deferred(usl)
21
22 var saver = saver_prefab.instantiate()
23 saver.name = "saver_collectables"
24 saver.type = "collectables"
25 saver.senderGroup.append(NodePath("/root/scene/Components/Collectables"))
26 root.get_node("/root/scene").add_child.call_deferred(saver)
diff --git a/apworld/client/maps/the_crystalline.gd b/apworld/client/maps/the_crystalline.gd new file mode 100644 index 0000000..7d43e78 --- /dev/null +++ b/apworld/client/maps/the_crystalline.gd
@@ -0,0 +1,34 @@
1func on_map_load(root):
2 # Add the mastery to The Crystalline.
3 var collectable_prefab = preload("res://objects/nodes/collectable.tscn")
4 var saver_prefab = preload("res://objects/nodes/saver.tscn")
5 var tpl_prefab = preload("res://objects/nodes/listeners/teleportListener.tscn")
6 var usl_prefab = preload("res://objects/nodes/listeners/unlockSetterListener.tscn")
7
8 var mastery = collectable_prefab.instantiate()
9 mastery.name = "collectable"
10 mastery.position = Vector3(0, 13, 37)
11 mastery.unlock_type = "smiley"
12 mastery.material_override = load("res://assets/materials/gold.material")
13 root.get_node("/root/scene/Components/Collectables").add_child.call_deferred(mastery)
14
15 var tpl = tpl_prefab.instantiate()
16 tpl.teleport_point = Vector3(0, 11.5, -20)
17 tpl.teleport_rotate = Vector3(0, 0, 180)
18 tpl.target_path = mastery
19 tpl.name = "Teleport"
20 tpl.senders.append(NodePath("/root/scene/Panels/Room_1/panel_3"))
21 mastery.add_child.call_deferred(tpl)
22
23 var usl = usl_prefab.instantiate()
24 usl.name = "unlockSetterListenerMastery"
25 usl.key = "crystalline_mastery"
26 usl.value = "unlocked"
27 usl.senders.append(NodePath("/root/scene/Components/Collectables/collectable"))
28 root.get_node("/root/scene/Components").add_child.call_deferred(usl)
29
30 var saver = saver_prefab.instantiate()
31 saver.name = "saver_collectables"
32 saver.type = "collectables"
33 saver.senderGroup.append(NodePath("/root/scene/Components/Collectables"))
34 root.get_node("/root/scene").add_child.call_deferred(saver)
diff --git a/apworld/client/maps/the_entry.gd b/apworld/client/maps/the_entry.gd new file mode 100644 index 0000000..3608bb3 --- /dev/null +++ b/apworld/client/maps/the_entry.gd
@@ -0,0 +1,156 @@
1func on_map_load(root):
2 var ap = global.get_node("Archipelago")
3
4 # Remove door behind X1.
5 var door_node = root.get_node("/root/scene/Components/Doors/exit_1")
6 door_node.handleTriggered()
7
8 # Display win condition.
9 var sign_prefab = preload("res://objects/nodes/sign.tscn")
10 var sign1 = sign_prefab.instantiate()
11 sign1.position = Vector3(-7, 5, -15.01)
12 sign1.text = "victory"
13 root.get_node("/root/scene").add_child.call_deferred(sign1)
14
15 var sign2 = sign_prefab.instantiate()
16 sign2.position = Vector3(-7, 4, -15.01)
17 sign2.text = "%s ending" % ap.kEndingNameByVictoryValue.get(ap.victory_condition, "?")
18
19 var sign2_color = ap.kEndingNameByVictoryValue.get(ap.victory_condition, "coral").to_lower()
20 if sign2_color == "white":
21 sign2_color = "silver"
22
23 sign2.material = load("res://assets/materials/%s.material" % sign2_color)
24 root.get_node("/root/scene").add_child.call_deferred(sign2)
25
26 # Add the gift map entry panel if needed.
27 if not ap.enable_gift_maps.is_empty():
28 var panel_prefab = preload("res://objects/nodes/panel.tscn")
29 var tpl_prefab = preload("res://objects/nodes/listeners/teleportListener.tscn")
30 var wpl_prefab = preload("res://objects/nodes/listeners/worldportListener.tscn")
31
32 var giftmap_parent = Node.new()
33 giftmap_parent.name = "GiftMapEntrance"
34 root.get_node("/root/scene/Components").add_child.call_deferred(giftmap_parent)
35
36 var symbolless_player = ""
37 for i in range(ap.client.ap_user.length()):
38 if "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".contains(
39 ap.client.ap_user[i]
40 ):
41 symbolless_player = symbolless_player + ap.client.ap_user[i].to_lower()
42
43 var giftmap_panel = panel_prefab.instantiate()
44 giftmap_panel.name = "Panel"
45 giftmap_panel.position = Vector3(33.5, -190, 5.5)
46 giftmap_panel.rotation_degrees = Vector3(-45, 0, 0)
47 giftmap_panel.clue = "player"
48 giftmap_panel.answer = symbolless_player
49
50 if ap.enable_gift_maps.has("The Advanced"):
51 var icely_panel = panel_prefab.instantiate()
52 icely_panel.name = "IcelyPanel"
53 icely_panel.answer = "icely"
54 icely_panel.position = Vector3(33.5, -200, 5.5)
55 giftmap_panel.proxies.append(NodePath("../IcelyPanel"))
56 giftmap_parent.add_child.call_deferred(icely_panel)
57
58 var icely_wpl = wpl_prefab.instantiate()
59 icely_wpl.name = "IcelyWpl"
60 icely_wpl.exit = "the_advanced"
61 icely_wpl.senders.append(NodePath("../IcelyPanel"))
62 giftmap_parent.add_child.call_deferred(icely_wpl)
63
64 if ap.enable_gift_maps.has("The Charismatic"):
65 var souvey_panel = panel_prefab.instantiate()
66 souvey_panel.name = "SouveyPanel"
67 souvey_panel.answer = "souvey"
68 souvey_panel.position = Vector3(33.5, -210, 5.5)
69 giftmap_panel.proxies.append(NodePath("../SouveyPanel"))
70 giftmap_parent.add_child.call_deferred(souvey_panel)
71
72 var souvey_wpl = wpl_prefab.instantiate()
73 souvey_wpl.name = "SouveyWpl"
74 souvey_wpl.exit = "the_charismatic"
75 souvey_wpl.senders.append(NodePath("../SouveyPanel"))
76 giftmap_parent.add_child.call_deferred(souvey_wpl)
77
78 if ap.enable_gift_maps.has("The Crystalline"):
79 var q_panel = panel_prefab.instantiate()
80 q_panel.name = "QPanel"
81 q_panel.answer = "q"
82 q_panel.position = Vector3(33.5, -220, 5.5)
83 giftmap_panel.proxies.append(NodePath("../QPanel"))
84 giftmap_parent.add_child.call_deferred(q_panel)
85
86 var q_wpl = wpl_prefab.instantiate()
87 q_wpl.name = "QWpl"
88 q_wpl.exit = "the_crystalline"
89 q_wpl.senders.append(NodePath("../QPanel"))
90 giftmap_parent.add_child.call_deferred(q_wpl)
91
92 if ap.enable_gift_maps.has("The Fuzzy"):
93 var gongus_panel = panel_prefab.instantiate()
94 gongus_panel.name = "GongusPanel"
95 gongus_panel.answer = "gongus"
96 gongus_panel.position = Vector3(33.5, -260, 5.5)
97 giftmap_panel.proxies.append(NodePath("../GongusPanel"))
98 giftmap_parent.add_child.call_deferred(gongus_panel)
99
100 var kiwi_panel = panel_prefab.instantiate()
101 kiwi_panel.name = "KiwiPanel"
102 kiwi_panel.answer = "kiwi"
103 kiwi_panel.position = Vector3(33.5, -270, 5.5)
104 giftmap_panel.proxies.append(NodePath("../KiwiPanel"))
105 giftmap_parent.add_child.call_deferred(kiwi_panel)
106
107 var fuzzy_wpl = wpl_prefab.instantiate()
108 fuzzy_wpl.name = "FuzzyWpl"
109 fuzzy_wpl.exit = "the_fuzzy"
110 fuzzy_wpl.senders.append(NodePath("../GongusPanel"))
111 fuzzy_wpl.senders.append(NodePath("../KiwiPanel"))
112 fuzzy_wpl.complete_at = 1
113 giftmap_parent.add_child.call_deferred(fuzzy_wpl)
114
115 if ap.enable_gift_maps.has("The Stellar"):
116 var hatkirby_panel = panel_prefab.instantiate()
117 hatkirby_panel.name = "HatkirbyPanel"
118 hatkirby_panel.answer = "hatkirby"
119 hatkirby_panel.position = Vector3(33.5, -230, 5.5)
120 giftmap_panel.proxies.append(NodePath("../HatkirbyPanel"))
121 giftmap_parent.add_child.call_deferred(hatkirby_panel)
122
123 var kirby_panel = panel_prefab.instantiate()
124 kirby_panel.name = "KirbyPanel"
125 kirby_panel.answer = "kirby"
126 kirby_panel.position = Vector3(33.5, -240, 5.5)
127 giftmap_panel.proxies.append(NodePath("../KirbyPanel"))
128 giftmap_parent.add_child.call_deferred(kirby_panel)
129
130 var star_panel = panel_prefab.instantiate()
131 star_panel.name = "StarPanel"
132 star_panel.answer = "star"
133 star_panel.position = Vector3(33.5, -250, 5.5)
134 giftmap_panel.proxies.append(NodePath("../StarPanel"))
135 giftmap_parent.add_child.call_deferred(star_panel)
136
137 var stellar_wpl = wpl_prefab.instantiate()
138 stellar_wpl.name = "StellarWpl"
139 stellar_wpl.exit = "the_stellar"
140 stellar_wpl.senders.append(NodePath("../HatkirbyPanel"))
141 stellar_wpl.senders.append(NodePath("../KirbyPanel"))
142 stellar_wpl.senders.append(NodePath("../StarPanel"))
143 stellar_wpl.complete_at = 1
144 giftmap_parent.add_child.call_deferred(stellar_wpl)
145
146 giftmap_parent.add_child.call_deferred(giftmap_panel)
147
148 var giftmap_tpl = tpl_prefab.instantiate()
149 giftmap_tpl.name = "PanelTeleporter"
150 giftmap_tpl.teleport_point = Vector3(33.5, 1, 5.5)
151 giftmap_tpl.teleport_rotate = Vector3(-45, 0, 0)
152 giftmap_tpl.target_path = giftmap_panel
153 giftmap_tpl.senders.append(
154 NodePath("/root/scene/Components/Listeners/unlockReaderListenerDoubles")
155 )
156 giftmap_parent.add_child.call_deferred(giftmap_tpl)
diff --git a/apworld/client/maps/the_fuzzy.gd b/apworld/client/maps/the_fuzzy.gd new file mode 100644 index 0000000..269dcee --- /dev/null +++ b/apworld/client/maps/the_fuzzy.gd
@@ -0,0 +1,25 @@
1func on_map_load(root):
2 # Add the mastery to The Fuzzy.
3 var collectable_prefab = preload("res://objects/nodes/collectable.tscn")
4 var saver_prefab = preload("res://objects/nodes/saver.tscn")
5 var usl_prefab = preload("res://objects/nodes/listeners/unlockSetterListener.tscn")
6
7 var mastery = collectable_prefab.instantiate()
8 mastery.name = "collectable"
9 mastery.position = Vector3(0, 2, -20)
10 mastery.unlock_type = "smiley"
11 mastery.material_override = load("res://assets/materials/gold.material")
12 root.get_node("/root/scene/Components/Collectables").add_child.call_deferred(mastery)
13
14 var usl = usl_prefab.instantiate()
15 usl.name = "unlockSetterListenerMastery"
16 usl.key = "fuzzy_mastery"
17 usl.value = "unlocked"
18 usl.senders.append(NodePath("/root/scene/Components/Collectables/collectable"))
19 root.get_node("/root/scene/Components").add_child.call_deferred(usl)
20
21 var saver = saver_prefab.instantiate()
22 saver.name = "saver_collectables"
23 saver.type = "collectables"
24 saver.senderGroup.append(NodePath("/root/scene/Components/Collectables"))
25 root.get_node("/root/scene").add_child.call_deferred(saver)
diff --git a/apworld/client/maps/the_gallery.gd b/apworld/client/maps/the_gallery.gd new file mode 100644 index 0000000..6731c16 --- /dev/null +++ b/apworld/client/maps/the_gallery.gd
@@ -0,0 +1,7 @@
1func on_map_load(root):
2 var ap = global.get_node("Archipelago")
3
4 if ap.daedalus_only:
5 # Prevent QUESTION door from opening on Daedalus Only mode.
6 var door = root.get_node("/root/scene/Components/Doors/entry_2")
7 door.animate_distance_y = 0
diff --git a/apworld/client/maps/the_parthenon.gd b/apworld/client/maps/the_parthenon.gd new file mode 100644 index 0000000..96510da --- /dev/null +++ b/apworld/client/maps/the_parthenon.gd
@@ -0,0 +1,51 @@
1func on_map_load(root):
2 var ap = global.get_node("Archipelago")
3
4 # Add the strict cyan ending validation.
5 if ap.strict_cyan_ending:
6 var panel_prefab = preload("res://objects/nodes/panel.tscn")
7 var tpl_prefab = preload("res://objects/nodes/listeners/teleportListener.tscn")
8 var reverse_prefab = preload("res://objects/nodes/listeners/reversingListener.tscn")
9
10 var previous_panel = null
11 var next_y = -100
12 var words = ["quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"]
13 for word in words:
14 var panel = panel_prefab.instantiate()
15 panel.position = Vector3(0, next_y, 0)
16 next_y -= 10
17 panel.clue = word
18 panel.symbol = "."
19 panel.answer = "%s%s" % [word, word]
20 panel.name = "EndCheck_%s" % word
21
22 var tpl = tpl_prefab.instantiate()
23 tpl.teleport_point = Vector3(0, 1, -11)
24 tpl.teleport_rotate = Vector3(-45, 0, 0)
25 tpl.target_path = panel
26 tpl.name = "Teleport"
27
28 if previous_panel == null:
29 tpl.senderGroup.append(NodePath("/root/scene/Panels/Rulers"))
30 else:
31 tpl.senders.append(NodePath("../../%s" % previous_panel.name))
32
33 var reversing = reverse_prefab.instantiate()
34 reversing.senders.append(NodePath(".."))
35 reversing.name = "Reversing"
36 tpl.senders.append(NodePath("../Reversing"))
37
38 panel.add_child.call_deferred(tpl)
39 panel.add_child.call_deferred(reversing)
40 root.get_node("/root/scene/Panels").add_child.call_deferred(panel)
41
42 previous_panel = panel
43
44 # Duplicate the door that usually waits on the rulers. We can't set the
45 # senders here for some reason so we actually set them in the door ready
46 # function.
47 var entry1 = root.get_node("/root/scene/Components/Doors/entry_1")
48 var entry12 = entry1.duplicate()
49 entry12.name = "spe_entry_1"
50 entry1.get_parent().add_child.call_deferred(entry12)
51 entry1.queue_free()
diff --git a/apworld/client/maps/the_plaza.gd b/apworld/client/maps/the_plaza.gd new file mode 100644 index 0000000..13e002d --- /dev/null +++ b/apworld/client/maps/the_plaza.gd
@@ -0,0 +1,4 @@
1func on_map_load(root):
2 # Move the Plaza RTE trigger outside of the turtle.
3 var rte_trigger = root.get_node("/root/scene/Components/Warps/triggerArea")
4 rte_trigger.position.z = 0
diff --git a/apworld/client/maps/the_stellar.gd b/apworld/client/maps/the_stellar.gd new file mode 100644 index 0000000..d633535 --- /dev/null +++ b/apworld/client/maps/the_stellar.gd
@@ -0,0 +1,30 @@
1func on_map_load(root):
2 # Add the mastery to The Stellar.
3 var collectable_prefab = preload("res://objects/nodes/collectable.tscn")
4 var saver_prefab = preload("res://objects/nodes/saver.tscn")
5 var usl_prefab = preload("res://objects/nodes/listeners/unlockSetterListener.tscn")
6
7 var collectables = Node.new()
8 collectables.name = "Collectables"
9
10 var mastery = collectable_prefab.instantiate()
11 mastery.name = "collectable"
12 mastery.position = Vector3(2, 2, -31)
13 mastery.rotation_degrees = Vector3(0, 90, 0)
14 mastery.unlock_type = "smiley"
15 mastery.material_override = load("res://assets/materials/gold.material")
16 collectables.add_child.call_deferred(mastery)
17 root.get_node("/root/scene/Components").add_child.call_deferred(collectables)
18
19 var usl = usl_prefab.instantiate()
20 usl.name = "unlockSetterListenerMastery"
21 usl.key = "stellar_mastery"
22 usl.value = "unlocked"
23 usl.senders.append(NodePath("/root/scene/Components/Collectables/collectable"))
24 root.get_node("/root/scene/Components").add_child.call_deferred(usl)
25
26 var saver = saver_prefab.instantiate()
27 saver.name = "saver_collectables"
28 saver.type = "collectables"
29 saver.senderGroup.append(NodePath("/root/scene/Components/Collectables"))
30 root.get_node("/root/scene").add_child.call_deferred(saver)
diff --git a/apworld/client/maps/the_sun_temple.gd b/apworld/client/maps/the_sun_temple.gd new file mode 100644 index 0000000..9804bf8 --- /dev/null +++ b/apworld/client/maps/the_sun_temple.gd
@@ -0,0 +1,56 @@
1func on_map_load(root):
2 var ap = global.get_node("Archipelago")
3
4 # Add the strict purple ending validation.
5 if ap.strict_purple_ending:
6 var panel_prefab = preload("res://objects/nodes/panel.tscn")
7 var tpl_prefab = preload("res://objects/nodes/listeners/teleportListener.tscn")
8 var reverse_prefab = preload("res://objects/nodes/listeners/reversingListener.tscn")
9
10 var previous_panel = null
11 var next_y = -100
12 var words = ["quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"]
13 for word in words:
14 var panel = panel_prefab.instantiate()
15 panel.position = Vector3(0, next_y, 0)
16 next_y -= 10
17 panel.clue = word
18 panel.symbol = ""
19 panel.answer = word
20 panel.name = "EndCheck_%s" % word
21
22 var tpl = tpl_prefab.instantiate()
23 tpl.teleport_point = Vector3(0, 1, 0)
24 tpl.teleport_rotate = Vector3(-45, 180, 0)
25 tpl.target_path = panel
26 tpl.name = "Teleport"
27
28 if previous_panel == null:
29 tpl.senders.append(NodePath("/root/scene/Panels/End/panel_24"))
30 else:
31 tpl.senders.append(NodePath("../../%s" % previous_panel.name))
32
33 var reversing = reverse_prefab.instantiate()
34 reversing.senders.append(NodePath(".."))
35 reversing.name = "Reversing"
36 tpl.senders.append(NodePath("../Reversing"))
37
38 panel.add_child.call_deferred(tpl)
39 panel.add_child.call_deferred(reversing)
40 root.get_node("/root/scene/Panels").add_child.call_deferred(panel)
41
42 previous_panel = panel
43
44 # Duplicate the doors that usually wait on EQUINOX. We can't set the senders
45 # here for some reason so we actually set them in the door ready function.
46 var endplat = root.get_node("/root/scene/Components/Doors/EndPlatform")
47 var endplat2 = endplat.duplicate()
48 endplat2.name = "spe_EndPlatform"
49 endplat.get_parent().add_child.call_deferred(endplat2)
50 endplat.queue_free()
51
52 var entry2 = root.get_node("/root/scene/Components/Doors/entry_2")
53 var entry22 = entry2.duplicate()
54 entry22.name = "spe_entry_2"
55 entry2.get_parent().add_child.call_deferred(entry22)
56 entry2.queue_free()
diff --git a/apworld/client/maps/the_unkempt.gd b/apworld/client/maps/the_unkempt.gd new file mode 100644 index 0000000..c907650 --- /dev/null +++ b/apworld/client/maps/the_unkempt.gd
@@ -0,0 +1,4 @@
1func on_map_load(root):
2 # Prevent the COLOR panel from disappearing.
3 var color_tpl = root.get_node("/root/scene/Panels/Assorted/panel_1/teleportListener")
4 color_tpl.target_path = color_tpl
diff --git a/apworld/client/maps/the_unyielding.gd b/apworld/client/maps/the_unyielding.gd new file mode 100644 index 0000000..a2f8eee --- /dev/null +++ b/apworld/client/maps/the_unyielding.gd
@@ -0,0 +1,5 @@
1func on_map_load(root):
2 # Shrink the painting trigger in The Unyielding.
3 var trigger_area = root.get_node("/root/scene/Components/PaintingUnlocker/triggerArea")
4 trigger_area.position = Vector3(0, 0, -6)
5 trigger_area.scale = Vector3(6, 1, 6)
diff --git a/apworld/client/messages.gd b/apworld/client/messages.gd new file mode 100644 index 0000000..ab4f071 --- /dev/null +++ b/apworld/client/messages.gd
@@ -0,0 +1,74 @@
1extends CanvasLayer
2
3var SCRIPT_rainbowText
4
5var _message_queue = []
6var _font
7var _container
8var _ordered_labels = []
9
10
11func _ready():
12 _container = VBoxContainer.new()
13 _container.set_name("Container")
14 _container.anchor_bottom = 1
15 _container.offset_left = 20.0
16 _container.offset_right = 1920.0
17 _container.offset_top = 0.0
18 _container.offset_bottom = -20.0
19 _container.alignment = BoxContainer.ALIGNMENT_END
20 _container.mouse_filter = Control.MOUSE_FILTER_IGNORE
21 self.add_child(_container)
22
23 _font = load("res://assets/fonts/Lingo2.ttf")
24
25
26func _add_message(text):
27 var new_label = RichTextLabel.new()
28 new_label.install_effect(SCRIPT_rainbowText.new())
29 new_label.push_font(_font)
30 new_label.push_font_size(36)
31 new_label.push_outline_color(Color(0, 0, 0, 1))
32 new_label.push_outline_size(2)
33 new_label.append_text(text)
34 new_label.fit_content = true
35
36 _container.add_child(new_label)
37 _ordered_labels.push_back(new_label)
38
39
40func showMessage(text):
41 if _ordered_labels.size() >= 9:
42 _message_queue.append(text)
43 return
44
45 _add_message(text)
46
47 if _ordered_labels.size() > 1:
48 return
49
50 var timeout = 10.0
51 while !_ordered_labels.is_empty():
52 await get_tree().create_timer(timeout).timeout
53
54 if !_ordered_labels.is_empty():
55 var to_remove = _ordered_labels.pop_front()
56 var to_tween = get_tree().create_tween().bind_node(to_remove)
57 to_tween.tween_property(to_remove, "modulate:a", 0.0, 0.5)
58 to_tween.tween_callback(to_remove.queue_free)
59
60 if !_message_queue.is_empty():
61 var next_msg = _message_queue.pop_front()
62 _add_message(next_msg)
63
64 if timeout > 4:
65 timeout -= 3
66
67
68func clear():
69 _message_queue.clear()
70
71 for message_label in _ordered_labels:
72 message_label.queue_free()
73
74 _ordered_labels.clear()
diff --git a/apworld/client/minimap.gd b/apworld/client/minimap.gd new file mode 100644 index 0000000..bf70114 --- /dev/null +++ b/apworld/client/minimap.gd
@@ -0,0 +1,178 @@
1extends CanvasLayer
2
3var player
4var drawer
5var sprite
6var label
7
8var cell_left
9var cell_top
10var cell_right
11var cell_bottom
12var cell_width
13var cell_height
14var center_x_min
15var center_x_max
16var center_y_min
17var center_y_max
18
19
20func _ready():
21 player = get_tree().get_root().get_node("scene/player")
22
23 var svc = PanelContainer.new()
24 svc.anchor_left = 1.0
25 svc.anchor_top = 1.0
26 svc.anchor_right = 1.0
27 svc.anchor_bottom = 1.0
28 svc.offset_left = -320.0
29 svc.offset_top = -320.0
30 svc.offset_right = -64.0
31 svc.offset_bottom = -64.0
32 svc.clip_contents = true
33 add_child(svc)
34
35 var background_color = Color.WHITE
36
37 var world_env = get_tree().get_root().get_node("scene/WorldEnvironment")
38 if world_env != null and world_env.environment != null:
39 if world_env.environment.background_mode == Environment.BG_COLOR:
40 background_color = world_env.environment.background_color
41 elif (
42 world_env.environment.background_mode == Environment.BG_SKY
43 and world_env.environment.sky != null
44 and world_env.environment.sky.sky_material != null
45 ):
46 var sky = world_env.environment.sky.sky_material
47 if sky is PhysicalSkyMaterial:
48 background_color = sky.ground_color
49 elif sky is ProceduralSkyMaterial:
50 background_color = sky.sky_top_color
51
52 var stylebox = StyleBoxFlat.new()
53 stylebox.bg_color = Color(background_color, 0.6)
54 svc.add_theme_stylebox_override("panel", stylebox)
55
56 drawer = Node2D.new()
57 svc.add_child(drawer)
58
59 var gridmap = get_tree().get_root().get_node("scene/GridMap")
60 if gridmap == null:
61 visible = false
62 return
63
64 cell_left = 0
65 cell_top = 0
66 cell_right = 0
67 cell_bottom = 0
68
69 for pos in gridmap.get_used_cells():
70 if pos.x < cell_left:
71 cell_left = pos.x
72 if pos.x > cell_right:
73 cell_right = pos.x
74 if pos.z < cell_top:
75 cell_top = pos.z
76 if pos.z > cell_bottom:
77 cell_bottom = pos.z
78
79 cell_width = cell_right - cell_left + 1
80 cell_height = cell_bottom - cell_top + 1
81
82 var rendered = _renderMap(gridmap)
83
84 var image_texture = ImageTexture.create_from_image(rendered)
85 sprite = Sprite2D.new()
86 sprite.texture = image_texture
87 sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
88 sprite.scale = Vector2(2, 2)
89 sprite.centered = false
90 drawer.add_child(sprite)
91
92 label = Label.new()
93 label.theme = preload("res://assets/themes/baseUI.tres")
94 label.add_theme_font_size_override("font_size", 32)
95 label.text = "@"
96 drawer.add_child(label)
97
98 #var local_tl = gridmap.map_to_local(Vector3i(cell_left, 0, cell_top))
99 #var global_tl = gridmap.to_global(local_tl)
100 #var local_br = gridmap.map_to_local(Vector3i(cell_right, 0, cell_bottom))
101 #var global_br = gridmap.to_global(local_br)
102
103 center_x_min = 0
104 center_x_max = cell_width - 128
105 center_y_min = 0
106 center_y_max = cell_height - 128
107
108 if center_x_max < center_x_min:
109 center_x_min = (center_x_min + center_x_max) / 2
110 center_x_max = center_x_min
111
112 if center_y_max < center_y_min:
113 center_y_min = (center_y_min + center_y_max) / 2
114 center_y_max = center_y_min
115
116
117func _process(_delta):
118 if visible == false:
119 return
120
121 drawer.position.x = clamp(player.position.x - cell_left - 64, center_x_min, center_x_max) * -2
122 drawer.position.y = clamp(player.position.z - cell_top - 64, center_y_min, center_y_max) * -2
123
124 label.position.x = (player.position.x - cell_left) * 2 - 16
125 label.position.y = (player.position.z - cell_top) * 2 - 16
126
127
128func _renderMap(gridmap):
129 var ap = global.get_node("Archipelago")
130 var heights = {}
131
132 var rendered = Image.create_empty(cell_width, cell_height, false, Image.FORMAT_RGBA8)
133 rendered.fill(Color.TRANSPARENT)
134
135 var meshes_node = get_tree().get_root().get_node("scene/Meshes")
136 if meshes_node != null:
137 _renderMeshNode(ap, gridmap, meshes_node, rendered)
138
139 for pos in gridmap.get_used_cells():
140 var in_plane = Vector2i(pos.x, pos.z)
141
142 if in_plane in heights and heights[in_plane] > pos.y:
143 continue
144
145 heights[in_plane] = pos.y
146
147 var cell_item = gridmap.get_cell_item(pos)
148 var mesh = gridmap.mesh_library.get_item_mesh(cell_item)
149 var material = mesh.surface_get_material(0)
150 var color = ap.color_by_material_path.get(material.resource_path, Color.TRANSPARENT)
151
152 rendered.set_pixel(pos.x - cell_left, pos.z - cell_top, color)
153
154 return rendered
155
156
157func _renderMeshNode(ap, gridmap, mesh, rendered):
158 if mesh is MeshInstance3D:
159 var local_tl = gridmap.map_to_local(Vector3i(cell_left, 0, cell_top))
160 var global_tl = gridmap.to_global(local_tl)
161 var mesh_material = mesh.get_surface_override_material(0)
162 if mesh_material != null:
163 var mesh_color = ap.color_by_material_path.get(
164 mesh_material.resource_path, Color.TRANSPARENT
165 )
166
167 for y in range(
168 max(mesh.position.z - mesh.scale.z / 2 - global_tl.z, 0),
169 min(mesh.position.z + mesh.scale.z / 2 - global_tl.z, cell_height)
170 ):
171 for x in range(
172 max(mesh.position.x - mesh.scale.x / 2 - global_tl.x, 0),
173 min(mesh.position.x + mesh.scale.x / 2 - global_tl.x, cell_width)
174 ):
175 rendered.set_pixel(x, y, mesh_color)
176
177 for child in mesh.get_children():
178 _renderMeshNode(ap, gridmap, child, rendered)
diff --git a/apworld/client/painting.gd b/apworld/client/painting.gd new file mode 100644 index 0000000..276d4eb --- /dev/null +++ b/apworld/client/painting.gd
@@ -0,0 +1,38 @@
1extends "res://scripts/nodes/painting.gd"
2
3var item_id
4var item_amount
5
6
7func _ready():
8 var node_path = String(
9 get_tree().get_root().get_node("scene").get_path_to(self).get_concatenated_names()
10 )
11
12 var gamedata = global.get_node("Gamedata")
13 var door_id = gamedata.get_door_for_map_node_path(global.map, node_path)
14 if door_id != null:
15 var ap = global.get_node("Archipelago")
16 var item_lock = ap.get_item_id_for_door(door_id)
17
18 if item_lock != null:
19 item_id = item_lock[0]
20 item_amount = item_lock[1]
21
22 self.senders = []
23 self.senderGroup = []
24 self.nested = false
25 self.complete_at = 0
26 self.max_length = 0
27 self.excludeSenders = []
28
29 call_deferred("_readier")
30
31 super._ready()
32
33
34func _readier():
35 var ap = global.get_node("Archipelago")
36
37 if ap.client.getItemAmount(item_id) >= item_amount:
38 handleTriggered()
diff --git a/apworld/client/paintingAuto.gd b/apworld/client/paintingAuto.gd new file mode 100644 index 0000000..553c2c9 --- /dev/null +++ b/apworld/client/paintingAuto.gd
@@ -0,0 +1,43 @@
1extends "res://scripts/nodes/paintingAuto.gd"
2
3var item_id
4var item_amount
5
6
7func _ready():
8 var node_path = String(
9 get_tree().get_root().get_node("scene").get_path_to(self).get_concatenated_names()
10 )
11
12 var gamedata = global.get_node("Gamedata")
13 var door_id = gamedata.get_door_for_map_node_path(global.map, node_path)
14 if door_id != null:
15 var ap = global.get_node("Archipelago")
16 var item_lock = ap.get_item_id_for_door(door_id)
17
18 if item_lock != null:
19 item_id = item_lock[0]
20 item_amount = item_lock[1]
21
22 self.senders = []
23 self.senderGroup = []
24 self.nested = false
25 self.complete_at = 0
26 self.max_length = 0
27 self.excludeSenders = []
28
29 call_deferred("_readier")
30
31 super._ready()
32
33 if item_id != null and activate_on_sender_complete:
34 enabled = false
35 if not hide_particles:
36 get_node("Hinge/paintingColliders/TeleportParticles").emitting = false
37
38
39func _readier():
40 var ap = global.get_node("Archipelago")
41
42 if ap.client.getItemAmount(item_id) >= item_amount:
43 handleTriggered()
diff --git a/apworld/client/panel.gd b/apworld/client/panel.gd new file mode 100644 index 0000000..2cef28e --- /dev/null +++ b/apworld/client/panel.gd
@@ -0,0 +1,101 @@
1extends "res://scripts/nodes/panel.gd"
2
3var panel_logic = null
4var symbol_solvable = true
5
6var black = load("res://assets/materials/black.material")
7
8
9func _ready():
10 super._ready()
11
12 var node_path = String(
13 get_tree().get_root().get_node("scene").get_path_to(self).get_concatenated_names()
14 )
15
16 var gamedata = global.get_node("Gamedata")
17 var panel_id = gamedata.get_panel_for_map_node_path(global.map, node_path)
18 if panel_id != null:
19 var ap = global.get_node("Archipelago")
20 if ap.shuffle_symbols:
21 if global.map == "the_entry" and node_path == "Panels/Entry/front_1":
22 clue = "i"
23 symbol = ""
24
25 setField("clue", clue)
26 setField("symbol", symbol)
27
28 panel_logic = gamedata.objects.get_panels()[panel_id]
29 checkSymbolSolvable()
30
31 if not symbol_solvable:
32 get_tree().get_root().get_node("scene/player").evaluate_solvability.connect(
33 evaluateSolvability
34 )
35
36
37func checkSymbolSolvable():
38 var old_solvable = symbol_solvable
39 symbol_solvable = true
40
41 if panel_logic == null:
42 # There's no logic for this panel.
43 return
44
45 var ap = global.get_node("Archipelago")
46 if not ap.shuffle_symbols:
47 # Symbols aren't item-locked.
48 return
49
50 var gamedata = global.get_node("Gamedata")
51 for symbol in panel_logic.get_symbols():
52 var item_name = gamedata.kSYMBOL_ITEMS.get(symbol)
53 var item_id = gamedata.objects.get_special_ids()[item_name]
54 if ap.client.getItemAmount(item_id) < 1:
55 symbol_solvable = false
56 break
57
58 if symbol_solvable != old_solvable:
59 if symbol_solvable:
60 setField("clue", clue)
61 setField("symbol", symbol)
62 setField("answer", answer)
63 else:
64 quad_mesh.surface_set_material(0, black)
65 get_node("Hinge/clue").text = "missing"
66 get_node("Hinge/answer").text = "symbols"
67
68
69func checkSolvable(key):
70 checkSymbolSolvable()
71 if not symbol_solvable:
72 return false
73
74 return super.checkSolvable(key)
75
76
77func evaluateSolvability():
78 checkSolvable("")
79
80
81func passedInput(key, skip_focus_check = false):
82 if not symbol_solvable:
83 return
84
85 super.passedInput(key, skip_focus_check)
86
87
88func focus():
89 if not symbol_solvable:
90 has_focus = false
91 return
92
93 super.focus()
94
95
96func unfocus():
97 if not symbol_solvable:
98 has_focus = false
99 return
100
101 super.unfocus()
diff --git a/apworld/client/pauseMenu.gd b/apworld/client/pauseMenu.gd new file mode 100644 index 0000000..72b45e8 --- /dev/null +++ b/apworld/client/pauseMenu.gd
@@ -0,0 +1,91 @@
1extends "res://scripts/ui/pauseMenu.gd"
2
3var compass_button
4var locations_button
5var minimap_button
6
7
8func _ready():
9 var ap_panel = Panel.new()
10 ap_panel.name = "Archipelago"
11 get_node("menu/settings/settingsInner/TabContainer").add_child(ap_panel)
12
13 var ap = global.get_node("Archipelago")
14
15 compass_button = CheckBox.new()
16 compass_button.text = "show compass"
17 compass_button.button_pressed = ap.show_compass
18 compass_button.position = Vector2(65, 100)
19 compass_button.theme = preload("res://assets/themes/baseUI.tres")
20 compass_button.add_theme_font_size_override("font_size", 60)
21 compass_button.pressed.connect(_toggle_compass)
22 ap_panel.add_child(compass_button)
23
24 locations_button = CheckBox.new()
25 locations_button.text = "show locations overlay"
26 locations_button.button_pressed = ap.show_locations
27 locations_button.position = Vector2(65, 200)
28 locations_button.theme = preload("res://assets/themes/baseUI.tres")
29 locations_button.add_theme_font_size_override("font_size", 60)
30 locations_button.pressed.connect(_toggle_locations)
31 ap_panel.add_child(locations_button)
32
33 minimap_button = CheckBox.new()
34 minimap_button.text = "show minimap"
35 minimap_button.button_pressed = ap.show_minimap
36 minimap_button.position = Vector2(65, 300)
37 minimap_button.theme = preload("res://assets/themes/baseUI.tres")
38 minimap_button.add_theme_font_size_override("font_size", 60)
39 minimap_button.pressed.connect(_toggle_minimap)
40 ap_panel.add_child(minimap_button)
41
42 super._ready()
43
44
45func _pause_game():
46 global.get_node("Textclient").dismiss()
47 super._pause_game()
48
49
50func _main_menu():
51 global.loaded = false
52 global.get_node("Archipelago").disconnect_from_ap()
53 global.get_node("Messages").clear()
54 global.get_node("Compass").visible = false
55 global.get_node("Textclient").reset()
56
57 autosplitter.reset()
58 _unpause_game()
59 Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
60 musicPlayer.stop()
61
62 var runtime = global.get_node("Runtime")
63 runtime.load_script_as_scene.call_deferred("settings_screen.gd", "settings_screen")
64
65
66func _toggle_compass():
67 var ap = global.get_node("Archipelago")
68 ap.show_compass = compass_button.button_pressed
69 ap.saveSettings()
70
71 var compass = global.get_node("Compass")
72 compass.visible = compass_button.button_pressed
73
74
75func _toggle_locations():
76 var ap = global.get_node("Archipelago")
77 ap.show_locations = locations_button.button_pressed
78 ap.saveSettings()
79
80 var textclient = global.get_node("Textclient")
81 textclient.update_locations_visibility()
82
83
84func _toggle_minimap():
85 var ap = global.get_node("Archipelago")
86 ap.show_minimap = minimap_button.button_pressed
87 ap.saveSettings()
88
89 var minimap = get_tree().get_root().get_node("scene/Minimap")
90 if minimap != null:
91 minimap.visible = ap.show_minimap
diff --git a/apworld/client/player.gd b/apworld/client/player.gd new file mode 100644 index 0000000..dabc15d --- /dev/null +++ b/apworld/client/player.gd
@@ -0,0 +1,219 @@
1extends "res://scripts/nodes/player.gd"
2
3signal evaluate_solvability
4
5var compass
6
7
8func _ready():
9 var khl_script = load("res://scripts/nodes/keyHolderListener.gd")
10
11 var pause_menu = get_node("pause_menu")
12 pause_menu.layer = 3
13
14 var ap = global.get_node("Archipelago")
15 var gamedata = global.get_node("Gamedata")
16 var map_id = gamedata.map_id_by_name.get(global.map)
17 var map_data = gamedata.objects.get_maps()[map_id]
18
19 compass = global.get_node("Compass")
20 compass.visible = ap.show_compass
21
22 ap.start_batching_locations()
23
24 # Run map-specific initialization.
25 var map_script = ap.get_map_script(global.map)
26 if map_script != null:
27 map_script.on_map_load(get_tree().get_root())
28
29 ap.update_job_well_done_sign()
30
31 # Set up the RTE trigger, if there is one.
32 if map_data.has_rte_trigger_pos():
33 var oneShotListener_prefab = preload("res://objects/nodes/listeners/oneShotListener.tscn")
34 var triggerArea_prefab = preload("res://objects/nodes/triggerArea.tscn")
35 var unlockSetterListener_prefab = preload(
36 "res://objects/nodes/listeners/unlockSetterListener.tscn"
37 )
38
39 var triggerArea = triggerArea_prefab.instantiate()
40 triggerArea.name = "rte_triggerArea"
41 triggerArea.position = gamedata.vec3d_to_vector3(map_data.get_rte_trigger_pos())
42 triggerArea.scale = gamedata.vec3d_to_vector3(map_data.get_rte_trigger_scale())
43 get_parent().add_child.call_deferred(triggerArea)
44
45 var osl = oneShotListener_prefab.instantiate()
46 osl.name = "rte_osl"
47 osl.senders.append(NodePath("/root/scene/rte_triggerArea"))
48 get_parent().add_child.call_deferred(osl)
49
50 var usl = unlockSetterListener_prefab.instantiate()
51 usl.name = "rte_usl"
52 usl.key = "rte_%s" % global.map
53 usl.value = "unlocked"
54 usl.senders.append(NodePath("/root/scene/rte_osl"))
55 get_parent().add_child.call_deferred(usl)
56
57 # Set up door locations.
58 for door in gamedata.objects.get_doors():
59 if door.get_map_id() != map_id:
60 continue
61
62 if not door.has_ap_id():
63 continue
64
65 if (
66 not (door.has_legacy_location() and door.get_legacy_location())
67 and (
68 door.get_type() == gamedata.SCRIPT_proto.DoorType.ITEM_ONLY
69 or door.get_type() == gamedata.SCRIPT_proto.DoorType.GALLERY_PAINTING
70 or door.get_type() == gamedata.SCRIPT_proto.DoorType.CONTROL_CENTER_COLOR
71 )
72 ):
73 continue
74
75 var locationListener = ap.SCRIPT_locationListener.new()
76 locationListener.location_id = door.get_ap_id()
77 locationListener.name = "locationListener_%d" % door.get_ap_id()
78
79 for panel_ref in door.get_panels():
80 var panel_data = gamedata.objects.get_panels()[panel_ref.get_panel()]
81 var panel_path = panel_data.get_path()
82
83 if panel_ref.has_answer():
84 for proxy in panel_data.get_proxies():
85 if proxy.get_answer() == panel_ref.get_answer():
86 panel_path = proxy.get_path()
87 break
88
89 locationListener.senders.append(NodePath("/root/scene/" + panel_path))
90
91 for keyholder_ref in door.get_keyholders():
92 var keyholder_data = gamedata.objects.get_keyholders()[keyholder_ref.get_keyholder()]
93
94 var khl = khl_script.new()
95 khl.name = (
96 "location_%d_keyholder_%d" % [door.get_ap_id(), keyholder_ref.get_keyholder()]
97 )
98 khl.answer = keyholder_ref.get_key()
99 khl.senders.append(NodePath("/root/scene/" + keyholder_data.get_path()))
100 get_parent().add_child.call_deferred(khl)
101
102 locationListener.senders.append(NodePath("../" + khl.name))
103
104 for sender in door.get_senders():
105 locationListener.senders.append(NodePath("/root/scene/" + sender))
106
107 if door.has_complete_at():
108 locationListener.complete_at = door.get_complete_at()
109
110 get_parent().add_child.call_deferred(locationListener)
111
112 # Set up letter locations.
113 for letter in gamedata.objects.get_letters():
114 var room = gamedata.objects.get_rooms()[letter.get_room_id()]
115 if room.get_map_id() != map_id:
116 continue
117
118 var locationListener = ap.SCRIPT_locationListener.new()
119 locationListener.location_id = letter.get_ap_id()
120 locationListener.name = "locationListener_%d" % letter.get_ap_id()
121 locationListener.senders.append(NodePath("/root/scene/" + letter.get_path()))
122
123 get_parent().add_child.call_deferred(locationListener)
124
125 if (
126 ap.get_letter_behavior(letter.get_key(), letter.has_level2() and letter.get_level2())
127 != ap.kLETTER_BEHAVIOR_VANILLA
128 ):
129 var scout = ap.scout_location(letter.get_ap_id())
130 if scout != null and not (scout["for_self"] and scout["flags"] & 4 != 0):
131 var collectable = get_tree().get_root().get_node("scene").get_node_or_null(
132 letter.get_path()
133 )
134 if collectable != null:
135 collectable.setScoutedText.call_deferred(scout["item"])
136
137 # Set up mastery locations.
138 for mastery in gamedata.objects.get_masteries():
139 var room = gamedata.objects.get_rooms()[mastery.get_room_id()]
140 if room.get_map_id() != map_id:
141 continue
142
143 var locationListener = ap.SCRIPT_locationListener.new()
144 locationListener.location_id = mastery.get_ap_id()
145 locationListener.name = "locationListener_%d" % mastery.get_ap_id()
146 locationListener.senders.append(NodePath("/root/scene/" + mastery.get_path()))
147
148 get_parent().add_child.call_deferred(locationListener)
149
150 # Set up ending locations.
151 for ending in gamedata.objects.get_endings():
152 var room = gamedata.objects.get_rooms()[ending.get_room_id()]
153 if room.get_map_id() != map_id:
154 continue
155
156 var locationListener = ap.SCRIPT_locationListener.new()
157 locationListener.location_id = ending.get_ap_id()
158 locationListener.name = "locationListener_%d" % ending.get_ap_id()
159 locationListener.senders.append(NodePath("/root/scene/" + ending.get_path()))
160
161 get_parent().add_child.call_deferred(locationListener)
162
163 if ap.kEndingNameByVictoryValue.get(ap.victory_condition, null) == ending.get_name():
164 var victoryListener = ap.SCRIPT_victoryListener.new()
165 victoryListener.name = "victoryListener"
166 victoryListener.senders.append(NodePath("/root/scene/" + ending.get_path()))
167
168 get_parent().add_child.call_deferred(victoryListener)
169
170 # Set up keyholder locations, in keyholder sanity.
171 if ap.keyholder_sanity:
172 for keyholder in gamedata.objects.get_keyholders():
173 if not keyholder.has_key():
174 continue
175
176 var room = gamedata.objects.get_rooms()[keyholder.get_room_id()]
177 if room.get_map_id() != map_id:
178 continue
179
180 var locationListener = ap.SCRIPT_locationListener.new()
181 locationListener.location_id = keyholder.get_ap_id()
182 locationListener.name = "locationListener_%d" % keyholder.get_ap_id()
183
184 var khl = khl_script.new()
185 khl.name = "location_%d_keyholder" % keyholder.get_ap_id()
186 khl.answer = keyholder.get_key()
187 khl.senders.append(NodePath("/root/scene/" + keyholder.get_path()))
188 get_parent().add_child.call_deferred(khl)
189
190 locationListener.senders.append(NodePath("../" + khl.name))
191
192 get_parent().add_child.call_deferred(locationListener)
193
194 var minimap = ap.SCRIPT_minimap.new()
195 minimap.name = "Minimap"
196 minimap.visible = ap.show_minimap
197 get_parent().add_child.call_deferred(minimap)
198
199 if ap.music_mapping.has(global.map):
200 var song_setter = get_node_or_null("/root/scene/songSetter")
201 if song_setter:
202 song_setter.song_name = ap.music_mapping[global.map]
203 else:
204 var song_setter_prefab = preload("res://objects/nodes/songSetter.tscn")
205 song_setter = song_setter_prefab.instantiate()
206 song_setter.name = "songSetter"
207 song_setter.song_name = ap.music_mapping[global.map]
208 get_parent().add_child.call_deferred(song_setter)
209
210 super._ready()
211
212 await get_tree().process_frame
213 await get_tree().process_frame
214
215 ap.stop_batching_locations()
216
217
218func _process(_dt):
219 compass.update_rotation(global_rotation.y)
diff --git a/apworld/client/rainbowText.gd b/apworld/client/rainbowText.gd new file mode 100644 index 0000000..9a4c1d0 --- /dev/null +++ b/apworld/client/rainbowText.gd
@@ -0,0 +1,10 @@
1extends RichTextEffect
2
3var bbcode = "rainbow"
4
5
6func _process_custom_fx(char_fx: CharFXTransform):
7 char_fx.color = Color.from_hsv(
8 char_fx.elapsed_time - floor(char_fx.elapsed_time), 1.0, 1.0, 1.0
9 )
10 return true
diff --git a/apworld/client/rteMenu.gd b/apworld/client/rteMenu.gd new file mode 100644 index 0000000..519f09f --- /dev/null +++ b/apworld/client/rteMenu.gd
@@ -0,0 +1,67 @@
1extends "res://scripts/ui/rteMenu.gd"
2
3var buttons = []
4
5
6func _readier():
7 var ap = global.get_node("Archipelago")
8 if ap.daedalus_only:
9 get_node("rte_the_entry").hide()
10 get_node("rte_daedalus").show()
11
12 switcher.preload_map("res://objects/scenes/daedalus.tscn")
13 elif !ap.rte_mapping.is_empty():
14 buttons = [$rte_the_plaza, $rte_the_gallery, $rte_daedalus, $rte_control_center]
15 for i in range(4):
16 buttons[i].name = "button_%d" % i
17 for i in range(4):
18 _setupButton(buttons[i], ap.rte_mapping[i])
19
20 refreshButtons()
21 else:
22 super()._readier()
23
24
25func _setupButton(button, map_name):
26 switcher.preload_map("res://objects/scenes/%s.tscn" % map_name)
27
28 button.hide()
29 button.text = map_name.replace("_", " ")
30 button.name = "rte_%s" % map_name
31 button.autowrap_mode = TextServer.AUTOWRAP_WORD
32
33 var ap = global.get_node("Archipelago")
34 if (
35 ap.fast_travel_access == ap.kFAST_TRAVEL_ACCESS_VANILLA
36 and !unlocks.data.has("rte_%s" % map_name)
37 ):
38 unlocks.data["rte_%s" % map_name] = ""
39
40
41func refreshButtons():
42 var ap = global.get_node("Archipelago")
43 if ap.rte_mapping.is_empty():
44 return
45
46 for i in range(4):
47 if _shouldShowButton(ap.rte_mapping[i]):
48 buttons[i].show()
49 else:
50 buttons[i].hide()
51
52
53func _shouldShowButton(map_name):
54 var ap = global.get_node("Archipelago")
55
56 if ap.fast_travel_access == ap.kFAST_TRAVEL_ACCESS_VANILLA:
57 return unlocks.data["rte_%s" % map_name] == "unlocked"
58 elif ap.fast_travel_access == ap.kFAST_TRAVEL_ACCESS_UNLOCKED:
59 return true
60 elif ap.fast_travel_access == ap.kFAST_TRAVEL_ACCESS_ITEMS:
61 var gamedata = global.get_node("Gamedata")
62 var map_id = gamedata.map_id_by_name[map_name]
63 var rte_ap_id = gamedata.objects.get_maps()[map_id].get_rte_ap_id()
64
65 return ap.client.hasItem(rte_ap_id)
66
67 return false
diff --git a/apworld/client/run_from_apworld.tscn b/apworld/client/run_from_apworld.tscn new file mode 100644 index 0000000..11373e0 --- /dev/null +++ b/apworld/client/run_from_apworld.tscn
@@ -0,0 +1,30 @@
1[gd_scene load_steps=11 format=2]
2
3[sub_resource id=2 type="GDScript"]
4script/source = "extends Node2D
5
6
7func _ready():
8 var args = OS.get_cmdline_user_args()
9 var apworld_path = args[0]
10
11 var zip_reader = ZIPReader.new()
12 zip_reader.open(apworld_path)
13
14 var runtime_script = GDScript.new()
15 runtime_script.source_code = zip_reader.read_file(\"lingo2/client/apworld_runtime.gd\").get_string_from_utf8()
16 runtime_script.reload()
17
18 zip_reader.close()
19
20 var runtime = runtime_script.new(apworld_path)
21 runtime.name = \"Runtime\"
22
23 global.add_child(runtime)
24
25 runtime.load_script_as_scene.call_deferred(\"settings_screen.gd\", \"settings_screen\")
26
27"
28
29[node name="loader" type="Node2D"]
30script = SubResource( 2 )
diff --git a/apworld/client/run_from_source.tscn b/apworld/client/run_from_source.tscn new file mode 100644 index 0000000..59a914d --- /dev/null +++ b/apworld/client/run_from_source.tscn
@@ -0,0 +1,22 @@
1[gd_scene load_steps=11 format=2]
2
3[sub_resource id=2 type="GDScript"]
4script/source = "extends Node2D
5
6
7func _ready():
8 var args = OS.get_cmdline_user_args()
9 var source_path = args[0]
10
11 var runtime_script = ResourceLoader.load(\"%s/source_runtime.gd\" % source_path)
12 var runtime = runtime_script.new(source_path)
13 runtime.name = \"Runtime\"
14
15 global.add_child(runtime)
16
17 runtime.load_script_as_scene.call_deferred(\"settings_screen.gd\", \"settings_screen\")
18
19"
20
21[node name="loader" type="Node2D"]
22script = SubResource( 2 )
diff --git a/apworld/client/saver.gd b/apworld/client/saver.gd new file mode 100644 index 0000000..44bc179 --- /dev/null +++ b/apworld/client/saver.gd
@@ -0,0 +1,23 @@
1extends "res://scripts/nodes/saver.gd"
2
3
4func levelLoaded():
5 if type == "keyholders":
6 var ap = global.get_node("Archipelago")
7 ap.keyboard.load_keyholders.call_deferred(global.map)
8 else:
9 reload.call_deferred()
10
11
12func reload():
13 # Just rewriting this whole thing so I can remove Chris's safeguard.
14 var file = FileAccess.open(path + type + ".save", FileAccess.READ)
15 if file:
16 var data = file.get_var(true)
17 file.close()
18 for datum in data:
19 var saveable = get_node_or_null(datum[0])
20 if saveable != null:
21 saveable.is_complete = datum[1]
22 if saveable.is_complete:
23 saveable.loadData(saveable.is_complete)
diff --git a/apworld/client/settings_screen.gd b/apworld/client/settings_screen.gd new file mode 100644 index 0000000..89e8b68 --- /dev/null +++ b/apworld/client/settings_screen.gd
@@ -0,0 +1,149 @@
1extends Node
2
3
4func _ready():
5 var theme = preload("res://assets/themes/baseUI.tres")
6
7 var simple_style_box = StyleBoxFlat.new()
8 simple_style_box.bg_color = Color(0, 0, 0, 0)
9
10 var panel = Panel.new()
11 panel.name = "Panel"
12 panel.offset_right = 1920.0
13 panel.offset_bottom = 1080.0
14 add_child(panel)
15
16 var title = Label.new()
17 title.name = "title"
18 title.offset_left = 0.0
19 title.offset_top = 75.0
20 title.offset_right = 1920.0
21 title.offset_bottom = 225.0
22 title.text = "ARCHIPELAGO"
23 title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
24 title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
25 title.theme = theme
26 panel.add_child(title)
27
28 var connect_button = Button.new()
29 connect_button.name = "connect_button"
30 connect_button.offset_left = 255.0
31 connect_button.offset_top = 875.0
32 connect_button.offset_right = 891.0
33 connect_button.offset_bottom = 1025.0
34 connect_button.add_theme_color_override("font_color_hover", Color(1, 0.501961, 0, 1))
35 connect_button.text = "CONNECT"
36 connect_button.theme = theme
37 panel.add_child(connect_button)
38
39 var quit_button = Button.new()
40 quit_button.name = "quit_button"
41 quit_button.offset_left = 1102.0
42 quit_button.offset_top = 875.0
43 quit_button.offset_right = 1738.0
44 quit_button.offset_bottom = 1025.0
45 quit_button.add_theme_color_override("font_color_hover", Color(1, 0, 0, 1))
46 quit_button.text = "QUIT"
47 quit_button.theme = theme
48 panel.add_child(quit_button)
49
50 var credit2 = Label.new()
51 credit2.name = "credit2"
52 credit2.offset_left = -105.0
53 credit2.offset_top = 346.0
54 credit2.offset_right = 485.0
55 credit2.offset_bottom = 410.0
56 credit2.add_theme_stylebox_override("normal", simple_style_box)
57 credit2.text = "SERVER"
58 credit2.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
59 credit2.theme = theme
60 panel.add_child(credit2)
61
62 var credit3 = Label.new()
63 credit3.name = "credit3"
64 credit3.offset_left = -105.0
65 credit3.offset_top = 519.0
66 credit3.offset_right = 485.0
67 credit3.offset_bottom = 583.0
68 credit3.add_theme_stylebox_override("normal", simple_style_box)
69 credit3.text = "PLAYER"
70 credit3.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
71 credit3.theme = theme
72 panel.add_child(credit3)
73
74 var credit4 = Label.new()
75 credit4.name = "credit4"
76 credit4.offset_left = -105.0
77 credit4.offset_top = 704.0
78 credit4.offset_right = 485.0
79 credit4.offset_bottom = 768.0
80 credit4.add_theme_stylebox_override("normal", simple_style_box)
81 credit4.text = "PASSWORD"
82 credit4.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
83 credit4.theme = theme
84 panel.add_child(credit4)
85
86 var credit5 = Label.new()
87 credit5.name = "credit5"
88 credit5.offset_left = 1239.0
89 credit5.offset_top = 422.0
90 credit5.offset_right = 1829.0
91 credit5.offset_bottom = 486.0
92 credit5.add_theme_stylebox_override("normal", simple_style_box)
93 credit5.text = "OPTIONS"
94 credit5.theme = theme
95 panel.add_child(credit5)
96
97 var server_box = LineEdit.new()
98 server_box.name = "server_box"
99 server_box.offset_left = 502.0
100 server_box.offset_top = 295.0
101 server_box.offset_right = 1144.0
102 server_box.offset_bottom = 445.0
103 server_box.alignment = HORIZONTAL_ALIGNMENT_CENTER
104 server_box.caret_blink = true
105 panel.add_child(server_box)
106
107 var player_box = LineEdit.new()
108 player_box.name = "player_box"
109 player_box.offset_left = 502.0
110 player_box.offset_top = 477.0
111 player_box.offset_right = 1144.0
112 player_box.offset_bottom = 627.0
113 player_box.alignment = HORIZONTAL_ALIGNMENT_CENTER
114 player_box.caret_blink = true
115 panel.add_child(player_box)
116
117 var password_box = LineEdit.new()
118 password_box.name = "password_box"
119 password_box.offset_left = 502.0
120 password_box.offset_top = 659.0
121 password_box.offset_right = 1144.0
122 password_box.offset_bottom = 809.0
123 password_box.alignment = HORIZONTAL_ALIGNMENT_CENTER
124 password_box.caret_blink = true
125 panel.add_child(password_box)
126
127 var accept_dialog = AcceptDialog.new()
128 accept_dialog.name = "AcceptDialog"
129 panel.add_child(accept_dialog)
130
131 var version_mismatch = ConfirmationDialog.new()
132 version_mismatch.name = "VersionMismatch"
133 panel.add_child(version_mismatch)
134
135 var connection_history = MenuButton.new()
136 connection_history.name = "connection_history"
137 connection_history.offset_left = 1239.0
138 connection_history.offset_top = 276.0
139 connection_history.offset_right = 1829.0
140 connection_history.offset_bottom = 372.0
141 connection_history.text = "connection history"
142 connection_history.flat = false
143 panel.add_child(connection_history)
144
145 var runtime = global.get_node("Runtime")
146 var main_script = runtime.load_script("main.gd")
147 var main_node = main_script.new()
148 main_node.name = "Main"
149 add_child(main_node)
diff --git a/apworld/client/source_runtime.gd b/apworld/client/source_runtime.gd new file mode 100644 index 0000000..146587a --- /dev/null +++ b/apworld/client/source_runtime.gd
@@ -0,0 +1,33 @@
1extends Node
2
3var source_path
4
5
6func _init(path):
7 source_path = path
8
9
10func path_exists(path):
11 return FileAccess.file_exists("%s/%s" % [source_path, path])
12
13
14func load_script(path):
15 return ResourceLoader.load("%s/%s" % [source_path, path])
16
17
18func read_path(path):
19 return FileAccess.get_file_as_bytes("%s/%s" % [source_path, path])
20
21
22func load_script_as_scene(path, scene_name):
23 var script = load_script(path)
24 var instance = script.new()
25 instance.name = scene_name
26
27 get_tree().unload_current_scene()
28 _load_scene.call_deferred(instance)
29
30
31func _load_scene(instance):
32 get_tree().get_root().add_child(instance)
33 get_tree().current_scene = instance
diff --git a/apworld/client/teleport.gd b/apworld/client/teleport.gd new file mode 100644 index 0000000..428d50b --- /dev/null +++ b/apworld/client/teleport.gd
@@ -0,0 +1,38 @@
1extends "res://scripts/nodes/teleport.gd"
2
3var item_id
4var item_amount
5
6
7func _ready():
8 var node_path = String(
9 get_tree().get_root().get_node("scene").get_path_to(self).get_concatenated_names()
10 )
11
12 var gamedata = global.get_node("Gamedata")
13 var door_id = gamedata.get_door_for_map_node_path(global.map, node_path)
14 if door_id != null:
15 var ap = global.get_node("Archipelago")
16 var item_lock = ap.get_item_id_for_door(door_id)
17
18 if item_lock != null:
19 item_id = item_lock[0]
20 item_amount = item_lock[1]
21
22 self.senders = []
23 self.senderGroup = []
24 self.nested = false
25 self.complete_at = 0
26 self.max_length = 0
27 self.excludeSenders = []
28
29 call_deferred("_readier")
30
31 super._ready()
32
33
34func _readier():
35 var ap = global.get_node("Archipelago")
36
37 if ap.client.getItemAmount(item_id) >= item_amount:
38 handleTriggered()
diff --git a/apworld/client/teleportListener.gd b/apworld/client/teleportListener.gd new file mode 100644 index 0000000..6f363af --- /dev/null +++ b/apworld/client/teleportListener.gd
@@ -0,0 +1,49 @@
1extends "res://scripts/nodes/listeners/teleportListener.gd"
2
3var item_id
4var item_amount
5
6
7func _ready():
8 var node_path = String(
9 get_tree().get_root().get_node("scene").get_path_to(self).get_concatenated_names()
10 )
11
12 if (
13 global.map == "daedalus"
14 and (
15 node_path == "Components/Triggers/teleportListenerConnections"
16 or node_path == "Components/Triggers/teleportListenerConnections2"
17 )
18 ):
19 # Effectively disable these.
20 teleport_point = target_path.position
21 return
22
23 var gamedata = global.get_node("Gamedata")
24 var door_id = gamedata.get_door_for_map_node_path(global.map, node_path)
25 if door_id != null:
26 var ap = global.get_node("Archipelago")
27 var item_lock = ap.get_item_id_for_door(door_id)
28
29 if item_lock != null:
30 item_id = item_lock[0]
31 item_amount = item_lock[1]
32
33 self.senders = []
34 self.senderGroup = []
35 self.nested = false
36 self.complete_at = 0
37 self.max_length = 0
38 self.excludeSenders = []
39
40 call_deferred("_readier")
41
42 super._ready()
43
44
45func _readier():
46 var ap = global.get_node("Archipelago")
47
48 if ap.client.getItemAmount(item_id) >= item_amount:
49 handleTriggered()
diff --git a/apworld/client/textclient.gd b/apworld/client/textclient.gd new file mode 100644 index 0000000..ce28a3a --- /dev/null +++ b/apworld/client/textclient.gd
@@ -0,0 +1,508 @@
1extends CanvasLayer
2
3var tabs
4var panel
5var label
6var entry
7var is_open = false
8
9var locations_overlay
10var location_texture
11var worldport_texture
12var goal_texture
13
14var tracker_tree
15var tracker_loc_tree_item_by_id = {}
16var tracker_port_tree_item_by_id = {}
17var tracker_goal_tree_item = null
18var tracker_object_by_index = {}
19var tracker_object_by_ignored_index = {}
20var tracker_ignored_group = null
21
22var worldports_tab
23var worldports_tree
24var port_tree_item_by_map = {}
25var port_tree_item_by_map_port = {}
26
27const kLocation = 0
28const kWorldport = 1
29const kGoal = 2
30
31
32func _ready():
33 process_mode = ProcessMode.PROCESS_MODE_ALWAYS
34 layer = 2
35
36 locations_overlay = RichTextLabel.new()
37 locations_overlay.name = "LocationsOverlay"
38 locations_overlay.offset_top = 220
39 locations_overlay.offset_bottom = 720
40 locations_overlay.offset_left = 20
41 locations_overlay.anchor_right = 1.0
42 locations_overlay.offset_right = -10
43 locations_overlay.scroll_active = false
44 locations_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
45 locations_overlay.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
46 add_child(locations_overlay)
47 update_locations_visibility()
48
49 tabs = TabContainer.new()
50 tabs.name = "Tabs"
51 tabs.offset_left = 100
52 tabs.offset_right = 1820
53 tabs.offset_top = 100
54 tabs.offset_bottom = 980
55 tabs.visible = false
56 tabs.theme = preload("res://assets/themes/baseUI.tres")
57 tabs.add_theme_font_size_override("font_size", 36)
58 add_child(tabs)
59
60 panel = MarginContainer.new()
61 panel.name = "Text Client"
62 panel.add_theme_constant_override("margin_top", 60)
63 panel.add_theme_constant_override("margin_left", 60)
64 panel.add_theme_constant_override("margin_right", 60)
65 panel.add_theme_constant_override("margin_bottom", 60)
66 tabs.add_child(panel)
67
68 label = RichTextLabel.new()
69 label.set_name("Label")
70 label.scroll_following = true
71 label.selection_enabled = true
72 label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
73 label.size_flags_vertical = Control.SIZE_EXPAND_FILL
74 label.push_font(preload("res://assets/fonts/Lingo2.ttf"))
75 label.push_font_size(30)
76
77 var entry_style = StyleBoxFlat.new()
78 entry_style.bg_color = Color(0.9, 0.9, 0.9, 1)
79
80 entry = LineEdit.new()
81 entry.set_name("Entry")
82 entry.add_theme_font_override("font", preload("res://assets/fonts/Lingo2.ttf"))
83 entry.add_theme_font_size_override("font_size", 36)
84 entry.add_theme_color_override("font_color", Color(0, 0, 0, 1))
85 entry.add_theme_color_override("cursor_color", Color(0, 0, 0, 1))
86 entry.add_theme_stylebox_override("focus", entry_style)
87 entry.text_submitted.connect(text_entered)
88
89 var tc_arranger = VBoxContainer.new()
90 tc_arranger.add_child(label)
91 tc_arranger.add_child(entry)
92 tc_arranger.add_theme_constant_override("separation", 40)
93 panel.add_child(tc_arranger)
94
95 var tracker_margins = MarginContainer.new()
96 tracker_margins.name = "Locations"
97 tracker_margins.add_theme_constant_override("margin_top", 60)
98 tracker_margins.add_theme_constant_override("margin_left", 60)
99 tracker_margins.add_theme_constant_override("margin_right", 60)
100 tracker_margins.add_theme_constant_override("margin_bottom", 60)
101 tabs.add_child(tracker_margins)
102
103 tracker_tree = Tree.new()
104 tracker_tree.columns = 4
105 tracker_tree.hide_root = true
106 tracker_tree.add_theme_font_size_override("font_size", 24)
107 tracker_tree.add_theme_color_override("font_color", Color(0.8, 0.8, 0.8, 1))
108 tracker_tree.add_theme_constant_override("v_separation", 1)
109 tracker_tree.item_edited.connect(_on_tracker_button_clicked)
110 tracker_tree.set_column_expand(0, false)
111 tracker_tree.set_column_expand(1, true)
112 tracker_tree.set_column_expand(2, false)
113 tracker_tree.set_column_expand(3, false)
114 tracker_tree.set_column_custom_minimum_width(2, 200)
115 tracker_tree.set_column_custom_minimum_width(3, 200)
116 tracker_margins.add_child(tracker_tree)
117
118 worldports_tab = MarginContainer.new()
119 worldports_tab.name = "Worldports"
120 worldports_tab.add_theme_constant_override("margin_top", 60)
121 worldports_tab.add_theme_constant_override("margin_left", 60)
122 worldports_tab.add_theme_constant_override("margin_right", 60)
123 worldports_tab.add_theme_constant_override("margin_bottom", 60)
124 tabs.add_child(worldports_tab)
125 tabs.set_tab_hidden(2, true)
126
127 worldports_tree = Tree.new()
128 worldports_tree.columns = 2
129 worldports_tree.hide_root = true
130 worldports_tree.theme = preload("res://assets/themes/baseUI.tres")
131 worldports_tree.add_theme_font_size_override("font_size", 24)
132 worldports_tab.add_child(worldports_tree)
133
134 var runtime = global.get_node("Runtime")
135 var location_image = Image.new()
136 location_image.load_png_from_buffer(runtime.read_path("assets/location.png"))
137 location_texture = ImageTexture.create_from_image(location_image)
138
139 var worldport_image = Image.new()
140 worldport_image.load_png_from_buffer(runtime.read_path("assets/worldport.png"))
141 worldport_texture = ImageTexture.create_from_image(worldport_image)
142
143 var goal_image = Image.new()
144 goal_image.load_png_from_buffer(runtime.read_path("assets/goal.png"))
145 goal_texture = ImageTexture.create_from_image(goal_image)
146
147
148func _input(event):
149 if global.loaded and event is InputEventKey and event.pressed:
150 if event.keycode == KEY_TAB and !Input.is_key_pressed(KEY_SHIFT):
151 if !get_tree().paused:
152 is_open = true
153 get_tree().paused = true
154 Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
155 tabs.visible = true
156 entry.grab_focus()
157 get_viewport().set_input_as_handled()
158 else:
159 dismiss()
160 elif event.keycode == KEY_ESCAPE:
161 if is_open:
162 dismiss()
163 get_viewport().set_input_as_handled()
164
165
166func dismiss():
167 if is_open:
168 get_tree().paused = false
169 Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
170 tabs.visible = false
171 is_open = false
172
173
174func parse_printjson(text):
175 label.append_text("[p]" + text + "[/p]")
176
177
178func text_entered(text):
179 var ap = global.get_node("Archipelago")
180 var cmd = text.trim_suffix("\n")
181 entry.text = ""
182 if OS.is_debug_build():
183 if cmd.begins_with("/tp_map "):
184 var new_map = cmd.substr(8)
185 global.map = new_map
186 global.sets_entry_point = false
187 switcher.switch_map("res://objects/scenes/%s.tscn" % new_map)
188 return
189
190 ap.client.say(cmd)
191
192
193func update_locations(reset_locations = true):
194 var ap = global.get_node("Archipelago")
195 var gamedata = global.get_node("Gamedata")
196
197 locations_overlay.clear()
198 locations_overlay.push_font(preload("res://assets/fonts/Lingo2.ttf"))
199 locations_overlay.push_font_size(24)
200 locations_overlay.push_color(Color(0.9, 0.9, 0.9, 1))
201 locations_overlay.push_outline_color(Color(0, 0, 0, 1))
202 locations_overlay.push_outline_size(2)
203
204 var locations = []
205 for location_id in ap.client._accessible_locations:
206 if not ap.client._checked_locations.has(location_id):
207 var location_name = gamedata.location_name_by_id.get(location_id, "(Unknown)")
208 (
209 locations
210 . append(
211 {
212 "name": location_name,
213 "type": kLocation,
214 "id": location_id,
215 "ignored": ap._ignored_locations.has(location_id),
216 "hint": ap.client._hinted_locations.has(location_id),
217 }
218 )
219 )
220
221 for port_id in ap.client._accessible_worldports:
222 if not ap.client._checked_worldports.has(port_id):
223 var port_name = gamedata.get_worldport_display_name(port_id)
224 (
225 locations
226 . append(
227 {
228 "name": port_name,
229 "type": kWorldport,
230 "id": port_id,
231 "ignored": false,
232 "hint": false,
233 }
234 )
235 )
236
237 locations.sort_custom(_cmp_tracker_objects)
238
239 if ap.client._goal_accessible:
240 var location_name = gamedata.ending_display_name_by_name[ap.kEndingNameByVictoryValue[
241 ap.victory_condition
242 ]]
243 (
244 locations
245 . push_front(
246 {
247 "name": location_name,
248 "type": kGoal,
249 "ignored": false,
250 "hint": false,
251 }
252 )
253 )
254
255 var count = 0
256 for location in locations:
257 if count < 18 and not location["ignored"]:
258 locations_overlay.push_paragraph(HORIZONTAL_ALIGNMENT_RIGHT)
259 if location["hint"]:
260 locations_overlay.push_color(Color("#fafad2"))
261 locations_overlay.append_text(location["name"])
262 locations_overlay.append_text(" ")
263 if location["type"] == kLocation:
264 locations_overlay.add_image(location_texture)
265 elif location["type"] == kWorldport:
266 locations_overlay.add_image(worldport_texture)
267 elif location["type"] == kGoal:
268 locations_overlay.add_image(goal_texture)
269 if location["hint"]:
270 locations_overlay.pop()
271 locations_overlay.pop()
272 count += 1
273
274 if count > 18:
275 locations_overlay.append_text("[p align=right][lb]...[rb][/p]")
276
277 if reset_locations:
278 reset_tracker_tab()
279
280 var root_ti = tracker_tree.create_item(null)
281
282 for location in locations:
283 var loc_row
284
285 if location["ignored"]:
286 if tracker_ignored_group == null:
287 tracker_ignored_group = root_ti.create_child()
288 tracker_ignored_group.set_text(1, "Ignored Locations")
289 tracker_ignored_group.set_selectable(0, false)
290 tracker_ignored_group.set_selectable(1, false)
291 tracker_ignored_group.set_selectable(2, false)
292 tracker_ignored_group.set_selectable(3, false)
293
294 loc_row = tracker_ignored_group.create_child()
295 else:
296 loc_row = root_ti.create_child()
297
298 loc_row.set_cell_mode(0, TreeItem.CELL_MODE_ICON)
299 loc_row.set_selectable(0, false)
300 loc_row.set_text(1, location["name"])
301 loc_row.set_selectable(1, false)
302 if location["hint"]:
303 loc_row.set_custom_color(1, Color("#fafad2"))
304 loc_row.set_cell_mode(2, TreeItem.CELL_MODE_CUSTOM)
305 loc_row.set_text(2, "Show Path")
306 loc_row.set_custom_as_button(2, true)
307 loc_row.set_editable(2, true)
308 loc_row.set_selectable(2, false)
309 loc_row.set_text_alignment(2, HORIZONTAL_ALIGNMENT_CENTER)
310 loc_row.set_selectable(3, false)
311 if location["type"] == kLocation:
312 loc_row.set_cell_mode(3, TreeItem.CELL_MODE_CUSTOM)
313 if location["ignored"]:
314 loc_row.set_text(3, "Unignore")
315 else:
316 loc_row.set_text(3, "Ignore")
317 loc_row.set_custom_as_button(3, true)
318 loc_row.set_editable(3, true)
319 loc_row.set_text_alignment(3, HORIZONTAL_ALIGNMENT_CENTER)
320
321 if location["type"] == kLocation:
322 loc_row.set_icon(0, location_texture)
323 tracker_loc_tree_item_by_id[location["id"]] = loc_row
324 elif location["type"] == kWorldport:
325 loc_row.set_icon(0, worldport_texture)
326 tracker_port_tree_item_by_id[location["id"]] = loc_row
327 elif location["type"] == kGoal:
328 loc_row.set_icon(0, goal_texture)
329 tracker_goal_tree_item = loc_row
330
331 if location["ignored"]:
332 tracker_object_by_ignored_index[loc_row.get_index()] = location
333 else:
334 tracker_object_by_index[loc_row.get_index()] = location
335 else:
336 for loc_row in tracker_tree.get_root().get_children():
337 loc_row.visible = false
338
339 for location_id in tracker_loc_tree_item_by_id.keys():
340 if (
341 ap.client._accessible_locations.has(location_id)
342 and not ap.client._checked_locations.has(location_id)
343 ):
344 tracker_loc_tree_item_by_id[location_id].visible = true
345
346 for port_id in tracker_port_tree_item_by_id.keys():
347 if (
348 ap.client._accessible_worldports.has(port_id)
349 and not ap.client._checked_worldports.has(port_id)
350 ):
351 tracker_port_tree_item_by_id[port_id].visible = true
352
353 if tracker_goal_tree_item != null and ap.client._goal_accessible:
354 tracker_goal_tree_item.visible = true
355
356 if tracker_ignored_group != null:
357 tracker_ignored_group.visible = true
358
359
360func _cmp_tracker_objects(a, b) -> bool:
361 if a["ignored"] != b["ignored"]:
362 return !a["ignored"]
363 elif a["hint"] != b["hint"]:
364 return a["hint"]
365 else:
366 return a["name"] < b["name"]
367
368
369func update_locations_visibility():
370 var ap = global.get_node("Archipelago")
371 locations_overlay.visible = ap.show_locations
372
373
374func _on_tracker_button_clicked():
375 var ap = global.get_node("Archipelago")
376
377 var edited_item = tracker_tree.get_edited()
378 var edited_index = edited_item.get_index()
379
380 if edited_item.get_parent() == tracker_tree.get_root():
381 if tracker_object_by_index.has(edited_index):
382 var tracker_object = tracker_object_by_index[edited_index]
383 if tracker_tree.get_edited_column() == 2:
384 var type_str = ""
385 if tracker_object["type"] == kLocation:
386 type_str = "location"
387 elif tracker_object["type"] == kWorldport:
388 type_str = "worldport"
389 elif tracker_object["type"] == kGoal:
390 type_str = "goal"
391 ap.client.getLogicalPath(type_str, tracker_object.get("id", null))
392 elif tracker_tree.get_edited_column() == 3:
393 ap.toggle_ignored_location(tracker_object["id"])
394 elif edited_item.get_parent() == tracker_ignored_group:
395 # This is the ignored locations group.
396 if (
397 tracker_object_by_ignored_index.has(edited_index)
398 and tracker_tree.get_edited_column() == 3
399 ):
400 var tracker_object = tracker_object_by_ignored_index[edited_index]
401 ap.toggle_ignored_location(tracker_object["id"])
402
403
404func display_logical_path(object_type, object_id, paths):
405 var ap = global.get_node("Archipelago")
406 var gamedata = global.get_node("Gamedata")
407
408 var location_name = "(Unknown)"
409 if object_type == "location" and object_id != null:
410 location_name = gamedata.location_name_by_id.get(object_id, "(Unknown)")
411 elif object_type == "worldport" and object_id != null:
412 location_name = gamedata.get_worldport_display_name(object_id)
413 elif object_type == "goal":
414 location_name = gamedata.ending_display_name_by_name[ap.kEndingNameByVictoryValue[
415 ap.victory_condition
416 ]]
417
418 label.append_text("[p]Path to %s:[/p]" % location_name)
419 label.append_text("[ol]" + "\n".join(paths) + "[/ol]")
420
421 panel.visible = true
422
423
424func setup_worldports():
425 tabs.set_tab_hidden(2, false)
426
427 var root_ti = worldports_tree.create_item(null)
428
429 var ports_by_map_id = {}
430 var display_names_by_map_id = {}
431 var display_names_by_port_id = {}
432
433 var ap = global.get_node("Archipelago")
434 var gamedata = global.get_node("Gamedata")
435 for fpid in ap.port_pairings:
436 var port = gamedata.objects.get_ports()[fpid]
437 var room = gamedata.objects.get_rooms()[port.get_room_id()]
438
439 if not ports_by_map_id.has(room.get_map_id()):
440 ports_by_map_id[room.get_map_id()] = []
441
442 var map = gamedata.objects.get_maps()[room.get_map_id()]
443 display_names_by_map_id[map.get_id()] = map.get_display_name()
444
445 ports_by_map_id[room.get_map_id()].append(fpid)
446 display_names_by_port_id[fpid] = port.get_display_name()
447
448 var sorted_map_ids = ports_by_map_id.keys().duplicate()
449 sorted_map_ids.sort_custom(
450 func(a, b): return display_names_by_map_id[a] < display_names_by_map_id[b]
451 )
452
453 for map_id in sorted_map_ids:
454 var map_ti = root_ti.create_child()
455 map_ti.set_text(0, display_names_by_map_id[map_id])
456 map_ti.visible = false
457 map_ti.collapsed = true
458 port_tree_item_by_map[map_id] = map_ti
459 port_tree_item_by_map_port[map_id] = {}
460
461 var port_ids = ports_by_map_id[map_id]
462 port_ids.sort_custom(
463 func(a, b): return display_names_by_port_id[a] < display_names_by_port_id[b]
464 )
465
466 for port_id in port_ids:
467 var port_ti = map_ti.create_child()
468 port_ti.set_text(0, display_names_by_port_id[port_id])
469 port_ti.set_text(1, gamedata.get_worldport_display_name(ap.port_pairings[port_id]))
470 port_ti.visible = false
471 port_tree_item_by_map_port[map_id][port_id] = port_ti
472
473 update_worldports()
474
475
476func update_worldports():
477 var ap = global.get_node("Archipelago")
478
479 for map_id in port_tree_item_by_map_port.keys():
480 var map_visible = false
481
482 for port_id in port_tree_item_by_map_port[map_id].keys():
483 var ti = port_tree_item_by_map_port[map_id][port_id]
484 ti.visible = ap.client._checked_worldports.has(port_id)
485
486 if ti.visible:
487 map_visible = true
488
489 port_tree_item_by_map[map_id].visible = map_visible
490
491
492func reset():
493 locations_overlay.clear()
494 tabs.set_tab_hidden(2, true)
495 port_tree_item_by_map.clear()
496 port_tree_item_by_map_port.clear()
497 worldports_tree.clear()
498 reset_tracker_tab()
499
500
501func reset_tracker_tab():
502 tracker_loc_tree_item_by_id.clear()
503 tracker_port_tree_item_by_id.clear()
504 tracker_goal_tree_item = null
505 tracker_object_by_index.clear()
506 tracker_object_by_ignored_index.clear()
507 tracker_ignored_group = null
508 tracker_tree.clear()
diff --git a/apworld/client/unlockReaderListener.gd b/apworld/client/unlockReaderListener.gd new file mode 100644 index 0000000..a5754b9 --- /dev/null +++ b/apworld/client/unlockReaderListener.gd
@@ -0,0 +1,46 @@
1extends "res://scripts/nodes/listeners/unlockReaderListener.gd"
2
3var item_id = null
4var item_amount
5
6
7func _ready():
8 var node_path = String(
9 get_tree().get_root().get_node("scene").get_path_to(self).get_concatenated_names()
10 )
11
12 var gamedata = global.get_node("Gamedata")
13 var door_id = gamedata.get_door_for_map_node_path(global.map, node_path)
14 if door_id != null:
15 var ap = global.get_node("Archipelago")
16 var item_lock = ap.get_item_id_for_door(door_id)
17
18 if item_lock != null:
19 item_id = item_lock[0]
20 item_amount = item_lock[1]
21
22 self.senders = []
23 self.senderGroup = []
24 self.nested = false
25 self.complete_at = 0
26 self.max_length = 0
27 self.excludeSenders = []
28
29 super._ready()
30
31
32func _readier():
33 if item_id != null:
34 var ap = global.get_node("Archipelago")
35
36 if ap.client.getItemAmount(item_id) >= item_amount:
37 handleTriggered()
38 else:
39 super._readier()
40
41
42func handleTriggered():
43 if item_id != null:
44 emit_signal("trigger")
45 else:
46 super.handleTriggered()
diff --git a/apworld/client/vendor/LICENSE b/apworld/client/vendor/LICENSE new file mode 100644 index 0000000..12763b1 --- /dev/null +++ b/apworld/client/vendor/LICENSE
@@ -0,0 +1,21 @@
1WebSocketServer.gd:
2
3Copyright (c) 2014-present Godot Engine contributors. Copyright (c) 2007-2014
4Juan Linietsky, Ariel Manzur.
5
6Permission is hereby granted, free of charge, to any person obtaining a copy of
7this software and associated documentation files (the "Software"), to deal in
8the Software without restriction, including without limitation the rights to
9use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
10the Software, and to permit persons to whom the Software is furnished to do so,
11subject to the following conditions:
12
13The above copyright notice and this permission notice shall be included in all
14copies or substantial portions of the Software.
15
16THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
18FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
19COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
20IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
21CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/apworld/client/vendor/WebSocketServer.gd b/apworld/client/vendor/WebSocketServer.gd new file mode 100644 index 0000000..2cee494 --- /dev/null +++ b/apworld/client/vendor/WebSocketServer.gd
@@ -0,0 +1,173 @@
1class_name WebSocketServer
2extends Node
3
4signal message_received(peer_id: int, message: String)
5signal client_connected(peer_id: int)
6signal client_disconnected(peer_id: int)
7
8@export var handshake_headers := PackedStringArray()
9@export var supported_protocols := PackedStringArray()
10@export var handshake_timout := 3000
11@export var use_tls := false
12@export var tls_cert: X509Certificate
13@export var tls_key: CryptoKey
14@export var refuse_new_connections := false:
15 set(refuse):
16 if refuse:
17 pending_peers.clear()
18
19
20class PendingPeer:
21 var connect_time: int
22 var tcp: StreamPeerTCP
23 var connection: StreamPeer
24 var ws: WebSocketPeer
25
26 func _init(p_tcp: StreamPeerTCP) -> void:
27 tcp = p_tcp
28 connection = p_tcp
29 connect_time = Time.get_ticks_msec()
30
31
32var tcp_server := TCPServer.new()
33var pending_peers: Array[PendingPeer] = []
34var peers: Dictionary
35
36
37func listen(port: int) -> int:
38 assert(not tcp_server.is_listening())
39 return tcp_server.listen(port)
40
41
42func stop() -> void:
43 tcp_server.stop()
44 pending_peers.clear()
45 peers.clear()
46
47
48func send(peer_id: int, message: String) -> int:
49 var type := typeof(message)
50 if peer_id <= 0:
51 # Send to multiple peers, (zero = broadcast, negative = exclude one).
52 for id: int in peers:
53 if id == -peer_id:
54 continue
55 if type == TYPE_STRING:
56 peers[id].send_text(message)
57 else:
58 peers[id].put_packet(message)
59 return OK
60
61 assert(peers.has(peer_id))
62 var socket: WebSocketPeer = peers[peer_id]
63 if type == TYPE_STRING:
64 return socket.send_text(message)
65 return socket.send(var_to_bytes(message))
66
67
68func get_message(peer_id: int) -> Variant:
69 assert(peers.has(peer_id))
70 var socket: WebSocketPeer = peers[peer_id]
71 if socket.get_available_packet_count() < 1:
72 return null
73 var pkt: PackedByteArray = socket.get_packet()
74 if socket.was_string_packet():
75 return pkt.get_string_from_utf8()
76 return bytes_to_var(pkt)
77
78
79func has_message(peer_id: int) -> bool:
80 assert(peers.has(peer_id))
81 return peers[peer_id].get_available_packet_count() > 0
82
83
84func _create_peer() -> WebSocketPeer:
85 var ws := WebSocketPeer.new()
86 ws.supported_protocols = supported_protocols
87 ws.handshake_headers = handshake_headers
88 return ws
89
90
91func poll() -> void:
92 if not tcp_server.is_listening():
93 return
94
95 while not refuse_new_connections and tcp_server.is_connection_available():
96 var conn: StreamPeerTCP = tcp_server.take_connection()
97 assert(conn != null)
98 pending_peers.append(PendingPeer.new(conn))
99
100 var to_remove := []
101
102 for p in pending_peers:
103 if not _connect_pending(p):
104 if p.connect_time + handshake_timout < Time.get_ticks_msec():
105 # Timeout.
106 to_remove.append(p)
107 continue # Still pending.
108
109 to_remove.append(p)
110
111 for r: RefCounted in to_remove:
112 pending_peers.erase(r)
113
114 to_remove.clear()
115
116 for id: int in peers:
117 var p: WebSocketPeer = peers[id]
118 p.poll()
119
120 if p.get_ready_state() != WebSocketPeer.STATE_OPEN:
121 client_disconnected.emit(id)
122 to_remove.append(id)
123 continue
124
125 while p.get_available_packet_count():
126 message_received.emit(id, get_message(id))
127
128 for r: int in to_remove:
129 peers.erase(r)
130 to_remove.clear()
131
132
133func _connect_pending(p: PendingPeer) -> bool:
134 if p.ws != null:
135 # Poll websocket client if doing handshake.
136 p.ws.poll()
137 var state := p.ws.get_ready_state()
138 if state == WebSocketPeer.STATE_OPEN:
139 var id := randi_range(2, 1 << 30)
140 peers[id] = p.ws
141 client_connected.emit(id)
142 return true # Success.
143 elif state != WebSocketPeer.STATE_CONNECTING:
144 return true # Failure.
145 return false # Still connecting.
146 elif p.tcp.get_status() != StreamPeerTCP.STATUS_CONNECTED:
147 return true # TCP disconnected.
148 elif not use_tls:
149 # TCP is ready, create WS peer.
150 p.ws = _create_peer()
151 p.ws.accept_stream(p.tcp)
152 return false # WebSocketPeer connection is pending.
153
154 else:
155 if p.connection == p.tcp:
156 assert(tls_key != null and tls_cert != null)
157 var tls := StreamPeerTLS.new()
158 tls.accept_stream(p.tcp, TLSOptions.server(tls_key, tls_cert))
159 p.connection = tls
160 p.connection.poll()
161 var status: StreamPeerTLS.Status = p.connection.get_status()
162 if status == StreamPeerTLS.STATUS_CONNECTED:
163 p.ws = _create_peer()
164 p.ws.accept_stream(p.connection)
165 return false # WebSocketPeer connection is pending.
166 if status != StreamPeerTLS.STATUS_HANDSHAKING:
167 return true # Failure.
168
169 return false
170
171
172func _process(_delta: float) -> void:
173 poll()
diff --git a/apworld/client/victoryListener.gd b/apworld/client/victoryListener.gd new file mode 100644 index 0000000..e9089d7 --- /dev/null +++ b/apworld/client/victoryListener.gd
@@ -0,0 +1,20 @@
1extends Receiver
2
3
4func _ready():
5 super._ready()
6
7
8func handleTriggered():
9 triggered += 1
10 if triggered >= total:
11 var ap = global.get_node("Archipelago")
12 ap.client.completedGoal()
13
14 global.get_node("Messages").showMessage("You have completed your goal!")
15
16
17func handleUntriggered():
18 triggered -= 1
19 if triggered < total:
20 pass
diff --git a/apworld/client/visibilityListener.gd b/apworld/client/visibilityListener.gd new file mode 100644 index 0000000..5ea17a0 --- /dev/null +++ b/apworld/client/visibilityListener.gd
@@ -0,0 +1,38 @@
1extends "res://scripts/nodes/listeners/visibilityListener.gd"
2
3var item_id
4var item_amount
5
6
7func _ready():
8 var node_path = String(
9 get_tree().get_root().get_node("scene").get_path_to(self).get_concatenated_names()
10 )
11
12 var gamedata = global.get_node("Gamedata")
13 var door_id = gamedata.get_door_for_map_node_path(global.map, node_path)
14 if door_id != null:
15 var ap = global.get_node("Archipelago")
16 var item_lock = ap.get_item_id_for_door(door_id)
17
18 if item_lock != null:
19 item_id = item_lock[0]
20 item_amount = item_lock[1]
21
22 self.senders = []
23 self.senderGroup = []
24 self.nested = false
25 self.complete_at = 0
26 self.max_length = 0
27 self.excludeSenders = []
28
29 call_deferred("_readier")
30
31 super._ready()
32
33
34func _readier():
35 var ap = global.get_node("Archipelago")
36
37 if ap.client.getItemAmount(item_id) >= item_amount:
38 handleTriggered()
diff --git a/apworld/client/worldport.gd b/apworld/client/worldport.gd new file mode 100644 index 0000000..ed9891e --- /dev/null +++ b/apworld/client/worldport.gd
@@ -0,0 +1,61 @@
1extends "res://scripts/nodes/worldport.gd"
2
3var absolute_rotation = false
4var target_rotation = 0
5
6var port_id = null
7
8
9func _ready():
10 var node_path = String(
11 get_tree().get_root().get_node("scene").get_path_to(self).get_concatenated_names()
12 )
13
14 var ap = global.get_node("Archipelago")
15
16 if ap.shuffle_worldports:
17 var gamedata = global.get_node("Gamedata")
18 port_id = gamedata.get_port_for_map_node_path(global.map, node_path)
19 if port_id != null:
20 if port_id in ap.port_pairings:
21 var target_port = gamedata.objects.get_ports()[ap.port_pairings[port_id]]
22 var target_room = gamedata.objects.get_rooms()[target_port.get_room_id()]
23 var target_map = gamedata.objects.get_maps()[target_room.get_map_id()]
24
25 exit = target_map.get_name()
26 entry_point.x = target_port.get_destination().get_x()
27 entry_point.y = target_port.get_destination().get_y()
28 entry_point.z = target_port.get_destination().get_z()
29 absolute_rotation = true
30 target_rotation = target_port.get_rotation()
31 sets_entry_point = true
32 invisible = false
33 fades = true
34 else:
35 port_id = null
36
37 if global.map == "icarus" and exit == "daedalus":
38 if not ap.daedalus_roof_access:
39 entry_point = Vector3(58, 10, 0)
40
41 super._ready()
42
43
44func bodyEntered(body):
45 if body.is_in_group("player"):
46 if port_id != null:
47 var ap = global.get_node("Archipelago")
48 ap.client.checkWorldport(port_id)
49
50 if absolute_rotation:
51 entry_rotate.y = target_rotation - body.rotation_degrees.y
52
53 super.bodyEntered(body)
54
55
56func changeScene():
57 var player = get_tree().get_root().get_node("scene/player")
58 if player != null:
59 player.playable = false
60
61 super.changeScene()
diff --git a/apworld/client/worldportListener.gd b/apworld/client/worldportListener.gd new file mode 100644 index 0000000..4cff8e9 --- /dev/null +++ b/apworld/client/worldportListener.gd
@@ -0,0 +1,8 @@
1extends "res://scripts/nodes/listeners/worldportListener.gd"
2
3
4func handleTriggered():
5 if exit.begins_with("menus/credits"):
6 return
7
8 super.handleTriggered()
diff --git a/apworld/context.py b/apworld/context.py new file mode 100644 index 0000000..86392f9 --- /dev/null +++ b/apworld/context.py
@@ -0,0 +1,800 @@
1import asyncio
2import os
3import pkgutil
4import subprocess
5import sys
6from typing import Any
7
8import websockets
9
10import Utils
11import settings
12from BaseClasses import ItemClassification
13from CommonClient import CommonContext, server_loop, gui_enabled, logger, get_base_parser, handle_url_arg
14from NetUtils import Endpoint, decode, encode, ClientStatus
15from Utils import async_start
16from . import Lingo2World
17from .tracker import Tracker
18
19ALL_LETTERS = "abcdefghijklmnopqrstuvwxyz"
20MESSAGE_MAX_SIZE = 16*1024*1024
21PORT = 43182
22
23KEY_STORAGE_MAPPING = {
24 "a": (1, 0), "b": (1, 1), "c": (1, 2), "d": (1, 3), "e": (1, 4), "f": (1, 5), "g": (1, 6), "h": (1, 7), "i": (1, 8),
25 "j": (1, 9), "k": (1, 10), "l": (1, 11), "m": (1, 12), "n": (2, 0), "o": (2, 1), "p": (2, 2), "q": (2, 3),
26 "r": (2, 4), "s": (2, 5), "t": (2, 6), "u": (2, 7), "v": (2, 8), "w": (2, 9), "x": (2, 10), "y": (2, 11),
27 "z": (2, 12),
28}
29
30REVERSE_KEY_STORAGE_MAPPING = {t: k for k, t in KEY_STORAGE_MAPPING.items()}
31
32
33# There is a distinction between an object's ID and its AP ID. The latter is stable between releases, whereas the former
34# can change and is also namespaced based on the object type. We should only store AP IDs in multiworld state (such as
35# slot data and data storage) to increase compatability between releases. The data we currently store is:
36# - Port pairings for worldport shuffle (slot data)
37# - Checked worldports for worldport shuffle (data storage)
38# - Latched doors (data storage)
39# The client generally deals in the actual object IDs rather than the stable IDs, although it does have to convert the
40# port pairing IDs when reading them from slot data. The context (this file here) does the work of converting back and
41# forth between the values. AP IDs are converted to IDs after reading them from data storage, and IDs are converted to
42# AP IDs before sending them to data storage.
43class Lingo2Manager:
44 game_ctx: "Lingo2GameContext"
45 client_ctx: "Lingo2ClientContext"
46 tracker: Tracker
47
48 keyboard: dict[str, int]
49 worldports: set[int]
50 goaled: bool
51 latches: set[int]
52 hinted_locations: set[int]
53
54 def __init__(self, game_ctx: "Lingo2GameContext", client_ctx: "Lingo2ClientContext"):
55 self.game_ctx = game_ctx
56 self.game_ctx.manager = self
57 self.client_ctx = client_ctx
58 self.client_ctx.manager = self
59 self.tracker = Tracker(self)
60 self.keyboard = {}
61
62 self.reset()
63
64 def reset(self):
65 for k in ALL_LETTERS:
66 self.keyboard[k] = 0
67
68 self.worldports = set()
69 self.goaled = False
70 self.latches = set()
71 self.hinted_locations = set()
72
73 def update_keyboard(self, new_keyboard: dict[str, int]) -> dict[str, int]:
74 ret: dict[str, int] = {}
75
76 for k, v in new_keyboard.items():
77 if v > self.keyboard.get(k, 0):
78 self.keyboard[k] = v
79 ret[k] = v
80
81 if len(ret) > 0:
82 self.tracker.refresh_state()
83 self.game_ctx.send_accessible_locations()
84
85 return ret
86
87 # Input should be real IDs, not AP IDs
88 def update_worldports(self, new_worldports: set[int]) -> set[int]:
89 ret = new_worldports.difference(self.worldports)
90 self.worldports.update(new_worldports)
91
92 if len(ret) > 0:
93 self.tracker.refresh_state()
94 self.game_ctx.send_accessible_locations()
95
96 return ret
97
98 def update_latches(self, new_latches: set[int]) -> set[int]:
99 ret = new_latches.difference(self.latches)
100 self.latches.update(new_latches)
101
102 return ret
103
104 def update_hinted_locations(self, new_locs: set[int]) -> set[int]:
105 ret = new_locs.difference(self.hinted_locations)
106 self.hinted_locations.update(new_locs)
107
108 return ret
109
110
111class Lingo2GameContext:
112 server: Endpoint | None
113 manager: Lingo2Manager
114
115 def __init__(self):
116 self.server = None
117
118 def send_connected(self):
119 if self.server is None:
120 return
121
122 msg = {
123 "cmd": "Connected",
124 "user": self.manager.client_ctx.username,
125 "seed_name": self.manager.client_ctx.seed_name,
126 "version": self.manager.client_ctx.server_version,
127 "generator_version": self.manager.client_ctx.generator_version,
128 "team": self.manager.client_ctx.team,
129 "slot": self.manager.client_ctx.slot,
130 "checked_locations": self.manager.client_ctx.checked_locations,
131 "slot_data": self.manager.client_ctx.slot_data,
132 }
133
134 async_start(self.send_msgs([msg]), name="game Connected")
135
136 def send_connection_refused(self, text):
137 if self.server is None:
138 return
139
140 msg = {
141 "cmd": "ConnectionRefused",
142 "text": text,
143 }
144
145 async_start(self.send_msgs([msg]), name="game ConnectionRefused")
146
147 def send_item_sent_notification(self, item_name, receiver_name, item_flags):
148 if self.server is None:
149 return
150
151 msg = {
152 "cmd": "ItemSentNotif",
153 "item_name": item_name,
154 "receiver_name": receiver_name,
155 "item_flags": item_flags,
156 }
157
158 async_start(self.send_msgs([msg]), name="item sent notif")
159
160 def send_hint_received(self, item_name, location_name, receiver_name, item_flags, for_self):
161 if self.server is None:
162 return
163
164 msg = {
165 "cmd": "HintReceived",
166 "item_name": item_name,
167 "location_name": location_name,
168 "receiver_name": receiver_name,
169 "item_flags": item_flags,
170 "self": int(for_self),
171 }
172
173 async_start(self.send_msgs([msg]), name="hint received notif")
174
175 def send_item_received(self, items):
176 if self.server is None:
177 return
178
179 msg = {
180 "cmd": "ItemReceived",
181 "items": items,
182 }
183
184 async_start(self.send_msgs([msg]), name="item received")
185
186 def send_location_info(self, locations):
187 if self.server is None:
188 return
189
190 msg = {
191 "cmd": "LocationInfo",
192 "locations": locations,
193 }
194
195 async_start(self.send_msgs([msg]), name="location info")
196
197 def send_text_message(self, parts):
198 if self.server is None:
199 return
200
201 msg = {
202 "cmd": "TextMessage",
203 "data": parts,
204 }
205
206 async_start(self.send_msgs([msg]), name="notif")
207
208 def send_accessible_locations(self):
209 if self.server is None:
210 return
211
212 msg = {
213 "cmd": "AccessibleLocations",
214 "locations": list(self.manager.tracker.accessible_locations),
215 }
216
217 if len(self.manager.tracker.accessible_worldports) > 0:
218 msg["worldports"] = list(self.manager.tracker.accessible_worldports)
219
220 if self.manager.tracker.goal_accessible and not self.manager.goaled:
221 msg["goal"] = True
222
223 async_start(self.send_msgs([msg]), name="accessible locations")
224
225 def send_update_locations(self, locations):
226 if self.server is None:
227 return
228
229 msg = {
230 "cmd": "UpdateLocations",
231 "locations": locations,
232 }
233
234 async_start(self.send_msgs([msg]), name="update locations")
235
236 def send_update_keyboard(self, updates):
237 if self.server is None:
238 return
239
240 msg = {
241 "cmd": "UpdateKeyboard",
242 "updates": updates,
243 }
244
245 async_start(self.send_msgs([msg]), name="update keyboard")
246
247 # Input should be real IDs, not AP IDs
248 def send_update_worldports(self, worldports):
249 if self.server is None:
250 return
251
252 msg = {
253 "cmd": "UpdateWorldports",
254 "worldports": worldports,
255 }
256
257 async_start(self.send_msgs([msg]), name="update worldports")
258
259 def send_path_reply(self, object_type: str, object_id: int | None, path: list[str]):
260 if self.server is None:
261 return
262
263 msg = {
264 "cmd": "PathReply",
265 "type": object_type,
266 "path": path,
267 }
268
269 if object_id is not None:
270 msg["id"] = object_id
271
272 async_start(self.send_msgs([msg]), name="path reply")
273
274 def send_update_latches(self, latches):
275 if self.server is None:
276 return
277
278 msg = {
279 "cmd": "UpdateLatches",
280 "latches": latches,
281 }
282
283 async_start(self.send_msgs([msg]), name="update latches")
284
285 def send_ignored_locations(self, ignored_locations):
286 if self.server is None:
287 return
288
289 msg = {
290 "cmd": "SetIgnoredLocations",
291 "locations": ignored_locations,
292 }
293
294 async_start(self.send_msgs([msg]), name="set ignored locations")
295
296 def send_update_hinted_locations(self, hinted_locations):
297 if self.server is None:
298 return
299
300 msg = {
301 "cmd": "UpdateHintedLocations",
302 "locations": hinted_locations,
303 }
304
305 async_start(self.send_msgs([msg]), name="update hinted locations")
306
307 async def send_msgs(self, msgs: list[Any]) -> None:
308 """ `msgs` JSON serializable """
309 if not self.server or not self.server.socket.open or self.server.socket.closed:
310 return
311 await self.server.socket.send(encode(msgs))
312
313
314class Lingo2ClientContext(CommonContext):
315 manager: Lingo2Manager
316
317 game = "Lingo 2"
318 items_handling = 0b111
319
320 slot_data: dict[str, Any] | None
321 hints_data_storage_key: str
322 victory_data_storage_key: str
323
324 def __init__(self, server_address: str | None = None, password: str | None = None):
325 super().__init__(server_address, password)
326
327 def make_gui(self):
328 ui = super().make_gui()
329 ui.base_title = "Archipelago Lingo 2 Client"
330 return ui
331
332 async def server_auth(self, password_requested: bool = False):
333 if password_requested and not self.password:
334 self.manager.game_ctx.send_connection_refused("Invalid password.")
335 else:
336 self.auth = self.username
337 await self.send_connect()
338
339 def handle_connection_loss(self, msg: str):
340 super().handle_connection_loss(msg)
341
342 exc_info = sys.exc_info()
343 self.manager.game_ctx.send_connection_refused(str(exc_info[1]))
344
345 def on_package(self, cmd: str, args: dict):
346 if cmd == "RoomInfo":
347 self.seed_name = args.get("seed_name", None)
348 elif cmd == "Connected":
349 self.slot_data = args.get("slot_data", None)
350
351 self.manager.reset()
352
353 self.manager.game_ctx.send_connected()
354
355 self.manager.tracker.setup_slot(self.slot_data)
356 self.manager.tracker.set_checked_locations(self.checked_locations)
357 self.manager.game_ctx.send_accessible_locations()
358
359 self.hints_data_storage_key = f"_read_hints_{self.team}_{self.slot}"
360 self.victory_data_storage_key = f"_read_client_status_{self.team}_{self.slot}"
361
362 self.set_notify(self.get_datastorage_key("keyboard1"), self.get_datastorage_key("keyboard2"),
363 self.victory_data_storage_key, self.get_datastorage_key("latches"),
364 self.get_datastorage_key("ignored_locations"))
365 msg_batch = [{
366 "cmd": "Set",
367 "key": self.get_datastorage_key("keyboard1"),
368 "default": 0,
369 "want_reply": True,
370 "operations": [{"operation": "default", "value": 0}]
371 }, {
372 "cmd": "Set",
373 "key": self.get_datastorage_key("keyboard2"),
374 "default": 0,
375 "want_reply": True,
376 "operations": [{"operation": "default", "value": 0}]
377 }, {
378 "cmd": "Set",
379 "key": self.get_datastorage_key("latches"),
380 "default": [],
381 "want_reply": True,
382 "operations": [{"operation": "default", "value": []}]
383 }, {
384 "cmd": "Set",
385 "key": self.get_datastorage_key("ignored_locations"),
386 "default": [],
387 "want_reply": True,
388 "operations": [{"operation": "default", "value": []}]
389 }]
390
391 if self.slot_data.get("shuffle_worldports", False):
392 self.set_notify(self.get_datastorage_key("worldports"))
393 msg_batch.append({
394 "cmd": "Set",
395 "key": self.get_datastorage_key("worldports"),
396 "default": [],
397 "want_reply": True,
398 "operations": [{"operation": "default", "value": []}]
399 })
400
401 async_start(self.send_msgs(msg_batch), name="default keys")
402 elif cmd == "RoomUpdate":
403 if "checked_locations" in args:
404 self.manager.tracker.set_checked_locations(self.checked_locations)
405 self.manager.game_ctx.send_update_locations(args["checked_locations"])
406 elif cmd == "ReceivedItems":
407 self.manager.tracker.set_collected_items(self.items_received)
408
409 cur_index = 0
410 items = []
411
412 for item in args["items"]:
413 index = cur_index + args["index"]
414 cur_index += 1
415
416 item_msg = {
417 "id": item.item,
418 "index": index,
419 "flags": item.flags,
420 "text": self.item_names.lookup_in_slot(item.item, self.slot),
421 }
422
423 if item.player != self.slot:
424 item_msg["sender"] = self.player_names.get(item.player)
425
426 items.append(item_msg)
427
428 self.manager.game_ctx.send_item_received(items)
429
430 if any(ItemClassification.progression in ItemClassification(item.flags) for item in args["items"]):
431 self.manager.game_ctx.send_accessible_locations()
432 elif cmd == "PrintJSON":
433 if "receiving" in args and "item" in args and args["item"].player == self.slot:
434 item_name = self.item_names.lookup_in_slot(args["item"].item, args["receiving"])
435 location_name = self.location_names.lookup_in_slot(args["item"].location, args["item"].player)
436 receiver_name = self.player_names.get(args["receiving"])
437
438 if args["type"] == "Hint" and not args.get("found", False):
439 self.manager.game_ctx.send_hint_received(item_name, location_name, receiver_name, args["item"].flags,
440 int(args["receiving"]) == self.slot)
441 elif args["receiving"] != self.slot:
442 self.manager.game_ctx.send_item_sent_notification(item_name, receiver_name, args["item"].flags)
443
444 parts = []
445 for message_part in args["data"]:
446 if "type" not in message_part and "text" in message_part:
447 parts.append({"type": "text", "text": message_part["text"]})
448 elif message_part["type"] == "player_id":
449 parts.append({
450 "type": "player",
451 "text": self.player_names.get(int(message_part["text"])),
452 "self": int(int(message_part["text"]) == self.slot),
453 })
454 elif message_part["type"] == "item_id":
455 parts.append({
456 "type": "item",
457 "text": self.item_names.lookup_in_slot(int(message_part["text"]), message_part["player"]),
458 "flags": message_part["flags"],
459 })
460 elif message_part["type"] == "location_id":
461 parts.append({
462 "type": "location",
463 "text": self.location_names.lookup_in_slot(int(message_part["text"]),
464 message_part["player"])
465 })
466 elif "text" in message_part:
467 parts.append({"type": "text", "text": message_part["text"]})
468
469 self.manager.game_ctx.send_text_message(parts)
470 elif cmd == "LocationInfo":
471 locations = []
472
473 for location in args["locations"]:
474 locations.append({
475 "id": location.location,
476 "item": self.item_names.lookup_in_slot(location.item, location.player),
477 "player": self.player_names.get(location.player),
478 "flags": location.flags,
479 "self": int(location.player) == self.slot,
480 })
481
482 self.manager.game_ctx.send_location_info(locations)
483 elif cmd == "Retrieved":
484 for k, v in args["keys"].items():
485 if k == self.victory_data_storage_key:
486 self.handle_status_update(v)
487 elif k == self.hints_data_storage_key:
488 self.update_hints()
489 elif cmd == "SetReply":
490 if args["key"] == self.get_datastorage_key("keyboard1"):
491 self.handle_keyboard_update(1, args)
492 elif args["key"] == self.get_datastorage_key("keyboard2"):
493 self.handle_keyboard_update(2, args)
494 elif args["key"] == self.get_datastorage_key("worldports"):
495 port_ids = set(Lingo2World.static_logic.port_id_by_ap_id[ap_id] for ap_id in args["value"])
496 updates = self.manager.update_worldports(port_ids)
497 if len(updates) > 0:
498 self.manager.game_ctx.send_update_worldports(updates)
499 elif args["key"] == self.victory_data_storage_key:
500 self.handle_status_update(args["value"])
501 elif args["key"] == self.get_datastorage_key("latches"):
502 door_ids = set(Lingo2World.static_logic.door_id_by_ap_id[ap_id] for ap_id in args["value"])
503 updates = self.manager.update_latches(door_ids)
504 if len(updates) > 0:
505 self.manager.game_ctx.send_update_latches(updates)
506 elif args["key"] == self.get_datastorage_key("ignored_locations"):
507 self.manager.game_ctx.send_ignored_locations(args["value"])
508 elif args["key"] == self.hints_data_storage_key:
509 self.update_hints()
510
511 def get_datastorage_key(self, name: str):
512 return f"Lingo2_{self.slot}_{name}"
513
514 async def update_keyboard(self, updates: dict[str, int]):
515 kb1 = 0
516 kb2 = 0
517
518 for k, v in updates.items():
519 if v == 0:
520 continue
521
522 effect = 0
523 if v >= 1:
524 effect |= 1
525 if v == 2:
526 effect |= 2
527
528 pos = KEY_STORAGE_MAPPING[k]
529 if pos[0] == 1:
530 kb1 |= (effect << pos[1] * 2)
531 else:
532 kb2 |= (effect << pos[1] * 2)
533
534 msgs = []
535
536 if kb1 != 0:
537 msgs.append({
538 "cmd": "Set",
539 "key": self.get_datastorage_key("keyboard1"),
540 "want_reply": True,
541 "operations": [{
542 "operation": "or",
543 "value": kb1
544 }]
545 })
546
547 if kb2 != 0:
548 msgs.append({
549 "cmd": "Set",
550 "key": self.get_datastorage_key("keyboard2"),
551 "want_reply": True,
552 "operations": [{
553 "operation": "or",
554 "value": kb2
555 }]
556 })
557
558 if len(msgs) > 0:
559 await self.send_msgs(msgs)
560
561 def handle_keyboard_update(self, field: int, args: dict[str, Any]):
562 keys = {}
563 value = args["value"]
564
565 for i in range(0, 13):
566 if (value & (1 << (i * 2))) != 0:
567 keys[REVERSE_KEY_STORAGE_MAPPING[(field, i)]] = 1
568 if (value & (1 << (i * 2 + 1))) != 0:
569 keys[REVERSE_KEY_STORAGE_MAPPING[(field, i)]] = 2
570
571 updates = self.manager.update_keyboard(keys)
572 if len(updates) > 0:
573 self.manager.game_ctx.send_update_keyboard(updates)
574
575 # Input should be real IDs, not AP IDs
576 async def update_worldports(self, updates: set[int]):
577 port_ap_ids = [Lingo2World.static_logic.objects.ports[port_id].ap_id for port_id in updates]
578 await self.send_msgs([{
579 "cmd": "Set",
580 "key": self.get_datastorage_key("worldports"),
581 "want_reply": True,
582 "operations": [{
583 "operation": "update",
584 "value": port_ap_ids
585 }]
586 }])
587
588 def handle_status_update(self, value: int):
589 self.manager.goaled = (value == ClientStatus.CLIENT_GOAL)
590 self.manager.tracker.refresh_state()
591 self.manager.game_ctx.send_accessible_locations()
592
593 async def update_latches(self, updates: set[int]):
594 door_ap_ids = [Lingo2World.static_logic.objects.doors[door_id].ap_id for door_id in updates]
595 await self.send_msgs([{
596 "cmd": "Set",
597 "key": self.get_datastorage_key("latches"),
598 "want_reply": True,
599 "operations": [{
600 "operation": "update",
601 "value": door_ap_ids
602 }]
603 }])
604
605 async def add_ignored_location(self, loc_id: int):
606 await self.send_msgs([{
607 "cmd": "Set",
608 "key": self.get_datastorage_key("ignored_locations"),
609 "want_reply": True,
610 "operations": [{
611 "operation": "update",
612 "value": [loc_id]
613 }]
614 }])
615
616 async def remove_ignored_location(self, loc_id: int):
617 await self.send_msgs([{
618 "cmd": "Set",
619 "key": self.get_datastorage_key("ignored_locations"),
620 "want_reply": True,
621 "operations": [{
622 "operation": "remove",
623 "value": loc_id
624 }]
625 }])
626
627 def update_hints(self):
628 hints = self.stored_data.get(self.hints_data_storage_key, [])
629
630 hinted_locations = set(hint["location"] for hint in hints if hint["finding_player"] == self.slot)
631 updates = self.manager.update_hinted_locations(hinted_locations)
632 if len(updates) > 0:
633 self.manager.game_ctx.send_update_hinted_locations(updates)
634
635
636async def pipe_loop(manager: Lingo2Manager):
637 while not manager.client_ctx.exit_event.is_set():
638 try:
639 socket = await websockets.connect("ws://localhost", port=PORT, ping_timeout=None, ping_interval=None,
640 max_size=MESSAGE_MAX_SIZE)
641 manager.game_ctx.server = Endpoint(socket)
642 logger.info("Connected to Lingo 2!")
643 if manager.client_ctx.auth is not None:
644 manager.game_ctx.send_connected()
645 manager.game_ctx.send_accessible_locations()
646 async for data in manager.game_ctx.server.socket:
647 for msg in decode(data):
648 await process_game_cmd(manager, msg)
649 except ConnectionRefusedError:
650 logger.info("Could not connect to Lingo 2.")
651 finally:
652 manager.game_ctx.server = None
653
654
655async def process_game_cmd(manager: Lingo2Manager, args: dict):
656 cmd = args["cmd"]
657
658 if cmd == "Connect":
659 manager.client_ctx.seed_name = None
660
661 server = args.get("server")
662 player = args.get("player")
663 password = args.get("password")
664
665 if password != "":
666 server_address = f"{player}:{password}@{server}"
667 else:
668 server_address = f"{player}:None@{server}"
669
670 async_start(manager.client_ctx.connect(server_address), name="client connect")
671 elif cmd == "Disconnect":
672 manager.client_ctx.seed_name = None
673
674 async_start(manager.client_ctx.disconnect(), name="client disconnect")
675 elif cmd in ["Sync", "LocationChecks", "Say", "StatusUpdate", "LocationScouts"]:
676 async_start(manager.client_ctx.send_msgs([args]), name="client forward")
677 elif cmd == "UpdateKeyboard":
678 updates = manager.update_keyboard(args["keyboard"])
679 if len(updates) > 0:
680 async_start(manager.client_ctx.update_keyboard(updates), name="client update keyboard")
681 elif cmd == "CheckWorldport":
682 port_id = args["port_id"]
683 port_ap_id = Lingo2World.static_logic.objects.ports[port_id].ap_id
684 worldports = {port_id}
685
686 # Also check the reverse port if it's a two-way connection.
687 port_pairings = manager.client_ctx.slot_data["port_pairings"]
688 if str(port_ap_id) in port_pairings and\
689 port_pairings.get(str(port_pairings[str(port_ap_id)]), None) == port_ap_id:
690 worldports.add(Lingo2World.static_logic.port_id_by_ap_id[port_pairings[str(port_ap_id)]])
691
692 updates = manager.update_worldports(worldports)
693 if len(updates) > 0:
694 async_start(manager.client_ctx.update_worldports(updates), name="client update worldports")
695 manager.game_ctx.send_update_worldports(updates)
696 elif cmd == "GetPath":
697 path = None
698
699 if args["type"] == "location":
700 path = manager.tracker.get_path_to_location(args["id"])
701 elif args["type"] == "worldport":
702 path = manager.tracker.get_path_to_port(args["id"])
703 elif args["type"] == "goal":
704 path = manager.tracker.get_path_to_goal()
705
706 manager.game_ctx.send_path_reply(args["type"], args.get("id", None), path)
707 elif cmd == "LatchDoor":
708 updates = manager.update_latches({args["door"]})
709 if len(updates) > 0:
710 async_start(manager.client_ctx.update_latches(updates), name="client update latches")
711 elif cmd == "IgnoreLocation":
712 async_start(manager.client_ctx.add_ignored_location(args["id"]), name="client ignore loc")
713 elif cmd == "UnignoreLocation":
714 async_start(manager.client_ctx.remove_ignored_location(args["id"]), name="client unignore loc")
715 elif cmd == "Quit":
716 manager.client_ctx.exit_event.set()
717
718
719async def run_game():
720 exe_file = settings.get_settings().lingo2_options.exe_file
721
722 # This ensures we can use Steam features without having to open the game
723 # through steam.
724 steam_appid_path = os.path.join(os.path.dirname(exe_file), "steam_appid.txt")
725 with open(steam_appid_path, "w") as said_handle:
726 said_handle.write("2523310")
727
728 if Lingo2World.zip_path is not None:
729 # This is a packaged apworld.
730 init_scene = pkgutil.get_data(__name__, "client/run_from_apworld.tscn")
731 init_path = Utils.local_path("data", "lingo2_init.tscn")
732
733 with open(init_path, "wb") as file_handle:
734 file_handle.write(init_scene)
735
736 subprocess.Popen(
737 [
738 exe_file,
739 "--scene",
740 init_path,
741 "--",
742 str(Lingo2World.zip_path.absolute()),
743 ],
744 cwd=os.path.dirname(exe_file),
745 )
746 else:
747 # The world is unzipped and being run in source.
748 subprocess.Popen(
749 [
750 exe_file,
751 "--scene",
752 Utils.local_path("worlds", "lingo2", "client", "run_from_source.tscn"),
753 "--",
754 Utils.local_path("worlds", "lingo2", "client"),
755 ],
756 cwd=os.path.dirname(exe_file),
757 )
758
759
760def client_main(*launch_args: str) -> None:
761 async def main(args):
762 if settings.get_settings().lingo2_options.start_game:
763 async_start(run_game())
764
765 client_ctx = Lingo2ClientContext(args.connect, args.password)
766 client_ctx.auth = args.name
767
768 game_ctx = Lingo2GameContext()
769 manager = Lingo2Manager(game_ctx, client_ctx)
770
771 client_ctx.server_task = asyncio.create_task(server_loop(client_ctx), name="ServerLoop")
772
773 if gui_enabled:
774 client_ctx.run_gui()
775 client_ctx.run_cli()
776
777 pipe_task = asyncio.create_task(pipe_loop(manager), name="GameWatcher")
778
779 try:
780 await pipe_task
781 except Exception as e:
782 logger.exception(e)
783
784 await client_ctx.exit_event.wait()
785 client_ctx.ui.stop()
786 await client_ctx.shutdown()
787
788 Utils.init_logging("Lingo2Client", exception_logger="Client")
789 import colorama
790
791 parser = get_base_parser(description="Lingo 2 Archipelago Client")
792 parser.add_argument('--name', default=None, help="Slot Name to connect as.")
793 parser.add_argument("url", nargs="?", help="Archipelago connection url")
794 args = parser.parse_args(launch_args)
795
796 args = handle_url_arg(args, parser=parser)
797
798 colorama.just_fix_windows_console()
799 asyncio.run(main(args))
800 colorama.deinit()
diff --git a/apworld/items.py b/apworld/items.py index 28158c3..143ccb1 100644 --- a/apworld/items.py +++ b/apworld/items.py
@@ -5,6 +5,8 @@ from BaseClasses import Item
5class Lingo2Item(Item): 5class Lingo2Item(Item):
6 game: str = "Lingo 2" 6 game: str = "Lingo 2"
7 7
8 is_letter: bool
9
8 10
9SYMBOL_ITEMS: dict[data_pb2.PuzzleSymbol, str] = { 11SYMBOL_ITEMS: dict[data_pb2.PuzzleSymbol, str] = {
10 data_pb2.PuzzleSymbol.SUN: "Sun Symbol", 12 data_pb2.PuzzleSymbol.SUN: "Sun Symbol",
@@ -28,4 +30,5 @@ SYMBOL_ITEMS: dict[data_pb2.PuzzleSymbol, str] = {
28 data_pb2.PuzzleSymbol.QUESTION: "Question Symbol", 30 data_pb2.PuzzleSymbol.QUESTION: "Question Symbol",
29} 31}
30 32
31ANTI_COLLECTABLE_TRAPS: list[str] = [f"Anti {letter}" for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"] 33ALL_LETTERS_UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
34ANTI_COLLECTABLE_TRAPS: list[str] = [f"Anti {letter}" for letter in ALL_LETTERS_UPPER]
diff --git a/apworld/locations.py b/apworld/locations.py index 108decb..c92215e 100644 --- a/apworld/locations.py +++ b/apworld/locations.py
@@ -1,5 +1,112 @@
1from BaseClasses import Location 1from enum import Enum
2from typing import TYPE_CHECKING
3
4from BaseClasses import Location, Item, Region, CollectionState, Entrance
5from .items import Lingo2Item
6from .rules import AccessRequirements
7
8if TYPE_CHECKING:
9 from . import Lingo2World
10
11
12class LetterPlacementType(Enum):
13 ANY = 0
14 DISALLOW = 1
15 FORCE = 2
16
17
18def get_required_regions(reqs: AccessRequirements, world: "Lingo2World",
19 regions: dict[str, Region] | None) -> list[Region]:
20 # Replace required rooms with regions for the top level requirement, which saves looking up the regions during rule
21 # checking.
22 if regions is not None:
23 return [regions[room_name] for room_name in reqs.rooms]
24 else:
25 return [world.multiworld.get_region(room_name, world.player) for room_name in reqs.rooms]
2 26
3 27
4class Lingo2Location(Location): 28class Lingo2Location(Location):
5 game: str = "Lingo 2" 29 game: str = "Lingo 2"
30
31 reqs: AccessRequirements | None
32 world: "Lingo2World"
33 required_regions: list[Region]
34
35 port_id: int
36 goal: bool
37 letter_placement_type: LetterPlacementType
38
39 @classmethod
40 def non_event_location(cls, world: "Lingo2World", code: int, region: Region):
41 result = cls(world.player, world.static_logic.location_id_to_name[code], code, region)
42 result.reqs = None
43 result.world = world
44 result.required_regions = []
45
46 return result
47
48 @classmethod
49 def event_location(cls, world: "Lingo2World", name: str, region: Region):
50 result = cls(world.player, name, None, region)
51 result.reqs = None
52 result.world = world
53 result.required_regions = []
54
55 return result
56
57 def set_access_rule(self, reqs: AccessRequirements, regions: dict[str, Region] | None):
58 self.reqs = reqs
59 self.required_regions = get_required_regions(reqs, self.world, regions)
60 self.access_rule = self._l2_access_rule
61
62 def _l2_access_rule(self, state: CollectionState) -> bool:
63 if self.reqs is not None and not self.reqs.check_access(state, self.world):
64 return False
65
66 if not all(state.can_reach(region) for region in self.required_regions):
67 return False
68
69 return True
70
71 def set_up_letter_rule(self, lpt: LetterPlacementType):
72 self.letter_placement_type = lpt
73 self.item_rule = self._l2_item_rule
74
75 def _l2_item_rule(self, item: Item) -> bool:
76 if not isinstance(item, Lingo2Item):
77 return True
78
79 if self.letter_placement_type == LetterPlacementType.FORCE:
80 return item.is_letter
81 elif self.letter_placement_type == LetterPlacementType.DISALLOW:
82 return not item.is_letter
83
84 return True
85
86
87class Lingo2Entrance(Entrance):
88 reqs: AccessRequirements | None
89 world: "Lingo2World"
90 required_regions: list[Region]
91
92 def __init__(self, world: "Lingo2World", description: str, region: Region):
93 super().__init__(world.player, description, region)
94
95 self.reqs = None
96 self.world = world
97 self.required_regions = []
98
99 def set_access_rule(self, reqs: AccessRequirements, regions: dict[str, Region] | None):
100 self.reqs = reqs
101 self.required_regions = get_required_regions(reqs, self.world, regions)
102 self.access_rule = self._l2_access_rule
103
104 def _l2_access_rule(self, state: CollectionState) -> bool:
105 if self.reqs is not None and not self.reqs.check_access(state, self.world):
106 return False
107
108 if not all(state.can_reach(region) for region in self.required_regions):
109 return False
110
111 return True
112
diff --git a/apworld/logo.png b/apworld/logo.png new file mode 100644 index 0000000..b9d00ba --- /dev/null +++ b/apworld/logo.png
Binary files differ
diff --git a/apworld/options.py b/apworld/options.py index 3646eea..c1eab33 100644 --- a/apworld/options.py +++ b/apworld/options.py
@@ -1,6 +1,6 @@
1from dataclasses import dataclass 1from dataclasses import dataclass
2 2
3from Options import PerGameCommonOptions, Toggle, Choice, DefaultOnToggle, Range 3from Options import PerGameCommonOptions, Toggle, Choice, DefaultOnToggle, Range, OptionSet, FreeText
4 4
5 5
6class ShuffleDoors(DefaultOnToggle): 6class ShuffleDoors(DefaultOnToggle):
@@ -44,6 +44,17 @@ class ShuffleLetters(Choice):
44 option_item_cyan = 4 44 option_item_cyan = 4
45 45
46 46
47class RestrictLetterPlacements(Toggle):
48 """
49 If enabled, letter items will be shuffled among letter locations in your local world. Shuffle Letters must be set to
50 Progressive or Item Cyan for this to be useful.
51
52 WARNING: This option may slow down generation. Additionally, it is only reliable with Shuffle Letters set to Item
53 Cyan. When set to Progressive, Shuffle Doors and Shuffle Symbols must be turned off.
54 """
55 display_name = "Restrict Letter Placements"
56
57
47class ShuffleSymbols(Toggle): 58class ShuffleSymbols(Toggle):
48 """ 59 """
49 If enabled, 19 items will be added to the pool, representing the different symbols that can appear on a panel. 60 If enabled, 19 items will be added to the pool, representing the different symbols that can appear on a panel.
@@ -52,6 +63,15 @@ class ShuffleSymbols(Toggle):
52 display_name = "Shuffle Symbols" 63 display_name = "Shuffle Symbols"
53 64
54 65
66class ShuffleWorldports(Toggle):
67 """
68 Randomizes the connections between maps. This affects worldports only, which are the loading zones you walk into in
69 order to change maps. This does not affect paintings, panels that teleport you, or certain other special connections
70 like the one between The Shop and Control Center.
71 """
72 display_name = "Shuffle Worldports"
73
74
55class KeyholderSanity(Toggle): 75class KeyholderSanity(Toggle):
56 """ 76 """
57 If enabled, 26 locations will be created for placing each key into its respective Green Ending keyholder. 77 If enabled, 26 locations will be created for placing each key into its respective Green Ending keyholder.
@@ -82,6 +102,72 @@ class CyanDoorBehavior(Choice):
82 option_item = 2 102 option_item = 2
83 103
84 104
105class ShuffleFastTravel(Toggle):
106 """If enabled, the list of maps you can fast travel to is randomized, except for The Entry, which is always
107 accessible."""
108 display_name = "Shuffle Fast Travel"
109
110
111class FastTravelAccess(Choice):
112 """
113 Controls how the fast travel buttons on the pause menu work.
114
115 - **Vanilla**: You can only fast travel to maps once you have been to them and stepped foot in the general area that
116 the warp would place you. This option means that fast travel has no impact on logic.
117 - **Unlocked**: All five fast travel maps will be available from the start.
118 - **Items**: Only The Entry is available from the start. The other fast travel buttons are locked behind items.
119 """
120 display_name = "Fast Travel Access"
121 option_vanilla = 0
122 option_unlocked = 1
123 option_items = 2
124
125
126class EnableIcarus(Toggle):
127 """
128 Controls whether Icarus is randomized. If disabled, which is the default, no locations or items will be created for
129 it, and its worldport will not be shuffled when worldport shuffle is on.
130 """
131 display_name = "Enable Icarus"
132
133
134class EnableGiftMaps(OptionSet):
135 """
136 Controls whether the beta tester gift maps are randomized. By default, these are not accessible at all from within
137 the randomizer. This option allows you to enter the maps, and creates items and locations for them. If worldport
138 shuffle is on, their worldports will be included in the randomization.
139
140 The gift maps are accessed via a panel in The Entry's Starting Room, which only appears if at least one gift map is
141 enabled. It is also treated like a cyan door, and will not appear until the condition specified in the Cyan Door
142 Behavior option is satisfied. Solving this panel with the name of one of the beta testers will teleport you to their
143 corresponding gift map.
144
145 In the base game, nothing happens once you complete a gift map. Masteries have been added to the gift maps in the
146 randomizer so that the player can be rewarded for completing them.
147
148 Note that the gift maps were originally only intended to be played by specific people, and as a result may be
149 frustrating or require knowledge of inside jokes. The Crystalline is particularly difficult as it requires
150 completing a parkour course.
151 """
152 display_name = "Enable Gift Maps"
153 valid_keys = ["The Advanced", "The Charismatic", "The Crystalline", "The Fuzzy", "The Stellar"]
154
155
156class DaedalusOnly(Toggle):
157 """
158 If enabled, all maps besides Daedalus, The Gold, and The Tenacious will be disabled. This overrides any other
159 map-based option, such as Enable Icarus. The player will start in Daedalus. The following options are restricted
160 with this setting:
161
162 - The ending must be set to Orange or Gold.
163 - Worldport shuffle must be enabled.
164 - Letter shuffle must Unlocked.
165 - Cyan Door Behavior cannot be set to Collect H2.
166 - Control Center Color shuffle must be enabled.
167 """
168 display_name = "Daedalus Only"
169
170
85class DaedalusRoofAccess(Toggle): 171class DaedalusRoofAccess(Toggle):
86 """ 172 """
87 If enabled, the player will be logically expected to be able to go from the castle entrance to any part of Daedalus 173 If enabled, the player will be logically expected to be able to go from the castle entrance to any part of Daedalus
@@ -92,6 +178,15 @@ class DaedalusRoofAccess(Toggle):
92 display_name = "Allow Daedalus Roof Access" 178 display_name = "Allow Daedalus Roof Access"
93 179
94 180
181class CustomMintEnding(FreeText):
182 """
183 If not blank, this will add a new panel that must be solved before collecting Mint Ending (EXIT in the Control
184 Center). The panel will only require typing the text provided for this option, which means the choice of letters
185 here has an impact on logic.
186 """
187 display_name = "Custom Mint Ending"
188
189
95class StrictPurpleEnding(DefaultOnToggle): 190class StrictPurpleEnding(DefaultOnToggle):
96 """ 191 """
97 If enabled, the player will be required to have all purple (level 1) letters in order to get Purple Ending. 192 If enabled, the player will be required to have all purple (level 1) letters in order to get Purple Ending.
@@ -142,6 +237,26 @@ class VictoryCondition(Choice):
142 option_white_ending = 12 237 option_white_ending = 12
143 238
144 239
240class EndingsRequirement(Range):
241 """The number of endings required to unlock White Ending."""
242 display_name = "Endings Requirement"
243 range_start = 0
244 range_end = 12
245 default = 12
246
247
248class MasteriesRequirement(Range):
249 """The number of masteries required to unlock White Ending.
250
251 There are only 13 masteries in the base game, but some of the other slot options may add more masteries to the
252 world. If the chosen number of masteries is higher than the total in your world, it will be automatically lowered to
253 the maximum."""
254 display_name = "Masteries Requirement"
255 range_start = 0
256 range_end = 19
257 default = 0
258
259
145class TrapPercentage(Range): 260class TrapPercentage(Range):
146 """Replaces junk items with traps, at the specified rate.""" 261 """Replaces junk items with traps, at the specified rate."""
147 display_name = "Trap Percentage" 262 display_name = "Trap Percentage"
@@ -150,17 +265,35 @@ class TrapPercentage(Range):
150 default = 0 265 default = 0
151 266
152 267
268class ShuffleMusic(Toggle):
269 """
270 If enabled, every map will be assigned a random music track.
271 """
272 display_name = "Shuffle Music"
273
274
153@dataclass 275@dataclass
154class Lingo2Options(PerGameCommonOptions): 276class Lingo2Options(PerGameCommonOptions):
155 shuffle_doors: ShuffleDoors 277 shuffle_doors: ShuffleDoors
156 shuffle_control_center_colors: ShuffleControlCenterColors 278 shuffle_control_center_colors: ShuffleControlCenterColors
157 shuffle_gallery_paintings: ShuffleGalleryPaintings 279 shuffle_gallery_paintings: ShuffleGalleryPaintings
158 shuffle_letters: ShuffleLetters 280 shuffle_letters: ShuffleLetters
281 restrict_letter_placements: RestrictLetterPlacements
159 shuffle_symbols: ShuffleSymbols 282 shuffle_symbols: ShuffleSymbols
283 shuffle_worldports: ShuffleWorldports
160 keyholder_sanity: KeyholderSanity 284 keyholder_sanity: KeyholderSanity
161 cyan_door_behavior: CyanDoorBehavior 285 cyan_door_behavior: CyanDoorBehavior
286 shuffle_fast_travel: ShuffleFastTravel
287 fast_travel_access: FastTravelAccess
288 enable_icarus: EnableIcarus
289 enable_gift_maps: EnableGiftMaps
290 daedalus_only: DaedalusOnly
162 daedalus_roof_access: DaedalusRoofAccess 291 daedalus_roof_access: DaedalusRoofAccess
292 custom_mint_ending: CustomMintEnding
163 strict_purple_ending: StrictPurpleEnding 293 strict_purple_ending: StrictPurpleEnding
164 strict_cyan_ending: StrictCyanEnding 294 strict_cyan_ending: StrictCyanEnding
165 victory_condition: VictoryCondition 295 victory_condition: VictoryCondition
296 endings_requirement: EndingsRequirement
297 masteries_requirement: MasteriesRequirement
166 trap_percentage: TrapPercentage 298 trap_percentage: TrapPercentage
299 shuffle_music: ShuffleMusic
diff --git a/apworld/player_logic.py b/apworld/player_logic.py index 4aa481d..2c3e08b 100644 --- a/apworld/player_logic.py +++ b/apworld/player_logic.py
@@ -1,10 +1,12 @@
1from enum import IntEnum, auto 1from enum import IntEnum, auto
2 2
3from Options import OptionError
3from .generated import data_pb2 as data_pb2 4from .generated import data_pb2 as data_pb2
4from .items import SYMBOL_ITEMS 5from .items import SYMBOL_ITEMS
5from typing import TYPE_CHECKING, NamedTuple 6from typing import TYPE_CHECKING, NamedTuple
6 7
7from .options import VictoryCondition, ShuffleLetters, CyanDoorBehavior 8from .options import ShuffleLetters, CyanDoorBehavior, VictoryCondition, FastTravelAccess
9from .rules import AccessRequirements
8 10
9if TYPE_CHECKING: 11if TYPE_CHECKING:
10 from . import Lingo2World 12 from . import Lingo2World
@@ -20,177 +22,10 @@ def calculate_letter_histogram(solution: str) -> dict[str, int]:
20 return histogram 22 return histogram
21 23
22 24
23class AccessRequirements:
24 items: set[str]
25 progressives: dict[str, int]
26 rooms: set[str]
27 letters: dict[str, int]
28 cyans: bool
29
30 # This is an AND of ORs.
31 or_logic: list[list["AccessRequirements"]]
32
33 # When complete_at is set, at least that many of the requirements in possibilities must be accessible. This should
34 # only be used for doors with complete_at > 1, as or_logic is more efficient for complete_at == 1.
35 complete_at: int | None
36 possibilities: list["AccessRequirements"]
37
38 def __init__(self):
39 self.items = set()
40 self.progressives = dict()
41 self.rooms = set()
42 self.letters = dict()
43 self.cyans = False
44 self.or_logic = list()
45 self.complete_at = None
46 self.possibilities = list()
47
48 def copy(self) -> "AccessRequirements":
49 reqs = AccessRequirements()
50 reqs.items = self.items.copy()
51 reqs.progressives = self.progressives.copy()
52 reqs.rooms = self.rooms.copy()
53 reqs.letters = self.letters.copy()
54 reqs.cyans = self.cyans
55 reqs.or_logic = [[other_req.copy() for other_req in disjunction] for disjunction in self.or_logic]
56 reqs.complete_at = self.complete_at
57 reqs.possibilities = self.possibilities.copy()
58 return reqs
59
60 def merge(self, other: "AccessRequirements"):
61 for item in other.items:
62 self.items.add(item)
63
64 for item, amount in other.progressives.items():
65 self.progressives[item] = max(amount, self.progressives.get(item, 0))
66
67 for room in other.rooms:
68 self.rooms.add(room)
69
70 for letter, level in other.letters.items():
71 self.letters[letter] = max(self.letters.get(letter, 0), level)
72
73 self.cyans = self.cyans or other.cyans
74
75 for disjunction in other.or_logic:
76 self.or_logic.append(disjunction)
77
78 if other.complete_at is not None:
79 # Merging multiple requirements that use complete_at sucks, and is part of why we want to minimize use of
80 # it. If both requirements use complete_at, we will cheat by using the or_logic field, which supports
81 # conjunctions of requirements.
82 if self.complete_at is not None:
83 print("Merging requirements with complete_at > 1. This is messy and should be avoided!")
84
85 left_req = AccessRequirements()
86 left_req.complete_at = self.complete_at
87 left_req.possibilities = self.possibilities
88 self.or_logic.append([left_req])
89
90 self.complete_at = None
91 self.possibilities = list()
92
93 right_req = AccessRequirements()
94 right_req.complete_at = other.complete_at
95 right_req.possibilities = other.possibilities
96 self.or_logic.append([right_req])
97 else:
98 self.complete_at = other.complete_at
99 self.possibilities = other.possibilities
100
101 def is_empty(self) -> bool:
102 return (len(self.items) == 0 and len(self.progressives) == 0 and len(self.rooms) == 0 and len(self.letters) == 0
103 and not self.cyans and len(self.or_logic) == 0 and self.complete_at is None)
104
105 def __eq__(self, other: "AccessRequirements"):
106 return (self.items == other.items and self.progressives == other.progressives and self.rooms == other.rooms and
107 self.letters == other.letters and self.cyans == other.cyans and self.or_logic == other.or_logic and
108 self.complete_at == other.complete_at and self.possibilities == other.possibilities)
109
110 def simplify(self):
111 resimplify = False
112
113 if len(self.or_logic) > 0:
114 old_or_logic = self.or_logic
115
116 def remove_redundant(sub_reqs: "AccessRequirements"):
117 new_reqs = sub_reqs.copy()
118 new_reqs.letters = {l: v for l, v in new_reqs.letters.items() if self.letters.get(l, 0) < v}
119 if new_reqs != sub_reqs:
120 return new_reqs
121 else:
122 return sub_reqs
123
124 self.or_logic = []
125 for disjunction in old_or_logic:
126 new_disjunction = []
127 for ssr in disjunction:
128 new_ssr = remove_redundant(ssr)
129 if not new_ssr.is_empty():
130 new_disjunction.append(new_ssr)
131 else:
132 new_disjunction.clear()
133 break
134 if len(new_disjunction) == 1:
135 self.merge(new_disjunction[0])
136 resimplify = True
137 elif len(new_disjunction) > 1:
138 if all(cjr == new_disjunction[0] for cjr in new_disjunction):
139 self.merge(new_disjunction[0])
140 resimplify = True
141 else:
142 self.or_logic.append(new_disjunction)
143
144 if resimplify:
145 self.simplify()
146
147 def get_referenced_rooms(self):
148 result = set(self.rooms)
149
150 for disjunction in self.or_logic:
151 for sub_req in disjunction:
152 result = result.union(sub_req.get_referenced_rooms())
153
154 for sub_req in self.possibilities:
155 result = result.union(sub_req.get_referenced_rooms())
156
157 return result
158
159 def remove_room(self, room: str):
160 if room in self.rooms:
161 self.rooms.remove(room)
162
163 for disjunction in self.or_logic:
164 for sub_req in disjunction:
165 sub_req.remove_room(room)
166
167 for sub_req in self.possibilities:
168 sub_req.remove_room(room)
169
170 def __repr__(self):
171 parts = []
172 if len(self.items) > 0:
173 parts.append(f"items={self.items}")
174 if len(self.progressives) > 0:
175 parts.append(f"progressives={self.progressives}")
176 if len(self.rooms) > 0:
177 parts.append(f"rooms={self.rooms}")
178 if len(self.letters) > 0:
179 parts.append(f"letters={self.letters}")
180 if self.cyans:
181 parts.append(f"cyans=True")
182 if len(self.or_logic) > 0:
183 parts.append(f"or_logic={self.or_logic}")
184 if self.complete_at is not None:
185 parts.append(f"complete_at={self.complete_at}")
186 if len(self.possibilities) > 0:
187 parts.append(f"possibilities={self.possibilities}")
188 return "AccessRequirements(" + ", ".join(parts) + ")"
189
190
191class PlayerLocation(NamedTuple): 25class PlayerLocation(NamedTuple):
192 code: int | None 26 code: int | None
193 reqs: AccessRequirements 27 reqs: AccessRequirements
28 is_letter: bool = False
194 29
195 30
196class LetterBehavior(IntEnum): 31class LetterBehavior(IntEnum):
@@ -202,6 +37,10 @@ class LetterBehavior(IntEnum):
202class Lingo2PlayerLogic: 37class Lingo2PlayerLogic:
203 world: "Lingo2World" 38 world: "Lingo2World"
204 39
40 shuffled_maps: set[int]
41 shuffled_rooms: set[int]
42 shuffled_doors: set[int]
43
205 locations_by_room: dict[int, list[PlayerLocation]] 44 locations_by_room: dict[int, list[PlayerLocation]]
206 event_loc_item_by_room: dict[int, dict[str, str]] 45 event_loc_item_by_room: dict[int, dict[str, str]]
207 46
@@ -212,11 +51,17 @@ class Lingo2PlayerLogic:
212 door_reqs: dict[int, AccessRequirements] 51 door_reqs: dict[int, AccessRequirements]
213 52
214 real_items: list[str] 53 real_items: list[str]
54 starting_items: list[str]
215 55
216 double_letter_amount: dict[str, int] 56 double_letter_amount: dict[str, int]
57 goal_room_id: int
58 rte_mapping: list[int]
59 custom_mint_ending: str | None
217 60
218 def __init__(self, world: "Lingo2World"): 61 def __init__(self, world: "Lingo2World"):
219 self.world = world 62 self.world = world
63 self.shuffled_rooms = set()
64 self.shuffled_doors = set()
220 self.locations_by_room = {} 65 self.locations_by_room = {}
221 self.event_loc_item_by_room = {} 66 self.event_loc_item_by_room = {}
222 self.item_by_door = {} 67 self.item_by_door = {}
@@ -224,35 +69,141 @@ class Lingo2PlayerLogic:
224 self.proxy_reqs = dict() 69 self.proxy_reqs = dict()
225 self.door_reqs = dict() 70 self.door_reqs = dict()
226 self.real_items = list() 71 self.real_items = list()
72 self.starting_items = list()
227 self.double_letter_amount = dict() 73 self.double_letter_amount = dict()
74 self.custom_mint_ending = None
75
76 def should_shuffle_map(game_map) -> bool | set[int]:
77 if world.options.daedalus_only:
78 return game_map.daedalus_only_mode == data_pb2.DaedalusOnlyMode.DAED_ONLY_ALLOW
79
80 if game_map.type == data_pb2.MapType.NORMAL_MAP:
81 return True
82 elif game_map.type == data_pb2.MapType.ICARUS:
83 return bool(world.options.enable_icarus)
84 elif game_map.type == data_pb2.MapType.GIFT_MAP:
85 if game_map.name == "the_advanced":
86 return "The Advanced" in world.options.enable_gift_maps.value
87 elif game_map.name == "the_charismatic":
88 return "The Charismatic" in world.options.enable_gift_maps.value
89 elif game_map.name == "the_crystalline":
90 return "The Crystalline" in world.options.enable_gift_maps.value
91 elif game_map.name == "the_fuzzy":
92 return "The Fuzzy" in world.options.enable_gift_maps.value
93 elif game_map.name == "the_stellar":
94 return "The Stellar" in world.options.enable_gift_maps.value
95
96 return False
97
98 self.shuffled_maps = set(game_map.id for game_map in world.static_logic.objects.maps
99 if should_shuffle_map(game_map))
100
101 if world.options.daedalus_only:
102 if world.options.victory_condition not in [VictoryCondition.option_orange_ending,
103 VictoryCondition.option_gold_ending]:
104 raise OptionError(f"When Daedalus Only mode is enabled, the victory condition must be Orange Ending or "
105 f"Gold Ending (Player {world.player}).")
106
107 if not world.options.shuffle_worldports:
108 raise OptionError(f"When Daedalus Only mode is enabled, worldport shuffle must also be enabled (Player "
109 f"{world.player}).")
110
111 if world.options.shuffle_letters != ShuffleLetters.option_unlocked:
112 raise OptionError(f"When Daedalus Only mode is enabled, letter shuffle must be set to Unlocked (Player "
113 f"{world.player}).")
114
115 if world.options.cyan_door_behavior == CyanDoorBehavior.option_collect_h2:
116 raise OptionError(f"When Daedalus Only mode is enabled, Cyan Door Behavior cannot be set to Collect H2 "
117 f"(Player {world.player}).")
118
119 if not world.options.shuffle_control_center_colors:
120 raise OptionError(f"When Daedalus Only mode is enabled, control center color shuffle must be enabled "
121 f"(Player {world.player}).")
122
123 if world.options.shuffle_symbols and not world.options.keyholder_sanity:
124 raise OptionError(f"When Daedalus Only mode is enabled and symbols are shuffled, keyholdersanity must "
125 f"also be enabled (Player {world.player}).")
126
127 for game_map in world.static_logic.objects.maps:
128 if game_map.daedalus_only_mode == data_pb2.DaedalusOnlyMode.DAED_ONLY_PARTIAL:
129 self.shuffled_rooms.update(set(room.id for room in world.static_logic.objects.rooms
130 if room.map_id == game_map.id and room.daedalus_only_allow))
131 self.shuffled_doors.update(set(door.id for door in world.static_logic.objects.doors
132 if door.map_id == game_map.id and door.daedalus_only_allow))
133
134 if (world.options.restrict_letter_placements
135 and world.options.shuffle_letters == ShuffleLetters.option_progressive
136 and (world.options.shuffle_doors or world.options.shuffle_symbols)):
137 raise OptionError(f"When Restrict Letter Placements is enabled and Shuffle Letters is set to Progressive, "
138 f"both Shuffle Doors and Shuffle Symbols must be disabled (Player {world.player}).")
139
140 if world.options.custom_mint_ending.value != "":
141 self.custom_mint_ending = ''.join(filter(str.isalpha, world.options.custom_mint_ending.value)).lower()
142
143 if len(self.custom_mint_ending) > 52:
144 raise OptionError(f"Custom Mint Ending should not be greater than 52 letters (Player {world.player}).")
145
146 maximum_masteries = 13 + len(world.options.enable_gift_maps.value)
147 if world.options.enable_icarus:
148 maximum_masteries += 1
149
150 if world.options.masteries_requirement > maximum_masteries:
151 world.options.masteries_requirement.value = maximum_masteries
152
153 if "The Fuzzy" in world.options.enable_gift_maps.value:
154 self.real_items.append("Numbers")
155
156 if world.options.shuffle_fast_travel:
157 travelable_maps = [map_id for map_id in self.shuffled_maps
158 if world.static_logic.objects.maps[map_id].HasField("rte_room")]
159 self.rte_mapping = world.random.sample(travelable_maps, 4)
160 else:
161 canonical_rtes = ["the_plaza", "the_gallery", "daedalus", "control_center"]
162 self.rte_mapping = [world.static_logic.map_id_by_name[map_name] for map_name in canonical_rtes
163 if world.static_logic.map_id_by_name[map_name] in self.shuffled_maps]
164
165 if world.options.fast_travel_access == FastTravelAccess.option_items:
166 for rte_map in self.rte_mapping:
167 self.real_items.append(world.static_logic.get_map_rte_item_name(rte_map))
228 168
229 if self.world.options.shuffle_doors: 169 if self.world.options.shuffle_doors:
230 for progressive in world.static_logic.objects.progressives: 170 for progressive in world.static_logic.objects.progressives:
231 for i in range(0, len(progressive.doors)): 171 for i in range(0, len(progressive.doors)):
172 door = world.static_logic.objects.doors[progressive.doors[i]]
173 if not self.should_shuffle_door(door.id):
174 continue
175
232 self.item_by_door[progressive.doors[i]] = (progressive.name, i + 1) 176 self.item_by_door[progressive.doors[i]] = (progressive.name, i + 1)
233 self.real_items.append(progressive.name) 177 self.real_items.append(progressive.name)
234 178
235 for door_group in world.static_logic.objects.door_groups: 179 for door_group in world.static_logic.objects.door_groups:
236 if door_group.type == data_pb2.DoorGroupType.CONNECTOR: 180 if door_group.type == data_pb2.DoorGroupType.CONNECTOR:
237 if not self.world.options.shuffle_doors: 181 if not self.world.options.shuffle_doors or self.world.options.shuffle_worldports:
238 continue 182 continue
239 elif door_group.type == data_pb2.DoorGroupType.COLOR_CONNECTOR: 183 elif door_group.type == data_pb2.DoorGroupType.COLOR_CONNECTOR:
240 if not self.world.options.shuffle_control_center_colors: 184 if not self.world.options.shuffle_control_center_colors or self.world.options.shuffle_worldports:
241 continue 185 continue
242 elif door_group.type == data_pb2.DoorGroupType.SHUFFLE_GROUP: 186 elif door_group.type == data_pb2.DoorGroupType.SHUFFLE_GROUP:
243 if not self.world.options.shuffle_doors: 187 if (not self.world.options.shuffle_doors and
188 not (door_group.daedalus_only_always_item and self.world.options.daedalus_only)):
244 continue 189 continue
245 else: 190 else:
246 continue 191 continue
247 192
248 for door in door_group.doors: 193 shuffleable_doors = [door_id for door_id in door_group.doors if self.should_shuffle_door(door_id)]
249 self.item_by_door[door] = (door_group.name, 1)
250 194
251 self.real_items.append(door_group.name) 195 if len(shuffleable_doors) > 0:
196 for door in shuffleable_doors:
197 self.item_by_door[door] = (door_group.name, 1)
198
199 self.real_items.append(door_group.name)
252 200
253 # We iterate through the doors in two parts because it is essential that we determine which doors are shuffled 201 # We iterate through the doors in two parts because it is essential that we determine which doors are shuffled
254 # before we calculate any access requirements. 202 # before we calculate any access requirements.
255 for door in world.static_logic.objects.doors: 203 for door in world.static_logic.objects.doors:
204 if not self.should_shuffle_door(door.id):
205 continue
206
256 if door.type in [data_pb2.DoorType.EVENT, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]: 207 if door.type in [data_pb2.DoorType.EVENT, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]:
257 continue 208 continue
258 209
@@ -260,7 +211,8 @@ class Lingo2PlayerLogic:
260 continue 211 continue
261 212
262 if (door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.ITEM_ONLY] and 213 if (door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.ITEM_ONLY] and
263 not self.world.options.shuffle_doors): 214 not self.world.options.shuffle_doors and
215 not (door.daedalus_only_always_item and self.world.options.daedalus_only)):
264 continue 216 continue
265 217
266 if (door.type == data_pb2.DoorType.CONTROL_CENTER_COLOR and 218 if (door.type == data_pb2.DoorType.CONTROL_CENTER_COLOR and
@@ -281,29 +233,41 @@ class Lingo2PlayerLogic:
281 if door_group.type != data_pb2.DoorGroupType.CYAN_DOORS: 233 if door_group.type != data_pb2.DoorGroupType.CYAN_DOORS:
282 continue 234 continue
283 235
284 for door in door_group.doors: 236 shuffleable_doors = [door_id for door_id in door_group.doors
285 if not door in self.item_by_door: 237 if self.should_shuffle_door(door_id) and door_id not in self.item_by_door]
238
239 if len(shuffleable_doors) > 0:
240 for door in shuffleable_doors:
286 self.item_by_door[door] = (door_group.name, 1) 241 self.item_by_door[door] = (door_group.name, 1)
287 242
288 self.real_items.append(door_group.name) 243 self.real_items.append(door_group.name)
289 244
290 for door in world.static_logic.objects.doors: 245 for door in world.static_logic.objects.doors:
246 if not self.should_shuffle_door(door.id):
247 continue
248
291 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]: 249 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]:
292 self.locations_by_room.setdefault(door.room_id, []).append(PlayerLocation(door.ap_id, 250 self.locations_by_room.setdefault(door.room_id, []).append(PlayerLocation(door.ap_id,
293 self.get_door_reqs(door.id))) 251 self.get_door_reqs(door.id)))
294 252
295 for letter in world.static_logic.objects.letters: 253 for letter in world.static_logic.objects.letters:
296 self.locations_by_room.setdefault(letter.room_id, []).append(PlayerLocation(letter.ap_id, 254 if not self.should_shuffle_room(letter.room_id):
297 AccessRequirements())) 255 continue
256
298 behavior = self.get_letter_behavior(letter.key, letter.level2) 257 behavior = self.get_letter_behavior(letter.key, letter.level2)
299 if behavior == LetterBehavior.VANILLA:
300 letter_name = f"{letter.key.upper()}{'2' if letter.level2 else '1'}"
301 event_name = f"{letter_name} (Collected)"
302 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper()
303 258
304 if letter.level2: 259 self.locations_by_room.setdefault(letter.room_id, []).append(
305 event_name = f"{letter_name} (Double Collected)" 260 PlayerLocation(letter.ap_id, AccessRequirements(), behavior == LetterBehavior.ITEM))
261
262 if behavior == LetterBehavior.VANILLA:
263 if not world.for_tracker:
264 letter_name = f"{letter.key.upper()}{'2' if letter.level2 else '1'}"
265 event_name = f"{letter_name} (Collected)"
306 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper() 266 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper()
267
268 if letter.level2:
269 event_name = f"{letter_name} (Double Collected)"
270 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper()
307 elif behavior == LetterBehavior.ITEM: 271 elif behavior == LetterBehavior.ITEM:
308 self.real_items.append(letter.key.upper()) 272 self.real_items.append(letter.key.upper())
309 273
@@ -311,30 +275,42 @@ class Lingo2PlayerLogic:
311 self.double_letter_amount[letter.key.upper()] = self.double_letter_amount.get(letter.key.upper(), 0) + 1 275 self.double_letter_amount[letter.key.upper()] = self.double_letter_amount.get(letter.key.upper(), 0) + 1
312 276
313 for mastery in world.static_logic.objects.masteries: 277 for mastery in world.static_logic.objects.masteries:
278 if not self.should_shuffle_room(mastery.room_id):
279 continue
280
314 self.locations_by_room.setdefault(mastery.room_id, []).append(PlayerLocation(mastery.ap_id, 281 self.locations_by_room.setdefault(mastery.room_id, []).append(PlayerLocation(mastery.ap_id,
315 AccessRequirements())) 282 AccessRequirements()))
316 283
284 if world.options.masteries_requirement > 0:
285 event_name = f"{world.static_logic.get_room_object_map_name(mastery)} - Mastery (Collected)"
286 self.event_loc_item_by_room.setdefault(mastery.room_id, {})[event_name] = "Mastery"
287
317 for ending in world.static_logic.objects.endings: 288 for ending in world.static_logic.objects.endings:
318 # Don't ever create a location for White Ending. Don't even make an event for it if it's not the victory 289 if not self.should_shuffle_room(ending.room_id):
319 # condition, since it is necessarily going to be in the postgame. 290 continue
320 if ending.name == "WHITE": 291
321 if self.world.options.victory_condition != VictoryCondition.option_white_ending: 292 # Don't create a location for your selected ending. Also don't create a location for White Ending if it's
322 continue 293 # necessarily in the postgame, i.e. it requires all 12 other endings.
323 else: 294 if world.options.victory_condition.current_key.removesuffix("_ending").upper() != ending.name\
295 and (ending.name != "WHITE" or world.options.endings_requirement < 12):
324 self.locations_by_room.setdefault(ending.room_id, []).append(PlayerLocation(ending.ap_id, 296 self.locations_by_room.setdefault(ending.room_id, []).append(PlayerLocation(ending.ap_id,
325 AccessRequirements())) 297 AccessRequirements()))
326 298
327 event_name = f"{ending.name.capitalize()} Ending (Achieved)"
328 item_name = event_name
329
330 if world.options.victory_condition.current_key.removesuffix("_ending").upper() == ending.name: 299 if world.options.victory_condition.current_key.removesuffix("_ending").upper() == ending.name:
331 item_name = "Victory" 300 event_name = f"{ending.name.capitalize()} Ending (Goal)"
301 self.event_loc_item_by_room.setdefault(ending.room_id, {})[event_name] = "Victory"
302 self.goal_room_id = ending.room_id
332 303
333 self.event_loc_item_by_room.setdefault(ending.room_id, {})[event_name] = item_name 304 if ending.name != "WHITE":
305 event_name = f"{ending.name.capitalize()} Ending (Achieved)"
306 self.event_loc_item_by_room.setdefault(ending.room_id, {})[event_name] = "Ending"
334 307
335 if self.world.options.keyholder_sanity: 308 if self.world.options.keyholder_sanity:
336 for keyholder in world.static_logic.objects.keyholders: 309 for keyholder in world.static_logic.objects.keyholders:
337 if keyholder.HasField("key"): 310 if keyholder.HasField("key"):
311 if not self.should_shuffle_room(keyholder.room_id):
312 continue
313
338 reqs = AccessRequirements() 314 reqs = AccessRequirements()
339 315
340 if self.get_letter_behavior(keyholder.key, False) != LetterBehavior.UNLOCKED: 316 if self.get_letter_behavior(keyholder.key, False) != LetterBehavior.UNLOCKED:
@@ -345,7 +321,10 @@ class Lingo2PlayerLogic:
345 321
346 if self.world.options.shuffle_symbols: 322 if self.world.options.shuffle_symbols:
347 for symbol_name in SYMBOL_ITEMS.values(): 323 for symbol_name in SYMBOL_ITEMS.values():
348 self.real_items.append(symbol_name) 324 if self.world.options.daedalus_only and symbol_name == "Sun Symbol":
325 self.starting_items.append(symbol_name)
326 else:
327 self.real_items.append(symbol_name)
349 328
350 def get_panel_reqs(self, panel_id: int, answer: str | None) -> AccessRequirements: 329 def get_panel_reqs(self, panel_id: int, answer: str | None) -> AccessRequirements:
351 if answer is None: 330 if answer is None:
@@ -442,7 +421,6 @@ class Lingo2PlayerLogic:
442 reqs.possibilities.append(panel_reqs) 421 reqs.possibilities.append(panel_reqs)
443 422
444 if door.HasField("control_center_color"): 423 if door.HasField("control_center_color"):
445 # TODO: Logic for ensuring two CC states aren't needed at once.
446 reqs.rooms.add("Control Center - Main Area") 424 reqs.rooms.add("Control Center - Main Area")
447 self.add_solution_reqs(reqs, door.control_center_color) 425 self.add_solution_reqs(reqs, door.control_center_color)
448 426
@@ -468,13 +446,12 @@ class Lingo2PlayerLogic:
468 for room in door.rooms: 446 for room in door.rooms:
469 reqs.rooms.add(self.world.static_logic.get_room_region_name(room)) 447 reqs.rooms.add(self.world.static_logic.get_room_region_name(room))
470 448
471 for ending_id in door.endings: 449 if door.white_ending:
472 ending = self.world.static_logic.objects.endings[ending_id] 450 if self.world.options.endings_requirement > 0:
451 reqs.progressives["Ending"] = self.world.options.endings_requirement.value
473 452
474 if self.world.options.victory_condition.current_key.removesuffix("_ending").upper() == ending.name: 453 if self.world.options.masteries_requirement > 0:
475 reqs.items.add("Victory") 454 reqs.progressives["Mastery"] = self.world.options.masteries_requirement.value
476 else:
477 reqs.items.add(f"{ending.name.capitalize()} Ending (Achieved)")
478 455
479 for sub_door_id in door.doors: 456 for sub_door_id in door.doors:
480 sub_reqs = self.get_door_open_reqs(sub_door_id) 457 sub_reqs = self.get_door_open_reqs(sub_door_id)
@@ -536,3 +513,24 @@ class Lingo2PlayerLogic:
536 513
537 if needed > 0: 514 if needed > 0:
538 reqs.letters[l] = max(reqs.letters.get(l, 0), needed) 515 reqs.letters[l] = max(reqs.letters.get(l, 0), needed)
516
517 if any(l.isnumeric() for l in solution):
518 reqs.items.add("Numbers")
519
520 def should_shuffle_room(self, room_id: int) -> bool:
521 if room_id in self.shuffled_rooms:
522 return True
523
524 room = self.world.static_logic.objects.rooms[room_id]
525 game_map = self.world.static_logic.objects.maps[room.map_id]
526
527 return game_map.id in self.shuffled_maps
528
529 def should_shuffle_door(self, door_id: int) -> bool:
530 if door_id in self.shuffled_doors:
531 return True
532
533 door = self.world.static_logic.objects.doors[door_id]
534 game_map = self.world.static_logic.objects.maps[door.map_id]
535
536 return game_map.id in self.shuffled_maps
diff --git a/apworld/regions.py b/apworld/regions.py index 993eec8..313fd02 100644 --- a/apworld/regions.py +++ b/apworld/regions.py
@@ -1,10 +1,12 @@
1from typing import TYPE_CHECKING 1from typing import TYPE_CHECKING
2 2
3import BaseClasses
3from BaseClasses import Region, ItemClassification, Entrance 4from BaseClasses import Region, ItemClassification, Entrance
5from entrance_rando import randomize_entrances
4from .items import Lingo2Item 6from .items import Lingo2Item
5from .locations import Lingo2Location 7from .locations import Lingo2Location, LetterPlacementType, Lingo2Entrance
8from .options import FastTravelAccess
6from .player_logic import AccessRequirements 9from .player_logic import AccessRequirements
7from .rules import make_location_lambda
8 10
9if TYPE_CHECKING: 11if TYPE_CHECKING:
10 from . import Lingo2World 12 from . import Lingo2World
@@ -19,17 +21,39 @@ def create_locations(room, new_region: Region, world: "Lingo2World", regions: di
19 reqs = location.reqs.copy() 21 reqs = location.reqs.copy()
20 reqs.remove_room(new_region.name) 22 reqs.remove_room(new_region.name)
21 23
22 new_location = Lingo2Location(world.player, world.static_logic.location_id_to_name[location.code], 24 new_location = Lingo2Location.non_event_location(world, location.code, new_region)
23 location.code, new_region) 25 new_location.set_access_rule(reqs, regions)
24 new_location.access_rule = make_location_lambda(reqs, world, regions) 26 if world.options.restrict_letter_placements:
27 if location.is_letter:
28 new_location.set_up_letter_rule(LetterPlacementType.FORCE)
29 else:
30 new_location.set_up_letter_rule(LetterPlacementType.DISALLOW)
25 new_region.locations.append(new_location) 31 new_region.locations.append(new_location)
26 32
27 for event_name, item_name in world.player_logic.event_loc_item_by_room.get(room.id, {}).items(): 33 for event_name, item_name in world.player_logic.event_loc_item_by_room.get(room.id, {}).items():
28 new_location = Lingo2Location(world.player, event_name, None, new_region) 34 new_location = Lingo2Location.event_location(world, event_name, new_region)
35 if world.for_tracker and item_name == "Victory":
36 new_location.goal = True
37
29 event_item = Lingo2Item(item_name, ItemClassification.progression, None, world.player) 38 event_item = Lingo2Item(item_name, ItemClassification.progression, None, world.player)
30 new_location.place_locked_item(event_item) 39 new_location.place_locked_item(event_item)
31 new_region.locations.append(new_location) 40 new_region.locations.append(new_location)
32 41
42 if world.for_tracker and world.options.shuffle_worldports:
43 for port_id in room.ports:
44 port = world.static_logic.objects.ports[port_id]
45 if port.no_shuffle:
46 continue
47
48 new_location = Lingo2Location.event_location(world, f"Worldport {port.id} Entered", new_region)
49 new_location.port_id = port.id
50
51 if port.HasField("required_door"):
52 new_location.set_access_rule(world.player_logic.get_door_open_reqs(port.required_door), regions)
53
54 new_region.locations.append(new_location)
55
56
33def create_regions(world: "Lingo2World"): 57def create_regions(world: "Lingo2World"):
34 regions = { 58 regions = {
35 "Menu": Region("Menu", world.player, world.multiworld) 59 "Menu": Region("Menu", world.player, world.multiworld)
@@ -41,6 +65,9 @@ def create_regions(world: "Lingo2World"):
41 # locations. This allows us to reference the actual region objects in the access rules for the locations, which is 65 # locations. This allows us to reference the actual region objects in the access rules for the locations, which is
42 # faster than having to look them up during access checking. 66 # faster than having to look them up during access checking.
43 for room in world.static_logic.objects.rooms: 67 for room in world.static_logic.objects.rooms:
68 if not world.player_logic.should_shuffle_room(room.id):
69 continue
70
44 region = create_region(room, world) 71 region = create_region(room, world)
45 regions[region.name] = region 72 regions[region.name] = region
46 region_and_room.append((region, room)) 73 region_and_room.append((region, room))
@@ -48,13 +75,18 @@ def create_regions(world: "Lingo2World"):
48 for (region, room) in region_and_room: 75 for (region, room) in region_and_room:
49 create_locations(room, region, world, regions) 76 create_locations(room, region, world, regions)
50 77
51 regions["Menu"].connect(regions["The Entry - Starting Room"], "Start Game") 78 if world.options.daedalus_only:
79 regions["Menu"].connect(regions["Daedalus - Starting Room"], "Start Game")
80 else:
81 regions["Menu"].connect(regions["The Entry - Starting Room"], "Start Game")
52 82
53 # TODO: The requirements of the opposite trigger also matter.
54 for connection in world.static_logic.objects.connections: 83 for connection in world.static_logic.objects.connections:
55 if connection.roof_access and not world.options.daedalus_roof_access: 84 if connection.roof_access and not world.options.daedalus_roof_access:
56 continue 85 continue
57 86
87 if connection.vanilla_only and world.options.shuffle_doors:
88 continue
89
58 from_region = world.static_logic.get_room_region_name(connection.from_room) 90 from_region = world.static_logic.get_room_region_name(connection.from_room)
59 to_region = world.static_logic.get_room_region_name(connection.to_room) 91 to_region = world.static_logic.get_room_region_name(connection.to_room)
60 92
@@ -74,7 +106,10 @@ def create_regions(world: "Lingo2World"):
74 106
75 if connection.HasField("port"): 107 if connection.HasField("port"):
76 port = world.static_logic.objects.ports[connection.port] 108 port = world.static_logic.objects.ports[connection.port]
77 connection_name = f"{connection_name} (via port {port.name})" 109 connection_name = f"{connection_name} (via {port.display_name})"
110
111 if world.options.shuffle_worldports and not port.no_shuffle:
112 continue
78 113
79 if port.HasField("required_door"): 114 if port.HasField("required_door"):
80 reqs.merge(world.player_logic.get_door_open_reqs(port.required_door)) 115 reqs.merge(world.player_logic.get_door_open_reqs(port.required_door))
@@ -103,11 +138,21 @@ def create_regions(world: "Lingo2World"):
103 if connection.HasField("cyan_ending") and connection.cyan_ending and world.options.strict_cyan_ending: 138 if connection.HasField("cyan_ending") and connection.cyan_ending and world.options.strict_cyan_ending:
104 world.player_logic.add_solution_reqs(reqs, "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz") 139 world.player_logic.add_solution_reqs(reqs, "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz")
105 140
141 if (connection.HasField("mint_ending") and connection.mint_ending
142 and world.player_logic.custom_mint_ending is not None):
143 world.player_logic.add_solution_reqs(reqs, world.player_logic.custom_mint_ending)
144
106 reqs.simplify() 145 reqs.simplify()
107 reqs.remove_room(from_region) 146 reqs.remove_room(from_region)
108 147
109 connection = Entrance(world.player, connection_name, regions[from_region]) 148 if to_region in reqs.rooms:
110 connection.access_rule = make_location_lambda(reqs, world, regions) 149 # This connection can't ever increase access because you're required to have access to the other side in
150 # order for it to be usable. We will just not create the connection at all, in order to help GER figure out
151 # what regions are dead ends.
152 continue
153
154 connection = Lingo2Entrance(world, connection_name, regions[from_region])
155 connection.set_access_rule(reqs, regions)
111 156
112 regions[from_region].exits.append(connection) 157 regions[from_region].exits.append(connection)
113 connection.connect(regions[to_region]) 158 connection.connect(regions[to_region])
@@ -115,4 +160,126 @@ def create_regions(world: "Lingo2World"):
115 for region in reqs.get_referenced_rooms(): 160 for region in reqs.get_referenced_rooms():
116 world.multiworld.register_indirect_condition(regions[region], connection) 161 world.multiworld.register_indirect_condition(regions[region], connection)
117 162
163 if world.options.fast_travel_access != FastTravelAccess.option_vanilla:
164 for rte_map_id in world.player_logic.rte_mapping:
165 rte_map = world.static_logic.objects.maps[rte_map_id]
166 to_region = world.static_logic.get_room_region_name(rte_map.rte_room)
167
168 if to_region not in regions:
169 continue
170
171 connection_name = f"Return to {to_region}"
172 connection = Lingo2Entrance(world, connection_name, regions["Menu"])
173 regions["Menu"].exits.append(connection)
174 connection.connect(regions[to_region])
175
176 if world.options.fast_travel_access == FastTravelAccess.option_items:
177 reqs = AccessRequirements()
178 reqs.items.add(world.static_logic.get_map_rte_item_name(rte_map_id))
179
180 connection.set_access_rule(reqs, regions)
181
118 world.multiworld.regions += regions.values() 182 world.multiworld.regions += regions.values()
183
184
185def shuffle_entrances(world: "Lingo2World"):
186 er_entrances: list[Entrance] = []
187 er_exits: list[Entrance] = []
188
189 port_id_by_name: dict[str, int] = {}
190
191 shuffleable_ports = [port for port in world.static_logic.objects.ports
192 if not port.no_shuffle and world.player_logic.should_shuffle_room(port.room_id)]
193
194 if len(shuffleable_ports) % 2 == 1:
195 # We have an odd number of shuffleable ports! Pick a port from a room that has more than one, and make it a
196 # redundant warp to another port.
197 redundant_rooms = set(room.id for room in world.static_logic.objects.rooms if len(room.ports) > 1)
198 redundant_ports = [port for port in shuffleable_ports if port.room_id in redundant_rooms]
199 chosen_port = world.random.choice(redundant_ports)
200
201 shuffleable_ports.remove(chosen_port)
202
203 chosen_destination = world.random.choice(shuffleable_ports)
204
205 world.port_pairings[chosen_port.id] = chosen_destination.id
206
207 from_region_name = world.static_logic.get_room_region_name(chosen_port.room_id)
208 to_region_name = world.static_logic.get_room_region_name(chosen_destination.room_id)
209
210 from_region = world.multiworld.get_region(from_region_name, world.player)
211 to_region = world.multiworld.get_region(to_region_name, world.player)
212
213 connection = Lingo2Entrance(world, f"{from_region_name} - {chosen_port.display_name}", from_region)
214 from_region.exits.append(connection)
215 connection.connect(to_region)
216
217 if chosen_port.HasField("required_door"):
218 door_reqs = world.player_logic.get_door_open_reqs(chosen_port.required_door)
219 connection.set_access_rule(door_reqs, None)
220
221 for region in door_reqs.get_referenced_rooms():
222 world.multiworld.register_indirect_condition(world.multiworld.get_region(region, world.player),
223 connection)
224
225 for port in shuffleable_ports:
226 port_region_name = world.static_logic.get_room_region_name(port.room_id)
227 port_region = world.multiworld.get_region(port_region_name, world.player)
228
229 connection_name = f"{port_region_name} - {port.display_name}"
230 port_id_by_name[connection_name] = port.id
231
232 entrance = port_region.create_er_target(connection_name)
233 entrance.randomization_type = BaseClasses.EntranceType.TWO_WAY
234
235 er_exit = Lingo2Entrance(world, connection_name, port_region)
236 port_region.exits.append(er_exit)
237 er_exit.randomization_type = BaseClasses.EntranceType.TWO_WAY
238
239 if port.HasField("required_door"):
240 door_reqs = world.player_logic.get_door_open_reqs(port.required_door)
241 er_exit.set_access_rule(door_reqs, None)
242
243 for region in door_reqs.get_referenced_rooms():
244 world.multiworld.register_indirect_condition(world.multiworld.get_region(region, world.player),
245 er_exit)
246
247 er_entrances.append(entrance)
248 er_exits.append(er_exit)
249
250 result = randomize_entrances(world, True, {0:[0]}, False, er_entrances,
251 er_exits)
252
253 for (f, to) in result.pairings:
254 world.port_pairings[port_id_by_name[f]] = port_id_by_name[to]
255
256
257def connect_ports_from_ut(port_pairings: dict[int, int], world: "Lingo2World"):
258 for fpid, tpid in port_pairings.items():
259 from_port = world.static_logic.objects.ports[fpid]
260 to_port = world.static_logic.objects.ports[tpid]
261
262 from_region_name = world.static_logic.get_room_region_name(from_port.room_id)
263 to_region_name = world.static_logic.get_room_region_name(to_port.room_id)
264
265 from_region = world.multiworld.get_region(from_region_name, world.player)
266 to_region = world.multiworld.get_region(to_region_name, world.player)
267
268 connection = Lingo2Entrance(world, f"{from_region_name} - {from_port.display_name}", from_region)
269
270 reqs = AccessRequirements()
271 if from_port.HasField("required_door"):
272 reqs = world.player_logic.get_door_open_reqs(from_port.required_door).copy()
273
274 if world.for_tracker:
275 reqs.items.add(f"Worldport {fpid} Entered")
276
277 if not reqs.is_empty():
278 connection.set_access_rule(reqs, None)
279
280 for region in reqs.get_referenced_rooms():
281 world.multiworld.register_indirect_condition(world.multiworld.get_region(region, world.player),
282 connection)
283
284 from_region.exits.append(connection)
285 connection.connect(to_region)
diff --git a/apworld/requirements.txt b/apworld/requirements.txt index dbc395b..f466c11 100644 --- a/apworld/requirements.txt +++ b/apworld/requirements.txt
@@ -1 +1 @@
protobuf==3.20.3 protobuf==6.31.1
diff --git a/apworld/rules.py b/apworld/rules.py index c077858..70a76c0 100644 --- a/apworld/rules.py +++ b/apworld/rules.py
@@ -1,63 +1,215 @@
1from collections.abc import Callable
2from typing import TYPE_CHECKING 1from typing import TYPE_CHECKING
3 2
4from BaseClasses import CollectionState, Region 3from BaseClasses import CollectionState
5from .player_logic import AccessRequirements
6 4
7if TYPE_CHECKING: 5if TYPE_CHECKING:
8 from . import Lingo2World 6 from . import Lingo2World
9 7
10 8
11def lingo2_can_satisfy_requirements(state: CollectionState, reqs: AccessRequirements, regions: list[Region], 9class AccessRequirements:
12 world: "Lingo2World") -> bool: 10 items: set[str]
13 if not all(state.has(item, world.player) for item in reqs.items): 11 progressives: dict[str, int]
14 return False 12 rooms: set[str]
13 letters: dict[str, int]
14 cyans: bool
15 15
16 if not all(state.has(item, world.player, amount) for item, amount in reqs.progressives.items()): 16 # This is an AND of ORs.
17 return False 17 or_logic: list[list["AccessRequirements"]]
18 18
19 if not all(state.can_reach_region(region_name, world.player) for region_name in reqs.rooms): 19 # When complete_at is set, at least that many of the requirements in possibilities must be accessible. This should
20 return False 20 # only be used for doors with complete_at > 1, as or_logic is more efficient for complete_at == 1.
21 complete_at: int | None
22 possibilities: list["AccessRequirements"]
21 23
22 if not all(state.can_reach(region) for region in regions): 24 def __init__(self):
23 return False 25 self.items = set()
26 self.progressives = dict()
27 self.rooms = set()
28 self.letters = dict()
29 self.cyans = False
30 self.or_logic = list()
31 self.complete_at = None
32 self.possibilities = list()
24 33
25 for letter_key, letter_level in reqs.letters.items(): 34 def copy(self) -> "AccessRequirements":
26 if not state.has(letter_key, world.player, letter_level): 35 reqs = AccessRequirements()
36 reqs.items = self.items.copy()
37 reqs.progressives = self.progressives.copy()
38 reqs.rooms = self.rooms.copy()
39 reqs.letters = self.letters.copy()
40 reqs.cyans = self.cyans
41 reqs.or_logic = [[other_req.copy() for other_req in disjunction] for disjunction in self.or_logic]
42 reqs.complete_at = self.complete_at
43 reqs.possibilities = self.possibilities.copy()
44 return reqs
45
46 def merge(self, other: "AccessRequirements"):
47 for item in other.items:
48 self.items.add(item)
49
50 for item, amount in other.progressives.items():
51 self.progressives[item] = max(amount, self.progressives.get(item, 0))
52
53 for room in other.rooms:
54 self.rooms.add(room)
55
56 for letter, level in other.letters.items():
57 self.letters[letter] = max(self.letters.get(letter, 0), level)
58
59 self.cyans = self.cyans or other.cyans
60
61 for disjunction in other.or_logic:
62 self.or_logic.append([sub_req.copy() for sub_req in disjunction])
63
64 if other.complete_at is not None:
65 # Merging multiple requirements that use complete_at sucks, and is part of why we want to minimize use of
66 # it. If both requirements use complete_at, we will cheat by using the or_logic field, which supports
67 # conjunctions of requirements.
68 if self.complete_at is not None:
69 print("Merging requirements with complete_at > 1. This is messy and should be avoided!")
70
71 left_req = AccessRequirements()
72 left_req.complete_at = self.complete_at
73 left_req.possibilities = [sub_req.copy() for sub_req in self.possibilities]
74 self.or_logic.append([left_req])
75
76 self.complete_at = None
77 self.possibilities = list()
78
79 right_req = AccessRequirements()
80 right_req.complete_at = other.complete_at
81 right_req.possibilities = [sub_req.copy() for sub_req in other.possibilities]
82 self.or_logic.append([right_req])
83 else:
84 self.complete_at = other.complete_at
85 self.possibilities = [sub_req.copy() for sub_req in other.possibilities]
86
87 def is_empty(self) -> bool:
88 return (len(self.items) == 0 and len(self.progressives) == 0 and len(self.rooms) == 0 and len(self.letters) == 0
89 and not self.cyans and len(self.or_logic) == 0 and self.complete_at is None)
90
91 def __eq__(self, other: "AccessRequirements"):
92 return (self.items == other.items and self.progressives == other.progressives and self.rooms == other.rooms and
93 self.letters == other.letters and self.cyans == other.cyans and self.or_logic == other.or_logic and
94 self.complete_at == other.complete_at and self.possibilities == other.possibilities)
95
96 def simplify(self):
97 resimplify = False
98
99 if len(self.or_logic) > 0:
100 old_or_logic = self.or_logic
101
102 def remove_redundant(sub_reqs: "AccessRequirements"):
103 new_reqs = sub_reqs.copy()
104 new_reqs.letters = {l: v for l, v in new_reqs.letters.items() if self.letters.get(l, 0) < v}
105 if new_reqs != sub_reqs:
106 return new_reqs
107 else:
108 return sub_reqs
109
110 self.or_logic = []
111 for disjunction in old_or_logic:
112 new_disjunction = []
113 for ssr in disjunction:
114 new_ssr = remove_redundant(ssr)
115 if not new_ssr.is_empty():
116 new_disjunction.append(new_ssr)
117 else:
118 new_disjunction.clear()
119 break
120 if len(new_disjunction) == 1:
121 self.merge(new_disjunction[0])
122 resimplify = True
123 elif len(new_disjunction) > 1:
124 if all(cjr == new_disjunction[0] for cjr in new_disjunction):
125 self.merge(new_disjunction[0])
126 resimplify = True
127 else:
128 self.or_logic.append(new_disjunction)
129
130 if resimplify:
131 self.simplify()
132
133 def get_referenced_rooms(self):
134 result = set(self.rooms)
135
136 for disjunction in self.or_logic:
137 for sub_req in disjunction:
138 result = result.union(sub_req.get_referenced_rooms())
139
140 for sub_req in self.possibilities:
141 result = result.union(sub_req.get_referenced_rooms())
142
143 return result
144
145 def remove_room(self, room: str):
146 if room in self.rooms:
147 self.rooms.remove(room)
148
149 for disjunction in self.or_logic:
150 for sub_req in disjunction:
151 sub_req.remove_room(room)
152
153 for sub_req in self.possibilities:
154 sub_req.remove_room(room)
155
156 def __repr__(self):
157 parts = []
158 if len(self.items) > 0:
159 parts.append(f"items={self.items}")
160 if len(self.progressives) > 0:
161 parts.append(f"progressives={self.progressives}")
162 if len(self.rooms) > 0:
163 parts.append(f"rooms={self.rooms}")
164 if len(self.letters) > 0:
165 parts.append(f"letters={self.letters}")
166 if self.cyans:
167 parts.append(f"cyans=True")
168 if len(self.or_logic) > 0:
169 parts.append(f"or_logic={self.or_logic}")
170 if self.complete_at is not None:
171 parts.append(f"complete_at={self.complete_at}")
172 if len(self.possibilities) > 0:
173 parts.append(f"possibilities={self.possibilities}")
174 return "AccessRequirements(" + ", ".join(parts) + ")"
175
176 def check_access(self, state: CollectionState, world: "Lingo2World") -> bool:
177 if not all(state.has(item, world.player) for item in self.items):
27 return False 178 return False
28 179
29 if reqs.cyans: 180 if not all(state.has(item, world.player, amount) for item, amount in self.progressives.items()):
30 if not any(state.has(letter, world.player, amount)
31 for letter, amount in world.player_logic.double_letter_amount.items()):
32 return False 181 return False
33 182
34 if len(reqs.or_logic) > 0: 183 if not all(state.can_reach_region(region_name, world.player) for region_name in self.rooms):
35 if not all(any(lingo2_can_satisfy_requirements(state, sub_reqs, [], world) for sub_reqs in subjunction)
36 for subjunction in reqs.or_logic):
37 return False 184 return False
38 185
39 if reqs.complete_at is not None: 186 for letter_key, letter_level in self.letters.items():
40 completed = 0 187 if not state.has(letter_key, world.player, letter_level):
41 checked = 0 188 return False
42 for possibility in reqs.possibilities: 189
43 checked += 1 190 if self.cyans:
44 if lingo2_can_satisfy_requirements(state, possibility, [], world): 191 if not any(state.has(letter, world.player, amount)
45 completed += 1 192 for letter, amount in world.player_logic.double_letter_amount.items()):
46 if completed >= reqs.complete_at: 193 return False
47 break 194
48 elif len(reqs.possibilities) - checked + completed < reqs.complete_at: 195 if len(self.or_logic) > 0:
49 # There aren't enough remaining possibilities for the check to pass. 196 if not all(any(sub_reqs.check_access(state, world) for sub_reqs in subjunction)
197 for subjunction in self.or_logic):
50 return False 198 return False
51 if completed < reqs.complete_at:
52 return False
53 199
54 return True 200 if self.complete_at is not None:
201 completed = 0
202 checked = 0
203 for possibility in self.possibilities:
204 checked += 1
205 if possibility.check_access(state, world):
206 completed += 1
207 if completed >= self.complete_at:
208 break
209 elif len(self.possibilities) - checked + completed < self.complete_at:
210 # There aren't enough remaining possibilities for the check to pass.
211 return False
212 if completed < self.complete_at:
213 return False
55 214
56def make_location_lambda(reqs: AccessRequirements, world: "Lingo2World", 215 return True
57 regions: dict[str, Region]) -> Callable[[CollectionState], bool]:
58 # Replace required rooms with regions for the top level requirement, which saves looking up the regions during rule
59 # checking.
60 required_regions = [regions[room_name] for room_name in reqs.rooms]
61 new_reqs = reqs.copy()
62 new_reqs.rooms.clear()
63 return lambda state: lingo2_can_satisfy_requirements(state, new_reqs, required_regions, world)
diff --git a/apworld/static_logic.py b/apworld/static_logic.py index e4d7d49..48ad78e 100644 --- a/apworld/static_logic.py +++ b/apworld/static_logic.py
@@ -2,6 +2,7 @@ from .generated import data_pb2 as data_pb2
2from .items import SYMBOL_ITEMS, ANTI_COLLECTABLE_TRAPS 2from .items import SYMBOL_ITEMS, ANTI_COLLECTABLE_TRAPS
3import pkgutil 3import pkgutil
4 4
5
5class Lingo2StaticLogic: 6class Lingo2StaticLogic:
6 item_id_to_name: dict[int, str] 7 item_id_to_name: dict[int, str]
7 location_id_to_name: dict[int, str] 8 location_id_to_name: dict[int, str]
@@ -14,6 +15,11 @@ class Lingo2StaticLogic:
14 15
15 letter_weights: dict[str, int] 16 letter_weights: dict[str, int]
16 17
18 door_id_by_ap_id: dict[int, int]
19 port_id_by_ap_id: dict[int, int]
20
21 map_id_by_name: dict[str, int]
22
17 def __init__(self): 23 def __init__(self):
18 self.item_id_to_name = {} 24 self.item_id_to_name = {}
19 self.location_id_to_name = {} 25 self.location_id_to_name = {}
@@ -67,19 +73,32 @@ class Lingo2StaticLogic:
67 self.location_name_groups.setdefault("Keyholders", []).append(location_name) 73 self.location_name_groups.setdefault("Keyholders", []).append(location_name)
68 74
69 self.item_id_to_name[self.objects.special_ids["A Job Well Done"]] = "A Job Well Done" 75 self.item_id_to_name[self.objects.special_ids["A Job Well Done"]] = "A Job Well Done"
76 self.item_id_to_name[self.objects.special_ids["Numbers"]] = "Numbers"
70 77
78 self.item_name_groups["Symbols"] = []
71 for symbol_name in SYMBOL_ITEMS.values(): 79 for symbol_name in SYMBOL_ITEMS.values():
72 self.item_id_to_name[self.objects.special_ids[symbol_name]] = symbol_name 80 self.item_id_to_name[self.objects.special_ids[symbol_name]] = symbol_name
81 self.item_name_groups["Symbols"].append(symbol_name)
73 82
74 for trap_name in ANTI_COLLECTABLE_TRAPS: 83 for trap_name in ANTI_COLLECTABLE_TRAPS:
75 self.item_id_to_name[self.objects.special_ids[trap_name]] = trap_name 84 self.item_id_to_name[self.objects.special_ids[trap_name]] = trap_name
76 85
86 for game_map in self.objects.maps:
87 if game_map.HasField("rte_room"):
88 self.item_id_to_name[game_map.rte_ap_id] = self.get_map_rte_item_name(game_map.id)
89
77 self.item_name_to_id = {name: ap_id for ap_id, name in self.item_id_to_name.items()} 90 self.item_name_to_id = {name: ap_id for ap_id, name in self.item_id_to_name.items()}
78 self.location_name_to_id = {name: ap_id for ap_id, name in self.location_id_to_name.items()} 91 self.location_name_to_id = {name: ap_id for ap_id, name in self.location_id_to_name.items()}
79 92
80 for panel in self.objects.panels: 93 for panel in self.objects.panels:
81 for letter in panel.answer.upper(): 94 for letter in panel.answer.upper():
82 self.letter_weights[letter] = self.letter_weights.get(letter, 0) + 1 95 if letter.isalpha():
96 self.letter_weights[letter] = self.letter_weights.get(letter, 0) + 1
97
98 self.door_id_by_ap_id = {door.ap_id: door.id for door in self.objects.doors if door.HasField("ap_id")}
99 self.port_id_by_ap_id = {port.ap_id: port.id for port in self.objects.ports if port.HasField("ap_id")}
100
101 self.map_id_by_name = {game_map.name: game_map.id for game_map in self.objects.maps}
83 102
84 def get_door_item_name(self, door: data_pb2.Door) -> str: 103 def get_door_item_name(self, door: data_pb2.Door) -> str:
85 return f"{self.get_map_object_map_name(door)} - {door.name}" 104 return f"{self.get_map_object_map_name(door)} - {door.name}"
@@ -104,7 +123,7 @@ class Lingo2StaticLogic:
104 if door.type != data_pb2.DoorType.STANDARD: 123 if door.type != data_pb2.DoorType.STANDARD:
105 return None 124 return None
106 125
107 if len(door.keyholders) > 0 or len(door.endings) > 0 or door.HasField("complete_at"): 126 if len(door.keyholders) > 0 or door.white_ending or door.HasField("complete_at"):
108 return None 127 return None
109 128
110 if len(door.panels) > 4: 129 if len(door.panels) > 4:
@@ -165,5 +184,13 @@ class Lingo2StaticLogic:
165 else: 184 else:
166 return game_map.display_name 185 return game_map.display_name
167 186
168 def get_data_version(self) -> int: 187 def get_room_object_map_id(self, obj) -> int:
169 return self.objects.version 188 return self.objects.rooms[obj.room_id].map_id
189
190 def get_map_rte_item_name(self, map_id: int) -> str:
191 game_map = self.objects.maps[map_id]
192 return f"Return to {game_map.display_name}"
193
194 def get_data_version(self) -> list[int]:
195 version = self.objects.version
196 return [version.major, version.minor, version.patch]
diff --git a/apworld/tracker.py b/apworld/tracker.py new file mode 100644 index 0000000..3e1cafb --- /dev/null +++ b/apworld/tracker.py
@@ -0,0 +1,151 @@
1from typing import TYPE_CHECKING, Iterator
2
3from BaseClasses import MultiWorld, CollectionState, ItemClassification, Region, Entrance
4from NetUtils import NetworkItem
5from . import Lingo2World, Lingo2Item
6from .regions import connect_ports_from_ut
7from .options import Lingo2Options, ShuffleLetters
8
9if TYPE_CHECKING:
10 from .context import Lingo2Manager
11
12PLAYER_NUM = 1
13
14
15class Tracker:
16 manager: "Lingo2Manager"
17
18 multiworld: MultiWorld
19 world: Lingo2World
20
21 collected_items: dict[int, int]
22 checked_locations: set[int]
23 accessible_locations: set[int]
24 accessible_worldports: set[int]
25 goal_accessible: bool
26
27 state: CollectionState
28
29 def __init__(self, manager: "Lingo2Manager"):
30 self.manager = manager
31 self.collected_items = {}
32 self.checked_locations = set()
33 self.accessible_locations = set()
34 self.accessible_worldports = set()
35 self.goal_accessible = False
36
37 def setup_slot(self, slot_data):
38 Lingo2World.for_tracker = True
39
40 self.multiworld = MultiWorld(players=PLAYER_NUM)
41 self.world = Lingo2World(self.multiworld, PLAYER_NUM)
42 self.multiworld.worlds[1] = self.world
43 self.world.options = Lingo2Options(**{k: t(slot_data.get(k, t.default))
44 for k, t in Lingo2Options.type_hints.items()})
45
46 self.world.generate_early()
47
48 self.world.player_logic.rte_mapping = [self.world.static_logic.map_id_by_name[map_name]
49 for map_name in slot_data.get("rte", [])]
50
51 self.world.create_regions()
52
53 if self.world.options.shuffle_worldports:
54 port_pairings = {
55 self.world.static_logic.port_id_by_ap_id[int(fp)]: self.world.static_logic.port_id_by_ap_id[int(tp)]
56 for fp, tp in slot_data["port_pairings"].items()
57 }
58 connect_ports_from_ut(port_pairings, self.world)
59
60 self.refresh_state()
61
62 def set_checked_locations(self, checked_locations: set[int]):
63 self.checked_locations = checked_locations.copy()
64
65 def set_collected_items(self, network_items: list[NetworkItem]):
66 self.collected_items = {}
67
68 for item in network_items:
69 self.collected_items[item.item] = self.collected_items.get(item.item, 0) + 1
70
71 self.refresh_state()
72
73 def refresh_state(self):
74 self.state = CollectionState(self.multiworld)
75
76 for item_id, item_amount in self.collected_items.items():
77 for i in range(item_amount):
78 self.state.collect(Lingo2Item(Lingo2World.static_logic.item_id_to_name.get(item_id),
79 ItemClassification.progression, item_id, PLAYER_NUM), prevent_sweep=True)
80
81 for k, v in self.manager.keyboard.items():
82 # Unless all level 1 letters are pre-unlocked, H1 I1 N1 and T1 act differently between the generator and
83 # game. The generator considers them to be unlocked, which means they are not included in logic
84 # requirements, and only one item/event is needed to unlock their level 2 forms. The game considers them to
85 # be vanilla, which means you still have to pick them up in the Starting Room in order for them to appear on
86 # your keyboard. This also means that whether or not you have the level 1 forms should be synced to the
87 # multiworld. The tracker specifically should collect one fewer item for these letters in this scenario.
88 tv = v
89 if k in "hint" and self.world.options.shuffle_letters in [ShuffleLetters.option_vanilla,
90 ShuffleLetters.option_progressive]:
91 tv = max(0, v - 1)
92
93 if tv > 0:
94 for i in range(tv):
95 self.state.collect(Lingo2Item(k.upper(), ItemClassification.progression, None, PLAYER_NUM),
96 prevent_sweep=True)
97
98 for port_id in self.manager.worldports:
99 self.state.collect(Lingo2Item(f"Worldport {port_id} Entered", ItemClassification.progression, None,
100 PLAYER_NUM), prevent_sweep=True)
101
102 self.state.sweep_for_advancements()
103 self.state.update_reachable_regions(PLAYER_NUM)
104
105 self.accessible_locations = set()
106 self.accessible_worldports = set()
107 self.goal_accessible = False
108
109 for region in self.state.reachable_regions[PLAYER_NUM]:
110 for location in region.locations:
111 if location.access_rule(self.state):
112 if location.address is not None:
113 if location.address not in self.checked_locations:
114 self.accessible_locations.add(location.address)
115 elif hasattr(location, "port_id"):
116 if location.port_id not in self.manager.worldports:
117 self.accessible_worldports.add(location.port_id)
118 elif hasattr(location, "goal") and location.goal:
119 if not self.manager.goaled:
120 self.goal_accessible = True
121
122 def get_path_to_location(self, location_id: int) -> list[str] | None:
123 location_name = self.world.location_id_to_name.get(location_id)
124 location = self.multiworld.get_location(location_name, PLAYER_NUM)
125 return self.get_logical_path(location.parent_region)
126
127 def get_path_to_port(self, port_id: int) -> list[str] | None:
128 port = self.world.static_logic.objects.ports[port_id]
129 region_name = self.world.static_logic.get_room_region_name(port.room_id)
130 region = self.multiworld.get_region(region_name, PLAYER_NUM)
131 return self.get_logical_path(region)
132
133 def get_path_to_goal(self):
134 room_id = self.world.player_logic.goal_room_id
135 region_name = self.world.static_logic.get_room_region_name(room_id)
136 region = self.multiworld.get_region(region_name, PLAYER_NUM)
137 return self.get_logical_path(region)
138
139 def get_logical_path(self, region: Region) -> list[str] | None:
140 if region not in self.state.path:
141 return None
142
143 def flist_to_iter(path_value) -> Iterator[str]:
144 while path_value:
145 region_or_entrance, path_value = path_value
146 yield region_or_entrance
147
148 reversed_path = self.state.path.get(region)
149 flat_path = reversed(list(map(str, flist_to_iter(reversed_path))))
150
151 return list(flat_path)[1::2]
diff --git a/apworld/version.py b/apworld/version.py deleted file mode 100644 index a0becae..0000000 --- a/apworld/version.py +++ /dev/null
@@ -1 +0,0 @@
1APWORLD_VERSION = 5