about summary refs log tree commit diff stats
path: root/apworld
diff options
context:
space:
mode:
Diffstat (limited to 'apworld')
-rw-r--r--apworld/README.md48
-rw-r--r--apworld/__init__.py111
-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.gd302
-rw-r--r--apworld/client/keyHolder.gd38
-rw-r--r--apworld/client/keyHolderChecker.gd24
-rw-r--r--apworld/client/keyHolderResetterListener.gd8
-rw-r--r--apworld/client/keyboard.gd231
-rw-r--r--apworld/client/locationListener.gd20
-rw-r--r--apworld/client/main.gd308
-rw-r--r--apworld/client/manager.gd717
-rw-r--r--apworld/client/maps/control_center.gd85
-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_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.gd181
-rw-r--r--apworld/client/rainbowText.gd10
-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/docs/en_Lingo_2.md4
-rw-r--r--apworld/items.py26
-rw-r--r--apworld/locations.py3
-rw-r--r--apworld/logo.pngbin0 -> 9429 bytes
-rw-r--r--apworld/options.py128
-rw-r--r--apworld/player_logic.py315
-rw-r--r--apworld/regions.py185
-rw-r--r--apworld/requirements.txt2
-rw-r--r--apworld/rules.py38
-rw-r--r--apworld/static_logic.py33
-rw-r--r--apworld/tracker.py146
71 files changed, 6548 insertions, 130 deletions
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 d05bd38..3d2f075 100644 --- a/apworld/__init__.py +++ b/apworld/__init__.py
@@ -1,18 +1,40 @@
1""" 1"""
2Archipelago init file for Lingo 2 2Archipelago init file for Lingo 2
3""" 3"""
4from BaseClasses import ItemClassification, Item 4from typing import ClassVar
5
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 10from .items import Lingo2Item, ANTI_COLLECTABLE_TRAPS
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
15from worlds.LauncherComponents import Component, Type, components, launch as launch_component, icon_paths
11 16
12 17
13class Lingo2WebWorld(WebWorld): 18class Lingo2WebWorld(WebWorld):
14 rich_text_options_doc = True 19 rich_text_options_doc = True
15 theme = "grass" 20 theme = "grass"
21 tutorials = [Tutorial(
22 "Multiworld Setup Guide",
23 "A guide to playing Lingo 2 with Archipelago.",
24 "English",
25 "en_Lingo_2.md",
26 "setup/en",
27 ["hatkirby"]
28 )]
29
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
16 38
17 39
18class Lingo2World(World): 40class Lingo2World(World):
@@ -24,6 +46,9 @@ class Lingo2World(World):
24 game = "Lingo 2" 46 game = "Lingo 2"
25 web = Lingo2WebWorld() 47 web = Lingo2WebWorld()
26 48
49 settings: ClassVar[Lingo2Settings]
50 settings_key = "lingo2_options"
51
27 topology_present = True 52 topology_present = True
28 53
29 options_dataclass = Lingo2Options 54 options_dataclass = Lingo2Options
@@ -35,17 +60,35 @@ class Lingo2World(World):
35 item_name_groups = static_logic.item_name_groups 60 item_name_groups = static_logic.item_name_groups
36 location_name_groups = static_logic.location_name_groups 61 location_name_groups = static_logic.location_name_groups
37 62
63 for_tracker: ClassVar[bool] = False
64
38 player_logic: Lingo2PlayerLogic 65 player_logic: Lingo2PlayerLogic
39 66
67 port_pairings: dict[int, int]
68
40 def generate_early(self): 69 def generate_early(self):
41 self.player_logic = Lingo2PlayerLogic(self) 70 self.player_logic = Lingo2PlayerLogic(self)
71 self.port_pairings = {}
42 72
43 def create_regions(self): 73 def create_regions(self):
44 create_regions(self) 74 create_regions(self)
45 75
46 from Utils import visualize_regions 76 def connect_entrances(self):
77 if self.options.shuffle_worldports:
78 if hasattr(self.multiworld, "re_gen_passthrough") and "Lingo 2" in self.multiworld.re_gen_passthrough:
79 slot_value = self.multiworld.re_gen_passthrough["Lingo 2"]["port_pairings"]
80 self.port_pairings = {
81 self.static_logic.port_id_by_ap_id[int(fp)]: self.static_logic.port_id_by_ap_id[int(tp)]
82 for fp, tp in slot_value.items()
83 }
84
85 connect_ports_from_ut(self.port_pairings, self)
86 else:
87 shuffle_entrances(self)
88
89 #from Utils import visualize_regions
47 90
48 visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml") 91 #visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml")
49 92
50 def create_items(self): 93 def create_items(self):
51 pool = [self.create_item(name) for name in self.player_logic.real_items] 94 pool = [self.create_item(name) for name in self.player_logic.real_items]
@@ -53,13 +96,33 @@ class Lingo2World(World):
53 total_locations = sum(len(locs) for locs in self.player_logic.locations_by_room.values()) 96 total_locations = sum(len(locs) for locs in self.player_logic.locations_by_room.values())
54 97
55 item_difference = total_locations - len(pool) 98 item_difference = total_locations - len(pool)
99
100 if self.options.trap_percentage > 0:
101 num_traps = int(item_difference * self.options.trap_percentage / 100)
102 item_difference = item_difference - num_traps
103
104 trap_names = []
105 trap_weights = []
106 for letter_name, weight in self.static_logic.letter_weights.items():
107 trap_names.append(f"Anti {letter_name}")
108 trap_weights.append(weight)
109
110 bad_letters = self.random.choices(trap_names, weights=trap_weights, k=num_traps)
111 pool += [self.create_item(trap_name) for trap_name in bad_letters]
112
56 for i in range(0, item_difference): 113 for i in range(0, item_difference):
57 pool.append(self.create_item(self.get_filler_item_name())) 114 pool.append(self.create_item(self.get_filler_item_name()))
58 115
116 if not any(ItemClassification.progression in item.classification for item in pool):
117 raise OptionError(f"Lingo 2 player {self.player} has no progression items. Please enable at least one "
118 f"option that would add progression gating to your world, such as Shuffle Doors or "
119 f"Shuffle Letters.")
120
59 self.multiworld.itempool += pool 121 self.multiworld.itempool += pool
60 122
61 def create_item(self, name: str) -> Item: 123 def create_item(self, name: str) -> Item:
62 return Lingo2Item(name, ItemClassification.filler if name == self.get_filler_item_name() else 124 return Lingo2Item(name, ItemClassification.filler if name == self.get_filler_item_name() else
125 ItemClassification.trap if name in ANTI_COLLECTABLE_TRAPS else
63 ItemClassification.progression, 126 ItemClassification.progression,
64 self.item_name_to_id.get(name), self.player) 127 self.item_name_to_id.get(name), self.player)
65 128
@@ -70,18 +133,54 @@ class Lingo2World(World):
70 slot_options = [ 133 slot_options = [
71 "cyan_door_behavior", 134 "cyan_door_behavior",
72 "daedalus_roof_access", 135 "daedalus_roof_access",
136 "enable_gift_maps",
137 "enable_icarus",
138 "endings_requirement",
73 "keyholder_sanity", 139 "keyholder_sanity",
140 "masteries_requirement",
74 "shuffle_control_center_colors", 141 "shuffle_control_center_colors",
75 "shuffle_doors", 142 "shuffle_doors",
143 "shuffle_gallery_paintings",
76 "shuffle_letters", 144 "shuffle_letters",
145 "shuffle_symbols",
146 "shuffle_worldports",
147 "strict_cyan_ending",
148 "strict_purple_ending",
77 "victory_condition", 149 "victory_condition",
78 ] 150 ]
79 151
80 slot_data = { 152 slot_data: dict[str, object] = {
81 **self.options.as_dict(*slot_options), 153 **self.options.as_dict(*slot_options),
154 "version": self.static_logic.get_data_version(),
82 } 155 }
83 156
157 if self.options.shuffle_worldports:
158 def get_port_ap_id(port_id):
159 return self.static_logic.objects.ports[port_id].ap_id
160
161 slot_data["port_pairings"] = {get_port_ap_id(from_id): get_port_ap_id(to_id)
162 for from_id, to_id in self.port_pairings.items()}
163
84 return slot_data 164 return slot_data
85 165
86 def get_filler_item_name(self) -> str: 166 def get_filler_item_name(self) -> str:
87 return "A Job Well Done" 167 return "A Job Well Done"
168
169 # for the universal tracker, doesn't get called in standard gen
170 # docs: https://github.com/FarisTheAncient/Archipelago/blob/tracker/worlds/tracker/docs/re-gen-passthrough.md
171 @staticmethod
172 def interpret_slot_data(slot_data: dict[str, object]) -> dict[str, object]:
173 # returning slot_data so it regens, giving it back in multiworld.re_gen_passthrough
174 # we are using re_gen_passthrough over modifying the world here due to complexities with ER
175 return slot_data
176
177
178def launch_client(*args):
179 from .context import client_main
180 launch_component(client_main, name="Lingo2Client", args=args)
181
182
183icon_paths["lingo2_ico"] = f"ap:{__name__}/logo.png"
184component = Component("Lingo 2 Client", component_type=Type.CLIENT, func=launch_client,
185 description="Open Lingo 2.", supports_uri=True, game_name="Lingo 2", icon="lingo2_ico")
186components.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..d7e3136 --- /dev/null +++ b/apworld/client/gamedata.gd
@@ -0,0 +1,302 @@
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 = {}
19
20var kSYMBOL_ITEMS
21
22
23func _init(proto_script):
24 SCRIPT_proto = proto_script
25
26 kSYMBOL_ITEMS = {
27 SCRIPT_proto.PuzzleSymbol.SUN: "Sun Symbol",
28 SCRIPT_proto.PuzzleSymbol.SPARKLES: "Sparkles Symbol",
29 SCRIPT_proto.PuzzleSymbol.ZERO: "Zero Symbol",
30 SCRIPT_proto.PuzzleSymbol.EXAMPLE: "Example Symbol",
31 SCRIPT_proto.PuzzleSymbol.BOXES: "Boxes Symbol",
32 SCRIPT_proto.PuzzleSymbol.PLANET: "Planet Symbol",
33 SCRIPT_proto.PuzzleSymbol.PYRAMID: "Pyramid Symbol",
34 SCRIPT_proto.PuzzleSymbol.CROSS: "Cross Symbol",
35 SCRIPT_proto.PuzzleSymbol.SWEET: "Sweet Symbol",
36 SCRIPT_proto.PuzzleSymbol.GENDER: "Gender Symbol",
37 SCRIPT_proto.PuzzleSymbol.AGE: "Age Symbol",
38 SCRIPT_proto.PuzzleSymbol.SOUND: "Sound Symbol",
39 SCRIPT_proto.PuzzleSymbol.ANAGRAM: "Anagram Symbol",
40 SCRIPT_proto.PuzzleSymbol.JOB: "Job Symbol",
41 SCRIPT_proto.PuzzleSymbol.STARS: "Stars Symbol",
42 SCRIPT_proto.PuzzleSymbol.NULL: "Null Symbol",
43 SCRIPT_proto.PuzzleSymbol.EVAL: "Eval Symbol",
44 SCRIPT_proto.PuzzleSymbol.LINGO: "Lingo Symbol",
45 SCRIPT_proto.PuzzleSymbol.QUESTION: "Question Symbol",
46 }
47
48
49func load(data_bytes):
50 objects = SCRIPT_proto.AllObjects.new()
51
52 var result_code = objects.from_bytes(data_bytes)
53 if result_code != SCRIPT_proto.PB_ERR.NO_ERRORS:
54 print("Could not load generated data: %d" % result_code)
55 return
56
57 for map in objects.get_maps():
58 map_id_by_name[map.get_name()] = map.get_id()
59
60 for door in objects.get_doors():
61 var map = objects.get_maps()[door.get_map_id()]
62
63 if not map.get_name() in door_id_by_map_node_path:
64 door_id_by_map_node_path[map.get_name()] = {}
65
66 var map_data = door_id_by_map_node_path[map.get_name()]
67 for receiver in door.get_receivers():
68 map_data[receiver] = door.get_id()
69
70 for painting_id in door.get_move_paintings():
71 var painting = objects.get_paintings()[painting_id]
72 map_data[painting.get_path()] = door.get_id()
73
74 if door.has_ap_id():
75 door_id_by_ap_id[door.get_ap_id()] = door.get_id()
76
77 if (
78 door.get_type() == SCRIPT_proto.DoorType.STANDARD
79 or door.get_type() == SCRIPT_proto.DoorType.LOCATION_ONLY
80 or door.get_type() == SCRIPT_proto.DoorType.GRAVESTONE
81 ):
82 location_name_by_id[door.get_ap_id()] = _get_door_location_name(door)
83
84 for painting in objects.get_paintings():
85 var room = objects.get_rooms()[painting.get_room_id()]
86 var map = objects.get_maps()[room.get_map_id()]
87
88 if not map.get_name() in painting_id_by_map_node_path:
89 painting_id_by_map_node_path[map.get_name()] = {}
90
91 var _map_data = painting_id_by_map_node_path[map.get_name()]
92
93 for port in objects.get_ports():
94 var room = objects.get_rooms()[port.get_room_id()]
95 var map = objects.get_maps()[room.get_map_id()]
96
97 if not map.get_name() in port_id_by_map_node_path:
98 port_id_by_map_node_path[map.get_name()] = {}
99
100 var map_data = port_id_by_map_node_path[map.get_name()]
101 map_data[port.get_path()] = port.get_id()
102
103 if port.has_ap_id():
104 port_id_by_ap_id[port.get_ap_id()] = port.get_id()
105
106 for progressive in objects.get_progressives():
107 progressive_id_by_ap_id[progressive.get_ap_id()] = progressive.get_id()
108
109 for letter in objects.get_letters():
110 letter_id_by_ap_id[letter.get_ap_id()] = letter.get_id()
111 location_name_by_id[letter.get_ap_id()] = _get_letter_location_name(letter)
112
113 for mastery in objects.get_masteries():
114 location_name_by_id[mastery.get_ap_id()] = _get_mastery_location_name(mastery)
115
116 for ending in objects.get_endings():
117 var location_name = _get_ending_location_name(ending)
118 location_name_by_id[ending.get_ap_id()] = location_name
119 ending_display_name_by_name[ending.get_name()] = location_name
120
121 for keyholder in objects.get_keyholders():
122 if keyholder.has_key():
123 location_name_by_id[keyholder.get_ap_id()] = _get_keyholder_location_name(keyholder)
124
125 for panel in objects.get_panels():
126 var room = objects.get_rooms()[panel.get_room_id()]
127 var map = objects.get_maps()[room.get_map_id()]
128
129 if not map.get_name() in panel_id_by_map_node_path:
130 panel_id_by_map_node_path[map.get_name()] = {}
131
132 var map_data = panel_id_by_map_node_path[map.get_name()]
133 map_data[panel.get_path()] = panel.get_id()
134
135 for symbol_name in kSYMBOL_ITEMS.values():
136 symbol_item_ids.append(objects.get_special_ids()[symbol_name])
137
138 for special_name in objects.get_special_ids().keys():
139 if special_name.begins_with("Anti "):
140 anti_trap_ids[objects.get_special_ids()[special_name]] = (
141 special_name.substr(5).to_lower()
142 )
143
144
145func get_door_for_map_node_path(map_name, node_path):
146 if not door_id_by_map_node_path.has(map_name):
147 return null
148
149 var map_data = door_id_by_map_node_path[map_name]
150 return map_data.get(node_path, null)
151
152
153func get_panel_for_map_node_path(map_name, node_path):
154 if not panel_id_by_map_node_path.has(map_name):
155 return null
156
157 var map_data = panel_id_by_map_node_path[map_name]
158 return map_data.get(node_path, null)
159
160
161func get_port_for_map_node_path(map_name, node_path):
162 if not port_id_by_map_node_path.has(map_name):
163 return null
164
165 var map_data = port_id_by_map_node_path[map_name]
166 return map_data.get(node_path, null)
167
168
169func get_door_ap_id(door_id):
170 var door = objects.get_doors()[door_id]
171 if door.has_ap_id():
172 return door.get_ap_id()
173 else:
174 return null
175
176
177func get_door_map_name(door_id):
178 var door = objects.get_doors()[door_id]
179 var map = objects.get_maps()[door.get_map_id()]
180 return map.get_name()
181
182
183func get_door_receivers(door_id):
184 var door = objects.get_doors()[door_id]
185 return door.get_receivers()
186
187
188func get_worldport_display_name(port_id):
189 var port = objects.get_ports()[port_id]
190 return "%s - %s" % [_get_room_object_map_name(port), port.get_display_name()]
191
192
193func _get_map_object_map_name(obj):
194 return objects.get_maps()[obj.get_map_id()].get_display_name()
195
196
197func _get_room_object_map_name(obj):
198 return _get_map_object_map_name(objects.get_rooms()[obj.get_room_id()])
199
200
201func _get_room_object_location_prefix(obj):
202 var room = objects.get_rooms()[obj.get_room_id()]
203 var game_map = objects.get_maps()[room.get_map_id()]
204
205 if room.has_panel_display_name():
206 return "%s (%s)" % [game_map.get_display_name(), room.get_panel_display_name()]
207 else:
208 return game_map.get_display_name()
209
210
211func _get_door_location_name(door):
212 var map_part = _get_room_object_location_prefix(door)
213
214 if door.has_location_name():
215 return "%s - %s" % [map_part, door.get_location_name()]
216
217 var generated_location_name = _get_generated_door_location_name(door)
218 if generated_location_name != null:
219 return generated_location_name
220
221 return "%s - %s" % [map_part, door.get_name()]
222
223
224func _get_generated_door_location_name(door):
225 if door.get_type() != SCRIPT_proto.DoorType.STANDARD:
226 return null
227
228 if (
229 door.get_keyholders().size() > 0
230 or (door.has_white_ending() and door.get_white_ending())
231 or door.has_complete_at()
232 ):
233 return null
234
235 if door.get_panels().size() > 4:
236 return null
237
238 var map_areas = []
239 for panel_id in door.get_panels():
240 var panel = objects.get_panels()[panel_id.get_panel()]
241 var panel_room = objects.get_rooms()[panel.get_room_id()]
242 # It's okay if panel_display_name is not present because then it's coalesced with other unnamed areas.
243 var panel_display_name = ""
244 if panel_room.has_panel_display_name():
245 panel_display_name = panel_room.get_panel_display_name()
246 if not map_areas.has(panel_display_name):
247 map_areas.append(panel_display_name)
248
249 if map_areas.size() > 1:
250 return null
251
252 var game_map = objects.get_maps()[door.get_map_id()]
253 var map_area = map_areas[0]
254 var map_part
255 if map_area == "":
256 map_part = game_map.get_display_name()
257 else:
258 map_part = "%s (%s)" % [game_map.get_display_name(), map_area]
259
260 var panel_names = []
261 for panel_id in door.get_panels():
262 var panel_data = objects.get_panels()[panel_id.get_panel()]
263 var panel_name
264 if panel_data.has_display_name():
265 panel_name = panel_data.get_display_name()
266 else:
267 panel_name = panel_data.get_name()
268
269 var location_part
270 if panel_id.has_answer():
271 location_part = "%s/%s" % [panel_name, panel_id.get_answer().to_upper()]
272 else:
273 location_part = panel_name
274
275 panel_names.append(location_part)
276
277 panel_names.sort()
278
279 return map_part + " - " + ", ".join(panel_names)
280
281
282func _get_letter_location_name(letter):
283 var letter_level = 2 if (letter.has_level2() and letter.get_level2()) else 1
284 var letter_name = "%s%d" % [letter.get_key().to_upper(), letter_level]
285 return "%s - %s" % [_get_room_object_map_name(letter), letter_name]
286
287
288func _get_mastery_location_name(mastery):
289 return "%s - Mastery" % _get_room_object_map_name(mastery)
290
291
292func _get_ending_location_name(ending):
293 return (
294 "%s - %s Ending" % [_get_room_object_map_name(ending), ending.get_name().to_pascal_case()]
295 )
296
297
298func _get_keyholder_location_name(keyholder):
299 return (
300 "%s - %s Keyholder"
301 % [_get_room_object_location_prefix(keyholder), keyholder.get_key().to_upper()]
302 )
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..d5300f3 --- /dev/null +++ b/apworld/client/keyHolderResetterListener.gd
@@ -0,0 +1,8 @@
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")
diff --git a/apworld/client/keyboard.gd b/apworld/client/keyboard.gd new file mode 100644 index 0000000..a59c4d0 --- /dev/null +++ b/apworld/client/keyboard.gd
@@ -0,0 +1,231 @@
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 if letters_in_keyholders.is_empty() and letters_blocked.is_empty():
195 return false
196
197 var cleared_anything = not letters_in_keyholders.is_empty() or not letters_blocked.is_empty()
198
199 if keyholder_state.has(global.map):
200 for path in keyholder_state[global.map]:
201 get_tree().get_root().get_node("scene").get_node(path).setFromAp(
202 keyholder_state[global.map][path], 0
203 )
204
205 keyholder_state.clear()
206 letters_in_keyholders.clear()
207 letters_blocked.clear()
208
209 update_unlocks()
210 save()
211
212 return cleared_anything
213
214
215func remote_keyboard_updated(updates):
216 var reverse = {}
217 var should_update = false
218
219 for k in updates:
220 if not letters_saved.has(k) or updates[k] > letters_saved[k]:
221 letters_saved[k] = updates[k]
222 should_update = true
223 elif updates[k] < letters_saved[k]:
224 reverse[k] = letters_saved[k]
225
226 if should_update:
227 update_unlocks()
228
229 if not reverse.is_empty():
230 var ap = global.get_node("Archipelago")
231 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..c90d6e7 --- /dev/null +++ b/apworld/client/main.gd
@@ -0,0 +1,308 @@
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("saver.gd"))
52 installScriptExtension(runtime.load_script("teleport.gd"))
53 installScriptExtension(runtime.load_script("teleportListener.gd"))
54 installScriptExtension(runtime.load_script("unlockReaderListener.gd"))
55 installScriptExtension(runtime.load_script("visibilityListener.gd"))
56 installScriptExtension(runtime.load_script("worldport.gd"))
57 installScriptExtension(runtime.load_script("worldportListener.gd"))
58
59 var proto_script = runtime.load_script("../generated/proto.gd")
60 var gamedata_script = runtime.load_script("gamedata.gd")
61 var gamedata_instance = gamedata_script.new(proto_script)
62 gamedata_instance.load(runtime.read_path("../generated/data.binpb"))
63 gamedata_instance.name = "Gamedata"
64 global.add_child(gamedata_instance)
65
66 var messages_script = runtime.load_script("messages.gd")
67 var messages_instance = messages_script.new()
68 messages_instance.name = "Messages"
69 messages_instance.SCRIPT_rainbowText = runtime.load_script("rainbowText.gd")
70 global.add_child(messages_instance)
71
72 var effects_script = runtime.load_script("effects.gd")
73 var effects_instance = effects_script.new()
74 effects_instance.name = "Effects"
75 global.add_child(effects_instance)
76
77 var textclient_script = runtime.load_script("textclient.gd")
78 var textclient_instance = textclient_script.new()
79 textclient_instance.name = "Textclient"
80 global.add_child(textclient_instance)
81
82 var compass_overlay_script = runtime.load_script("compass_overlay.gd")
83 var compass_overlay_instance = compass_overlay_script.new()
84 compass_overlay_instance.name = "Compass"
85 compass_overlay_instance.SCRIPT_compass = runtime.load_script("compass.gd")
86 global.add_child(compass_overlay_instance)
87
88 unlocks.data["advanced_mastery"] = ""
89 unlocks.data["charismatic_mastery"] = ""
90 unlocks.data["crystalline_mastery"] = ""
91 unlocks.data["fuzzy_mastery"] = ""
92 unlocks.data["icarus_mastery"] = ""
93 unlocks.data["stellar_mastery"] = ""
94
95 var ap = global.get_node("Archipelago")
96 var gamedata = global.get_node("Gamedata")
97 ap.ap_connected.connect(connectionSuccessful)
98 ap.could_not_connect.connect(connectionUnsuccessful)
99 ap.connect_status.connect(connectionStatus)
100
101 # Populate textboxes with AP settings.
102 get_node("../Panel/server_box").text = ap.ap_server
103 get_node("../Panel/player_box").text = ap.ap_user
104 get_node("../Panel/password_box").text = ap.ap_pass
105
106 var history_box = get_node("../Panel/connection_history")
107 if ap.connection_history.is_empty():
108 history_box.disabled = true
109 else:
110 history_box.disabled = false
111
112 var i = 0
113 for details in ap.connection_history:
114 history_box.get_popup().add_item("%s (%s)" % [details[1], details[0]], i)
115 i += 1
116
117 history_box.get_popup().id_pressed.connect(historySelected)
118
119 # Show client version.
120 var version = gamedata.objects.get_version()
121 get_node("../Panel/title").text = (
122 "ARCHIPELAGO (%d.%d.%d)" % [version.get_major(), version.get_minor(), version.get_patch()]
123 )
124
125 # Increase font size in text boxes.
126 get_node("../Panel/server_box").add_theme_font_size_override("font_size", 36)
127 get_node("../Panel/player_box").add_theme_font_size_override("font_size", 36)
128 get_node("../Panel/password_box").add_theme_font_size_override("font_size", 36)
129
130 # Set up version mismatch dialog.
131 get_node("../Panel/VersionMismatch").confirmed.connect(startGame)
132 get_node("../Panel/VersionMismatch").get_cancel_button().pressed.connect(
133 versionMismatchDeclined
134 )
135
136 # Set up buttons.
137 get_node("../Panel/connect_button").pressed.connect(_connect_pressed)
138 get_node("../Panel/quit_button").pressed.connect(_back_pressed)
139
140
141func _connect_pressed():
142 get_node("../Panel/connect_button").disabled = true
143
144 var ap = global.get_node("Archipelago")
145 ap.ap_server = get_node("../Panel/server_box").text
146 ap.ap_user = get_node("../Panel/player_box").text
147 ap.ap_pass = get_node("../Panel/password_box").text
148 ap.saveSettings()
149
150 ap.connectToServer()
151
152
153func _back_pressed():
154 var ap = global.get_node("Archipelago")
155 ap.disconnect_from_ap()
156 ap.client.sendQuit()
157
158 get_tree().quit()
159
160
161# Adapted from https://gitlab.com/Delta-V-Modding/Mods/-/blob/main/game/ModLoader.gd
162func installScriptExtension(childScript: Resource):
163 # Force Godot to compile the script now.
164 # We need to do this here to ensure that the inheritance chain is
165 # properly set up, and multiple mods can chain-extend the same
166 # class multiple times.
167 # This is also needed to make Godot instantiate the extended class
168 # when creating singletons.
169 # The actual instance is thrown away.
170 childScript.new()
171
172 var parentScript = childScript.get_base_script()
173 var parentScriptPath = parentScript.resource_path
174 global._print("ModLoader: Installing script extension over %s" % parentScriptPath)
175 childScript.take_over_path(parentScriptPath)
176
177
178func connectionStatus(message):
179 var popup = get_node("../Panel/AcceptDialog")
180 popup.title = "Connecting to Archipelago"
181 popup.dialog_text = message
182 popup.exclusive = true
183 popup.get_ok_button().visible = false
184 popup.popup_centered()
185
186
187func connectionSuccessful():
188 var ap = global.get_node("Archipelago")
189 var gamedata = global.get_node("Gamedata")
190
191 # Check for major version mismatch.
192 if ap.apworld_version[0] != gamedata.objects.get_version().get_major():
193 get_node("../Panel/AcceptDialog").exclusive = false
194
195 var popup = get_node("../Panel/VersionMismatch")
196 popup.title = "Version Mismatch!"
197 popup.dialog_text = (
198 "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."
199 % [
200 ap.apworld_version[0],
201 ap.apworld_version[1],
202 ap.apworld_version[2],
203 gamedata.objects.get_version().get_major(),
204 gamedata.objects.get_version().get_minor(),
205 gamedata.objects.get_version().get_patch()
206 ]
207 )
208 popup.exclusive = true
209 popup.popup_centered()
210
211 return
212
213 startGame()
214
215
216func startGame():
217 var ap = global.get_node("Archipelago")
218
219 # Save connection details
220 var connection_details = [ap.ap_server, ap.ap_user, ap.ap_pass]
221 if ap.connection_history.has(connection_details):
222 ap.connection_history.erase(connection_details)
223 ap.connection_history.push_front(connection_details)
224 if ap.connection_history.size() > 10:
225 ap.connection_history.resize(10)
226 ap.saveSettings()
227
228 # Switch to the_entry
229 Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
230 global.user = ap.getSaveFileName()
231 global.universe = "lingo"
232 global.map = "the_entry"
233
234 unlocks.resetCollectables()
235 unlocks.resetData()
236 unlocks.loadCollectables()
237 unlocks.loadData()
238
239 ap.setup_keys()
240
241 unlocks.unlockKey("capslock", 1)
242
243 if ap.shuffle_worldports:
244 settings.worldport_fades = "default"
245 else:
246 settings.worldport_fades = "never"
247
248 clearResourceCache("res://objects/meshes/gridDoor.tscn")
249 clearResourceCache("res://objects/nodes/allowNumbers.tscn")
250 clearResourceCache("res://objects/nodes/collectable.tscn")
251 clearResourceCache("res://objects/nodes/door.tscn")
252 clearResourceCache("res://objects/nodes/keyHolder.tscn")
253 clearResourceCache("res://objects/nodes/listeners/animationListener.tscn")
254 clearResourceCache("res://objects/nodes/listeners/keyHolderChecker.tscn")
255 clearResourceCache("res://objects/nodes/listeners/keyHolderResetterListener.tscn")
256 clearResourceCache("res://objects/nodes/listeners/teleportListener.tscn")
257 clearResourceCache("res://objects/nodes/listeners/unlockReaderListener.tscn")
258 clearResourceCache("res://objects/nodes/listeners/visibilityListener.tscn")
259 clearResourceCache("res://objects/nodes/listeners/worldportListener.tscn")
260 clearResourceCache("res://objects/nodes/panel.tscn")
261 clearResourceCache("res://objects/nodes/player.tscn")
262 clearResourceCache("res://objects/nodes/saver.tscn")
263 clearResourceCache("res://objects/nodes/teleport.tscn")
264 clearResourceCache("res://objects/nodes/worldport.tscn")
265 clearResourceCache("res://objects/scenes/menus/pause_menu.tscn")
266
267 var paintings_dir = DirAccess.open("res://objects/meshes/paintings")
268 if paintings_dir:
269 paintings_dir.list_dir_begin()
270 var file_name = paintings_dir.get_next()
271 while file_name != "":
272 if not paintings_dir.current_is_dir() and file_name.ends_with(".tscn"):
273 clearResourceCache("res://objects/meshes/paintings/" + file_name)
274 file_name = paintings_dir.get_next()
275
276 switcher.switch_map.call_deferred("res://objects/scenes/the_entry.tscn")
277
278
279func connectionUnsuccessful(error_message):
280 get_node("../Panel/connect_button").disabled = false
281
282 var popup = get_node("../Panel/AcceptDialog")
283 popup.title = "Could not connect to Archipelago"
284 popup.dialog_text = error_message
285 popup.exclusive = true
286 popup.get_ok_button().visible = true
287 popup.popup_centered()
288
289
290func versionMismatchDeclined():
291 get_node("../Panel/AcceptDialog").hide()
292 get_node("../Panel/connect_button").disabled = false
293
294 var ap = global.get_node("Archipelago")
295 ap.disconnect_from_ap()
296
297
298func historySelected(index):
299 var ap = global.get_node("Archipelago")
300 var details = ap.connection_history[index]
301
302 get_node("../Panel/server_box").text = details[0]
303 get_node("../Panel/player_box").text = details[1]
304 get_node("../Panel/password_box").text = details[2]
305
306
307func clearResourceCache(path):
308 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..8c981f9 --- /dev/null +++ b/apworld/client/manager.gd
@@ -0,0 +1,717 @@
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 kEndingNameByVictoryValue = {
50 0: "GRAY",
51 1: "PURPLE",
52 2: "MINT",
53 3: "BLACK",
54 4: "BLUE",
55 5: "CYAN",
56 6: "RED",
57 7: "PLUM",
58 8: "ORANGE",
59 9: "GOLD",
60 10: "YELLOW",
61 11: "GREEN",
62 12: "WHITE",
63}
64
65var apworld_version = [0, 0, 0]
66var cyan_door_behavior = kCYAN_DOOR_BEHAVIOR_H2
67var daedalus_roof_access = false
68var enable_gift_maps = []
69var enable_icarus = false
70var endings_requirement = 0
71var keyholder_sanity = false
72var masteries_requirement = 0
73var port_pairings = {}
74var shuffle_control_center_colors = false
75var shuffle_doors = false
76var shuffle_gallery_paintings = false
77var shuffle_letters = kSHUFFLE_LETTERS_VANILLA
78var shuffle_symbols = false
79var shuffle_worldports = false
80var strict_cyan_ending = false
81var strict_purple_ending = false
82var victory_condition = -1
83
84var color_by_material_path = {}
85
86signal could_not_connect
87signal connect_status
88signal ap_connected
89
90
91func _init():
92 # Read AP settings from file, if there are any
93 if FileAccess.file_exists("user://ap_settings"):
94 var file = FileAccess.open("user://ap_settings", FileAccess.READ)
95 var data = file.get_var(true)
96 file.close()
97
98 if typeof(data) != TYPE_ARRAY:
99 global._print("AP settings file is corrupted")
100 data = []
101
102 if data.size() > 0:
103 ap_server = data[0]
104
105 if data.size() > 1:
106 ap_user = data[1]
107
108 if data.size() > 2:
109 ap_pass = data[2]
110
111 if data.size() > 3:
112 connection_history = data[3]
113
114 if data.size() > 4:
115 show_compass = data[4]
116
117 if data.size() > 5:
118 show_locations = data[5]
119
120 if data.size() > 6:
121 show_minimap = data[6]
122
123 # We need to create a mapping from material paths to the original colors of
124 # those materials. We force reload the materials, overwriting any custom
125 # textures, and create the mapping. We then reload the textures in case the
126 # player had a custom one enabled.
127 var directory = DirAccess.open("res://assets/materials")
128 for material_name in directory.get_files():
129 var material = ResourceLoader.load(
130 "res://assets/materials/" + material_name, "", ResourceLoader.CACHE_MODE_REPLACE
131 )
132
133 color_by_material_path[material.resource_path] = Color(material.albedo_color)
134
135 settings.load_user_textures()
136
137
138func _ready():
139 client = SCRIPT_client.new()
140 client.SCRIPT_websocketserver = SCRIPT_websocketserver
141
142 client.item_received.connect(_process_item)
143 client.location_scout_received.connect(_process_location_scout)
144 client.text_message_received.connect(_process_text_message)
145 client.item_sent_notification.connect(_process_item_sent_notification)
146 client.hint_received.connect(_process_hint_received)
147 client.accessible_locations_updated.connect(_on_accessible_locations_updated)
148 client.checked_locations_updated.connect(_on_checked_locations_updated)
149 client.ignored_locations_updated.connect(_on_ignored_locations_updated)
150 client.hinted_locations_updated.connect(_on_hinted_locations_updated)
151 client.checked_worldports_updated.connect(_on_checked_worldports_updated)
152 client.door_latched.connect(_on_door_latched)
153
154 client.could_not_connect.connect(_client_could_not_connect)
155 client.connect_status.connect(_client_connect_status)
156 client.client_connected.connect(_client_connected)
157
158 add_child(client)
159
160 keyboard = SCRIPT_keyboard.new()
161 add_child(keyboard)
162 client.keyboard_update_received.connect(keyboard.remote_keyboard_updated)
163
164
165func saveSettings():
166 # Save the AP settings to disk.
167 var path = "user://ap_settings"
168 var file = FileAccess.open(path, FileAccess.WRITE)
169
170 var data = [
171 ap_server,
172 ap_user,
173 ap_pass,
174 connection_history,
175 show_compass,
176 show_locations,
177 show_minimap,
178 ]
179 file.store_var(data, true)
180 file.close()
181
182
183func saveLocaldata():
184 # Save the MW/slot specific settings to disk.
185 var dir = DirAccess.open("user://")
186 var folder = "archipelago_data"
187 if not dir.dir_exists(folder):
188 dir.make_dir(folder)
189
190 var file = FileAccess.open(_localdata_file, FileAccess.WRITE)
191
192 var data = [
193 _last_new_item,
194 ]
195 file.store_var(data, true)
196 file.close()
197
198
199func connectToServer():
200 _last_new_item = -1
201 _batch_locations = false
202 _held_locations = []
203 _held_location_scouts = []
204 _location_scouts = {}
205 _letters_setup = false
206 _held_letters = {}
207 _already_connected = false
208
209 client.connectToServer(ap_server, ap_user, ap_pass)
210
211
212func getSaveFileName():
213 return "zzAP_%s_%d" % [client._seed, client._slot]
214
215
216func disconnect_from_ap():
217 _already_connected = false
218
219 var effects = global.get_node("Effects")
220 effects.set_connection_lost(false)
221
222 client.disconnect_from_ap()
223
224
225func get_item_id_for_door(door_id):
226 return _item_locks.get(door_id, null)
227
228
229func _process_item(item, amount):
230 var gamedata = global.get_node("Gamedata")
231
232 var item_id = int(item["id"])
233 var prog_id = null
234 if _inverse_item_locks.has(item_id):
235 for lock in _inverse_item_locks.get(item_id):
236 if lock[1] != amount:
237 continue
238
239 if gamedata.progressive_id_by_ap_id.has(item_id):
240 prog_id = lock[0]
241
242 if gamedata.get_door_map_name(lock[0]) != global.map:
243 continue
244
245 # TODO: fix doors opening from door groups
246 var receivers = gamedata.get_door_receivers(lock[0])
247 var scene = get_tree().get_root().get_node_or_null("scene")
248 if scene != null:
249 for receiver in receivers:
250 var rnode = scene.get_node_or_null(receiver)
251 if rnode != null:
252 rnode.handleTriggered()
253
254 var letter_id = gamedata.letter_id_by_ap_id.get(item_id, null)
255 if letter_id != null:
256 var letter = gamedata.objects.get_letters()[letter_id]
257 if not letter.has_level2() or not letter.get_level2():
258 _process_key_item(letter.get_key(), amount)
259
260 if gamedata.symbol_item_ids.has(item_id):
261 var player = get_tree().get_root().get_node_or_null("scene/player")
262 if player != null:
263 player.evaluate_solvability.emit()
264
265 if item_id == gamedata.objects.get_special_ids()["A Job Well Done"]:
266 update_job_well_done_sign()
267
268 if item_id == gamedata.objects.get_special_ids()["Numbers"] and global.map == "the_fuzzy":
269 global.allow_numbers = true
270
271 # Show a message about the item if it's new.
272 if int(item["index"]) > _last_new_item:
273 _last_new_item = int(item["index"])
274 saveLocaldata()
275
276 var full_item_name = item["text"]
277 if prog_id != null:
278 var door = gamedata.objects.get_doors()[prog_id]
279 full_item_name = "%s (%s)" % [full_item_name, door.get_name()]
280
281 var message
282 if "sender" in item:
283 message = (
284 "Received %s from %s"
285 % [wrapInItemColorTags(full_item_name, item["flags"]), item["sender"]]
286 )
287 else:
288 message = "Found %s" % wrapInItemColorTags(full_item_name, item["flags"])
289
290 if gamedata.anti_trap_ids.has(item):
291 keyboard.block_letter(gamedata.anti_trap_ids[item])
292
293 global._print(message)
294
295 global.get_node("Messages").showMessage(message)
296
297
298func _process_item_sent_notification(message):
299 var sentMsg = (
300 "Sent %s to %s"
301 % [
302 wrapInItemColorTags(message["item_name"], message["item_flags"]),
303 message["receiver_name"]
304 ]
305 )
306 #if _hinted_locations.has(message["item"]["location"]):
307 # sentMsg += " ([color=#fafad2]Hinted![/color])"
308 global.get_node("Messages").showMessage(sentMsg)
309
310
311func _process_hint_received(message):
312 var is_for = ""
313 if message["self"] == 0:
314 is_for = " for %s" % message["receiver_name"]
315
316 global.get_node("Messages").showMessage(
317 (
318 "Hint: %s%s is on %s"
319 % [
320 wrapInItemColorTags(message["item_name"], message["item_flags"]),
321 is_for,
322 message["location_name"]
323 ]
324 )
325 )
326
327
328func _process_text_message(message):
329 var parts = []
330 for message_part in message:
331 if message_part["type"] == "text":
332 parts.append(message_part["text"])
333 elif message_part["type"] == "player":
334 if message_part["self"] == 1:
335 parts.append("[color=#ee00ee]%s[/color]" % message_part["text"])
336 else:
337 parts.append("[color=#fafad2]%s[/color]" % message_part["text"])
338 elif message_part["type"] == "item":
339 parts.append(wrapInItemColorTags(message_part["text"], int(message_part["flags"])))
340 elif message_part["type"] == "location":
341 parts.append("[color=#00ff7f]%s[/color]" % message_part["text"])
342
343 var textclient_node = global.get_node("Textclient")
344 if textclient_node != null:
345 textclient_node.parse_printjson("".join(parts))
346
347
348func _process_location_scout(location_id, item_name, player_name, flags, for_self):
349 _location_scouts[location_id] = {
350 "item": item_name, "player": player_name, "flags": flags, "for_self": for_self
351 }
352
353 if for_self and flags & 4 != 0:
354 # This is a trap for us, so let's not display it.
355 return
356
357 var gamedata = global.get_node("Gamedata")
358 var map_id = gamedata.map_id_by_name.get(global.map)
359
360 var letter_id = gamedata.letter_id_by_ap_id.get(location_id, null)
361 if letter_id != null:
362 var letter = gamedata.objects.get_letters()[letter_id]
363 var room = gamedata.objects.get_rooms()[letter.get_room_id()]
364 if room.get_map_id() == map_id:
365 var collectable = get_tree().get_root().get_node("scene").get_node_or_null(
366 letter.get_path()
367 )
368 if collectable != null:
369 collectable.setScoutedText(item_name)
370
371
372func _on_accessible_locations_updated():
373 var textclient_node = global.get_node("Textclient")
374 if textclient_node != null:
375 textclient_node.update_locations()
376
377
378func _on_checked_locations_updated():
379 var textclient_node = global.get_node("Textclient")
380 if textclient_node != null:
381 textclient_node.update_locations(false)
382
383
384func _on_checked_worldports_updated():
385 var textclient_node = global.get_node("Textclient")
386 if textclient_node != null:
387 textclient_node.update_locations()
388 textclient_node.update_worldports()
389
390
391func _on_ignored_locations_updated(locations):
392 _ignored_locations = locations
393
394 var textclient_node = global.get_node("Textclient")
395 if textclient_node != null:
396 textclient_node.update_locations()
397
398
399func _on_hinted_locations_updated():
400 var textclient_node = global.get_node("Textclient")
401 if textclient_node != null:
402 textclient_node.update_locations()
403
404
405func _on_door_latched(door_id):
406 var gamedata = global.get_node("Gamedata")
407 if gamedata.get_door_map_name(door_id) != global.map:
408 return
409
410 var receivers = gamedata.get_door_receivers(door_id)
411 var scene = get_tree().get_root().get_node_or_null("scene")
412 if scene != null:
413 for receiver in receivers:
414 var rnode = scene.get_node_or_null(receiver)
415 if rnode != null:
416 rnode.handleTriggered()
417
418
419func _client_could_not_connect(message):
420 could_not_connect.emit(message)
421
422 if global.loaded:
423 var effects = global.get_node("Effects")
424 effects.set_connection_lost(true)
425
426 var messages = global.get_node("Messages")
427 messages.showMessage("Connection to multiworld lost.")
428
429
430func _client_connect_status(message):
431 connect_status.emit(message)
432
433
434func _client_connected(slot_data):
435 var effects = global.get_node("Effects")
436 effects.set_connection_lost(false)
437
438 if _already_connected:
439 var messages = global.get_node("Messages")
440 messages.showMessage("Reconnected to multiworld!")
441 return
442
443 _already_connected = true
444
445 var gamedata = global.get_node("Gamedata")
446
447 _localdata_file = "user://archipelago_data/%s_%d" % [client._seed, client._slot]
448 _last_new_item = -1
449
450 if FileAccess.file_exists(_localdata_file):
451 var ap_file = FileAccess.open(_localdata_file, FileAccess.READ)
452 var localdata = []
453 if ap_file != null:
454 localdata = ap_file.get_var(true)
455 ap_file.close()
456
457 if typeof(localdata) != TYPE_ARRAY:
458 print("AP localdata file is corrupted")
459 localdata = []
460
461 if localdata.size() > 0:
462 _last_new_item = localdata[0]
463
464 # Read slot data.
465 cyan_door_behavior = int(slot_data.get("cyan_door_behavior", 0))
466 daedalus_roof_access = bool(slot_data.get("daedalus_roof_access", false))
467 enable_gift_maps = slot_data.get("enable_gift_maps", [])
468 enable_icarus = bool(slot_data.get("enable_icarus", false))
469 endings_requirement = int(slot_data.get("endings_requirement", 0))
470 keyholder_sanity = bool(slot_data.get("keyholder_sanity", false))
471 masteries_requirement = int(slot_data.get("masteries_requirement", 0))
472 shuffle_control_center_colors = bool(slot_data.get("shuffle_control_center_colors", false))
473 shuffle_doors = bool(slot_data.get("shuffle_doors", false))
474 shuffle_gallery_paintings = bool(slot_data.get("shuffle_gallery_paintings", false))
475 shuffle_letters = int(slot_data.get("shuffle_letters", 0))
476 shuffle_symbols = bool(slot_data.get("shuffle_symbols", false))
477 shuffle_worldports = bool(slot_data.get("shuffle_worldports", false))
478 strict_cyan_ending = bool(slot_data.get("strict_cyan_ending", false))
479 strict_purple_ending = bool(slot_data.get("strict_purple_ending", false))
480 victory_condition = int(slot_data.get("victory_condition", 0))
481
482 if slot_data.has("version"):
483 var version_msg = slot_data["version"]
484 apworld_version = [int(version_msg[0]), int(version_msg[1]), 0]
485 if version_msg.size() > 2:
486 apworld_version[2] = int(version_msg[2])
487
488 port_pairings.clear()
489 if slot_data.has("port_pairings"):
490 var raw_pp = slot_data.get("port_pairings")
491
492 for p1 in raw_pp.keys():
493 port_pairings[gamedata.port_id_by_ap_id[int(p1)]] = gamedata.port_id_by_ap_id[int(
494 raw_pp[p1]
495 )]
496
497 # Set up item locks.
498 _item_locks = {}
499
500 if shuffle_doors:
501 for door in gamedata.objects.get_doors():
502 if (
503 door.get_type() == gamedata.SCRIPT_proto.DoorType.STANDARD
504 or door.get_type() == gamedata.SCRIPT_proto.DoorType.ITEM_ONLY
505 ):
506 _item_locks[door.get_id()] = [door.get_ap_id(), 1]
507
508 for progressive in gamedata.objects.get_progressives():
509 for i in range(0, progressive.get_doors().size()):
510 var door = gamedata.objects.get_doors()[progressive.get_doors()[i]]
511 _item_locks[door.get_id()] = [progressive.get_ap_id(), i + 1]
512
513 for door_group in gamedata.objects.get_door_groups():
514 if door_group.get_type() == gamedata.SCRIPT_proto.DoorGroupType.CONNECTOR:
515 if shuffle_worldports:
516 continue
517 elif door_group.get_type() != gamedata.SCRIPT_proto.DoorGroupType.SHUFFLE_GROUP:
518 continue
519
520 for door in door_group.get_doors():
521 _item_locks[door] = [door_group.get_ap_id(), 1]
522
523 if shuffle_control_center_colors:
524 for door in gamedata.objects.get_doors():
525 if door.get_type() == gamedata.SCRIPT_proto.DoorType.CONTROL_CENTER_COLOR:
526 _item_locks[door.get_id()] = [door.get_ap_id(), 1]
527
528 for door_group in gamedata.objects.get_door_groups():
529 if (
530 door_group.get_type() == gamedata.SCRIPT_proto.DoorGroupType.COLOR_CONNECTOR
531 and not shuffle_worldports
532 ):
533 for door in door_group.get_doors():
534 _item_locks[door] = [door_group.get_ap_id(), 1]
535
536 if shuffle_gallery_paintings:
537 for door in gamedata.objects.get_doors():
538 if door.get_type() == gamedata.SCRIPT_proto.DoorType.GALLERY_PAINTING:
539 _item_locks[door.get_id()] = [door.get_ap_id(), 1]
540
541 if cyan_door_behavior == kCYAN_DOOR_BEHAVIOR_ITEM:
542 for door_group in gamedata.objects.get_door_groups():
543 if door_group.get_type() == gamedata.SCRIPT_proto.DoorGroupType.CYAN_DOORS:
544 for door in door_group.get_doors():
545 if not _item_locks.has(door):
546 _item_locks[door] = [door_group.get_ap_id(), 1]
547
548 # Create a reverse item locks map for processing items.
549 _inverse_item_locks = {}
550
551 for door_id in _item_locks.keys():
552 var lock = _item_locks.get(door_id)
553
554 if not _inverse_item_locks.has(lock[0]):
555 _inverse_item_locks[lock[0]] = []
556
557 _inverse_item_locks[lock[0]].append([door_id, lock[1]])
558
559 if shuffle_worldports:
560 var textclient = global.get_node("Textclient")
561 textclient.setup_worldports()
562
563 ap_connected.emit()
564
565
566func start_batching_locations():
567 _batch_locations = true
568
569
570func send_location(loc_id):
571 if client._checked_locations.has(loc_id):
572 return
573
574 if _batch_locations:
575 _held_locations.append(loc_id)
576 else:
577 client.sendLocation(loc_id)
578
579
580func scout_location(loc_id):
581 if _location_scouts.has(loc_id):
582 return _location_scouts.get(loc_id)
583
584 if _batch_locations:
585 _held_location_scouts.append(loc_id)
586 else:
587 client.scoutLocation(loc_id)
588
589 return null
590
591
592func stop_batching_locations():
593 _batch_locations = false
594
595 if not _held_locations.is_empty():
596 client.sendLocations(_held_locations)
597 _held_locations.clear()
598
599 if not _held_location_scouts.is_empty():
600 client.scoutLocations(_held_location_scouts)
601 _held_location_scouts.clear()
602
603
604func colorForItemType(flags):
605 var int_flags = int(flags)
606 if int_flags & 1: # progression
607 if int_flags & 2: # proguseful
608 return "#f0d200"
609 else:
610 return "#bc51e0"
611 elif int_flags & 2: # useful
612 return "#2b67ff"
613 elif int_flags & 4: # trap
614 return "#d63a22"
615 else: # filler
616 return "#14de9e"
617
618
619func wrapInItemColorTags(text, flags):
620 var int_flags = int(flags)
621 if int_flags & 1 and int_flags & 2: # proguseful
622 return "[rainbow]%s[/rainbow]" % text
623 else:
624 return "[color=%s]%s[/color]" % [colorForItemType(flags), text]
625
626
627func get_letter_behavior(key, level2):
628 if shuffle_letters == kSHUFFLE_LETTERS_UNLOCKED:
629 return kLETTER_BEHAVIOR_UNLOCKED
630
631 if [kSHUFFLE_LETTERS_VANILLA_CYAN, kSHUFFLE_LETTERS_ITEM_CYAN].has(shuffle_letters):
632 if level2:
633 if shuffle_letters == kSHUFFLE_LETTERS_VANILLA_CYAN:
634 return kLETTER_BEHAVIOR_VANILLA
635 else:
636 return kLETTER_BEHAVIOR_ITEM
637 else:
638 return kLETTER_BEHAVIOR_UNLOCKED
639
640 if not level2 and ["h", "i", "n", "t"].has(key):
641 # This differs from the equivalent function in the apworld. Logically it is
642 # the same as UNLOCKED since they are in the starting room, but VANILLA
643 # means the player still has to actually pick up the letters.
644 return kLETTER_BEHAVIOR_VANILLA
645
646 if shuffle_letters == kSHUFFLE_LETTERS_PROGRESSIVE:
647 return kLETTER_BEHAVIOR_ITEM
648
649 return kLETTER_BEHAVIOR_VANILLA
650
651
652func setup_keys():
653 keyboard.load_seed()
654
655 _letters_setup = true
656
657 for k in _held_letters.keys():
658 _process_key_item(k, _held_letters[k])
659
660 _held_letters.clear()
661
662
663func _process_key_item(key, level):
664 if not _letters_setup:
665 _held_letters[key] = max(_held_letters.get(key, 0), level)
666 return
667
668 if shuffle_letters == kSHUFFLE_LETTERS_ITEM_CYAN:
669 level += 1
670
671 keyboard.collect_remote_letter(key, level)
672
673
674func update_job_well_done_sign():
675 if global.map != "daedalus":
676 return
677
678 var gamedata = global.get_node("Gamedata")
679 var job_item = gamedata.objects.get_special_ids()["A Job Well Done"]
680 var jobs_done = client.getItemAmount(job_item)
681
682 var sign2 = get_tree().get_root().get_node_or_null("scene/Meshes/Miscellaneous/sign2")
683 var sign3 = get_tree().get_root().get_node_or_null("scene/Meshes/Miscellaneous/sign3")
684
685 if sign2 != null and sign3 != null:
686 if jobs_done == 0:
687 sign2.text = "what are you doing"
688 sign3.text = "?"
689 elif jobs_done == 1:
690 sign2.text = "a job well done"
691 sign3.text = "is its own reward"
692 else:
693 sign2.text = "%d jobs well done" % jobs_done
694 sign3.text = "are their own reward"
695
696 sign2.get_node("MeshInstance3D").mesh.text = sign2.text
697 sign3.get_node("MeshInstance3D").mesh.text = sign3.text
698
699
700func toggle_ignored_location(loc_id):
701 if loc_id in _ignored_locations:
702 client.removeIgnoredLocation(loc_id)
703 else:
704 client.addIgnoredLocation(loc_id)
705
706
707func get_map_script(map_name):
708 if !_map_scripts.has(map_name):
709 var runtime = global.get_node("Runtime")
710 var script_path = "maps/%s.gd" % map_name
711 if runtime.path_exists(script_path):
712 var script = runtime.load_script(script_path)
713 _map_scripts[map_name] = script.new()
714 else:
715 _map_scripts[map_name] = null
716
717 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..de9ae4b --- /dev/null +++ b/apworld/client/maps/control_center.gd
@@ -0,0 +1,85 @@
1func on_map_load(root):
2 var ap = global.get_node("Archipelago")
3
4 # Remove the door blocking the trophy case.
5 root.get_node("/root/scene/Components/Doors/entry_18").queue_free()
6
7 # Set up mastery listeners for extra maps.
8 _set_up_mastery_listener(root, "advanced")
9 _set_up_mastery_listener(root, "charismatic")
10 _set_up_mastery_listener(root, "crystalline")
11 _set_up_mastery_listener(root, "fuzzy")
12 _set_up_mastery_listener(root, "icarus")
13 _set_up_mastery_listener(root, "stellar")
14
15 if ap.endings_requirement != 12 or ap.masteries_requirement != 0:
16 # Set up listeners for the potential White Ending requirements.
17 var merging_prefab = preload("res://objects/nodes/listeners/mergingListener.tscn")
18
19 var old_door = root.get_node("/root/scene/Components/Doors/entry_19")
20 var new_door = old_door.duplicate()
21 new_door.name = "entry_19_new"
22 new_door.senders.clear()
23 new_door.senderGroup.clear()
24 new_door.excludeSenders.clear()
25
26 if ap.endings_requirement == 12:
27 new_door.senderGroup.append(NodePath("/root/scene/Meshes/Trophies/Listeners"))
28 elif ap.endings_requirement > 0:
29 if ap.masteries_requirement == 0:
30 new_door.senderGroup.append(NodePath("/root/scene/Meshes/Trophies/Listeners"))
31 new_door.complete_at = ap.endings_requirement
32 else:
33 var endings_merge = merging_prefab.instantiate()
34 endings_merge.name = "EndingsMerge"
35 endings_merge.senderGroup.append(NodePath("/root/scene/Meshes/Trophies/Listeners"))
36 endings_merge.complete_at = ap.endings_requirement
37 root.get_node("/root/scene/Components").add_child.call_deferred(endings_merge)
38 new_door.senders.append(NodePath("/root/scene/Components/EndingsMerge"))
39
40 var max_masteries = 13 + ap.enable_gift_maps.size()
41 if ap.enable_icarus:
42 max_masteries += 1
43
44 if ap.masteries_requirement == max_masteries:
45 new_door.senderGroup.append(NodePath("/root/scene/Meshes/Trophies/MasteryListeners"))
46 new_door.excludeSenders.append(
47 NodePath("/root/scene/Meshes/Trophies/MasteryListeners/unlockReaderListenerWhite")
48 )
49 elif ap.masteries_requirement > 0:
50 if ap.endings_requirement == 0:
51 new_door.senderGroup.append(
52 NodePath("/root/scene/Meshes/Trophies/MasteryListeners")
53 )
54 new_door.excludeSenders.append(
55 NodePath(
56 "/root/scene/Meshes/Trophies/MasteryListeners/unlockReaderListenerWhite"
57 )
58 )
59 new_door.complete_at = ap.masteries_requirement
60 else:
61 var masteries_merge = merging_prefab.instantiate()
62 masteries_merge.name = "MasteriesMerge"
63 masteries_merge.senderGroup.append(
64 NodePath("/root/scene/Meshes/Trophies/MasteryListeners")
65 )
66 masteries_merge.excludeSenders.append(
67 NodePath(
68 "/root/scene/Meshes/Trophies/MasteryListeners/unlockReaderListenerWhite"
69 )
70 )
71 masteries_merge.complete_at = ap.masteries_requirement
72 root.get_node("/root/scene/Components").add_child.call_deferred(masteries_merge)
73 new_door.senders.append(NodePath("/root/scene/Components/MasteriesMerge"))
74
75 old_door.queue_free()
76 root.get_node("/root/scene/Components/Doors").add_child.call_deferred(new_door)
77
78
79func _set_up_mastery_listener(root, name):
80 var prefab = preload("res://objects/nodes/listeners/unlockReaderListener.tscn")
81 var url = prefab.instantiate()
82 url.name = "unlockReaderListenerMastery_%s" % name
83 url.key = "%s_mastery" % name
84 url.value = "unlocked"
85 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_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..5fac9fd --- /dev/null +++ b/apworld/client/player.gd
@@ -0,0 +1,181 @@
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
17 compass = global.get_node("Compass")
18 compass.visible = ap.show_compass
19
20 ap.start_batching_locations()
21
22 # Run map-specific initialization.
23 var map_script = ap.get_map_script(global.map)
24 if map_script != null:
25 map_script.on_map_load(get_tree().get_root())
26
27 ap.update_job_well_done_sign()
28
29 # Set up door locations.
30 var map_id = gamedata.map_id_by_name.get(global.map)
31 for door in gamedata.objects.get_doors():
32 if door.get_map_id() != map_id:
33 continue
34
35 if not door.has_ap_id():
36 continue
37
38 if (
39 not (door.has_legacy_location() and door.get_legacy_location())
40 and (
41 door.get_type() == gamedata.SCRIPT_proto.DoorType.ITEM_ONLY
42 or door.get_type() == gamedata.SCRIPT_proto.DoorType.GALLERY_PAINTING
43 or door.get_type() == gamedata.SCRIPT_proto.DoorType.CONTROL_CENTER_COLOR
44 )
45 ):
46 continue
47
48 var locationListener = ap.SCRIPT_locationListener.new()
49 locationListener.location_id = door.get_ap_id()
50 locationListener.name = "locationListener_%d" % door.get_ap_id()
51
52 for panel_ref in door.get_panels():
53 var panel_data = gamedata.objects.get_panels()[panel_ref.get_panel()]
54 var panel_path = panel_data.get_path()
55
56 if panel_ref.has_answer():
57 for proxy in panel_data.get_proxies():
58 if proxy.get_answer() == panel_ref.get_answer():
59 panel_path = proxy.get_path()
60 break
61
62 locationListener.senders.append(NodePath("/root/scene/" + panel_path))
63
64 for keyholder_ref in door.get_keyholders():
65 var keyholder_data = gamedata.objects.get_keyholders()[keyholder_ref.get_keyholder()]
66
67 var khl = khl_script.new()
68 khl.name = (
69 "location_%d_keyholder_%d" % [door.get_ap_id(), keyholder_ref.get_keyholder()]
70 )
71 khl.answer = keyholder_ref.get_key()
72 khl.senders.append(NodePath("/root/scene/" + keyholder_data.get_path()))
73 get_parent().add_child.call_deferred(khl)
74
75 locationListener.senders.append(NodePath("../" + khl.name))
76
77 for sender in door.get_senders():
78 locationListener.senders.append(NodePath("/root/scene/" + sender))
79
80 if door.has_complete_at():
81 locationListener.complete_at = door.get_complete_at()
82
83 get_parent().add_child.call_deferred(locationListener)
84
85 # Set up letter locations.
86 for letter in gamedata.objects.get_letters():
87 var room = gamedata.objects.get_rooms()[letter.get_room_id()]
88 if room.get_map_id() != map_id:
89 continue
90
91 var locationListener = ap.SCRIPT_locationListener.new()
92 locationListener.location_id = letter.get_ap_id()
93 locationListener.name = "locationListener_%d" % letter.get_ap_id()
94 locationListener.senders.append(NodePath("/root/scene/" + letter.get_path()))
95
96 get_parent().add_child.call_deferred(locationListener)
97
98 if (
99 ap.get_letter_behavior(letter.get_key(), letter.has_level2() and letter.get_level2())
100 != ap.kLETTER_BEHAVIOR_VANILLA
101 ):
102 var scout = ap.scout_location(letter.get_ap_id())
103 if scout != null and not (scout["for_self"] and scout["flags"] & 4 != 0):
104 var collectable = get_tree().get_root().get_node("scene").get_node_or_null(
105 letter.get_path()
106 )
107 if collectable != null:
108 collectable.setScoutedText.call_deferred(scout["item"])
109
110 # Set up mastery locations.
111 for mastery in gamedata.objects.get_masteries():
112 var room = gamedata.objects.get_rooms()[mastery.get_room_id()]
113 if room.get_map_id() != map_id:
114 continue
115
116 var locationListener = ap.SCRIPT_locationListener.new()
117 locationListener.location_id = mastery.get_ap_id()
118 locationListener.name = "locationListener_%d" % mastery.get_ap_id()
119 locationListener.senders.append(NodePath("/root/scene/" + mastery.get_path()))
120
121 get_parent().add_child.call_deferred(locationListener)
122
123 # Set up ending locations.
124 for ending in gamedata.objects.get_endings():
125 var room = gamedata.objects.get_rooms()[ending.get_room_id()]
126 if room.get_map_id() != map_id:
127 continue
128
129 var locationListener = ap.SCRIPT_locationListener.new()
130 locationListener.location_id = ending.get_ap_id()
131 locationListener.name = "locationListener_%d" % ending.get_ap_id()
132 locationListener.senders.append(NodePath("/root/scene/" + ending.get_path()))
133
134 get_parent().add_child.call_deferred(locationListener)
135
136 if ap.kEndingNameByVictoryValue.get(ap.victory_condition, null) == ending.get_name():
137 var victoryListener = ap.SCRIPT_victoryListener.new()
138 victoryListener.name = "victoryListener"
139 victoryListener.senders.append(NodePath("/root/scene/" + ending.get_path()))
140
141 get_parent().add_child.call_deferred(victoryListener)
142
143 # Set up keyholder locations, in keyholder sanity.
144 if ap.keyholder_sanity:
145 for keyholder in gamedata.objects.get_keyholders():
146 if not keyholder.has_key():
147 continue
148
149 var room = gamedata.objects.get_rooms()[keyholder.get_room_id()]
150 if room.get_map_id() != map_id:
151 continue
152
153 var locationListener = ap.SCRIPT_locationListener.new()
154 locationListener.location_id = keyholder.get_ap_id()
155 locationListener.name = "locationListener_%d" % keyholder.get_ap_id()
156
157 var khl = khl_script.new()
158 khl.name = "location_%d_keyholder" % keyholder.get_ap_id()
159 khl.answer = keyholder.get_key()
160 khl.senders.append(NodePath("/root/scene/" + keyholder.get_path()))
161 get_parent().add_child.call_deferred(khl)
162
163 locationListener.senders.append(NodePath("../" + khl.name))
164
165 get_parent().add_child.call_deferred(locationListener)
166
167 var minimap = ap.SCRIPT_minimap.new()
168 minimap.name = "Minimap"
169 minimap.visible = ap.show_minimap
170 get_parent().add_child.call_deferred(minimap)
171
172 super._ready()
173
174 await get_tree().process_frame
175 await get_tree().process_frame
176
177 ap.stop_batching_locations()
178
179
180func _process(_dt):
181 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/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/docs/en_Lingo_2.md b/apworld/docs/en_Lingo_2.md new file mode 100644 index 0000000..977795a --- /dev/null +++ b/apworld/docs/en_Lingo_2.md
@@ -0,0 +1,4 @@
1# Lingo 2
2
3See [the project README](https://code.fourisland.com/lingo2-archipelago/about/)
4for installation instructions and frequently asked questions. \ No newline at end of file
diff --git a/apworld/items.py b/apworld/items.py index 971a709..28158c3 100644 --- a/apworld/items.py +++ b/apworld/items.py
@@ -1,5 +1,31 @@
1from .generated import data_pb2 as data_pb2
1from BaseClasses import Item 2from BaseClasses import Item
2 3
3 4
4class Lingo2Item(Item): 5class Lingo2Item(Item):
5 game: str = "Lingo 2" 6 game: str = "Lingo 2"
7
8
9SYMBOL_ITEMS: dict[data_pb2.PuzzleSymbol, str] = {
10 data_pb2.PuzzleSymbol.SUN: "Sun Symbol",
11 data_pb2.PuzzleSymbol.SPARKLES: "Sparkles Symbol",
12 data_pb2.PuzzleSymbol.ZERO: "Zero Symbol",
13 data_pb2.PuzzleSymbol.EXAMPLE: "Example Symbol",
14 data_pb2.PuzzleSymbol.BOXES: "Boxes Symbol",
15 data_pb2.PuzzleSymbol.PLANET: "Planet Symbol",
16 data_pb2.PuzzleSymbol.PYRAMID: "Pyramid Symbol",
17 data_pb2.PuzzleSymbol.CROSS: "Cross Symbol",
18 data_pb2.PuzzleSymbol.SWEET: "Sweet Symbol",
19 data_pb2.PuzzleSymbol.GENDER: "Gender Symbol",
20 data_pb2.PuzzleSymbol.AGE: "Age Symbol",
21 data_pb2.PuzzleSymbol.SOUND: "Sound Symbol",
22 data_pb2.PuzzleSymbol.ANAGRAM: "Anagram Symbol",
23 data_pb2.PuzzleSymbol.JOB: "Job Symbol",
24 data_pb2.PuzzleSymbol.STARS: "Stars Symbol",
25 data_pb2.PuzzleSymbol.NULL: "Null Symbol",
26 data_pb2.PuzzleSymbol.EVAL: "Eval Symbol",
27 data_pb2.PuzzleSymbol.LINGO: "Lingo Symbol",
28 data_pb2.PuzzleSymbol.QUESTION: "Question Symbol",
29}
30
31ANTI_COLLECTABLE_TRAPS: list[str] = [f"Anti {letter}" for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"]
diff --git a/apworld/locations.py b/apworld/locations.py index 108decb..3d619dc 100644 --- a/apworld/locations.py +++ b/apworld/locations.py
@@ -3,3 +3,6 @@ from BaseClasses import Location
3 3
4class Lingo2Location(Location): 4class Lingo2Location(Location):
5 game: str = "Lingo 2" 5 game: str = "Lingo 2"
6
7 port_id: int
8 goal: bool
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 2197b0f..f687434 100644 --- a/apworld/options.py +++ b/apworld/options.py
@@ -1,9 +1,9 @@
1from dataclasses import dataclass 1from dataclasses import dataclass
2 2
3from Options import PerGameCommonOptions, Toggle, Choice 3from Options import PerGameCommonOptions, Toggle, Choice, DefaultOnToggle, Range, OptionSet
4 4
5 5
6class ShuffleDoors(Toggle): 6class ShuffleDoors(DefaultOnToggle):
7 """If enabled, most doors will open from receiving an item rather than fulfilling the in-game requirements.""" 7 """If enabled, most doors will open from receiving an item rather than fulfilling the in-game requirements."""
8 display_name = "Shuffle Doors" 8 display_name = "Shuffle Doors"
9 9
@@ -16,6 +16,11 @@ class ShuffleControlCenterColors(Toggle):
16 display_name = "Shuffle Control Center Colors" 16 display_name = "Shuffle Control Center Colors"
17 17
18 18
19class ShuffleGalleryPaintings(Toggle):
20 """If enabled, gallery paintings will appear from receiving an item rather than by triggering them normally."""
21 display_name = "Shuffle Gallery Paintings"
22
23
19class ShuffleLetters(Choice): 24class ShuffleLetters(Choice):
20 """ 25 """
21 Controls how letter unlocks are handled. Note that H1, I1, N1, and T1 will always be present at their vanilla 26 Controls how letter unlocks are handled. Note that H1, I1, N1, and T1 will always be present at their vanilla
@@ -39,6 +44,23 @@ class ShuffleLetters(Choice):
39 option_item_cyan = 4 44 option_item_cyan = 4
40 45
41 46
47class ShuffleSymbols(Toggle):
48 """
49 If enabled, 19 items will be added to the pool, representing the different symbols that can appear on a panel.
50 Players will be prevented from solving puzzles with symbols on them until all of the required symbols are unlocked.
51 """
52 display_name = "Shuffle Symbols"
53
54
55class ShuffleWorldports(Toggle):
56 """
57 Randomizes the connections between maps. This affects worldports only, which are the loading zones you walk into in
58 order to change maps. This does not affect paintings, panels that teleport you, or certain other special connections
59 like the one between The Shop and Control Center.
60 """
61 display_name = "Shuffle Worldports"
62
63
42class KeyholderSanity(Toggle): 64class KeyholderSanity(Toggle):
43 """ 65 """
44 If enabled, 26 locations will be created for placing each key into its respective Green Ending keyholder. 66 If enabled, 26 locations will be created for placing each key into its respective Green Ending keyholder.
@@ -69,6 +91,36 @@ class CyanDoorBehavior(Choice):
69 option_item = 2 91 option_item = 2
70 92
71 93
94class EnableIcarus(Toggle):
95 """
96 Controls whether Icarus is randomized. If disabled, which is the default, no locations or items will be created for
97 it, and its worldport will not be shuffled when worldport shuffle is on.
98 """
99 display_name = "Enable Icarus"
100
101
102class EnableGiftMaps(OptionSet):
103 """
104 Controls whether the beta tester gift maps are randomized. By default, these are not accessible at all from within
105 the randomizer. This option allows you to enter the maps, and creates items and locations for them. If worldport
106 shuffle is on, their worldports will be included in the randomization.
107
108 The gift maps are accessed via a panel in The Entry's Starting Room, which only appears if at least one gift map is
109 enabled. It is also treated like a cyan door, and will not appear until the condition specified in the Cyan Door
110 Behavior option is satisfied. Solving this panel with the name of one of the beta testers will teleport you to their
111 corresponding gift map.
112
113 In the base game, nothing happens once you complete a gift map. Masteries have been added to the gift maps in the
114 randomizer so that the player can be rewarded for completing them.
115
116 Note that the gift maps were originally only intended to be played by specific people, and as a result may be
117 frustrating or require knowledge of inside jokes. The Crystalline is particularly difficult as it requires
118 completing a parkour course.
119 """
120 display_name = "Enable Gift Maps"
121 valid_keys = ["The Advanced", "The Charismatic", "The Crystalline", "The Fuzzy", "The Stellar"]
122
123
72class DaedalusRoofAccess(Toggle): 124class DaedalusRoofAccess(Toggle):
73 """ 125 """
74 If enabled, the player will be logically expected to be able to go from the castle entrance to any part of Daedalus 126 If enabled, the player will be logically expected to be able to go from the castle entrance to any part of Daedalus
@@ -79,8 +131,40 @@ class DaedalusRoofAccess(Toggle):
79 display_name = "Allow Daedalus Roof Access" 131 display_name = "Allow Daedalus Roof Access"
80 132
81 133
134class StrictPurpleEnding(DefaultOnToggle):
135 """
136 If enabled, the player will be required to have all purple (level 1) letters in order to get Purple Ending.
137 Otherwise, some of the letters may be skippable depending on the other options.
138 """
139 display_name = "Strict Purple Ending"
140
141
142class StrictCyanEnding(DefaultOnToggle):
143 """
144 If enabled, the player will be required to have all cyan (level 2) letters in order to get Cyan Ending. Otherwise,
145 at least J2, Q2, and V2 are skippable. Others may also be skippable depending on the options chosen.
146 """
147 display_name = "Strict Cyan Ending"
148
149
82class VictoryCondition(Choice): 150class VictoryCondition(Choice):
83 """Victory condition.""" 151 """
152 This option determines what your goal is.
153
154 - **Gray Ending** (The Colorful)
155 - **Purple Ending** (The Sun Temple). This ordinarily requires all level 1 (purple) letters.
156 - **Mint Ending** (typing EXIT into the keyholders in Control Center)
157 - **Black Ending** (The Graveyard)
158 - **Blue Ending** (The Words)
159 - **Cyan Ending** (The Parthenon). This ordinarily requires almost all level 2 (cyan) letters.
160 - **Red Ending** (The Tower)
161 - **Plum Ending** (The Wondrous / The Door)
162 - **Orange Ending** (the castle in Daedalus)
163 - **Gold Ending** (The Gold). This involves going through the color rooms in Daedalus.
164 - **Yellow Ending** (The Gallery). This requires unlocking all gallery paintings.
165 - **Green Ending** (The Ancient). This requires filling all keyholders with specific letters.
166 - **White Ending** (Control Center). This combines every other ending.
167 """
84 display_name = "Victory Condition" 168 display_name = "Victory Condition"
85 option_gray_ending = 0 169 option_gray_ending = 0
86 option_purple_ending = 1 170 option_purple_ending = 1
@@ -97,12 +181,50 @@ class VictoryCondition(Choice):
97 option_white_ending = 12 181 option_white_ending = 12
98 182
99 183
184class EndingsRequirement(Range):
185 """The number of endings required to unlock White Ending."""
186 display_name = "Endings Requirement"
187 range_start = 0
188 range_end = 12
189 default = 12
190
191
192class MasteriesRequirement(Range):
193 """The number of masteries required to unlock White Ending.
194
195 There are only 13 masteries in the base game, but some of the other slot options may add more masteries to the
196 world. If the chosen number of masteries is higher than the total in your world, it will be automatically lowered to
197 the maximum."""
198 display_name = "Masteries Requirement"
199 range_start = 0
200 range_end = 19
201 default = 0
202
203
204class TrapPercentage(Range):
205 """Replaces junk items with traps, at the specified rate."""
206 display_name = "Trap Percentage"
207 range_start = 0
208 range_end = 100
209 default = 0
210
211
100@dataclass 212@dataclass
101class Lingo2Options(PerGameCommonOptions): 213class Lingo2Options(PerGameCommonOptions):
102 shuffle_doors: ShuffleDoors 214 shuffle_doors: ShuffleDoors
103 shuffle_control_center_colors: ShuffleControlCenterColors 215 shuffle_control_center_colors: ShuffleControlCenterColors
216 shuffle_gallery_paintings: ShuffleGalleryPaintings
104 shuffle_letters: ShuffleLetters 217 shuffle_letters: ShuffleLetters
218 shuffle_symbols: ShuffleSymbols
219 shuffle_worldports: ShuffleWorldports
105 keyholder_sanity: KeyholderSanity 220 keyholder_sanity: KeyholderSanity
106 cyan_door_behavior: CyanDoorBehavior 221 cyan_door_behavior: CyanDoorBehavior
222 enable_icarus: EnableIcarus
223 enable_gift_maps: EnableGiftMaps
107 daedalus_roof_access: DaedalusRoofAccess 224 daedalus_roof_access: DaedalusRoofAccess
225 strict_purple_ending: StrictPurpleEnding
226 strict_cyan_ending: StrictCyanEnding
108 victory_condition: VictoryCondition 227 victory_condition: VictoryCondition
228 endings_requirement: EndingsRequirement
229 masteries_requirement: MasteriesRequirement
230 trap_percentage: TrapPercentage
diff --git a/apworld/player_logic.py b/apworld/player_logic.py index c94b809..3ee8f38 100644 --- a/apworld/player_logic.py +++ b/apworld/player_logic.py
@@ -1,9 +1,10 @@
1from enum import IntEnum, auto 1from enum import IntEnum, auto
2 2
3from .generated import data_pb2 as data_pb2 3from .generated import data_pb2 as data_pb2
4from .items import SYMBOL_ITEMS
4from typing import TYPE_CHECKING, NamedTuple 5from typing import TYPE_CHECKING, NamedTuple
5 6
6from .options import VictoryCondition, ShuffleLetters, CyanDoorBehavior 7from .options import ShuffleLetters, CyanDoorBehavior
7 8
8if TYPE_CHECKING: 9if TYPE_CHECKING:
9 from . import Lingo2World 10 from . import Lingo2World
@@ -23,21 +24,38 @@ class AccessRequirements:
23 items: set[str] 24 items: set[str]
24 progressives: dict[str, int] 25 progressives: dict[str, int]
25 rooms: set[str] 26 rooms: set[str]
26 symbols: set[str]
27 letters: dict[str, int] 27 letters: dict[str, int]
28 cyans: bool 28 cyans: bool
29 29
30 # This is an AND of ORs. 30 # This is an AND of ORs.
31 or_logic: list[list["AccessRequirements"]] 31 or_logic: list[list["AccessRequirements"]]
32 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
33 def __init__(self): 38 def __init__(self):
34 self.items = set() 39 self.items = set()
35 self.progressives = dict() 40 self.progressives = dict()
36 self.rooms = set() 41 self.rooms = set()
37 self.symbols = set()
38 self.letters = dict() 42 self.letters = dict()
39 self.cyans = False 43 self.cyans = False
40 self.or_logic = list() 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
41 59
42 def merge(self, other: "AccessRequirements"): 60 def merge(self, other: "AccessRequirements"):
43 for item in other.items: 61 for item in other.items:
@@ -49,16 +67,105 @@ class AccessRequirements:
49 for room in other.rooms: 67 for room in other.rooms:
50 self.rooms.add(room) 68 self.rooms.add(room)
51 69
52 for symbol in other.symbols:
53 self.symbols.add(symbol)
54
55 for letter, level in other.letters.items(): 70 for letter, level in other.letters.items():
56 self.letters[letter] = max(self.letters.get(letter, 0), level) 71 self.letters[letter] = max(self.letters.get(letter, 0), level)
57 72
58 self.cyans = self.cyans or other.cyans 73 self.cyans = self.cyans or other.cyans
59 74
60 for disjunction in other.or_logic: 75 for disjunction in other.or_logic:
61 self.or_logic.append(disjunction) 76 self.or_logic.append([sub_req.copy() for sub_req in 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 = [sub_req.copy() for sub_req in 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 = [sub_req.copy() for sub_req in other.possibilities]
96 self.or_logic.append([right_req])
97 else:
98 self.complete_at = other.complete_at
99 self.possibilities = [sub_req.copy() for sub_req in 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)
62 169
63 def __repr__(self): 170 def __repr__(self):
64 parts = [] 171 parts = []
@@ -68,15 +175,17 @@ class AccessRequirements:
68 parts.append(f"progressives={self.progressives}") 175 parts.append(f"progressives={self.progressives}")
69 if len(self.rooms) > 0: 176 if len(self.rooms) > 0:
70 parts.append(f"rooms={self.rooms}") 177 parts.append(f"rooms={self.rooms}")
71 if len(self.symbols) > 0:
72 parts.append(f"symbols={self.symbols}")
73 if len(self.letters) > 0: 178 if len(self.letters) > 0:
74 parts.append(f"letters={self.letters}") 179 parts.append(f"letters={self.letters}")
75 if self.cyans: 180 if self.cyans:
76 parts.append(f"cyans=True") 181 parts.append(f"cyans=True")
77 if len(self.or_logic) > 0: 182 if len(self.or_logic) > 0:
78 parts.append(f"or_logic={self.or_logic}") 183 parts.append(f"or_logic={self.or_logic}")
79 return f"AccessRequirements({", ".join(parts)})" 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) + ")"
80 189
81 190
82class PlayerLocation(NamedTuple): 191class PlayerLocation(NamedTuple):
@@ -93,6 +202,8 @@ class LetterBehavior(IntEnum):
93class Lingo2PlayerLogic: 202class Lingo2PlayerLogic:
94 world: "Lingo2World" 203 world: "Lingo2World"
95 204
205 shuffled_maps: set[int]
206
96 locations_by_room: dict[int, list[PlayerLocation]] 207 locations_by_room: dict[int, list[PlayerLocation]]
97 event_loc_item_by_room: dict[int, dict[str, str]] 208 event_loc_item_by_room: dict[int, dict[str, str]]
98 209
@@ -105,6 +216,7 @@ class Lingo2PlayerLogic:
105 real_items: list[str] 216 real_items: list[str]
106 217
107 double_letter_amount: dict[str, int] 218 double_letter_amount: dict[str, int]
219 goal_room_id: int
108 220
109 def __init__(self, world: "Lingo2World"): 221 def __init__(self, world: "Lingo2World"):
110 self.world = world 222 self.world = world
@@ -117,18 +229,54 @@ class Lingo2PlayerLogic:
117 self.real_items = list() 229 self.real_items = list()
118 self.double_letter_amount = dict() 230 self.double_letter_amount = dict()
119 231
232 def should_shuffle_map(game_map) -> bool:
233 if game_map.type == data_pb2.MapType.NORMAL_MAP:
234 return True
235 elif game_map.type == data_pb2.MapType.ICARUS:
236 return bool(world.options.enable_icarus)
237 elif game_map.type == data_pb2.MapType.GIFT_MAP:
238 if game_map.name == "the_advanced":
239 return "The Advanced" in world.options.enable_gift_maps.value
240 elif game_map.name == "the_charismatic":
241 return "The Charismatic" in world.options.enable_gift_maps.value
242 elif game_map.name == "the_crystalline":
243 return "The Crystalline" in world.options.enable_gift_maps.value
244 elif game_map.name == "the_fuzzy":
245 return "The Fuzzy" in world.options.enable_gift_maps.value
246 elif game_map.name == "the_stellar":
247 return "The Stellar" in world.options.enable_gift_maps.value
248
249 return False
250
251 self.shuffled_maps = set(game_map.id for game_map in world.static_logic.objects.maps
252 if should_shuffle_map(game_map))
253
254 maximum_masteries = 13 + len(world.options.enable_gift_maps.value)
255 if world.options.enable_icarus:
256 maximum_masteries += 1
257
258 if world.options.masteries_requirement > maximum_masteries:
259 world.options.masteries_requirement.value = maximum_masteries
260
261 if "The Fuzzy" in world.options.enable_gift_maps.value:
262 self.real_items.append("Numbers")
263
120 if self.world.options.shuffle_doors: 264 if self.world.options.shuffle_doors:
121 for progressive in world.static_logic.objects.progressives: 265 for progressive in world.static_logic.objects.progressives:
122 for i in range(0, len(progressive.doors)): 266 for i in range(0, len(progressive.doors)):
267 door = world.static_logic.objects.doors[progressive.doors[i]]
268 if door.map_id not in self.shuffled_maps:
269 continue
270
123 self.item_by_door[progressive.doors[i]] = (progressive.name, i + 1) 271 self.item_by_door[progressive.doors[i]] = (progressive.name, i + 1)
124 self.real_items.append(progressive.name) 272 self.real_items.append(progressive.name)
125 273
126 for door_group in world.static_logic.objects.door_groups: 274 for door_group in world.static_logic.objects.door_groups:
127 if door_group.type == data_pb2.DoorGroupType.CONNECTOR: 275 if door_group.type == data_pb2.DoorGroupType.CONNECTOR:
128 if not self.world.options.shuffle_doors: 276 if not self.world.options.shuffle_doors or self.world.options.shuffle_worldports:
129 continue 277 continue
130 elif door_group.type == data_pb2.DoorGroupType.COLOR_CONNECTOR: 278 elif door_group.type == data_pb2.DoorGroupType.COLOR_CONNECTOR:
131 if not self.world.options.shuffle_control_center_colors: 279 if not self.world.options.shuffle_control_center_colors or self.world.options.shuffle_worldports:
132 continue 280 continue
133 elif door_group.type == data_pb2.DoorGroupType.SHUFFLE_GROUP: 281 elif door_group.type == data_pb2.DoorGroupType.SHUFFLE_GROUP:
134 if not self.world.options.shuffle_doors: 282 if not self.world.options.shuffle_doors:
@@ -136,14 +284,21 @@ class Lingo2PlayerLogic:
136 else: 284 else:
137 continue 285 continue
138 286
139 for door in door_group.doors: 287 shuffleable_doors = [door_id for door_id in door_group.doors
140 self.item_by_door[door] = (door_group.name, 1) 288 if world.static_logic.objects.doors[door_id].map_id in self.shuffled_maps]
289
290 if len(shuffleable_doors) > 0:
291 for door in shuffleable_doors:
292 self.item_by_door[door] = (door_group.name, 1)
141 293
142 self.real_items.append(door_group.name) 294 self.real_items.append(door_group.name)
143 295
144 # We iterate through the doors in two parts because it is essential that we determine which doors are shuffled 296 # We iterate through the doors in two parts because it is essential that we determine which doors are shuffled
145 # before we calculate any access requirements. 297 # before we calculate any access requirements.
146 for door in world.static_logic.objects.doors: 298 for door in world.static_logic.objects.doors:
299 if door.map_id not in self.shuffled_maps:
300 continue
301
147 if door.type in [data_pb2.DoorType.EVENT, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]: 302 if door.type in [data_pb2.DoorType.EVENT, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]:
148 continue 303 continue
149 304
@@ -158,6 +313,9 @@ class Lingo2PlayerLogic:
158 not self.world.options.shuffle_control_center_colors): 313 not self.world.options.shuffle_control_center_colors):
159 continue 314 continue
160 315
316 if door.type == data_pb2.DoorType.GALLERY_PAINTING and not self.world.options.shuffle_gallery_paintings:
317 continue
318
161 door_item_name = self.world.static_logic.get_door_item_name(door) 319 door_item_name = self.world.static_logic.get_door_item_name(door)
162 self.item_by_door[door.id] = (door_item_name, 1) 320 self.item_by_door[door.id] = (door_item_name, 1)
163 self.real_items.append(door_item_name) 321 self.real_items.append(door_item_name)
@@ -169,29 +327,40 @@ class Lingo2PlayerLogic:
169 if door_group.type != data_pb2.DoorGroupType.CYAN_DOORS: 327 if door_group.type != data_pb2.DoorGroupType.CYAN_DOORS:
170 continue 328 continue
171 329
172 for door in door_group.doors: 330 shuffleable_doors = [door_id for door_id in door_group.doors
173 if not door in self.item_by_door: 331 if world.static_logic.objects.doors[door_id].map_id in self.shuffled_maps
332 and door_id not in self.item_by_door]
333
334 if len(shuffleable_doors) > 0:
335 for door in shuffleable_doors:
174 self.item_by_door[door] = (door_group.name, 1) 336 self.item_by_door[door] = (door_group.name, 1)
175 337
176 self.real_items.append(door_group.name) 338 self.real_items.append(door_group.name)
177 339
178 for door in world.static_logic.objects.doors: 340 for door in world.static_logic.objects.doors:
341 if door.map_id not in self.shuffled_maps:
342 continue
343
179 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]: 344 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]:
180 self.locations_by_room.setdefault(door.room_id, []).append(PlayerLocation(door.ap_id, 345 self.locations_by_room.setdefault(door.room_id, []).append(PlayerLocation(door.ap_id,
181 self.get_door_reqs(door.id))) 346 self.get_door_reqs(door.id)))
182 347
183 for letter in world.static_logic.objects.letters: 348 for letter in world.static_logic.objects.letters:
349 if world.static_logic.get_room_object_map_id(letter) not in self.shuffled_maps:
350 continue
351
184 self.locations_by_room.setdefault(letter.room_id, []).append(PlayerLocation(letter.ap_id, 352 self.locations_by_room.setdefault(letter.room_id, []).append(PlayerLocation(letter.ap_id,
185 AccessRequirements())) 353 AccessRequirements()))
186 behavior = self.get_letter_behavior(letter.key, letter.level2) 354 behavior = self.get_letter_behavior(letter.key, letter.level2)
187 if behavior == LetterBehavior.VANILLA: 355 if behavior == LetterBehavior.VANILLA:
188 letter_name = f"{letter.key.upper()}{'2' if letter.level2 else '1'}" 356 if not world.for_tracker:
189 event_name = f"{letter_name} (Collected)" 357 letter_name = f"{letter.key.upper()}{'2' if letter.level2 else '1'}"
190 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper() 358 event_name = f"{letter_name} (Collected)"
191
192 if letter.level2:
193 event_name = f"{letter_name} (Double Collected)"
194 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper() 359 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper()
360
361 if letter.level2:
362 event_name = f"{letter_name} (Double Collected)"
363 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper()
195 elif behavior == LetterBehavior.ITEM: 364 elif behavior == LetterBehavior.ITEM:
196 self.real_items.append(letter.key.upper()) 365 self.real_items.append(letter.key.upper())
197 366
@@ -199,30 +368,42 @@ class Lingo2PlayerLogic:
199 self.double_letter_amount[letter.key.upper()] = self.double_letter_amount.get(letter.key.upper(), 0) + 1 368 self.double_letter_amount[letter.key.upper()] = self.double_letter_amount.get(letter.key.upper(), 0) + 1
200 369
201 for mastery in world.static_logic.objects.masteries: 370 for mastery in world.static_logic.objects.masteries:
371 if world.static_logic.get_room_object_map_id(mastery) not in self.shuffled_maps:
372 continue
373
202 self.locations_by_room.setdefault(mastery.room_id, []).append(PlayerLocation(mastery.ap_id, 374 self.locations_by_room.setdefault(mastery.room_id, []).append(PlayerLocation(mastery.ap_id,
203 AccessRequirements())) 375 AccessRequirements()))
204 376
377 if world.options.masteries_requirement > 0:
378 event_name = f"{world.static_logic.get_room_object_map_name(mastery)} - Mastery (Collected)"
379 self.event_loc_item_by_room.setdefault(mastery.room_id, {})[event_name] = "Mastery"
380
205 for ending in world.static_logic.objects.endings: 381 for ending in world.static_logic.objects.endings:
206 # Don't ever create a location for White Ending. Don't even make an event for it if it's not the victory 382 if world.static_logic.get_room_object_map_id(ending) not in self.shuffled_maps:
207 # condition, since it is necessarily going to be in the postgame. 383 continue
208 if ending.name == "WHITE": 384
209 if self.world.options.victory_condition != VictoryCondition.option_white_ending: 385 # Don't create a location for your selected ending. Also don't create a location for White Ending if it's
210 continue 386 # necessarily in the postgame, i.e. it requires all 12 other endings.
211 else: 387 if world.options.victory_condition.current_key.removesuffix("_ending").upper() != ending.name\
388 and (ending.name != "WHITE" or world.options.endings_requirement < 12):
212 self.locations_by_room.setdefault(ending.room_id, []).append(PlayerLocation(ending.ap_id, 389 self.locations_by_room.setdefault(ending.room_id, []).append(PlayerLocation(ending.ap_id,
213 AccessRequirements())) 390 AccessRequirements()))
214 391
215 event_name = f"{ending.name.capitalize()} Ending (Achieved)"
216 item_name = event_name
217
218 if world.options.victory_condition.current_key.removesuffix("_ending").upper() == ending.name: 392 if world.options.victory_condition.current_key.removesuffix("_ending").upper() == ending.name:
219 item_name = "Victory" 393 event_name = f"{ending.name.capitalize()} Ending (Goal)"
394 self.event_loc_item_by_room.setdefault(ending.room_id, {})[event_name] = "Victory"
395 self.goal_room_id = ending.room_id
220 396
221 self.event_loc_item_by_room.setdefault(ending.room_id, {})[event_name] = item_name 397 if ending.name != "WHITE":
398 event_name = f"{ending.name.capitalize()} Ending (Achieved)"
399 self.event_loc_item_by_room.setdefault(ending.room_id, {})[event_name] = "Ending"
222 400
223 if self.world.options.keyholder_sanity: 401 if self.world.options.keyholder_sanity:
224 for keyholder in world.static_logic.objects.keyholders: 402 for keyholder in world.static_logic.objects.keyholders:
225 if keyholder.HasField("key"): 403 if keyholder.HasField("key"):
404 if world.static_logic.get_room_object_map_id(keyholder) not in self.shuffled_maps:
405 continue
406
226 reqs = AccessRequirements() 407 reqs = AccessRequirements()
227 408
228 if self.get_letter_behavior(keyholder.key, False) != LetterBehavior.UNLOCKED: 409 if self.get_letter_behavior(keyholder.key, False) != LetterBehavior.UNLOCKED:
@@ -231,6 +412,10 @@ class Lingo2PlayerLogic:
231 self.locations_by_room.setdefault(keyholder.room_id, []).append(PlayerLocation(keyholder.ap_id, 412 self.locations_by_room.setdefault(keyholder.room_id, []).append(PlayerLocation(keyholder.ap_id,
232 reqs)) 413 reqs))
233 414
415 if self.world.options.shuffle_symbols:
416 for symbol_name in SYMBOL_ITEMS.values():
417 self.real_items.append(symbol_name)
418
234 def get_panel_reqs(self, panel_id: int, answer: str | None) -> AccessRequirements: 419 def get_panel_reqs(self, panel_id: int, answer: str | None) -> AccessRequirements:
235 if answer is None: 420 if answer is None:
236 if panel_id not in self.panel_reqs: 421 if panel_id not in self.panel_reqs:
@@ -253,25 +438,35 @@ class Lingo2PlayerLogic:
253 self.add_solution_reqs(reqs, answer) 438 self.add_solution_reqs(reqs, answer)
254 elif len(panel.proxies) > 0: 439 elif len(panel.proxies) > 0:
255 possibilities = [] 440 possibilities = []
441 already_filled = False
256 442
257 for proxy in panel.proxies: 443 for proxy in panel.proxies:
258 proxy_reqs = AccessRequirements() 444 proxy_reqs = AccessRequirements()
259 self.add_solution_reqs(proxy_reqs, proxy.answer) 445 self.add_solution_reqs(proxy_reqs, proxy.answer)
260 446
261 possibilities.append(proxy_reqs) 447 if not proxy_reqs.is_empty():
448 possibilities.append(proxy_reqs)
449 else:
450 already_filled = True
451 break
262 452
263 if not any(proxy.answer == panel.answer for proxy in panel.proxies): 453 if not already_filled and not any(proxy.answer == panel.answer for proxy in panel.proxies):
264 proxy_reqs = AccessRequirements() 454 proxy_reqs = AccessRequirements()
265 self.add_solution_reqs(proxy_reqs, panel.answer) 455 self.add_solution_reqs(proxy_reqs, panel.answer)
266 456
267 possibilities.append(proxy_reqs) 457 if not proxy_reqs.is_empty():
458 possibilities.append(proxy_reqs)
459 else:
460 already_filled = True
268 461
269 reqs.or_logic.append(possibilities) 462 if not already_filled:
463 reqs.or_logic.append(possibilities)
270 else: 464 else:
271 self.add_solution_reqs(reqs, panel.answer) 465 self.add_solution_reqs(reqs, panel.answer)
272 466
273 for symbol in panel.symbols: 467 if self.world.options.shuffle_symbols:
274 reqs.symbols.add(symbol) 468 for symbol in panel.symbols:
469 reqs.items.add(SYMBOL_ITEMS.get(symbol))
275 470
276 if panel.HasField("required_door"): 471 if panel.HasField("required_door"):
277 door_reqs = self.get_door_open_reqs(panel.required_door) 472 door_reqs = self.get_door_open_reqs(panel.required_door)
@@ -294,21 +489,28 @@ class Lingo2PlayerLogic:
294 door = self.world.static_logic.objects.doors[door_id] 489 door = self.world.static_logic.objects.doors[door_id]
295 reqs = AccessRequirements() 490 reqs = AccessRequirements()
296 491
297 # TODO: lavender_cubes, endings
298 if not door.HasField("complete_at") or door.complete_at == 0: 492 if not door.HasField("complete_at") or door.complete_at == 0:
299 for proxy in door.panels: 493 for proxy in door.panels:
300 panel_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None) 494 panel_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None)
301 reqs.merge(panel_reqs) 495 reqs.merge(panel_reqs)
302 elif door.complete_at == 1: 496 elif door.complete_at == 1:
303 reqs.or_logic.append([self.get_panel_reqs(proxy.panel, 497 disjunction = []
304 proxy.answer if proxy.HasField("answer") else None) 498 for proxy in door.panels:
305 for proxy in door.panels]) 499 proxy_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None)
500 if proxy_reqs.is_empty():
501 disjunction.clear()
502 break
503 else:
504 disjunction.append(proxy_reqs)
505 if len(disjunction) > 0:
506 reqs.or_logic.append(disjunction)
306 else: 507 else:
307 # TODO: Handle complete_at > 1 508 reqs.complete_at = door.complete_at
308 pass 509 for proxy in door.panels:
510 panel_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None)
511 reqs.possibilities.append(panel_reqs)
309 512
310 if door.HasField("control_center_color"): 513 if door.HasField("control_center_color"):
311 # TODO: Logic for ensuring two CC states aren't needed at once.
312 reqs.rooms.add("Control Center - Main Area") 514 reqs.rooms.add("Control Center - Main Area")
313 self.add_solution_reqs(reqs, door.control_center_color) 515 self.add_solution_reqs(reqs, door.control_center_color)
314 516
@@ -316,7 +518,8 @@ class Lingo2PlayerLogic:
316 if self.world.options.cyan_door_behavior == CyanDoorBehavior.option_collect_h2: 518 if self.world.options.cyan_door_behavior == CyanDoorBehavior.option_collect_h2:
317 reqs.rooms.add("The Repetitive - Main Room") 519 reqs.rooms.add("The Repetitive - Main Room")
318 elif self.world.options.cyan_door_behavior == CyanDoorBehavior.option_any_double_letter: 520 elif self.world.options.cyan_door_behavior == CyanDoorBehavior.option_any_double_letter:
319 reqs.cyans = True 521 if self.world.options.shuffle_letters != ShuffleLetters.option_unlocked:
522 reqs.cyans = True
320 elif self.world.options.cyan_door_behavior == CyanDoorBehavior.option_item: 523 elif self.world.options.cyan_door_behavior == CyanDoorBehavior.option_item:
321 # There shouldn't be any locations that are cyan doors. 524 # There shouldn't be any locations that are cyan doors.
322 pass 525 pass
@@ -333,14 +536,19 @@ class Lingo2PlayerLogic:
333 for room in door.rooms: 536 for room in door.rooms:
334 reqs.rooms.add(self.world.static_logic.get_room_region_name(room)) 537 reqs.rooms.add(self.world.static_logic.get_room_region_name(room))
335 538
336 for ending_id in door.endings: 539 if door.white_ending:
337 ending = self.world.static_logic.objects.endings[ending_id] 540 if self.world.options.endings_requirement > 0:
338 reqs.items.add(f"{ending.name.capitalize()} Ending (Achieved)") 541 reqs.progressives["Ending"] = self.world.options.endings_requirement.value
542
543 if self.world.options.masteries_requirement > 0:
544 reqs.progressives["Mastery"] = self.world.options.masteries_requirement.value
339 545
340 for sub_door_id in door.doors: 546 for sub_door_id in door.doors:
341 sub_reqs = self.get_door_open_reqs(sub_door_id) 547 sub_reqs = self.get_door_open_reqs(sub_door_id)
342 reqs.merge(sub_reqs) 548 reqs.merge(sub_reqs)
343 549
550 reqs.simplify()
551
344 return reqs 552 return reqs
345 553
346 # This gets the requirements to open a door within the world. When a door is shuffled, this means having the item 554 # This gets the requirements to open a door within the world. When a door is shuffled, this means having the item
@@ -395,3 +603,6 @@ class Lingo2PlayerLogic:
395 603
396 if needed > 0: 604 if needed > 0:
397 reqs.letters[l] = max(reqs.letters.get(l, 0), needed) 605 reqs.letters[l] = max(reqs.letters.get(l, 0), needed)
606
607 if any(l.isnumeric() for l in solution):
608 reqs.items.add("Numbers")
diff --git a/apworld/regions.py b/apworld/regions.py index e30493c..1118603 100644 --- a/apworld/regions.py +++ b/apworld/regions.py
@@ -1,6 +1,8 @@
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
6from .player_logic import AccessRequirements 8from .player_logic import AccessRequirements
@@ -11,21 +13,42 @@ if TYPE_CHECKING:
11 13
12 14
13def create_region(room, world: "Lingo2World") -> Region: 15def create_region(room, world: "Lingo2World") -> Region:
14 new_region = Region(world.static_logic.get_room_region_name(room.id), world.player, world.multiworld) 16 return Region(world.static_logic.get_room_region_name(room.id), world.player, world.multiworld)
15 17
18
19def create_locations(room, new_region: Region, world: "Lingo2World", regions: dict[str, Region]):
16 for location in world.player_logic.locations_by_room.get(room.id, {}): 20 for location in world.player_logic.locations_by_room.get(room.id, {}):
21 reqs = location.reqs.copy()
22 reqs.remove_room(new_region.name)
23
17 new_location = Lingo2Location(world.player, world.static_logic.location_id_to_name[location.code], 24 new_location = Lingo2Location(world.player, world.static_logic.location_id_to_name[location.code],
18 location.code, new_region) 25 location.code, new_region)
19 new_location.access_rule = make_location_lambda(location.reqs, world) 26 new_location.access_rule = make_location_lambda(reqs, world, regions)
20 new_region.locations.append(new_location) 27 new_region.locations.append(new_location)
21 28
22 for event_name, item_name in world.player_logic.event_loc_item_by_room.get(room.id, {}).items(): 29 for event_name, item_name in world.player_logic.event_loc_item_by_room.get(room.id, {}).items():
23 new_location = Lingo2Location(world.player, event_name, None, new_region) 30 new_location = Lingo2Location(world.player, event_name, None, new_region)
31 if world.for_tracker and item_name == "Victory":
32 new_location.goal = True
33
24 event_item = Lingo2Item(item_name, ItemClassification.progression, None, world.player) 34 event_item = Lingo2Item(item_name, ItemClassification.progression, None, world.player)
25 new_location.place_locked_item(event_item) 35 new_location.place_locked_item(event_item)
26 new_region.locations.append(new_location) 36 new_region.locations.append(new_location)
27 37
28 return new_region 38 if world.for_tracker and world.options.shuffle_worldports:
39 for port_id in room.ports:
40 port = world.static_logic.objects.ports[port_id]
41 if port.no_shuffle:
42 continue
43
44 new_location = Lingo2Location(world.player, f"Worldport {port.id} Entered", None, new_region)
45 new_location.port_id = port.id
46
47 if port.HasField("required_door"):
48 new_location.access_rule = \
49 make_location_lambda(world.player_logic.get_door_open_reqs(port.required_door), world, regions)
50
51 new_region.locations.append(new_location)
29 52
30 53
31def create_regions(world: "Lingo2World"): 54def create_regions(world: "Lingo2World"):
@@ -33,19 +56,37 @@ def create_regions(world: "Lingo2World"):
33 "Menu": Region("Menu", world.player, world.multiworld) 56 "Menu": Region("Menu", world.player, world.multiworld)
34 } 57 }
35 58
59 region_and_room = []
60
61 # Create the regions in two stages. First, make the actual region objects and memoize them. Then, add all of the
62 # locations. This allows us to reference the actual region objects in the access rules for the locations, which is
63 # faster than having to look them up during access checking.
36 for room in world.static_logic.objects.rooms: 64 for room in world.static_logic.objects.rooms:
65 if room.map_id not in world.player_logic.shuffled_maps:
66 continue
67
37 region = create_region(room, world) 68 region = create_region(room, world)
38 regions[region.name] = region 69 regions[region.name] = region
70 region_and_room.append((region, room))
71
72 for (region, room) in region_and_room:
73 create_locations(room, region, world, regions)
39 74
40 regions["Menu"].connect(regions["The Entry - Starting Room"], "Start Game") 75 regions["Menu"].connect(regions["The Entry - Starting Room"], "Start Game")
41 76
42 # TODO: The requirements of the opposite trigger also matter.
43 for connection in world.static_logic.objects.connections: 77 for connection in world.static_logic.objects.connections:
44 if connection.roof_access and not world.options.daedalus_roof_access: 78 if connection.roof_access and not world.options.daedalus_roof_access:
45 continue 79 continue
46 80
81 if connection.vanilla_only and world.options.shuffle_doors:
82 continue
83
47 from_region = world.static_logic.get_room_region_name(connection.from_room) 84 from_region = world.static_logic.get_room_region_name(connection.from_room)
48 to_region = world.static_logic.get_room_region_name(connection.to_room) 85 to_region = world.static_logic.get_room_region_name(connection.to_room)
86
87 if from_region not in regions or to_region not in regions:
88 continue
89
49 connection_name = f"{from_region} -> {to_region}" 90 connection_name = f"{from_region} -> {to_region}"
50 91
51 reqs = AccessRequirements() 92 reqs = AccessRequirements()
@@ -59,7 +100,10 @@ def create_regions(world: "Lingo2World"):
59 100
60 if connection.HasField("port"): 101 if connection.HasField("port"):
61 port = world.static_logic.objects.ports[connection.port] 102 port = world.static_logic.objects.ports[connection.port]
62 connection_name = f"{connection_name} (via port {port.name})" 103 connection_name = f"{connection_name} (via {port.display_name})"
104
105 if world.options.shuffle_worldports and not port.no_shuffle:
106 continue
63 107
64 if port.HasField("required_door"): 108 if port.HasField("required_door"):
65 reqs.merge(world.player_logic.get_door_open_reqs(port.required_door)) 109 reqs.merge(world.player_logic.get_door_open_reqs(port.required_door))
@@ -82,14 +126,131 @@ def create_regions(world: "Lingo2World"):
82 else: 126 else:
83 connection_name = f"{connection_name} (via panel {panel.name})" 127 connection_name = f"{connection_name} (via panel {panel.name})"
84 128
85 if from_region in regions and to_region in regions: 129 if connection.HasField("purple_ending") and connection.purple_ending and world.options.strict_purple_ending:
86 connection = Entrance(world.player, connection_name, regions[from_region]) 130 world.player_logic.add_solution_reqs(reqs, "abcdefghijklmnopqrstuvwxyz")
87 connection.access_rule = make_location_lambda(reqs, world) 131
132 if connection.HasField("cyan_ending") and connection.cyan_ending and world.options.strict_cyan_ending:
133 world.player_logic.add_solution_reqs(reqs, "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz")
134
135 reqs.simplify()
136 reqs.remove_room(from_region)
137
138 if to_region in reqs.rooms:
139 # This connection can't ever increase access because you're required to have access to the other side in
140 # order for it to be usable. We will just not create the connection at all, in order to help GER figure out
141 # what regions are dead ends.
142 continue
143
144 connection = Entrance(world.player, connection_name, regions[from_region])
145 connection.access_rule = make_location_lambda(reqs, world, regions)
88 146
89 regions[from_region].exits.append(connection) 147 regions[from_region].exits.append(connection)
90 connection.connect(regions[to_region]) 148 connection.connect(regions[to_region])
91 149
92 for region in reqs.rooms: 150 for region in reqs.get_referenced_rooms():
93 world.multiworld.register_indirect_condition(regions[region], connection) 151 world.multiworld.register_indirect_condition(regions[region], connection)
94 152
95 world.multiworld.regions += regions.values() 153 world.multiworld.regions += regions.values()
154
155
156def shuffle_entrances(world: "Lingo2World"):
157 er_entrances: list[Entrance] = []
158 er_exits: list[Entrance] = []
159
160 port_id_by_name: dict[str, int] = {}
161
162 shuffleable_ports = [port for port in world.static_logic.objects.ports
163 if not port.no_shuffle
164 and world.static_logic.get_room_object_map_id(port) in world.player_logic.shuffled_maps]
165
166 if len(shuffleable_ports) % 2 == 1:
167 # We have an odd number of shuffleable ports! Pick a port from a room that has more than one, and make it a
168 # redundant warp to another port.
169 redundant_rooms = set(room.id for room in world.static_logic.objects.rooms if len(room.ports) > 1)
170 redundant_ports = [port for port in shuffleable_ports if port.room_id in redundant_rooms]
171 chosen_port = world.random.choice(redundant_ports)
172
173 shuffleable_ports.remove(chosen_port)
174
175 chosen_destination = world.random.choice(shuffleable_ports)
176
177 world.port_pairings[chosen_port.id] = chosen_destination.id
178
179 from_region_name = world.static_logic.get_room_region_name(chosen_port.room_id)
180 to_region_name = world.static_logic.get_room_region_name(chosen_destination.room_id)
181
182 from_region = world.multiworld.get_region(from_region_name, world.player)
183 to_region = world.multiworld.get_region(to_region_name, world.player)
184
185 connection = Entrance(world.player, f"{from_region_name} - {chosen_port.display_name}", from_region)
186 from_region.exits.append(connection)
187 connection.connect(to_region)
188
189 if chosen_port.HasField("required_door"):
190 door_reqs = world.player_logic.get_door_open_reqs(chosen_port.required_door)
191 connection.access_rule = make_location_lambda(door_reqs, world, None)
192
193 for region in door_reqs.get_referenced_rooms():
194 world.multiworld.register_indirect_condition(world.multiworld.get_region(region, world.player),
195 connection)
196
197 for port in shuffleable_ports:
198 port_region_name = world.static_logic.get_room_region_name(port.room_id)
199 port_region = world.multiworld.get_region(port_region_name, world.player)
200
201 connection_name = f"{port_region_name} - {port.display_name}"
202 port_id_by_name[connection_name] = port.id
203
204 entrance = port_region.create_er_target(connection_name)
205 entrance.randomization_type = BaseClasses.EntranceType.TWO_WAY
206
207 er_exit = port_region.create_exit(connection_name)
208 er_exit.randomization_type = BaseClasses.EntranceType.TWO_WAY
209
210 if port.HasField("required_door"):
211 door_reqs = world.player_logic.get_door_open_reqs(port.required_door)
212 er_exit.access_rule = make_location_lambda(door_reqs, world, None)
213
214 for region in door_reqs.get_referenced_rooms():
215 world.multiworld.register_indirect_condition(world.multiworld.get_region(region, world.player),
216 er_exit)
217
218 er_entrances.append(entrance)
219 er_exits.append(er_exit)
220
221 result = randomize_entrances(world, True, {0:[0]}, False, er_entrances,
222 er_exits)
223
224 for (f, to) in result.pairings:
225 world.port_pairings[port_id_by_name[f]] = port_id_by_name[to]
226
227
228def connect_ports_from_ut(port_pairings: dict[int, int], world: "Lingo2World"):
229 for fpid, tpid in port_pairings.items():
230 from_port = world.static_logic.objects.ports[fpid]
231 to_port = world.static_logic.objects.ports[tpid]
232
233 from_region_name = world.static_logic.get_room_region_name(from_port.room_id)
234 to_region_name = world.static_logic.get_room_region_name(to_port.room_id)
235
236 from_region = world.multiworld.get_region(from_region_name, world.player)
237 to_region = world.multiworld.get_region(to_region_name, world.player)
238
239 connection = Entrance(world.player, f"{from_region_name} - {from_port.display_name}", from_region)
240
241 reqs = AccessRequirements()
242 if from_port.HasField("required_door"):
243 reqs = world.player_logic.get_door_open_reqs(from_port.required_door).copy()
244
245 if world.for_tracker:
246 reqs.items.add(f"Worldport {fpid} Entered")
247
248 if not reqs.is_empty():
249 connection.access_rule = make_location_lambda(reqs, world, None)
250
251 for region in reqs.get_referenced_rooms():
252 world.multiworld.register_indirect_condition(world.multiworld.get_region(region, world.player),
253 connection)
254
255 from_region.exits.append(connection)
256 connection.connect(to_region)
diff --git a/apworld/requirements.txt b/apworld/requirements.txt index 49ca0a7..dbc395b 100644 --- a/apworld/requirements.txt +++ b/apworld/requirements.txt
@@ -1 +1 @@
protobuf==3.20.3 \ No newline at end of file protobuf==3.20.3
diff --git a/apworld/rules.py b/apworld/rules.py index 56486fa..f859e75 100644 --- a/apworld/rules.py +++ b/apworld/rules.py
@@ -1,14 +1,15 @@
1from collections.abc import Callable 1from collections.abc import Callable
2from typing import TYPE_CHECKING 2from typing import TYPE_CHECKING
3 3
4from BaseClasses import CollectionState 4from BaseClasses import CollectionState, Region
5from .player_logic import AccessRequirements 5from .player_logic import AccessRequirements
6 6
7if TYPE_CHECKING: 7if TYPE_CHECKING:
8 from . import Lingo2World 8 from . import Lingo2World
9 9
10 10
11def lingo2_can_satisfy_requirements(state: CollectionState, reqs: AccessRequirements, world: "Lingo2World") -> bool: 11def lingo2_can_satisfy_requirements(state: CollectionState, reqs: AccessRequirements, regions: list[Region],
12 world: "Lingo2World") -> bool:
12 if not all(state.has(item, world.player) for item in reqs.items): 13 if not all(state.has(item, world.player) for item in reqs.items):
13 return False 14 return False
14 15
@@ -18,7 +19,8 @@ def lingo2_can_satisfy_requirements(state: CollectionState, reqs: AccessRequirem
18 if not all(state.can_reach_region(region_name, world.player) for region_name in reqs.rooms): 19 if not all(state.can_reach_region(region_name, world.player) for region_name in reqs.rooms):
19 return False 20 return False
20 21
21 # TODO: symbols 22 if not all(state.can_reach(region) for region in regions):
23 return False
22 24
23 for letter_key, letter_level in reqs.letters.items(): 25 for letter_key, letter_level in reqs.letters.items():
24 if not state.has(letter_key, world.player, letter_level): 26 if not state.has(letter_key, world.player, letter_level):
@@ -30,11 +32,35 @@ def lingo2_can_satisfy_requirements(state: CollectionState, reqs: AccessRequirem
30 return False 32 return False
31 33
32 if len(reqs.or_logic) > 0: 34 if len(reqs.or_logic) > 0:
33 if not all(any(lingo2_can_satisfy_requirements(state, sub_reqs, world) for sub_reqs in subjunction) 35 if not all(any(lingo2_can_satisfy_requirements(state, sub_reqs, [], world) for sub_reqs in subjunction)
34 for subjunction in reqs.or_logic): 36 for subjunction in reqs.or_logic):
35 return False 37 return False
36 38
39 if reqs.complete_at is not None:
40 completed = 0
41 checked = 0
42 for possibility in reqs.possibilities:
43 checked += 1
44 if lingo2_can_satisfy_requirements(state, possibility, [], world):
45 completed += 1
46 if completed >= reqs.complete_at:
47 break
48 elif len(reqs.possibilities) - checked + completed < reqs.complete_at:
49 # There aren't enough remaining possibilities for the check to pass.
50 return False
51 if completed < reqs.complete_at:
52 return False
53
37 return True 54 return True
38 55
39def make_location_lambda(reqs: AccessRequirements, world: "Lingo2World") -> Callable[[CollectionState], bool]: 56def make_location_lambda(reqs: AccessRequirements, world: "Lingo2World",
40 return lambda state: lingo2_can_satisfy_requirements(state, reqs, world) 57 regions: dict[str, Region] | None) -> 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 if regions is not None:
61 required_regions = [regions[room_name] for room_name in reqs.rooms]
62 else:
63 required_regions = [world.multiworld.get_region(room_name, world.player) for room_name in reqs.rooms]
64 new_reqs = reqs.copy()
65 new_reqs.rooms.clear()
66 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 0cc7e55..8a84111 100644 --- a/apworld/static_logic.py +++ b/apworld/static_logic.py
@@ -1,6 +1,8 @@
1from .generated import data_pb2 as data_pb2 1from .generated import data_pb2 as data_pb2
2from .items import SYMBOL_ITEMS, ANTI_COLLECTABLE_TRAPS
2import pkgutil 3import pkgutil
3 4
5
4class Lingo2StaticLogic: 6class Lingo2StaticLogic:
5 item_id_to_name: dict[int, str] 7 item_id_to_name: dict[int, str]
6 location_id_to_name: dict[int, str] 8 location_id_to_name: dict[int, str]
@@ -11,11 +13,17 @@ class Lingo2StaticLogic:
11 item_name_groups: dict[str, list[str]] 13 item_name_groups: dict[str, list[str]]
12 location_name_groups: dict[str, list[str]] 14 location_name_groups: dict[str, list[str]]
13 15
16 letter_weights: dict[str, int]
17
18 door_id_by_ap_id: dict[int, int]
19 port_id_by_ap_id: dict[int, int]
20
14 def __init__(self): 21 def __init__(self):
15 self.item_id_to_name = {} 22 self.item_id_to_name = {}
16 self.location_id_to_name = {} 23 self.location_id_to_name = {}
17 self.item_name_groups = {} 24 self.item_name_groups = {}
18 self.location_name_groups = {} 25 self.location_name_groups = {}
26 self.letter_weights = {}
19 27
20 file = pkgutil.get_data(__name__, "generated/data.binpb") 28 file = pkgutil.get_data(__name__, "generated/data.binpb")
21 self.objects = data_pb2.AllObjects() 29 self.objects = data_pb2.AllObjects()
@@ -63,10 +71,24 @@ class Lingo2StaticLogic:
63 self.location_name_groups.setdefault("Keyholders", []).append(location_name) 71 self.location_name_groups.setdefault("Keyholders", []).append(location_name)
64 72
65 self.item_id_to_name[self.objects.special_ids["A Job Well Done"]] = "A Job Well Done" 73 self.item_id_to_name[self.objects.special_ids["A Job Well Done"]] = "A Job Well Done"
74 self.item_id_to_name[self.objects.special_ids["Numbers"]] = "Numbers"
75
76 for symbol_name in SYMBOL_ITEMS.values():
77 self.item_id_to_name[self.objects.special_ids[symbol_name]] = symbol_name
78
79 for trap_name in ANTI_COLLECTABLE_TRAPS:
80 self.item_id_to_name[self.objects.special_ids[trap_name]] = trap_name
66 81
67 self.item_name_to_id = {name: ap_id for ap_id, name in self.item_id_to_name.items()} 82 self.item_name_to_id = {name: ap_id for ap_id, name in self.item_id_to_name.items()}
68 self.location_name_to_id = {name: ap_id for ap_id, name in self.location_id_to_name.items()} 83 self.location_name_to_id = {name: ap_id for ap_id, name in self.location_id_to_name.items()}
69 84
85 for panel in self.objects.panels:
86 for letter in panel.answer.upper():
87 self.letter_weights[letter] = self.letter_weights.get(letter, 0) + 1
88
89 self.door_id_by_ap_id = {door.ap_id: door.id for door in self.objects.doors if door.HasField("ap_id")}
90 self.port_id_by_ap_id = {port.ap_id: port.id for port in self.objects.ports if port.HasField("ap_id")}
91
70 def get_door_item_name(self, door: data_pb2.Door) -> str: 92 def get_door_item_name(self, door: data_pb2.Door) -> str:
71 return f"{self.get_map_object_map_name(door)} - {door.name}" 93 return f"{self.get_map_object_map_name(door)} - {door.name}"
72 94
@@ -90,7 +112,7 @@ class Lingo2StaticLogic:
90 if door.type != data_pb2.DoorType.STANDARD: 112 if door.type != data_pb2.DoorType.STANDARD:
91 return None 113 return None
92 114
93 if len(door.keyholders) > 0 or len(door.endings) > 0: 115 if len(door.keyholders) > 0 or door.white_ending or door.HasField("complete_at"):
94 return None 116 return None
95 117
96 if len(door.panels) > 4: 118 if len(door.panels) > 4:
@@ -126,7 +148,7 @@ class Lingo2StaticLogic:
126 for panel_id in door.panels] 148 for panel_id in door.panels]
127 panel_names.sort() 149 panel_names.sort()
128 150
129 return f"{map_part} - {", ".join(panel_names)}" 151 return map_part + " - " + ", ".join(panel_names)
130 152
131 def get_door_location_name_by_id(self, door_id: int) -> str: 153 def get_door_location_name_by_id(self, door_id: int) -> str:
132 door = self.objects.doors[door_id] 154 door = self.objects.doors[door_id]
@@ -150,3 +172,10 @@ class Lingo2StaticLogic:
150 return f"{game_map.display_name} ({room.panel_display_name})" 172 return f"{game_map.display_name} ({room.panel_display_name})"
151 else: 173 else:
152 return game_map.display_name 174 return game_map.display_name
175
176 def get_room_object_map_id(self, obj) -> int:
177 return self.objects.rooms[obj.room_id].map_id
178
179 def get_data_version(self) -> list[int]:
180 version = self.objects.version
181 return [version.major, version.minor, version.patch]
diff --git a/apworld/tracker.py b/apworld/tracker.py new file mode 100644 index 0000000..d473af4 --- /dev/null +++ b/apworld/tracker.py
@@ -0,0 +1,146 @@
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 self.world.create_regions()
48
49 if self.world.options.shuffle_worldports:
50 port_pairings = {
51 self.world.static_logic.port_id_by_ap_id[int(fp)]: self.world.static_logic.port_id_by_ap_id[int(tp)]
52 for fp, tp in slot_data["port_pairings"].items()
53 }
54 connect_ports_from_ut(port_pairings, self.world)
55
56 self.refresh_state()
57
58 def set_checked_locations(self, checked_locations: set[int]):
59 self.checked_locations = checked_locations.copy()
60
61 def set_collected_items(self, network_items: list[NetworkItem]):
62 self.collected_items = {}
63
64 for item in network_items:
65 self.collected_items[item.item] = self.collected_items.get(item.item, 0) + 1
66
67 self.refresh_state()
68
69 def refresh_state(self):
70 self.state = CollectionState(self.multiworld)
71
72 for item_id, item_amount in self.collected_items.items():
73 for i in range(item_amount):
74 self.state.collect(Lingo2Item(Lingo2World.static_logic.item_id_to_name.get(item_id),
75 ItemClassification.progression, item_id, PLAYER_NUM), prevent_sweep=True)
76
77 for k, v in self.manager.keyboard.items():
78 # Unless all level 1 letters are pre-unlocked, H1 I1 N1 and T1 act differently between the generator and
79 # game. The generator considers them to be unlocked, which means they are not included in logic
80 # requirements, and only one item/event is needed to unlock their level 2 forms. The game considers them to
81 # be vanilla, which means you still have to pick them up in the Starting Room in order for them to appear on
82 # your keyboard. This also means that whether or not you have the level 1 forms should be synced to the
83 # multiworld. The tracker specifically should collect one fewer item for these letters in this scenario.
84 tv = v
85 if k in "hint" and self.world.options.shuffle_letters in [ShuffleLetters.option_vanilla,
86 ShuffleLetters.option_progressive]:
87 tv = max(0, v - 1)
88
89 if tv > 0:
90 for i in range(tv):
91 self.state.collect(Lingo2Item(k.upper(), ItemClassification.progression, None, PLAYER_NUM),
92 prevent_sweep=True)
93
94 for port_id in self.manager.worldports:
95 self.state.collect(Lingo2Item(f"Worldport {port_id} Entered", ItemClassification.progression, None,
96 PLAYER_NUM), prevent_sweep=True)
97
98 self.state.sweep_for_advancements()
99
100 self.accessible_locations = set()
101 self.accessible_worldports = set()
102 self.goal_accessible = False
103
104 for region in self.state.reachable_regions[PLAYER_NUM]:
105 for location in region.locations:
106 if location.access_rule(self.state):
107 if location.address is not None:
108 if location.address not in self.checked_locations:
109 self.accessible_locations.add(location.address)
110 elif hasattr(location, "port_id"):
111 if location.port_id not in self.manager.worldports:
112 self.accessible_worldports.add(location.port_id)
113 elif hasattr(location, "goal") and location.goal:
114 if not self.manager.goaled:
115 self.goal_accessible = True
116
117 def get_path_to_location(self, location_id: int) -> list[str] | None:
118 location_name = self.world.location_id_to_name.get(location_id)
119 location = self.multiworld.get_location(location_name, PLAYER_NUM)
120 return self.get_logical_path(location.parent_region)
121
122 def get_path_to_port(self, port_id: int) -> list[str] | None:
123 port = self.world.static_logic.objects.ports[port_id]
124 region_name = self.world.static_logic.get_room_region_name(port.room_id)
125 region = self.multiworld.get_region(region_name, PLAYER_NUM)
126 return self.get_logical_path(region)
127
128 def get_path_to_goal(self):
129 room_id = self.world.player_logic.goal_room_id
130 region_name = self.world.static_logic.get_room_region_name(room_id)
131 region = self.multiworld.get_region(region_name, PLAYER_NUM)
132 return self.get_logical_path(region)
133
134 def get_logical_path(self, region: Region) -> list[str] | None:
135 if region not in self.state.path:
136 return None
137
138 def flist_to_iter(path_value) -> Iterator[str]:
139 while path_value:
140 region_or_entrance, path_value = path_value
141 yield region_or_entrance
142
143 reversed_path = self.state.path.get(region)
144 flat_path = reversed(list(map(str, flist_to_iter(reversed_path))))
145
146 return list(flat_path)[1::2]