about summary refs log tree commit diff stats
path: root/apworld
diff options
context:
space:
mode:
Diffstat (limited to 'apworld')
-rw-r--r--apworld/__init__.py125
-rw-r--r--apworld/client/animationListener.gd38
-rw-r--r--apworld/client/apworld_runtime.gd44
-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.gd275
-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.gd46
-rw-r--r--apworld/client/effects.gd32
-rw-r--r--apworld/client/gamedata.gd294
-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.gd296
-rw-r--r--apworld/client/manager.gd651
-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/panel.gd101
-rw-r--r--apworld/client/pauseMenu.gd91
-rw-r--r--apworld/client/player.gd349
-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.gd29
-rw-r--r--apworld/client/teleport.gd38
-rw-r--r--apworld/client/teleportListener.gd49
-rw-r--r--apworld/client/textclient.gd438
-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.py654
-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.py154
-rw-r--r--apworld/player_logic.py391
-rw-r--r--apworld/regions.py153
-rw-r--r--apworld/requirements.txt2
-rw-r--r--apworld/rules.py46
-rw-r--r--apworld/static_logic.py122
-rw-r--r--apworld/tracker.py143
53 files changed, 5774 insertions, 85 deletions
diff --git a/apworld/__init__.py b/apworld/__init__.py index 14bb4bc..e126fc0 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
@@ -32,18 +57,35 @@ class Lingo2World(World):
32 static_logic = Lingo2StaticLogic() 57 static_logic = Lingo2StaticLogic()
33 item_name_to_id = static_logic.item_name_to_id 58 item_name_to_id = static_logic.item_name_to_id
34 location_name_to_id = static_logic.location_name_to_id 59 location_name_to_id = static_logic.location_name_to_id
60 item_name_groups = static_logic.item_name_groups
61 location_name_groups = static_logic.location_name_groups
62
63 for_tracker: ClassVar[bool] = False
35 64
36 player_logic: Lingo2PlayerLogic 65 player_logic: Lingo2PlayerLogic
37 66
67 port_pairings: dict[int, int]
68
38 def generate_early(self): 69 def generate_early(self):
39 self.player_logic = Lingo2PlayerLogic(self) 70 self.player_logic = Lingo2PlayerLogic(self)
71 self.port_pairings = {}
40 72
41 def create_regions(self): 73 def create_regions(self):
42 create_regions(self) 74 create_regions(self)
43 75
44 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 = {int(fp): int(tp) for fp, tp in slot_value.items()}
81
82 connect_ports_from_ut(self.port_pairings, self)
83 else:
84 shuffle_entrances(self)
45 85
46 visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml") 86 #from Utils import visualize_regions
87
88 #visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml")
47 89
48 def create_items(self): 90 def create_items(self):
49 pool = [self.create_item(name) for name in self.player_logic.real_items] 91 pool = [self.create_item(name) for name in self.player_logic.real_items]
@@ -51,14 +93,83 @@ class Lingo2World(World):
51 total_locations = sum(len(locs) for locs in self.player_logic.locations_by_room.values()) 93 total_locations = sum(len(locs) for locs in self.player_logic.locations_by_room.values())
52 94
53 item_difference = total_locations - len(pool) 95 item_difference = total_locations - len(pool)
96
97 if self.options.trap_percentage > 0:
98 num_traps = int(item_difference * self.options.trap_percentage / 100)
99 item_difference = item_difference - num_traps
100
101 trap_names = []
102 trap_weights = []
103 for letter_name, weight in self.static_logic.letter_weights.items():
104 trap_names.append(f"Anti {letter_name}")
105 trap_weights.append(weight)
106
107 bad_letters = self.random.choices(trap_names, weights=trap_weights, k=num_traps)
108 pool += [self.create_item(trap_name) for trap_name in bad_letters]
109
54 for i in range(0, item_difference): 110 for i in range(0, item_difference):
55 pool.append(self.create_item("Nothing")) 111 pool.append(self.create_item(self.get_filler_item_name()))
112
113 if not any(ItemClassification.progression in item.classification for item in pool):
114 raise OptionError(f"Lingo 2 player {self.player} has no progression items. Please enable at least one "
115 f"option that would add progression gating to your world, such as Shuffle Doors or "
116 f"Shuffle Letters.")
56 117
57 self.multiworld.itempool += pool 118 self.multiworld.itempool += pool
58 119
59 def create_item(self, name: str) -> Item: 120 def create_item(self, name: str) -> Item:
60 return Lingo2Item(name, ItemClassification.filler if name == "Nothing" else ItemClassification.progression, 121 return Lingo2Item(name, ItemClassification.filler if name == self.get_filler_item_name() else
122 ItemClassification.trap if name in ANTI_COLLECTABLE_TRAPS else
123 ItemClassification.progression,
61 self.item_name_to_id.get(name), self.player) 124 self.item_name_to_id.get(name), self.player)
62 125
63 def set_rules(self): 126 def set_rules(self):
64 self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player) 127 self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player)
128
129 def fill_slot_data(self):
130 slot_options = [
131 "cyan_door_behavior",
132 "daedalus_roof_access",
133 "keyholder_sanity",
134 "shuffle_control_center_colors",
135 "shuffle_doors",
136 "shuffle_gallery_paintings",
137 "shuffle_letters",
138 "shuffle_symbols",
139 "shuffle_worldports",
140 "strict_cyan_ending",
141 "strict_purple_ending",
142 "victory_condition",
143 ]
144
145 slot_data: dict[str, object] = {
146 **self.options.as_dict(*slot_options),
147 "version": self.static_logic.get_data_version(),
148 }
149
150 if self.options.shuffle_worldports:
151 slot_data["port_pairings"] = self.port_pairings
152
153 return slot_data
154
155 def get_filler_item_name(self) -> str:
156 return "A Job Well Done"
157
158 # for the universal tracker, doesn't get called in standard gen
159 # docs: https://github.com/FarisTheAncient/Archipelago/blob/tracker/worlds/tracker/docs/re-gen-passthrough.md
160 @staticmethod
161 def interpret_slot_data(slot_data: dict[str, object]) -> dict[str, object]:
162 # returning slot_data so it regens, giving it back in multiworld.re_gen_passthrough
163 # we are using re_gen_passthrough over modifying the world here due to complexities with ER
164 return slot_data
165
166
167def launch_client(*args):
168 from .context import client_main
169 launch_component(client_main, name="Lingo2Client", args=args)
170
171
172icon_paths["lingo2_ico"] = f"ap:{__name__}/logo.png"
173component = Component("Lingo 2 Client", component_type=Type.CLIENT, func=launch_client,
174 description="Open Lingo 2.", supports_uri=True, game_name="Lingo 2", icon="lingo2_ico")
175components.append(component)
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..faf8e0c --- /dev/null +++ b/apworld/client/apworld_runtime.gd
@@ -0,0 +1,44 @@
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 load_script(path):
19 var true_path = _get_true_path(path)
20
21 var script = GDScript.new()
22 script.source_code = apworld_reader.read_file(true_path).get_string_from_utf8()
23 script.reload()
24
25 return script
26
27
28func read_path(path):
29 var true_path = _get_true_path(path)
30 return apworld_reader.read_file(true_path)
31
32
33func load_script_as_scene(path, scene_name):
34 var script = load_script(path)
35 var instance = script.new()
36 instance.name = scene_name
37
38 get_tree().unload_current_scene()
39 _load_scene.call_deferred(instance)
40
41
42func _load_scene(instance):
43 get_tree().get_root().add_child(instance)
44 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..9a4b402 --- /dev/null +++ b/apworld/client/client.gd
@@ -0,0 +1,275 @@
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
28
29signal could_not_connect
30signal connect_status
31signal client_connected(slot_data)
32signal item_received(item, amount)
33signal location_scout_received(location_id, item_name, player_name, flags, for_self)
34signal text_message_received(message)
35signal item_sent_notification(message)
36signal hint_received(message)
37signal accessible_locations_updated
38signal checked_locations_updated
39signal checked_worldports_updated
40signal keyboard_update_received
41
42
43func _init():
44 set_process_mode(Node.PROCESS_MODE_ALWAYS)
45
46 global._print("Instantiated APClient")
47
48
49func _ready():
50 _server = SCRIPT_websocketserver.new()
51 _server.client_connected.connect(_on_web_socket_server_client_connected)
52 _server.client_disconnected.connect(_on_web_socket_server_client_disconnected)
53 _server.message_received.connect(_on_web_socket_server_message_received)
54 add_child(_server)
55 _server.listen(43182)
56
57
58func _reset_state():
59 _should_process = false
60 _received_items = {}
61 _received_indexes = []
62 _checked_worldports = []
63 _accessible_locations = []
64 _accessible_worldports = []
65 _goal_accessible = false
66
67
68func disconnect_from_ap():
69 sendMessage([{"cmd": "Disconnect"}])
70
71
72func _on_web_socket_server_client_connected(peer_id: int) -> void:
73 var peer: WebSocketPeer = _server.peers[peer_id]
74 print("Remote client connected: %d. Protocol: %s" % [peer_id, peer.get_selected_protocol()])
75 _server.send(-peer_id, "[%d] connected" % peer_id)
76
77
78func _on_web_socket_server_client_disconnected(peer_id: int) -> void:
79 var peer: WebSocketPeer = _server.peers[peer_id]
80 print(
81 (
82 "Remote client disconnected: %d. Code: %d, Reason: %s"
83 % [peer_id, peer.get_close_code(), peer.get_close_reason()]
84 )
85 )
86 _server.send(-peer_id, "[%d] disconnected" % peer_id)
87
88
89func _on_web_socket_server_message_received(_peer_id: int, packet: String) -> void:
90 global._print("Got data from server: " + packet)
91 var json = JSON.new()
92 var jserror = json.parse(packet)
93 if jserror != OK:
94 global._print("Error parsing packet from AP: " + jserror.error_string)
95 return
96
97 for message in json.data:
98 var cmd = message["cmd"]
99 global._print("Received command: " + cmd)
100
101 if cmd == "Connected":
102 _seed = message["seed_name"]
103 _remote_version = message["version"]
104 _gen_version = message["generator_version"]
105 _team = message["team"]
106 _slot = message["slot"]
107 _slot_data = message["slot_data"]
108
109 _checked_locations = []
110 for location in message["checked_locations"]:
111 _checked_locations.append(int(location))
112
113 client_connected.emit(_slot_data)
114
115 elif cmd == "ConnectionRefused":
116 could_not_connect.emit(message["text"])
117 global._print("Connection to AP refused")
118
119 elif cmd == "UpdateLocations":
120 for location in message["locations"]:
121 var lint = int(location)
122 if not _checked_locations.has(lint):
123 _checked_locations.append(lint)
124
125 checked_locations_updated.emit()
126
127 elif cmd == "UpdateWorldports":
128 for port_id in message["worldports"]:
129 var lint = int(port_id)
130 if not _checked_worldports.has(lint):
131 _checked_worldports.append(lint)
132
133 checked_worldports_updated.emit()
134
135 elif cmd == "ItemReceived":
136 for item in message["items"]:
137 var index = int(item["index"])
138 if _received_indexes.has(index):
139 # Do not re-process items.
140 continue
141
142 _received_indexes.append(index)
143
144 var item_id = int(item["id"])
145 _received_items[item_id] = _received_items.get(item_id, 0) + 1
146
147 item_received.emit(item, _received_items[item_id])
148
149 elif cmd == "TextMessage":
150 text_message_received.emit(message["data"])
151
152 elif cmd == "ItemSentNotif":
153 item_sent_notification.emit(message)
154
155 elif cmd == "HintReceived":
156 hint_received.emit(message)
157
158 elif cmd == "LocationInfo":
159 for loc in message["locations"]:
160 location_scout_received.emit(
161 int(loc["id"]), loc["item"], loc["player"], int(loc["flags"]), int(loc["self"])
162 )
163
164 elif cmd == "AccessibleLocations":
165 _accessible_locations.clear()
166 _accessible_worldports.clear()
167
168 for loc in message["locations"]:
169 _accessible_locations.append(int(loc))
170
171 if "worldports" in message:
172 for port_id in message["worldports"]:
173 _accessible_worldports.append(int(port_id))
174
175 _goal_accessible = bool(message.get("goal", false))
176
177 accessible_locations_updated.emit()
178
179 elif cmd == "UpdateKeyboard":
180 var updates = {}
181 for k in message["updates"]:
182 updates[k] = int(message["updates"][k])
183
184 keyboard_update_received.emit(updates)
185
186 elif cmd == "PathReply":
187 var textclient = global.get_node("Textclient")
188 textclient.display_logical_path(
189 message["type"], int(message.get("id", null)), message["path"]
190 )
191
192
193func connectToServer(server, un, pw):
194 sendMessage([{"cmd": "Connect", "server": server, "player": un, "password": pw}])
195
196 ap_server = server
197 ap_user = un
198 ap_pass = pw
199
200 _should_process = true
201
202 connect_status.emit("Connecting...")
203
204
205func sendMessage(msg):
206 var payload = JSON.stringify(msg)
207 _server.send(0, payload)
208
209
210func connectToRoom():
211 connect_status.emit("Authenticating...")
212
213 sendMessage(
214 [
215 {
216 "cmd": "Connect",
217 "password": ap_pass,
218 "game": "Lingo 2",
219 "name": ap_user,
220 }
221 ]
222 )
223
224
225func requestSync():
226 sendMessage([{"cmd": "Sync"}])
227
228
229func sendLocation(loc_id):
230 sendMessage([{"cmd": "LocationChecks", "locations": [loc_id]}])
231
232
233func sendLocations(loc_ids):
234 sendMessage([{"cmd": "LocationChecks", "locations": loc_ids}])
235
236
237func say(textdata):
238 sendMessage([{"cmd": "Say", "text": textdata}])
239
240
241func completedGoal():
242 sendMessage([{"cmd": "StatusUpdate", "status": 30}]) # CLIENT_GOAL
243
244
245func scoutLocations(loc_ids):
246 sendMessage([{"cmd": "LocationScouts", "locations": loc_ids}])
247
248
249func updateKeyboard(updates):
250 sendMessage([{"cmd": "UpdateKeyboard", "keyboard": updates}])
251
252
253func checkWorldport(port_id):
254 if not _checked_worldports.has(port_id):
255 sendMessage([{"cmd": "CheckWorldport", "port_id": port_id}])
256
257
258func getLogicalPath(object_type, object_id):
259 var msg = {"cmd": "GetPath", "type": object_type}
260 if object_id != null:
261 msg["id"] = object_id
262
263 sendMessage([msg])
264
265
266func sendQuit():
267 sendMessage([{"cmd": "Quit"}])
268
269
270func hasItem(item_id):
271 return _received_items.has(item_id)
272
273
274func getItemAmount(item_id):
275 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..49f5728 --- /dev/null +++ b/apworld/client/door.gd
@@ -0,0 +1,46 @@
1extends "res://scripts/nodes/door.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 if global.map == "the_sun_temple":
32 if name == "spe_EndPlatform" or name == "spe_entry_2":
33 senders = [NodePath("/root/scene/Panels/EndCheck_dog")]
34
35 if global.map == "the_parthenon":
36 if name == "spe_entry_1":
37 senders = [NodePath("/root/scene/Panels/EndCheck_dog")]
38
39 super._ready()
40
41
42func _readier():
43 var ap = global.get_node("Archipelago")
44
45 if ap.client.getItemAmount(item_id) >= item_amount:
46 handleTriggered()
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..9305003 --- /dev/null +++ b/apworld/client/gamedata.gd
@@ -0,0 +1,294 @@
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 = {}
18
19var kSYMBOL_ITEMS
20
21
22func _init(proto_script):
23 SCRIPT_proto = proto_script
24
25 kSYMBOL_ITEMS = {
26 SCRIPT_proto.PuzzleSymbol.SUN: "Sun Symbol",
27 SCRIPT_proto.PuzzleSymbol.SPARKLES: "Sparkles Symbol",
28 SCRIPT_proto.PuzzleSymbol.ZERO: "Zero Symbol",
29 SCRIPT_proto.PuzzleSymbol.EXAMPLE: "Example Symbol",
30 SCRIPT_proto.PuzzleSymbol.BOXES: "Boxes Symbol",
31 SCRIPT_proto.PuzzleSymbol.PLANET: "Planet Symbol",
32 SCRIPT_proto.PuzzleSymbol.PYRAMID: "Pyramid Symbol",
33 SCRIPT_proto.PuzzleSymbol.CROSS: "Cross Symbol",
34 SCRIPT_proto.PuzzleSymbol.SWEET: "Sweet Symbol",
35 SCRIPT_proto.PuzzleSymbol.GENDER: "Gender Symbol",
36 SCRIPT_proto.PuzzleSymbol.AGE: "Age Symbol",
37 SCRIPT_proto.PuzzleSymbol.SOUND: "Sound Symbol",
38 SCRIPT_proto.PuzzleSymbol.ANAGRAM: "Anagram Symbol",
39 SCRIPT_proto.PuzzleSymbol.JOB: "Job Symbol",
40 SCRIPT_proto.PuzzleSymbol.STARS: "Stars Symbol",
41 SCRIPT_proto.PuzzleSymbol.NULL: "Null Symbol",
42 SCRIPT_proto.PuzzleSymbol.EVAL: "Eval Symbol",
43 SCRIPT_proto.PuzzleSymbol.LINGO: "Lingo Symbol",
44 SCRIPT_proto.PuzzleSymbol.QUESTION: "Question Symbol",
45 }
46
47
48func load(data_bytes):
49 objects = SCRIPT_proto.AllObjects.new()
50
51 var result_code = objects.from_bytes(data_bytes)
52 if result_code != SCRIPT_proto.PB_ERR.NO_ERRORS:
53 print("Could not load generated data: %d" % result_code)
54 return
55
56 for map in objects.get_maps():
57 map_id_by_name[map.get_name()] = map.get_id()
58
59 for door in objects.get_doors():
60 var map = objects.get_maps()[door.get_map_id()]
61
62 if not map.get_name() in door_id_by_map_node_path:
63 door_id_by_map_node_path[map.get_name()] = {}
64
65 var map_data = door_id_by_map_node_path[map.get_name()]
66 for receiver in door.get_receivers():
67 map_data[receiver] = door.get_id()
68
69 for painting_id in door.get_move_paintings():
70 var painting = objects.get_paintings()[painting_id]
71 map_data[painting.get_path()] = door.get_id()
72
73 if door.has_ap_id():
74 door_id_by_ap_id[door.get_ap_id()] = door.get_id()
75
76 if (
77 door.get_type() == SCRIPT_proto.DoorType.STANDARD
78 or door.get_type() == SCRIPT_proto.DoorType.LOCATION_ONLY
79 or door.get_type() == SCRIPT_proto.DoorType.GRAVESTONE
80 ):
81 location_name_by_id[door.get_ap_id()] = _get_door_location_name(door)
82
83 for painting in objects.get_paintings():
84 var room = objects.get_rooms()[painting.get_room_id()]
85 var map = objects.get_maps()[room.get_map_id()]
86
87 if not map.get_name() in painting_id_by_map_node_path:
88 painting_id_by_map_node_path[map.get_name()] = {}
89
90 var _map_data = painting_id_by_map_node_path[map.get_name()]
91
92 for port in objects.get_ports():
93 var room = objects.get_rooms()[port.get_room_id()]
94 var map = objects.get_maps()[room.get_map_id()]
95
96 if not map.get_name() in port_id_by_map_node_path:
97 port_id_by_map_node_path[map.get_name()] = {}
98
99 var map_data = port_id_by_map_node_path[map.get_name()]
100 map_data[port.get_path()] = port.get_id()
101
102 for progressive in objects.get_progressives():
103 progressive_id_by_ap_id[progressive.get_ap_id()] = progressive.get_id()
104
105 for letter in objects.get_letters():
106 letter_id_by_ap_id[letter.get_ap_id()] = letter.get_id()
107 location_name_by_id[letter.get_ap_id()] = _get_letter_location_name(letter)
108
109 for mastery in objects.get_masteries():
110 location_name_by_id[mastery.get_ap_id()] = _get_mastery_location_name(mastery)
111
112 for ending in objects.get_endings():
113 var location_name = _get_ending_location_name(ending)
114 location_name_by_id[ending.get_ap_id()] = location_name
115 ending_display_name_by_name[ending.get_name()] = location_name
116
117 for keyholder in objects.get_keyholders():
118 if keyholder.has_key():
119 location_name_by_id[keyholder.get_ap_id()] = _get_keyholder_location_name(keyholder)
120
121 for panel in objects.get_panels():
122 var room = objects.get_rooms()[panel.get_room_id()]
123 var map = objects.get_maps()[room.get_map_id()]
124
125 if not map.get_name() in panel_id_by_map_node_path:
126 panel_id_by_map_node_path[map.get_name()] = {}
127
128 var map_data = panel_id_by_map_node_path[map.get_name()]
129 map_data[panel.get_path()] = panel.get_id()
130
131 for symbol_name in kSYMBOL_ITEMS.values():
132 symbol_item_ids.append(objects.get_special_ids()[symbol_name])
133
134 for special_name in objects.get_special_ids().keys():
135 if special_name.begins_with("Anti "):
136 anti_trap_ids[objects.get_special_ids()[special_name]] = (
137 special_name.substr(5).to_lower()
138 )
139
140
141func get_door_for_map_node_path(map_name, node_path):
142 if not door_id_by_map_node_path.has(map_name):
143 return null
144
145 var map_data = door_id_by_map_node_path[map_name]
146 return map_data.get(node_path, null)
147
148
149func get_panel_for_map_node_path(map_name, node_path):
150 if not panel_id_by_map_node_path.has(map_name):
151 return null
152
153 var map_data = panel_id_by_map_node_path[map_name]
154 return map_data.get(node_path, null)
155
156
157func get_port_for_map_node_path(map_name, node_path):
158 if not port_id_by_map_node_path.has(map_name):
159 return null
160
161 var map_data = port_id_by_map_node_path[map_name]
162 return map_data.get(node_path, null)
163
164
165func get_door_ap_id(door_id):
166 var door = objects.get_doors()[door_id]
167 if door.has_ap_id():
168 return door.get_ap_id()
169 else:
170 return null
171
172
173func get_door_map_name(door_id):
174 var door = objects.get_doors()[door_id]
175 var map = objects.get_maps()[door.get_map_id()]
176 return map.get_name()
177
178
179func get_door_receivers(door_id):
180 var door = objects.get_doors()[door_id]
181 return door.get_receivers()
182
183
184func get_worldport_display_name(port_id):
185 var port = objects.get_ports()[port_id]
186 return "%s - %s" % [_get_room_object_map_name(port), port.get_display_name()]
187
188
189func _get_map_object_map_name(obj):
190 return objects.get_maps()[obj.get_map_id()].get_display_name()
191
192
193func _get_room_object_map_name(obj):
194 return _get_map_object_map_name(objects.get_rooms()[obj.get_room_id()])
195
196
197func _get_room_object_location_prefix(obj):
198 var room = objects.get_rooms()[obj.get_room_id()]
199 var game_map = objects.get_maps()[room.get_map_id()]
200
201 if room.has_panel_display_name():
202 return "%s (%s)" % [game_map.get_display_name(), room.get_panel_display_name()]
203 else:
204 return game_map.get_display_name()
205
206
207func _get_door_location_name(door):
208 var map_part = _get_room_object_location_prefix(door)
209
210 if door.has_location_name():
211 return "%s - %s" % [map_part, door.get_location_name()]
212
213 var generated_location_name = _get_generated_door_location_name(door)
214 if generated_location_name != null:
215 return generated_location_name
216
217 return "%s - %s" % [map_part, door.get_name()]
218
219
220func _get_generated_door_location_name(door):
221 if door.get_type() != SCRIPT_proto.DoorType.STANDARD:
222 return null
223
224 if door.get_keyholders().size() > 0 or door.get_endings().size() > 0 or door.has_complete_at():
225 return null
226
227 if door.get_panels().size() > 4:
228 return null
229
230 var map_areas = []
231 for panel_id in door.get_panels():
232 var panel = objects.get_panels()[panel_id.get_panel()]
233 var panel_room = objects.get_rooms()[panel.get_room_id()]
234 # It's okay if panel_display_name is not present because then it's coalesced with other unnamed areas.
235 var panel_display_name = ""
236 if panel_room.has_panel_display_name():
237 panel_display_name = panel_room.get_panel_display_name()
238 if not map_areas.has(panel_display_name):
239 map_areas.append(panel_display_name)
240
241 if map_areas.size() > 1:
242 return null
243
244 var game_map = objects.get_maps()[door.get_map_id()]
245 var map_area = map_areas[0]
246 var map_part
247 if map_area == "":
248 map_part = game_map.get_display_name()
249 else:
250 map_part = "%s (%s)" % [game_map.get_display_name(), map_area]
251
252 var panel_names = []
253 for panel_id in door.get_panels():
254 var panel_data = objects.get_panels()[panel_id.get_panel()]
255 var panel_name
256 if panel_data.has_display_name():
257 panel_name = panel_data.get_display_name()
258 else:
259 panel_name = panel_data.get_name()
260
261 var location_part
262 if panel_id.has_answer():
263 location_part = "%s/%s" % [panel_name, panel_id.get_answer().to_upper()]
264 else:
265 location_part = panel_name
266
267 panel_names.append(location_part)
268
269 panel_names.sort()
270
271 return map_part + " - " + ", ".join(panel_names)
272
273
274func _get_letter_location_name(letter):
275 var letter_level = 2 if (letter.has_level2() and letter.get_level2()) else 1
276 var letter_name = "%s%d" % [letter.get_key().to_upper(), letter_level]
277 return "%s - %s" % [_get_room_object_map_name(letter), letter_name]
278
279
280func _get_mastery_location_name(mastery):
281 return "%s - Mastery" % _get_room_object_map_name(mastery)
282
283
284func _get_ending_location_name(ending):
285 return (
286 "%s - %s Ending" % [_get_room_object_map_name(ending), ending.get_name().to_pascal_case()]
287 )
288
289
290func _get_keyholder_location_name(keyholder):
291 return (
292 "%s - %s Keyholder"
293 % [_get_room_object_location_prefix(keyholder), keyholder.get_key().to_upper()]
294 )
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..e1f9610 --- /dev/null +++ b/apworld/client/main.gd
@@ -0,0 +1,296 @@
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("animationListener.gd"))
40 installScriptExtension(runtime.load_script("collectable.gd"))
41 installScriptExtension(runtime.load_script("door.gd"))
42 installScriptExtension(runtime.load_script("keyHolder.gd"))
43 installScriptExtension(runtime.load_script("keyHolderChecker.gd"))
44 installScriptExtension(runtime.load_script("keyHolderResetterListener.gd"))
45 installScriptExtension(runtime.load_script("painting.gd"))
46 installScriptExtension(runtime.load_script("panel.gd"))
47 installScriptExtension(runtime.load_script("pauseMenu.gd"))
48 installScriptExtension(runtime.load_script("player.gd"))
49 installScriptExtension(runtime.load_script("saver.gd"))
50 installScriptExtension(runtime.load_script("teleport.gd"))
51 installScriptExtension(runtime.load_script("teleportListener.gd"))
52 installScriptExtension(runtime.load_script("visibilityListener.gd"))
53 installScriptExtension(runtime.load_script("worldport.gd"))
54 installScriptExtension(runtime.load_script("worldportListener.gd"))
55
56 var proto_script = runtime.load_script("../generated/proto.gd")
57 var gamedata_script = runtime.load_script("gamedata.gd")
58 var gamedata_instance = gamedata_script.new(proto_script)
59 gamedata_instance.load(runtime.read_path("../generated/data.binpb"))
60 gamedata_instance.name = "Gamedata"
61 global.add_child(gamedata_instance)
62
63 var messages_script = runtime.load_script("messages.gd")
64 var messages_instance = messages_script.new()
65 messages_instance.name = "Messages"
66 messages_instance.SCRIPT_rainbowText = runtime.load_script("rainbowText.gd")
67 global.add_child(messages_instance)
68
69 var effects_script = runtime.load_script("effects.gd")
70 var effects_instance = effects_script.new()
71 effects_instance.name = "Effects"
72 global.add_child(effects_instance)
73
74 var textclient_script = runtime.load_script("textclient.gd")
75 var textclient_instance = textclient_script.new()
76 textclient_instance.name = "Textclient"
77 global.add_child(textclient_instance)
78
79 var compass_overlay_script = runtime.load_script("compass_overlay.gd")
80 var compass_overlay_instance = compass_overlay_script.new()
81 compass_overlay_instance.name = "Compass"
82 compass_overlay_instance.SCRIPT_compass = runtime.load_script("compass.gd")
83 global.add_child(compass_overlay_instance)
84
85 var ap = global.get_node("Archipelago")
86 var gamedata = global.get_node("Gamedata")
87 ap.ap_connected.connect(connectionSuccessful)
88 ap.could_not_connect.connect(connectionUnsuccessful)
89 ap.connect_status.connect(connectionStatus)
90
91 # Populate textboxes with AP settings.
92 get_node("../Panel/server_box").text = ap.ap_server
93 get_node("../Panel/player_box").text = ap.ap_user
94 get_node("../Panel/password_box").text = ap.ap_pass
95
96 var history_box = get_node("../Panel/connection_history")
97 if ap.connection_history.is_empty():
98 history_box.disabled = true
99 else:
100 history_box.disabled = false
101
102 var i = 0
103 for details in ap.connection_history:
104 history_box.get_popup().add_item("%s (%s)" % [details[1], details[0]], i)
105 i += 1
106
107 history_box.get_popup().id_pressed.connect(historySelected)
108
109 # Show client version.
110 var version = gamedata.objects.get_version()
111 get_node("../Panel/title").text = (
112 "ARCHIPELAGO (%d.%d.%d)" % [version.get_major(), version.get_minor(), version.get_patch()]
113 )
114
115 # Increase font size in text boxes.
116 get_node("../Panel/server_box").add_theme_font_size_override("font_size", 36)
117 get_node("../Panel/player_box").add_theme_font_size_override("font_size", 36)
118 get_node("../Panel/password_box").add_theme_font_size_override("font_size", 36)
119
120 # Set up version mismatch dialog.
121 get_node("../Panel/VersionMismatch").confirmed.connect(startGame)
122 get_node("../Panel/VersionMismatch").get_cancel_button().pressed.connect(
123 versionMismatchDeclined
124 )
125
126 # Set up buttons.
127 get_node("../Panel/connect_button").pressed.connect(_connect_pressed)
128 get_node("../Panel/quit_button").pressed.connect(_back_pressed)
129
130
131func _connect_pressed():
132 get_node("../Panel/connect_button").disabled = true
133
134 var ap = global.get_node("Archipelago")
135 ap.ap_server = get_node("../Panel/server_box").text
136 ap.ap_user = get_node("../Panel/player_box").text
137 ap.ap_pass = get_node("../Panel/password_box").text
138 ap.saveSettings()
139
140 ap.connectToServer()
141
142
143func _back_pressed():
144 var ap = global.get_node("Archipelago")
145 ap.disconnect_from_ap()
146 ap.client.sendQuit()
147
148 get_tree().quit()
149
150
151# Adapted from https://gitlab.com/Delta-V-Modding/Mods/-/blob/main/game/ModLoader.gd
152func installScriptExtension(childScript: Resource):
153 # Force Godot to compile the script now.
154 # We need to do this here to ensure that the inheritance chain is
155 # properly set up, and multiple mods can chain-extend the same
156 # class multiple times.
157 # This is also needed to make Godot instantiate the extended class
158 # when creating singletons.
159 # The actual instance is thrown away.
160 childScript.new()
161
162 var parentScript = childScript.get_base_script()
163 var parentScriptPath = parentScript.resource_path
164 global._print("ModLoader: Installing script extension over %s" % parentScriptPath)
165 childScript.take_over_path(parentScriptPath)
166
167
168func connectionStatus(message):
169 var popup = get_node("../Panel/AcceptDialog")
170 popup.title = "Connecting to Archipelago"
171 popup.dialog_text = message
172 popup.exclusive = true
173 popup.get_ok_button().visible = false
174 popup.popup_centered()
175
176
177func connectionSuccessful():
178 var ap = global.get_node("Archipelago")
179 var gamedata = global.get_node("Gamedata")
180
181 # Check for major version mismatch.
182 if ap.apworld_version[0] != gamedata.objects.get_version().get_major():
183 get_node("../Panel/AcceptDialog").exclusive = false
184
185 var popup = get_node("../Panel/VersionMismatch")
186 popup.title = "Version Mismatch!"
187 popup.dialog_text = (
188 "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."
189 % [
190 ap.apworld_version[0],
191 ap.apworld_version[1],
192 ap.apworld_version[2],
193 gamedata.objects.get_version().get_major(),
194 gamedata.objects.get_version().get_minor(),
195 gamedata.objects.get_version().get_patch()
196 ]
197 )
198 popup.exclusive = true
199 popup.popup_centered()
200
201 return
202
203 startGame()
204
205
206func startGame():
207 var ap = global.get_node("Archipelago")
208
209 # Save connection details
210 var connection_details = [ap.ap_server, ap.ap_user, ap.ap_pass]
211 if ap.connection_history.has(connection_details):
212 ap.connection_history.erase(connection_details)
213 ap.connection_history.push_front(connection_details)
214 if ap.connection_history.size() > 10:
215 ap.connection_history.resize(10)
216 ap.saveSettings()
217
218 # Switch to the_entry
219 Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
220 global.user = ap.getSaveFileName()
221 global.universe = "lingo"
222 global.map = "the_entry"
223
224 unlocks.resetCollectables()
225 unlocks.resetData()
226
227 ap.setup_keys()
228
229 unlocks.loadCollectables()
230 unlocks.loadData()
231 unlocks.unlockKey("capslock", 1)
232
233 if ap.shuffle_worldports:
234 settings.worldport_fades = "default"
235 else:
236 settings.worldport_fades = "never"
237
238 clearResourceCache("res://objects/meshes/gridDoor.tscn")
239 clearResourceCache("res://objects/nodes/collectable.tscn")
240 clearResourceCache("res://objects/nodes/door.tscn")
241 clearResourceCache("res://objects/nodes/keyHolder.tscn")
242 clearResourceCache("res://objects/nodes/listeners/animationListener.tscn")
243 clearResourceCache("res://objects/nodes/listeners/keyHolderChecker.tscn")
244 clearResourceCache("res://objects/nodes/listeners/keyHolderResetterListener.tscn")
245 clearResourceCache("res://objects/nodes/listeners/teleportListener.tscn")
246 clearResourceCache("res://objects/nodes/listeners/visibilityListener.tscn")
247 clearResourceCache("res://objects/nodes/listeners/worldportListener.tscn")
248 clearResourceCache("res://objects/nodes/panel.tscn")
249 clearResourceCache("res://objects/nodes/player.tscn")
250 clearResourceCache("res://objects/nodes/saver.tscn")
251 clearResourceCache("res://objects/nodes/teleport.tscn")
252 clearResourceCache("res://objects/nodes/worldport.tscn")
253 clearResourceCache("res://objects/scenes/menus/pause_menu.tscn")
254
255 var paintings_dir = DirAccess.open("res://objects/meshes/paintings")
256 if paintings_dir:
257 paintings_dir.list_dir_begin()
258 var file_name = paintings_dir.get_next()
259 while file_name != "":
260 if not paintings_dir.current_is_dir() and file_name.ends_with(".tscn"):
261 clearResourceCache("res://objects/meshes/paintings/" + file_name)
262 file_name = paintings_dir.get_next()
263
264 switcher.switch_map.call_deferred("res://objects/scenes/the_entry.tscn")
265
266
267func connectionUnsuccessful(error_message):
268 get_node("../Panel/connect_button").disabled = false
269
270 var popup = get_node("../Panel/AcceptDialog")
271 popup.title = "Could not connect to Archipelago"
272 popup.dialog_text = error_message
273 popup.exclusive = true
274 popup.get_ok_button().visible = true
275 popup.popup_centered()
276
277
278func versionMismatchDeclined():
279 get_node("../Panel/AcceptDialog").hide()
280 get_node("../Panel/connect_button").disabled = false
281
282 var ap = global.get_node("Archipelago")
283 ap.disconnect_from_ap()
284
285
286func historySelected(index):
287 var ap = global.get_node("Archipelago")
288 var details = ap.connection_history[index]
289
290 get_node("../Panel/server_box").text = details[0]
291 get_node("../Panel/player_box").text = details[1]
292 get_node("../Panel/password_box").text = details[2]
293
294
295func clearResourceCache(path):
296 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..dac09b2 --- /dev/null +++ b/apworld/client/manager.gd
@@ -0,0 +1,651 @@
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
32
33const kSHUFFLE_LETTERS_VANILLA = 0
34const kSHUFFLE_LETTERS_UNLOCKED = 1
35const kSHUFFLE_LETTERS_PROGRESSIVE = 2
36const kSHUFFLE_LETTERS_VANILLA_CYAN = 3
37const kSHUFFLE_LETTERS_ITEM_CYAN = 4
38
39const kLETTER_BEHAVIOR_VANILLA = 0
40const kLETTER_BEHAVIOR_ITEM = 1
41const kLETTER_BEHAVIOR_UNLOCKED = 2
42
43const kCYAN_DOOR_BEHAVIOR_H2 = 0
44const kCYAN_DOOR_BEHAVIOR_DOUBLE_LETTER = 1
45const kCYAN_DOOR_BEHAVIOR_ITEM = 2
46
47const kEndingNameByVictoryValue = {
48 0: "GRAY",
49 1: "PURPLE",
50 2: "MINT",
51 3: "BLACK",
52 4: "BLUE",
53 5: "CYAN",
54 6: "RED",
55 7: "PLUM",
56 8: "ORANGE",
57 9: "GOLD",
58 10: "YELLOW",
59 11: "GREEN",
60 12: "WHITE",
61}
62
63var apworld_version = [0, 0, 0]
64var cyan_door_behavior = kCYAN_DOOR_BEHAVIOR_H2
65var daedalus_roof_access = false
66var keyholder_sanity = false
67var port_pairings = {}
68var shuffle_control_center_colors = false
69var shuffle_doors = false
70var shuffle_gallery_paintings = false
71var shuffle_letters = kSHUFFLE_LETTERS_VANILLA
72var shuffle_symbols = false
73var shuffle_worldports = false
74var strict_cyan_ending = false
75var strict_purple_ending = false
76var victory_condition = -1
77
78var color_by_material_path = {}
79
80signal could_not_connect
81signal connect_status
82signal ap_connected
83
84
85func _init():
86 # Read AP settings from file, if there are any
87 if FileAccess.file_exists("user://ap_settings"):
88 var file = FileAccess.open("user://ap_settings", FileAccess.READ)
89 var data = file.get_var(true)
90 file.close()
91
92 if typeof(data) != TYPE_ARRAY:
93 global._print("AP settings file is corrupted")
94 data = []
95
96 if data.size() > 0:
97 ap_server = data[0]
98
99 if data.size() > 1:
100 ap_user = data[1]
101
102 if data.size() > 2:
103 ap_pass = data[2]
104
105 if data.size() > 3:
106 connection_history = data[3]
107
108 if data.size() > 4:
109 show_compass = data[4]
110
111 if data.size() > 5:
112 show_locations = data[5]
113
114 if data.size() > 6:
115 show_minimap = data[6]
116
117 # We need to create a mapping from material paths to the original colors of
118 # those materials. We force reload the materials, overwriting any custom
119 # textures, and create the mapping. We then reload the textures in case the
120 # player had a custom one enabled.
121 var directory = DirAccess.open("res://assets/materials")
122 for material_name in directory.get_files():
123 var material = ResourceLoader.load(
124 "res://assets/materials/" + material_name, "", ResourceLoader.CACHE_MODE_REPLACE
125 )
126
127 color_by_material_path[material.resource_path] = Color(material.albedo_color)
128
129 settings.load_user_textures()
130
131
132func _ready():
133 client = SCRIPT_client.new()
134 client.SCRIPT_websocketserver = SCRIPT_websocketserver
135
136 client.item_received.connect(_process_item)
137 client.location_scout_received.connect(_process_location_scout)
138 client.text_message_received.connect(_process_text_message)
139 client.item_sent_notification.connect(_process_item_sent_notification)
140 client.hint_received.connect(_process_hint_received)
141 client.accessible_locations_updated.connect(_on_accessible_locations_updated)
142 client.checked_locations_updated.connect(_on_checked_locations_updated)
143 client.checked_worldports_updated.connect(_on_checked_worldports_updated)
144
145 client.could_not_connect.connect(_client_could_not_connect)
146 client.connect_status.connect(_client_connect_status)
147 client.client_connected.connect(_client_connected)
148
149 add_child(client)
150
151 keyboard = SCRIPT_keyboard.new()
152 add_child(keyboard)
153 client.keyboard_update_received.connect(keyboard.remote_keyboard_updated)
154
155
156func saveSettings():
157 # Save the AP settings to disk.
158 var path = "user://ap_settings"
159 var file = FileAccess.open(path, FileAccess.WRITE)
160
161 var data = [
162 ap_server,
163 ap_user,
164 ap_pass,
165 connection_history,
166 show_compass,
167 show_locations,
168 show_minimap,
169 ]
170 file.store_var(data, true)
171 file.close()
172
173
174func saveLocaldata():
175 # Save the MW/slot specific settings to disk.
176 var dir = DirAccess.open("user://")
177 var folder = "archipelago_data"
178 if not dir.dir_exists(folder):
179 dir.make_dir(folder)
180
181 var file = FileAccess.open(_localdata_file, FileAccess.WRITE)
182
183 var data = [
184 _last_new_item,
185 ]
186 file.store_var(data, true)
187 file.close()
188
189
190func connectToServer():
191 _last_new_item = -1
192 _batch_locations = false
193 _held_locations = []
194 _held_location_scouts = []
195 _location_scouts = {}
196 _letters_setup = false
197 _held_letters = {}
198 _already_connected = false
199
200 client.connectToServer(ap_server, ap_user, ap_pass)
201
202
203func getSaveFileName():
204 return "zzAP_%s_%d" % [client._seed, client._slot]
205
206
207func disconnect_from_ap():
208 _already_connected = false
209
210 var effects = global.get_node("Effects")
211 effects.set_connection_lost(false)
212
213 client.disconnect_from_ap()
214
215
216func get_item_id_for_door(door_id):
217 return _item_locks.get(door_id, null)
218
219
220func _process_item(item, amount):
221 var gamedata = global.get_node("Gamedata")
222
223 var item_id = int(item["id"])
224 var prog_id = null
225 if _inverse_item_locks.has(item_id):
226 for lock in _inverse_item_locks.get(item_id):
227 if lock[1] != amount:
228 continue
229
230 if gamedata.progressive_id_by_ap_id.has(item_id):
231 prog_id = lock[0]
232
233 if gamedata.get_door_map_name(lock[0]) != global.map:
234 continue
235
236 # TODO: fix doors opening from door groups
237 var receivers = gamedata.get_door_receivers(lock[0])
238 var scene = get_tree().get_root().get_node_or_null("scene")
239 if scene != null:
240 for receiver in receivers:
241 var rnode = scene.get_node_or_null(receiver)
242 if rnode != null:
243 rnode.handleTriggered()
244
245 var letter_id = gamedata.letter_id_by_ap_id.get(item_id, null)
246 if letter_id != null:
247 var letter = gamedata.objects.get_letters()[letter_id]
248 if not letter.has_level2() or not letter.get_level2():
249 _process_key_item(letter.get_key(), amount)
250
251 if gamedata.symbol_item_ids.has(item_id):
252 var player = get_tree().get_root().get_node_or_null("scene/player")
253 if player != null:
254 player.evaluate_solvability.emit()
255
256 if item_id == gamedata.objects.get_special_ids()["A Job Well Done"]:
257 update_job_well_done_sign()
258
259 # Show a message about the item if it's new.
260 if int(item["index"]) > _last_new_item:
261 _last_new_item = int(item["index"])
262 saveLocaldata()
263
264 var full_item_name = item["text"]
265 if prog_id != null:
266 var door = gamedata.objects.get_doors()[prog_id]
267 full_item_name = "%s (%s)" % [full_item_name, door.get_name()]
268
269 var message
270 if "sender" in item:
271 message = (
272 "Received %s from %s"
273 % [wrapInItemColorTags(full_item_name, item["flags"]), item["sender"]]
274 )
275 else:
276 message = "Found %s" % wrapInItemColorTags(full_item_name, item["flags"])
277
278 if gamedata.anti_trap_ids.has(item):
279 keyboard.block_letter(gamedata.anti_trap_ids[item])
280
281 global._print(message)
282
283 global.get_node("Messages").showMessage(message)
284
285
286func _process_item_sent_notification(message):
287 var sentMsg = (
288 "Sent %s to %s"
289 % [
290 wrapInItemColorTags(message["item_name"], message["item_flags"]),
291 message["receiver_name"]
292 ]
293 )
294 #if _hinted_locations.has(message["item"]["location"]):
295 # sentMsg += " ([color=#fafad2]Hinted![/color])"
296 global.get_node("Messages").showMessage(sentMsg)
297
298
299func _process_hint_received(message):
300 var is_for = ""
301 if message["self"] == 0:
302 is_for = " for %s" % message["receiver_name"]
303
304 global.get_node("Messages").showMessage(
305 (
306 "Hint: %s%s is on %s"
307 % [
308 wrapInItemColorTags(message["item_name"], message["item_flags"]),
309 is_for,
310 message["location_name"]
311 ]
312 )
313 )
314
315
316func _process_text_message(message):
317 var parts = []
318 for message_part in message:
319 if message_part["type"] == "text":
320 parts.append(message_part["text"])
321 elif message_part["type"] == "player":
322 if message_part["self"] == 1:
323 parts.append("[color=#ee00ee]%s[/color]" % message_part["text"])
324 else:
325 parts.append("[color=#fafad2]%s[/color]" % message_part["text"])
326 elif message_part["type"] == "item":
327 parts.append(wrapInItemColorTags(message_part["text"], int(message_part["flags"])))
328 elif message_part["type"] == "location":
329 parts.append("[color=#00ff7f]%s[/color]" % message_part["text"])
330
331 var textclient_node = global.get_node("Textclient")
332 if textclient_node != null:
333 textclient_node.parse_printjson("".join(parts))
334
335
336func _process_location_scout(location_id, item_name, player_name, flags, for_self):
337 _location_scouts[location_id] = {
338 "item": item_name, "player": player_name, "flags": flags, "for_self": for_self
339 }
340
341 if for_self and flags & 4 != 0:
342 # This is a trap for us, so let's not display it.
343 return
344
345 var gamedata = global.get_node("Gamedata")
346 var map_id = gamedata.map_id_by_name.get(global.map)
347
348 var letter_id = gamedata.letter_id_by_ap_id.get(location_id, null)
349 if letter_id != null:
350 var letter = gamedata.objects.get_letters()[letter_id]
351 var room = gamedata.objects.get_rooms()[letter.get_room_id()]
352 if room.get_map_id() == map_id:
353 var collectable = get_tree().get_root().get_node("scene").get_node_or_null(
354 letter.get_path()
355 )
356 if collectable != null:
357 collectable.setScoutedText(item_name)
358
359
360func _on_accessible_locations_updated():
361 var textclient_node = global.get_node("Textclient")
362 if textclient_node != null:
363 textclient_node.update_locations()
364
365
366func _on_checked_locations_updated():
367 var textclient_node = global.get_node("Textclient")
368 if textclient_node != null:
369 textclient_node.update_locations(false)
370
371
372func _on_checked_worldports_updated():
373 var textclient_node = global.get_node("Textclient")
374 if textclient_node != null:
375 textclient_node.update_locations()
376 textclient_node.update_worldports()
377
378
379func _client_could_not_connect(message):
380 could_not_connect.emit(message)
381
382 if global.loaded:
383 var effects = global.get_node("Effects")
384 effects.set_connection_lost(true)
385
386 var messages = global.get_node("Messages")
387 messages.showMessage("Connection to multiworld lost.")
388
389
390func _client_connect_status(message):
391 connect_status.emit(message)
392
393
394func _client_connected(slot_data):
395 var effects = global.get_node("Effects")
396 effects.set_connection_lost(false)
397
398 if _already_connected:
399 var messages = global.get_node("Messages")
400 messages.showMessage("Reconnected to multiworld!")
401 return
402
403 _already_connected = true
404
405 var gamedata = global.get_node("Gamedata")
406
407 _localdata_file = "user://archipelago_data/%s_%d" % [client._seed, client._slot]
408 _last_new_item = -1
409
410 if FileAccess.file_exists(_localdata_file):
411 var ap_file = FileAccess.open(_localdata_file, FileAccess.READ)
412 var localdata = []
413 if ap_file != null:
414 localdata = ap_file.get_var(true)
415 ap_file.close()
416
417 if typeof(localdata) != TYPE_ARRAY:
418 print("AP localdata file is corrupted")
419 localdata = []
420
421 if localdata.size() > 0:
422 _last_new_item = localdata[0]
423
424 # Read slot data.
425 cyan_door_behavior = int(slot_data.get("cyan_door_behavior", 0))
426 daedalus_roof_access = bool(slot_data.get("daedalus_roof_access", false))
427 keyholder_sanity = bool(slot_data.get("keyholder_sanity", false))
428 shuffle_control_center_colors = bool(slot_data.get("shuffle_control_center_colors", false))
429 shuffle_doors = bool(slot_data.get("shuffle_doors", false))
430 shuffle_gallery_paintings = bool(slot_data.get("shuffle_gallery_paintings", false))
431 shuffle_letters = int(slot_data.get("shuffle_letters", 0))
432 shuffle_symbols = bool(slot_data.get("shuffle_symbols", false))
433 shuffle_worldports = bool(slot_data.get("shuffle_worldports", false))
434 strict_cyan_ending = bool(slot_data.get("strict_cyan_ending", false))
435 strict_purple_ending = bool(slot_data.get("strict_purple_ending", false))
436 victory_condition = int(slot_data.get("victory_condition", 0))
437
438 if slot_data.has("version"):
439 var version_msg = slot_data["version"]
440 apworld_version = [int(version_msg[0]), int(version_msg[1]), 0]
441 if version_msg.size() > 2:
442 apworld_version[2] = int(version_msg[2])
443
444 port_pairings.clear()
445 if slot_data.has("port_pairings"):
446 var raw_pp = slot_data.get("port_pairings")
447
448 for p1 in raw_pp.keys():
449 port_pairings[int(p1)] = int(raw_pp[p1])
450
451 # Set up item locks.
452 _item_locks = {}
453
454 if shuffle_doors:
455 for door in gamedata.objects.get_doors():
456 if (
457 door.get_type() == gamedata.SCRIPT_proto.DoorType.STANDARD
458 or door.get_type() == gamedata.SCRIPT_proto.DoorType.ITEM_ONLY
459 ):
460 _item_locks[door.get_id()] = [door.get_ap_id(), 1]
461
462 for progressive in gamedata.objects.get_progressives():
463 for i in range(0, progressive.get_doors().size()):
464 var door = gamedata.objects.get_doors()[progressive.get_doors()[i]]
465 _item_locks[door.get_id()] = [progressive.get_ap_id(), i + 1]
466
467 for door_group in gamedata.objects.get_door_groups():
468 if door_group.get_type() == gamedata.SCRIPT_proto.DoorGroupType.CONNECTOR:
469 if shuffle_worldports:
470 continue
471 elif door_group.get_type() != gamedata.SCRIPT_proto.DoorGroupType.SHUFFLE_GROUP:
472 continue
473
474 for door in door_group.get_doors():
475 _item_locks[door] = [door_group.get_ap_id(), 1]
476
477 if shuffle_control_center_colors:
478 for door in gamedata.objects.get_doors():
479 if door.get_type() == gamedata.SCRIPT_proto.DoorType.CONTROL_CENTER_COLOR:
480 _item_locks[door.get_id()] = [door.get_ap_id(), 1]
481
482 for door_group in gamedata.objects.get_door_groups():
483 if (
484 door_group.get_type() == gamedata.SCRIPT_proto.DoorGroupType.COLOR_CONNECTOR
485 and not shuffle_worldports
486 ):
487 for door in door_group.get_doors():
488 _item_locks[door] = [door_group.get_ap_id(), 1]
489
490 if shuffle_gallery_paintings:
491 for door in gamedata.objects.get_doors():
492 if door.get_type() == gamedata.SCRIPT_proto.DoorType.GALLERY_PAINTING:
493 _item_locks[door.get_id()] = [door.get_ap_id(), 1]
494
495 if cyan_door_behavior == kCYAN_DOOR_BEHAVIOR_ITEM:
496 for door_group in gamedata.objects.get_door_groups():
497 if door_group.get_type() == gamedata.SCRIPT_proto.DoorGroupType.CYAN_DOORS:
498 for door in door_group.get_doors():
499 if not _item_locks.has(door):
500 _item_locks[door] = [door_group.get_ap_id(), 1]
501
502 # Create a reverse item locks map for processing items.
503 _inverse_item_locks = {}
504
505 for door_id in _item_locks.keys():
506 var lock = _item_locks.get(door_id)
507
508 if not _inverse_item_locks.has(lock[0]):
509 _inverse_item_locks[lock[0]] = []
510
511 _inverse_item_locks[lock[0]].append([door_id, lock[1]])
512
513 if shuffle_worldports:
514 var textclient = global.get_node("Textclient")
515 textclient.setup_worldports()
516
517 ap_connected.emit()
518
519
520func start_batching_locations():
521 _batch_locations = true
522
523
524func send_location(loc_id):
525 if client._checked_locations.has(loc_id):
526 return
527
528 if _batch_locations:
529 _held_locations.append(loc_id)
530 else:
531 client.sendLocation(loc_id)
532
533
534func scout_location(loc_id):
535 if _location_scouts.has(loc_id):
536 return _location_scouts.get(loc_id)
537
538 if _batch_locations:
539 _held_location_scouts.append(loc_id)
540 else:
541 client.scoutLocation(loc_id)
542
543 return null
544
545
546func stop_batching_locations():
547 _batch_locations = false
548
549 if not _held_locations.is_empty():
550 client.sendLocations(_held_locations)
551 _held_locations.clear()
552
553 if not _held_location_scouts.is_empty():
554 client.scoutLocations(_held_location_scouts)
555 _held_location_scouts.clear()
556
557
558func colorForItemType(flags):
559 var int_flags = int(flags)
560 if int_flags & 1: # progression
561 if int_flags & 2: # proguseful
562 return "#f0d200"
563 else:
564 return "#bc51e0"
565 elif int_flags & 2: # useful
566 return "#2b67ff"
567 elif int_flags & 4: # trap
568 return "#d63a22"
569 else: # filler
570 return "#14de9e"
571
572
573func wrapInItemColorTags(text, flags):
574 var int_flags = int(flags)
575 if int_flags & 1 and int_flags & 2: # proguseful
576 return "[rainbow]%s[/rainbow]" % text
577 else:
578 return "[color=%s]%s[/color]" % [colorForItemType(flags), text]
579
580
581func get_letter_behavior(key, level2):
582 if shuffle_letters == kSHUFFLE_LETTERS_UNLOCKED:
583 return kLETTER_BEHAVIOR_UNLOCKED
584
585 if [kSHUFFLE_LETTERS_VANILLA_CYAN, kSHUFFLE_LETTERS_ITEM_CYAN].has(shuffle_letters):
586 if level2:
587 if shuffle_letters == kSHUFFLE_LETTERS_VANILLA_CYAN:
588 return kLETTER_BEHAVIOR_VANILLA
589 else:
590 return kLETTER_BEHAVIOR_ITEM
591 else:
592 return kLETTER_BEHAVIOR_UNLOCKED
593
594 if not level2 and ["h", "i", "n", "t"].has(key):
595 # This differs from the equivalent function in the apworld. Logically it is
596 # the same as UNLOCKED since they are in the starting room, but VANILLA
597 # means the player still has to actually pick up the letters.
598 return kLETTER_BEHAVIOR_VANILLA
599
600 if shuffle_letters == kSHUFFLE_LETTERS_PROGRESSIVE:
601 return kLETTER_BEHAVIOR_ITEM
602
603 return kLETTER_BEHAVIOR_VANILLA
604
605
606func setup_keys():
607 keyboard.load_seed()
608
609 _letters_setup = true
610
611 for k in _held_letters.keys():
612 _process_key_item(k, _held_letters[k])
613
614 _held_letters.clear()
615
616
617func _process_key_item(key, level):
618 if not _letters_setup:
619 _held_letters[key] = max(_held_letters.get(key, 0), level)
620 return
621
622 if shuffle_letters == kSHUFFLE_LETTERS_ITEM_CYAN:
623 level += 1
624
625 keyboard.collect_remote_letter(key, level)
626
627
628func update_job_well_done_sign():
629 if global.map != "daedalus":
630 return
631
632 var gamedata = global.get_node("Gamedata")
633 var job_item = gamedata.objects.get_special_ids()["A Job Well Done"]
634 var jobs_done = client.getItemAmount(job_item)
635
636 var sign2 = get_tree().get_root().get_node_or_null("scene/Meshes/Miscellaneous/sign2")
637 var sign3 = get_tree().get_root().get_node_or_null("scene/Meshes/Miscellaneous/sign3")
638
639 if sign2 != null and sign3 != null:
640 if jobs_done == 0:
641 sign2.text = "what are you doing"
642 sign3.text = "?"
643 elif jobs_done == 1:
644 sign2.text = "a job well done"
645 sign3.text = "is its own reward"
646 else:
647 sign2.text = "%d jobs well done" % jobs_done
648 sign3.text = "are their own reward"
649
650 sign2.get_node("MeshInstance3D").mesh.text = sign2.text
651 sign3.get_node("MeshInstance3D").mesh.text = sign3.text
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/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..b73f61e --- /dev/null +++ b/apworld/client/player.gd
@@ -0,0 +1,349 @@
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 # Set up door locations.
23 var map_id = gamedata.map_id_by_name.get(global.map)
24 for door in gamedata.objects.get_doors():
25 if door.get_map_id() != map_id:
26 continue
27
28 if not door.has_ap_id():
29 continue
30
31 if (
32 door.get_type() == gamedata.SCRIPT_proto.DoorType.ITEM_ONLY
33 or door.get_type() == gamedata.SCRIPT_proto.DoorType.GALLERY_PAINTING
34 or door.get_type() == gamedata.SCRIPT_proto.DoorType.CONTROL_CENTER_COLOR
35 ):
36 continue
37
38 var locationListener = ap.SCRIPT_locationListener.new()
39 locationListener.location_id = door.get_ap_id()
40 locationListener.name = "locationListener_%d" % door.get_ap_id()
41
42 for panel_ref in door.get_panels():
43 var panel_data = gamedata.objects.get_panels()[panel_ref.get_panel()]
44 var panel_path = panel_data.get_path()
45
46 if panel_ref.has_answer():
47 for proxy in panel_data.get_proxies():
48 if proxy.get_answer() == panel_ref.get_answer():
49 panel_path = proxy.get_path()
50 break
51
52 locationListener.senders.append(NodePath("/root/scene/" + panel_path))
53
54 for keyholder_ref in door.get_keyholders():
55 var keyholder_data = gamedata.objects.get_keyholders()[keyholder_ref.get_keyholder()]
56
57 var khl = khl_script.new()
58 khl.name = (
59 "location_%d_keyholder_%d" % [door.get_ap_id(), keyholder_ref.get_keyholder()]
60 )
61 khl.answer = keyholder_ref.get_key()
62 khl.senders.append(NodePath("/root/scene/" + keyholder_data.get_path()))
63 get_parent().add_child.call_deferred(khl)
64
65 locationListener.senders.append(NodePath("../" + khl.name))
66
67 for sender in door.get_senders():
68 locationListener.senders.append(NodePath("/root/scene/" + sender))
69
70 if door.has_complete_at():
71 locationListener.complete_at = door.get_complete_at()
72
73 get_parent().add_child.call_deferred(locationListener)
74
75 # Set up letter locations.
76 for letter in gamedata.objects.get_letters():
77 var room = gamedata.objects.get_rooms()[letter.get_room_id()]
78 if room.get_map_id() != map_id:
79 continue
80
81 var locationListener = ap.SCRIPT_locationListener.new()
82 locationListener.location_id = letter.get_ap_id()
83 locationListener.name = "locationListener_%d" % letter.get_ap_id()
84 locationListener.senders.append(NodePath("/root/scene/" + letter.get_path()))
85
86 get_parent().add_child.call_deferred(locationListener)
87
88 if (
89 ap.get_letter_behavior(letter.get_key(), letter.has_level2() and letter.get_level2())
90 != ap.kLETTER_BEHAVIOR_VANILLA
91 ):
92 var scout = ap.scout_location(letter.get_ap_id())
93 if scout != null and not (scout["for_self"] and scout["flags"] & 4 != 0):
94 var collectable = get_tree().get_root().get_node("scene").get_node_or_null(
95 letter.get_path()
96 )
97 if collectable != null:
98 collectable.setScoutedText.call_deferred(scout["item"])
99
100 # Set up mastery locations.
101 for mastery in gamedata.objects.get_masteries():
102 var room = gamedata.objects.get_rooms()[mastery.get_room_id()]
103 if room.get_map_id() != map_id:
104 continue
105
106 var locationListener = ap.SCRIPT_locationListener.new()
107 locationListener.location_id = mastery.get_ap_id()
108 locationListener.name = "locationListener_%d" % mastery.get_ap_id()
109 locationListener.senders.append(NodePath("/root/scene/" + mastery.get_path()))
110
111 get_parent().add_child.call_deferred(locationListener)
112
113 # Set up ending locations.
114 for ending in gamedata.objects.get_endings():
115 var room = gamedata.objects.get_rooms()[ending.get_room_id()]
116 if room.get_map_id() != map_id:
117 continue
118
119 var locationListener = ap.SCRIPT_locationListener.new()
120 locationListener.location_id = ending.get_ap_id()
121 locationListener.name = "locationListener_%d" % ending.get_ap_id()
122 locationListener.senders.append(NodePath("/root/scene/" + ending.get_path()))
123
124 get_parent().add_child.call_deferred(locationListener)
125
126 if ap.kEndingNameByVictoryValue.get(ap.victory_condition, null) == ending.get_name():
127 var victoryListener = ap.SCRIPT_victoryListener.new()
128 victoryListener.name = "victoryListener"
129 victoryListener.senders.append(NodePath("/root/scene/" + ending.get_path()))
130
131 get_parent().add_child.call_deferred(victoryListener)
132
133 # Set up keyholder locations, in keyholder sanity.
134 if ap.keyholder_sanity:
135 for keyholder in gamedata.objects.get_keyholders():
136 if not keyholder.has_key():
137 continue
138
139 var room = gamedata.objects.get_rooms()[keyholder.get_room_id()]
140 if room.get_map_id() != map_id:
141 continue
142
143 var locationListener = ap.SCRIPT_locationListener.new()
144 locationListener.location_id = keyholder.get_ap_id()
145 locationListener.name = "locationListener_%d" % keyholder.get_ap_id()
146
147 var khl = khl_script.new()
148 khl.name = "location_%d_keyholder" % keyholder.get_ap_id()
149 khl.answer = keyholder.get_key()
150 khl.senders.append(NodePath("/root/scene/" + keyholder.get_path()))
151 get_parent().add_child.call_deferred(khl)
152
153 locationListener.senders.append(NodePath("../" + khl.name))
154
155 get_parent().add_child.call_deferred(locationListener)
156
157 # Block off roof access in Daedalus.
158 if global.map == "daedalus" and not ap.daedalus_roof_access:
159 _set_up_invis_wall(75.5, 11, -24.5, 1, 10, 49)
160 _set_up_invis_wall(51.5, 11, -17, 16, 10, 1)
161 _set_up_invis_wall(46, 10, -9.5, 1, 10, 10)
162 _set_up_invis_wall(67.5, 11, 17, 16, 10, 1)
163 _set_up_invis_wall(50.5, 11, 14, 10, 10, 1)
164 _set_up_invis_wall(39, 10, 18.5, 1, 10, 22)
165 _set_up_invis_wall(20, 15, 18.5, 1, 10, 16)
166 _set_up_invis_wall(11.5, 15, 3, 32, 10, 1)
167 _set_up_invis_wall(11.5, 16, -20, 14, 20, 1)
168 _set_up_invis_wall(14, 16, -26.5, 1, 20, 4)
169 _set_up_invis_wall(28.5, 20.5, -26.5, 1, 15, 25)
170 _set_up_invis_wall(40.5, 20.5, -11, 30, 15, 1)
171 _set_up_invis_wall(50.5, 15, 5.5, 7, 10, 1)
172 _set_up_invis_wall(83.5, 33.5, 5.5, 1, 7, 11)
173 _set_up_invis_wall(83.5, 33.5, -5.5, 1, 7, 11)
174
175 var warp_exit_prefab = preload("res://objects/nodes/exit.tscn")
176 var warp_exit = warp_exit_prefab.instantiate()
177 warp_exit.name = "roof_access_blocker_warp_exit"
178 warp_exit.position = Vector3(58, 10, 0)
179 warp_exit.rotation_degrees.y = 90
180 get_parent().add_child.call_deferred(warp_exit)
181
182 var warp_enter_prefab = preload("res://objects/nodes/teleportAuto.tscn")
183 var warp_enter = warp_enter_prefab.instantiate()
184 warp_enter.target = warp_exit
185 warp_enter.position = Vector3(76.5, 30, 1)
186 warp_enter.scale = Vector3(4, 1.5, 1)
187 warp_enter.rotation_degrees.y = 90
188 get_parent().add_child.call_deferred(warp_enter)
189
190 if global.map == "the_entry":
191 # Remove door behind X1.
192 var door_node = get_tree().get_root().get_node("/root/scene/Components/Doors/exit_1")
193 door_node.handleTriggered()
194
195 # Display win condition.
196 var sign_prefab = preload("res://objects/nodes/sign.tscn")
197 var sign1 = sign_prefab.instantiate()
198 sign1.position = Vector3(-7, 5, -15.01)
199 sign1.text = "victory"
200 get_parent().add_child.call_deferred(sign1)
201
202 var sign2 = sign_prefab.instantiate()
203 sign2.position = Vector3(-7, 4, -15.01)
204 sign2.text = "%s ending" % ap.kEndingNameByVictoryValue.get(ap.victory_condition, "?")
205
206 var sign2_color = ap.kEndingNameByVictoryValue.get(ap.victory_condition, "coral").to_lower()
207 if sign2_color == "white":
208 sign2_color = "silver"
209
210 sign2.material = load("res://assets/materials/%s.material" % sign2_color)
211 get_parent().add_child.call_deferred(sign2)
212
213 # Add the strict purple ending validation.
214 if global.map == "the_sun_temple" and ap.strict_purple_ending:
215 var panel_prefab = preload("res://objects/nodes/panel.tscn")
216 var tpl_prefab = preload("res://objects/nodes/listeners/teleportListener.tscn")
217 var reverse_prefab = preload("res://objects/nodes/listeners/reversingListener.tscn")
218
219 var previous_panel = null
220 var next_y = -100
221 var words = ["quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"]
222 for word in words:
223 var panel = panel_prefab.instantiate()
224 panel.position = Vector3(0, next_y, 0)
225 next_y -= 10
226 panel.clue = word
227 panel.symbol = ""
228 panel.answer = word
229 panel.name = "EndCheck_%s" % word
230
231 var tpl = tpl_prefab.instantiate()
232 tpl.teleport_point = Vector3(0, 1, 0)
233 tpl.teleport_rotate = Vector3(-45, 180, 0)
234 tpl.target_path = panel
235 tpl.name = "Teleport"
236
237 if previous_panel == null:
238 tpl.senders.append(NodePath("/root/scene/Panels/End/panel_24"))
239 else:
240 tpl.senders.append(NodePath("../../%s" % previous_panel.name))
241
242 var reversing = reverse_prefab.instantiate()
243 reversing.senders.append(NodePath(".."))
244 reversing.name = "Reversing"
245 tpl.senders.append(NodePath("../Reversing"))
246
247 panel.add_child.call_deferred(tpl)
248 panel.add_child.call_deferred(reversing)
249 get_parent().get_node("Panels").add_child.call_deferred(panel)
250
251 previous_panel = panel
252
253 # Duplicate the doors that usually wait on EQUINOX. We can't set the senders
254 # here for some reason so we actually set them in the door ready function.
255 var endplat = get_node("/root/scene/Components/Doors/EndPlatform")
256 var endplat2 = endplat.duplicate()
257 endplat2.name = "spe_EndPlatform"
258 endplat.get_parent().add_child.call_deferred(endplat2)
259 endplat.queue_free()
260
261 var entry2 = get_node("/root/scene/Components/Doors/entry_2")
262 var entry22 = entry2.duplicate()
263 entry22.name = "spe_entry_2"
264 entry2.get_parent().add_child.call_deferred(entry22)
265 entry2.queue_free()
266
267 # Add the strict cyan ending validation.
268 if global.map == "the_parthenon" and ap.strict_cyan_ending:
269 var panel_prefab = preload("res://objects/nodes/panel.tscn")
270 var tpl_prefab = preload("res://objects/nodes/listeners/teleportListener.tscn")
271 var reverse_prefab = preload("res://objects/nodes/listeners/reversingListener.tscn")
272
273 var previous_panel = null
274 var next_y = -100
275 var words = ["quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"]
276 for word in words:
277 var panel = panel_prefab.instantiate()
278 panel.position = Vector3(0, next_y, 0)
279 next_y -= 10
280 panel.clue = word
281 panel.symbol = "."
282 panel.answer = "%s%s" % [word, word]
283 panel.name = "EndCheck_%s" % word
284
285 var tpl = tpl_prefab.instantiate()
286 tpl.teleport_point = Vector3(0, 1, -11)
287 tpl.teleport_rotate = Vector3(-45, 0, 0)
288 tpl.target_path = panel
289 tpl.name = "Teleport"
290
291 if previous_panel == null:
292 tpl.senderGroup.append(NodePath("/root/scene/Panels/Rulers"))
293 else:
294 tpl.senders.append(NodePath("../../%s" % previous_panel.name))
295
296 var reversing = reverse_prefab.instantiate()
297 reversing.senders.append(NodePath(".."))
298 reversing.name = "Reversing"
299 tpl.senders.append(NodePath("../Reversing"))
300
301 panel.add_child.call_deferred(tpl)
302 panel.add_child.call_deferred(reversing)
303 get_parent().get_node("Panels").add_child.call_deferred(panel)
304
305 previous_panel = panel
306
307 # Duplicate the door that usually waits on the rulers. We can't set the
308 # senders here for some reason so we actually set them in the door ready
309 # function.
310 var entry1 = get_node("/root/scene/Components/Doors/entry_1")
311 var entry12 = entry1.duplicate()
312 entry12.name = "spe_entry_1"
313 entry1.get_parent().add_child.call_deferred(entry12)
314 entry1.queue_free()
315
316 ap.update_job_well_done_sign()
317
318 var minimap = ap.SCRIPT_minimap.new()
319 minimap.name = "Minimap"
320 minimap.visible = ap.show_minimap
321 get_parent().add_child.call_deferred(minimap)
322
323 super._ready()
324
325 await get_tree().process_frame
326 await get_tree().process_frame
327
328 ap.stop_batching_locations()
329
330
331func _set_up_invis_wall(x, y, z, sx, sy, sz):
332 var prefab = preload("res://objects/nodes/block.tscn")
333 var newwall = prefab.instantiate()
334 newwall.position.x = x
335 newwall.position.y = y
336 newwall.position.z = z
337 newwall.scale.x = sz
338 newwall.scale.y = sy
339 newwall.scale.z = sx
340 newwall.set_surface_override_material(0, preload("res://assets/materials/blackMatte.material"))
341 newwall.visibility_range_end = 3
342 newwall.visibility_range_end_margin = 1
343 newwall.visibility_range_fade_mode = RenderingServer.VISIBILITY_RANGE_FADE_SELF
344 newwall.skeleton = ".."
345 get_parent().add_child.call_deferred(newwall)
346
347
348func _process(_dt):
349 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..35428ea --- /dev/null +++ b/apworld/client/source_runtime.gd
@@ -0,0 +1,29 @@
1extends Node
2
3var source_path
4
5
6func _init(path):
7 source_path = path
8
9
10func load_script(path):
11 return ResourceLoader.load("%s/%s" % [source_path, path])
12
13
14func read_path(path):
15 return FileAccess.get_file_as_bytes("%s/%s" % [source_path, path])
16
17
18func load_script_as_scene(path, scene_name):
19 var script = load_script(path)
20 var instance = script.new()
21 instance.name = scene_name
22
23 get_tree().unload_current_scene()
24 _load_scene.call_deferred(instance)
25
26
27func _load_scene(instance):
28 get_tree().get_root().add_child(instance)
29 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..f785a03 --- /dev/null +++ b/apworld/client/textclient.gd
@@ -0,0 +1,438 @@
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 = {}
19
20var worldports_tab
21var worldports_tree
22var port_tree_item_by_map = {}
23var port_tree_item_by_map_port = {}
24
25const kLocation = 0
26const kWorldport = 1
27const kGoal = 2
28
29
30func _ready():
31 process_mode = ProcessMode.PROCESS_MODE_ALWAYS
32 layer = 2
33
34 locations_overlay = RichTextLabel.new()
35 locations_overlay.name = "LocationsOverlay"
36 locations_overlay.offset_top = 220
37 locations_overlay.offset_bottom = 720
38 locations_overlay.offset_left = 20
39 locations_overlay.anchor_right = 1.0
40 locations_overlay.offset_right = -10
41 locations_overlay.scroll_active = false
42 locations_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
43 locations_overlay.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
44 add_child(locations_overlay)
45 update_locations_visibility()
46
47 tabs = TabContainer.new()
48 tabs.name = "Tabs"
49 tabs.offset_left = 100
50 tabs.offset_right = 1820
51 tabs.offset_top = 100
52 tabs.offset_bottom = 980
53 tabs.visible = false
54 tabs.theme = preload("res://assets/themes/baseUI.tres")
55 tabs.add_theme_font_size_override("font_size", 36)
56 add_child(tabs)
57
58 panel = MarginContainer.new()
59 panel.name = "Text Client"
60 panel.add_theme_constant_override("margin_top", 60)
61 panel.add_theme_constant_override("margin_left", 60)
62 panel.add_theme_constant_override("margin_right", 60)
63 panel.add_theme_constant_override("margin_bottom", 60)
64 tabs.add_child(panel)
65
66 label = RichTextLabel.new()
67 label.set_name("Label")
68 label.scroll_following = true
69 label.selection_enabled = true
70 label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
71 label.size_flags_vertical = Control.SIZE_EXPAND_FILL
72 label.push_font(preload("res://assets/fonts/Lingo2.ttf"))
73 label.push_font_size(30)
74
75 var entry_style = StyleBoxFlat.new()
76 entry_style.bg_color = Color(0.9, 0.9, 0.9, 1)
77
78 entry = LineEdit.new()
79 entry.set_name("Entry")
80 entry.add_theme_font_override("font", preload("res://assets/fonts/Lingo2.ttf"))
81 entry.add_theme_font_size_override("font_size", 36)
82 entry.add_theme_color_override("font_color", Color(0, 0, 0, 1))
83 entry.add_theme_color_override("cursor_color", Color(0, 0, 0, 1))
84 entry.add_theme_stylebox_override("focus", entry_style)
85 entry.text_submitted.connect(text_entered)
86
87 var tc_arranger = VBoxContainer.new()
88 tc_arranger.add_child(label)
89 tc_arranger.add_child(entry)
90 tc_arranger.add_theme_constant_override("separation", 40)
91 panel.add_child(tc_arranger)
92
93 var tracker_margins = MarginContainer.new()
94 tracker_margins.name = "Locations"
95 tracker_margins.add_theme_constant_override("margin_top", 60)
96 tracker_margins.add_theme_constant_override("margin_left", 60)
97 tracker_margins.add_theme_constant_override("margin_right", 60)
98 tracker_margins.add_theme_constant_override("margin_bottom", 60)
99 tabs.add_child(tracker_margins)
100
101 tracker_tree = Tree.new()
102 tracker_tree.columns = 3
103 tracker_tree.hide_root = true
104 tracker_tree.add_theme_font_size_override("font_size", 24)
105 tracker_tree.add_theme_color_override("font_color", Color(0.8, 0.8, 0.8, 1))
106 tracker_tree.add_theme_constant_override("v_separation", 1)
107 tracker_tree.item_edited.connect(_on_tracker_button_clicked)
108 tracker_tree.set_column_expand(0, false)
109 tracker_tree.set_column_expand(1, true)
110 tracker_tree.set_column_expand(2, false)
111 tracker_tree.set_column_custom_minimum_width(2, 200)
112 tracker_margins.add_child(tracker_tree)
113
114 worldports_tab = MarginContainer.new()
115 worldports_tab.name = "Worldports"
116 worldports_tab.add_theme_constant_override("margin_top", 60)
117 worldports_tab.add_theme_constant_override("margin_left", 60)
118 worldports_tab.add_theme_constant_override("margin_right", 60)
119 worldports_tab.add_theme_constant_override("margin_bottom", 60)
120 tabs.add_child(worldports_tab)
121 tabs.set_tab_hidden(2, true)
122
123 worldports_tree = Tree.new()
124 worldports_tree.columns = 2
125 worldports_tree.hide_root = true
126 worldports_tree.theme = preload("res://assets/themes/baseUI.tres")
127 worldports_tree.add_theme_font_size_override("font_size", 24)
128 worldports_tab.add_child(worldports_tree)
129
130 var runtime = global.get_node("Runtime")
131 var location_image = Image.new()
132 location_image.load_png_from_buffer(runtime.read_path("assets/location.png"))
133 location_texture = ImageTexture.create_from_image(location_image)
134
135 var worldport_image = Image.new()
136 worldport_image.load_png_from_buffer(runtime.read_path("assets/worldport.png"))
137 worldport_texture = ImageTexture.create_from_image(worldport_image)
138
139 var goal_image = Image.new()
140 goal_image.load_png_from_buffer(runtime.read_path("assets/goal.png"))
141 goal_texture = ImageTexture.create_from_image(goal_image)
142
143
144func _input(event):
145 if global.loaded and event is InputEventKey and event.pressed:
146 if event.keycode == KEY_TAB and !Input.is_key_pressed(KEY_SHIFT):
147 if !get_tree().paused:
148 is_open = true
149 get_tree().paused = true
150 Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
151 tabs.visible = true
152 entry.grab_focus()
153 get_viewport().set_input_as_handled()
154 else:
155 dismiss()
156 elif event.keycode == KEY_ESCAPE:
157 if is_open:
158 dismiss()
159 get_viewport().set_input_as_handled()
160
161
162func dismiss():
163 if is_open:
164 get_tree().paused = false
165 Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
166 tabs.visible = false
167 is_open = false
168
169
170func parse_printjson(text):
171 label.append_text("[p]" + text + "[/p]")
172
173
174func text_entered(text):
175 var ap = global.get_node("Archipelago")
176 var cmd = text.trim_suffix("\n")
177 entry.text = ""
178 if OS.is_debug_build():
179 if cmd.begins_with("/tp_map "):
180 var new_map = cmd.substr(8)
181 global.map = new_map
182 global.sets_entry_point = false
183 switcher.switch_map("res://objects/scenes/%s.tscn" % new_map)
184 return
185
186 ap.client.say(cmd)
187
188
189func update_locations(reset_locations = true):
190 var ap = global.get_node("Archipelago")
191 var gamedata = global.get_node("Gamedata")
192
193 locations_overlay.clear()
194 locations_overlay.push_font(preload("res://assets/fonts/Lingo2.ttf"))
195 locations_overlay.push_font_size(24)
196 locations_overlay.push_color(Color(0.9, 0.9, 0.9, 1))
197 locations_overlay.push_outline_color(Color(0, 0, 0, 1))
198 locations_overlay.push_outline_size(2)
199
200 var locations = []
201 for location_id in ap.client._accessible_locations:
202 if not ap.client._checked_locations.has(location_id):
203 var location_name = gamedata.location_name_by_id.get(location_id, "(Unknown)")
204 (
205 locations
206 . append(
207 {
208 "name": location_name,
209 "type": kLocation,
210 "id": location_id,
211 }
212 )
213 )
214
215 for port_id in ap.client._accessible_worldports:
216 if not ap.client._checked_worldports.has(port_id):
217 var port_name = gamedata.get_worldport_display_name(port_id)
218 (
219 locations
220 . append(
221 {
222 "name": port_name,
223 "type": kWorldport,
224 "id": port_id,
225 }
226 )
227 )
228
229 locations.sort_custom(func(a, b): return a["name"] < b["name"])
230
231 if ap.client._goal_accessible:
232 var location_name = gamedata.ending_display_name_by_name[ap.kEndingNameByVictoryValue[
233 ap.victory_condition
234 ]]
235 (
236 locations
237 . push_front(
238 {
239 "name": location_name,
240 "type": kGoal,
241 }
242 )
243 )
244
245 var count = 0
246 for location in locations:
247 if count < 18:
248 locations_overlay.push_paragraph(HORIZONTAL_ALIGNMENT_RIGHT)
249 locations_overlay.append_text(location["name"])
250 locations_overlay.append_text(" ")
251 if location["type"] == kLocation:
252 locations_overlay.add_image(location_texture)
253 elif location["type"] == kWorldport:
254 locations_overlay.add_image(worldport_texture)
255 elif location["type"] == kGoal:
256 locations_overlay.add_image(goal_texture)
257 locations_overlay.pop()
258 count += 1
259
260 if count > 18:
261 locations_overlay.append_text("[p align=right][lb]...[rb][/p]")
262
263 if reset_locations:
264 reset_tracker_tab()
265
266 var root_ti = tracker_tree.create_item(null)
267
268 for location in locations:
269 var loc_row = root_ti.create_child()
270 loc_row.set_cell_mode(0, TreeItem.CELL_MODE_ICON)
271 loc_row.set_selectable(0, false)
272 loc_row.set_text(1, location["name"])
273 loc_row.set_selectable(1, false)
274 loc_row.set_cell_mode(2, TreeItem.CELL_MODE_CUSTOM)
275 loc_row.set_text(2, "Show Path")
276 loc_row.set_custom_as_button(2, true)
277 loc_row.set_editable(2, true)
278 loc_row.set_selectable(2, false)
279 loc_row.set_text_alignment(2, HORIZONTAL_ALIGNMENT_CENTER)
280
281 if location["type"] == kLocation:
282 loc_row.set_icon(0, location_texture)
283 tracker_loc_tree_item_by_id[location["id"]] = loc_row
284 elif location["type"] == kWorldport:
285 loc_row.set_icon(0, worldport_texture)
286 tracker_port_tree_item_by_id[location["id"]] = loc_row
287 elif location["type"] == kGoal:
288 loc_row.set_icon(0, goal_texture)
289 tracker_goal_tree_item = loc_row
290
291 tracker_object_by_index[loc_row.get_index()] = location
292 else:
293 for loc_row in tracker_tree.get_root().get_children():
294 loc_row.visible = false
295
296 for location_id in tracker_loc_tree_item_by_id.keys():
297 if (
298 ap.client._accessible_locations.has(location_id)
299 and not ap.client._checked_locations.has(location_id)
300 ):
301 tracker_loc_tree_item_by_id[location_id].visible = true
302
303 for port_id in tracker_port_tree_item_by_id.keys():
304 if (
305 ap.client._accessible_worldports.has(port_id)
306 and not ap.client._checked_worldports.has(port_id)
307 ):
308 tracker_port_tree_item_by_id[port_id].visible = true
309
310 if tracker_goal_tree_item != null and ap.client._goal_accessible:
311 tracker_goal_tree_item.visible = true
312
313
314func update_locations_visibility():
315 var ap = global.get_node("Archipelago")
316 locations_overlay.visible = ap.show_locations
317
318
319func _on_tracker_button_clicked():
320 var edited_item = tracker_tree.get_edited()
321 var edited_index = edited_item.get_index()
322
323 if tracker_object_by_index.has(edited_index):
324 var tracker_object = tracker_object_by_index[edited_index]
325 var ap = global.get_node("Archipelago")
326 var type_str = ""
327 if tracker_object["type"] == kLocation:
328 type_str = "location"
329 elif tracker_object["type"] == kWorldport:
330 type_str = "worldport"
331 elif tracker_object["type"] == kGoal:
332 type_str = "goal"
333 ap.client.getLogicalPath(type_str, tracker_object.get("id", null))
334
335
336func display_logical_path(object_type, object_id, paths):
337 var ap = global.get_node("Archipelago")
338 var gamedata = global.get_node("Gamedata")
339
340 var location_name = "(Unknown)"
341 if object_type == "location" and object_id != null:
342 location_name = gamedata.location_name_by_id.get(object_id, "(Unknown)")
343 elif object_type == "worldport" and object_id != null:
344 location_name = gamedata.get_worldport_display_name(object_id)
345 elif object_type == "goal":
346 location_name = gamedata.ending_display_name_by_name[ap.kEndingNameByVictoryValue[
347 ap.victory_condition
348 ]]
349
350 label.append_text("[p]Path to %s:[/p]" % location_name)
351 label.append_text("[ol]" + "\n".join(paths) + "[/ol]")
352
353 panel.visible = true
354
355
356func setup_worldports():
357 tabs.set_tab_hidden(2, false)
358
359 var root_ti = worldports_tree.create_item(null)
360
361 var ports_by_map_id = {}
362 var display_names_by_map_id = {}
363 var display_names_by_port_id = {}
364
365 var ap = global.get_node("Archipelago")
366 var gamedata = global.get_node("Gamedata")
367 for fpid in ap.port_pairings:
368 var port = gamedata.objects.get_ports()[fpid]
369 var room = gamedata.objects.get_rooms()[port.get_room_id()]
370
371 if not ports_by_map_id.has(room.get_map_id()):
372 ports_by_map_id[room.get_map_id()] = []
373
374 var map = gamedata.objects.get_maps()[room.get_map_id()]
375 display_names_by_map_id[map.get_id()] = map.get_display_name()
376
377 ports_by_map_id[room.get_map_id()].append(fpid)
378 display_names_by_port_id[fpid] = port.get_display_name()
379
380 var sorted_map_ids = ports_by_map_id.keys().duplicate()
381 sorted_map_ids.sort_custom(
382 func(a, b): return display_names_by_map_id[a] < display_names_by_map_id[b]
383 )
384
385 for map_id in sorted_map_ids:
386 var map_ti = root_ti.create_child()
387 map_ti.set_text(0, display_names_by_map_id[map_id])
388 map_ti.visible = false
389 map_ti.collapsed = true
390 port_tree_item_by_map[map_id] = map_ti
391 port_tree_item_by_map_port[map_id] = {}
392
393 var port_ids = ports_by_map_id[map_id]
394 port_ids.sort_custom(
395 func(a, b): return display_names_by_port_id[a] < display_names_by_port_id[b]
396 )
397
398 for port_id in port_ids:
399 var port_ti = map_ti.create_child()
400 port_ti.set_text(0, display_names_by_port_id[port_id])
401 port_ti.set_text(1, gamedata.get_worldport_display_name(ap.port_pairings[port_id]))
402 port_ti.visible = false
403 port_tree_item_by_map_port[map_id][port_id] = port_ti
404
405 update_worldports()
406
407
408func update_worldports():
409 var ap = global.get_node("Archipelago")
410
411 for map_id in port_tree_item_by_map_port.keys():
412 var map_visible = false
413
414 for port_id in port_tree_item_by_map_port[map_id].keys():
415 var ti = port_tree_item_by_map_port[map_id][port_id]
416 ti.visible = ap.client._checked_worldports.has(port_id)
417
418 if ti.visible:
419 map_visible = true
420
421 port_tree_item_by_map[map_id].visible = map_visible
422
423
424func reset():
425 locations_overlay.clear()
426 tabs.set_tab_hidden(2, true)
427 port_tree_item_by_map.clear()
428 port_tree_item_by_map_port.clear()
429 worldports_tree.clear()
430 reset_tracker_tab()
431
432
433func reset_tracker_tab():
434 tracker_loc_tree_item_by_id.clear()
435 tracker_port_tree_item_by_id.clear()
436 tracker_goal_tree_item = null
437 tracker_object_by_index.clear()
438 tracker_tree.clear()
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..a0ee34d --- /dev/null +++ b/apworld/context.py
@@ -0,0 +1,654 @@
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
33class Lingo2Manager:
34 game_ctx: "Lingo2GameContext"
35 client_ctx: "Lingo2ClientContext"
36 tracker: Tracker
37
38 keyboard: dict[str, int]
39 worldports: set[int]
40 goaled: bool
41
42 def __init__(self, game_ctx: "Lingo2GameContext", client_ctx: "Lingo2ClientContext"):
43 self.game_ctx = game_ctx
44 self.game_ctx.manager = self
45 self.client_ctx = client_ctx
46 self.client_ctx.manager = self
47 self.tracker = Tracker(self)
48 self.keyboard = {}
49 self.worldports = set()
50
51 self.reset()
52
53 def reset(self):
54 for k in ALL_LETTERS:
55 self.keyboard[k] = 0
56
57 self.worldports = set()
58 self.goaled = False
59
60 def update_keyboard(self, new_keyboard: dict[str, int]) -> dict[str, int]:
61 ret: dict[str, int] = {}
62
63 for k, v in new_keyboard.items():
64 if v > self.keyboard.get(k, 0):
65 self.keyboard[k] = v
66 ret[k] = v
67
68 if len(ret) > 0:
69 self.tracker.refresh_state()
70 self.game_ctx.send_accessible_locations()
71
72 return ret
73
74 def update_worldports(self, new_worldports: set[int]) -> set[int]:
75 ret = new_worldports.difference(self.worldports)
76 self.worldports.update(new_worldports)
77
78 if len(ret) > 0:
79 self.tracker.refresh_state()
80 self.game_ctx.send_accessible_locations()
81
82 return ret
83
84
85class Lingo2GameContext:
86 server: Endpoint | None
87 manager: Lingo2Manager
88
89 def __init__(self):
90 self.server = None
91
92 def send_connected(self):
93 if self.server is None:
94 return
95
96 msg = {
97 "cmd": "Connected",
98 "user": self.manager.client_ctx.username,
99 "seed_name": self.manager.client_ctx.seed_name,
100 "version": self.manager.client_ctx.server_version,
101 "generator_version": self.manager.client_ctx.generator_version,
102 "team": self.manager.client_ctx.team,
103 "slot": self.manager.client_ctx.slot,
104 "checked_locations": self.manager.client_ctx.checked_locations,
105 "slot_data": self.manager.client_ctx.slot_data,
106 }
107
108 async_start(self.send_msgs([msg]), name="game Connected")
109
110 def send_connection_refused(self, text):
111 if self.server is None:
112 return
113
114 msg = {
115 "cmd": "ConnectionRefused",
116 "text": text,
117 }
118
119 async_start(self.send_msgs([msg]), name="game ConnectionRefused")
120
121 def send_item_sent_notification(self, item_name, receiver_name, item_flags):
122 if self.server is None:
123 return
124
125 msg = {
126 "cmd": "ItemSentNotif",
127 "item_name": item_name,
128 "receiver_name": receiver_name,
129 "item_flags": item_flags,
130 }
131
132 async_start(self.send_msgs([msg]), name="item sent notif")
133
134 def send_hint_received(self, item_name, location_name, receiver_name, item_flags, for_self):
135 if self.server is None:
136 return
137
138 msg = {
139 "cmd": "HintReceived",
140 "item_name": item_name,
141 "location_name": location_name,
142 "receiver_name": receiver_name,
143 "item_flags": item_flags,
144 "self": int(for_self),
145 }
146
147 async_start(self.send_msgs([msg]), name="hint received notif")
148
149 def send_item_received(self, items):
150 if self.server is None:
151 return
152
153 msg = {
154 "cmd": "ItemReceived",
155 "items": items,
156 }
157
158 async_start(self.send_msgs([msg]), name="item received")
159
160 def send_location_info(self, locations):
161 if self.server is None:
162 return
163
164 msg = {
165 "cmd": "LocationInfo",
166 "locations": locations,
167 }
168
169 async_start(self.send_msgs([msg]), name="location info")
170
171 def send_text_message(self, parts):
172 if self.server is None:
173 return
174
175 msg = {
176 "cmd": "TextMessage",
177 "data": parts,
178 }
179
180 async_start(self.send_msgs([msg]), name="notif")
181
182 def send_accessible_locations(self):
183 if self.server is None:
184 return
185
186 msg = {
187 "cmd": "AccessibleLocations",
188 "locations": list(self.manager.tracker.accessible_locations),
189 }
190
191 if len(self.manager.tracker.accessible_worldports) > 0:
192 msg["worldports"] = list(self.manager.tracker.accessible_worldports)
193
194 if self.manager.tracker.goal_accessible and not self.manager.goaled:
195 msg["goal"] = True
196
197 async_start(self.send_msgs([msg]), name="accessible locations")
198
199 def send_update_locations(self, locations):
200 if self.server is None:
201 return
202
203 msg = {
204 "cmd": "UpdateLocations",
205 "locations": locations,
206 }
207
208 async_start(self.send_msgs([msg]), name="update locations")
209
210 def send_update_keyboard(self, updates):
211 if self.server is None:
212 return
213
214 msg = {
215 "cmd": "UpdateKeyboard",
216 "updates": updates,
217 }
218
219 async_start(self.send_msgs([msg]), name="update keyboard")
220
221 def send_update_worldports(self, worldports):
222 if self.server is None:
223 return
224
225 msg = {
226 "cmd": "UpdateWorldports",
227 "worldports": worldports,
228 }
229
230 async_start(self.send_msgs([msg]), name="update worldports")
231
232 def send_path_reply(self, object_type: str, object_id: int | None, path: list[str]):
233 if self.server is None:
234 return
235
236 msg = {
237 "cmd": "PathReply",
238 "type": object_type,
239 "path": path,
240 }
241
242 if object_id is not None:
243 msg["id"] = object_id
244
245 async_start(self.send_msgs([msg]), name="path reply")
246
247 async def send_msgs(self, msgs: list[Any]) -> None:
248 """ `msgs` JSON serializable """
249 if not self.server or not self.server.socket.open or self.server.socket.closed:
250 return
251 await self.server.socket.send(encode(msgs))
252
253
254class Lingo2ClientContext(CommonContext):
255 manager: Lingo2Manager
256
257 game = "Lingo 2"
258 items_handling = 0b111
259
260 slot_data: dict[str, Any] | None
261 victory_data_storage_key: str
262
263 def __init__(self, server_address: str | None = None, password: str | None = None):
264 super().__init__(server_address, password)
265
266 def make_gui(self):
267 ui = super().make_gui()
268 ui.base_title = "Archipelago Lingo 2 Client"
269 return ui
270
271 async def server_auth(self, password_requested: bool = False):
272 if password_requested and not self.password:
273 self.manager.game_ctx.send_connection_refused("Invalid password.")
274 else:
275 self.auth = self.username
276 await self.send_connect()
277
278 def handle_connection_loss(self, msg: str):
279 super().handle_connection_loss(msg)
280
281 exc_info = sys.exc_info()
282 self.manager.game_ctx.send_connection_refused(str(exc_info[1]))
283
284 def on_package(self, cmd: str, args: dict):
285 if cmd == "RoomInfo":
286 self.seed_name = args.get("seed_name", None)
287 elif cmd == "Connected":
288 self.slot_data = args.get("slot_data", None)
289
290 self.manager.reset()
291
292 self.manager.game_ctx.send_connected()
293
294 self.manager.tracker.setup_slot(self.slot_data)
295 self.manager.tracker.set_checked_locations(self.checked_locations)
296 self.manager.game_ctx.send_accessible_locations()
297
298 self.victory_data_storage_key = f"_read_client_status_{self.team}_{self.slot}"
299
300 self.set_notify(self.get_datastorage_key("keyboard1"), self.get_datastorage_key("keyboard2"),
301 self.victory_data_storage_key)
302 msg_batch = [{
303 "cmd": "Set",
304 "key": self.get_datastorage_key("keyboard1"),
305 "default": 0,
306 "want_reply": True,
307 "operations": [{"operation": "default", "value": 0}]
308 }, {
309 "cmd": "Set",
310 "key": self.get_datastorage_key("keyboard2"),
311 "default": 0,
312 "want_reply": True,
313 "operations": [{"operation": "default", "value": 0}]
314 }]
315
316 if self.slot_data.get("shuffle_worldports", False):
317 self.set_notify(self.get_datastorage_key("worldports"))
318 msg_batch.append({
319 "cmd": "Set",
320 "key": self.get_datastorage_key("worldports"),
321 "default": [],
322 "want_reply": True,
323 "operations": [{"operation": "default", "value": []}]
324 })
325
326 async_start(self.send_msgs(msg_batch), name="default keys")
327 elif cmd == "RoomUpdate":
328 if "checked_locations" in args:
329 self.manager.tracker.set_checked_locations(self.checked_locations)
330 self.manager.game_ctx.send_update_locations(args["checked_locations"])
331 elif cmd == "ReceivedItems":
332 self.manager.tracker.set_collected_items(self.items_received)
333
334 cur_index = 0
335 items = []
336
337 for item in args["items"]:
338 index = cur_index + args["index"]
339 cur_index += 1
340
341 item_msg = {
342 "id": item.item,
343 "index": index,
344 "flags": item.flags,
345 "text": self.item_names.lookup_in_slot(item.item, self.slot),
346 }
347
348 if item.player != self.slot:
349 item_msg["sender"] = self.player_names.get(item.player)
350
351 items.append(item_msg)
352
353 self.manager.game_ctx.send_item_received(items)
354
355 if any(ItemClassification.progression in ItemClassification(item.flags) for item in args["items"]):
356 self.manager.game_ctx.send_accessible_locations()
357 elif cmd == "PrintJSON":
358 if "receiving" in args and "item" in args and args["item"].player == self.slot:
359 item_name = self.item_names.lookup_in_slot(args["item"].item, args["receiving"])
360 location_name = self.location_names.lookup_in_slot(args["item"].location, args["item"].player)
361 receiver_name = self.player_names.get(args["receiving"])
362
363 if args["type"] == "Hint" and not args.get("found", False):
364 self.manager.game_ctx.send_hint_received(item_name, location_name, receiver_name, args["item"].flags,
365 int(args["receiving"]) == self.slot)
366 elif args["receiving"] != self.slot:
367 self.manager.game_ctx.send_item_sent_notification(item_name, receiver_name, args["item"].flags)
368
369 parts = []
370 for message_part in args["data"]:
371 if "type" not in message_part and "text" in message_part:
372 parts.append({"type": "text", "text": message_part["text"]})
373 elif message_part["type"] == "player_id":
374 parts.append({
375 "type": "player",
376 "text": self.player_names.get(int(message_part["text"])),
377 "self": int(int(message_part["text"]) == self.slot),
378 })
379 elif message_part["type"] == "item_id":
380 parts.append({
381 "type": "item",
382 "text": self.item_names.lookup_in_slot(int(message_part["text"]), message_part["player"]),
383 "flags": message_part["flags"],
384 })
385 elif message_part["type"] == "location_id":
386 parts.append({
387 "type": "location",
388 "text": self.location_names.lookup_in_slot(int(message_part["text"]),
389 message_part["player"])
390 })
391 elif "text" in message_part:
392 parts.append({"type": "text", "text": message_part["text"]})
393
394 self.manager.game_ctx.send_text_message(parts)
395 elif cmd == "LocationInfo":
396 locations = []
397
398 for location in args["locations"]:
399 locations.append({
400 "id": location.location,
401 "item": self.item_names.lookup_in_slot(location.item, location.player),
402 "player": self.player_names.get(location.player),
403 "flags": location.flags,
404 "self": int(location.player) == self.slot,
405 })
406
407 self.manager.game_ctx.send_location_info(locations)
408 elif cmd == "Retrieved":
409 for k, v in args["keys"].items():
410 if k == self.victory_data_storage_key:
411 self.handle_status_update(v)
412 elif cmd == "SetReply":
413 if args["key"] == self.get_datastorage_key("keyboard1"):
414 self.handle_keyboard_update(1, args)
415 elif args["key"] == self.get_datastorage_key("keyboard2"):
416 self.handle_keyboard_update(2, args)
417 elif args["key"] == self.get_datastorage_key("worldports"):
418 updates = self.manager.update_worldports(set(args["value"]))
419 if len(updates) > 0:
420 self.manager.game_ctx.send_update_worldports(updates)
421 elif args["key"] == self.victory_data_storage_key:
422 self.handle_status_update(args["value"])
423
424 def get_datastorage_key(self, name: str):
425 return f"Lingo2_{self.slot}_{name}"
426
427 async def update_keyboard(self, updates: dict[str, int]):
428 kb1 = 0
429 kb2 = 0
430
431 for k, v in updates.items():
432 if v == 0:
433 continue
434
435 effect = 0
436 if v >= 1:
437 effect |= 1
438 if v == 2:
439 effect |= 2
440
441 pos = KEY_STORAGE_MAPPING[k]
442 if pos[0] == 1:
443 kb1 |= (effect << pos[1] * 2)
444 else:
445 kb2 |= (effect << pos[1] * 2)
446
447 msgs = []
448
449 if kb1 != 0:
450 msgs.append({
451 "cmd": "Set",
452 "key": self.get_datastorage_key("keyboard1"),
453 "want_reply": True,
454 "operations": [{
455 "operation": "or",
456 "value": kb1
457 }]
458 })
459
460 if kb2 != 0:
461 msgs.append({
462 "cmd": "Set",
463 "key": self.get_datastorage_key("keyboard2"),
464 "want_reply": True,
465 "operations": [{
466 "operation": "or",
467 "value": kb2
468 }]
469 })
470
471 if len(msgs) > 0:
472 await self.send_msgs(msgs)
473
474 def handle_keyboard_update(self, field: int, args: dict[str, Any]):
475 keys = {}
476 value = args["value"]
477
478 for i in range(0, 13):
479 if (value & (1 << (i * 2))) != 0:
480 keys[REVERSE_KEY_STORAGE_MAPPING[(field, i)]] = 1
481 if (value & (1 << (i * 2 + 1))) != 0:
482 keys[REVERSE_KEY_STORAGE_MAPPING[(field, i)]] = 2
483
484 updates = self.manager.update_keyboard(keys)
485 if len(updates) > 0:
486 self.manager.game_ctx.send_update_keyboard(updates)
487
488 async def update_worldports(self, updates: set[int]):
489 await self.send_msgs([{
490 "cmd": "Set",
491 "key": self.get_datastorage_key("worldports"),
492 "want_reply": True,
493 "operations": [{
494 "operation": "update",
495 "value": updates
496 }]
497 }])
498
499 def handle_status_update(self, value: int):
500 self.manager.goaled = (value == ClientStatus.CLIENT_GOAL)
501 self.manager.tracker.refresh_state()
502 self.manager.game_ctx.send_accessible_locations()
503
504
505async def pipe_loop(manager: Lingo2Manager):
506 while not manager.client_ctx.exit_event.is_set():
507 try:
508 socket = await websockets.connect("ws://localhost", port=PORT, ping_timeout=None, ping_interval=None,
509 max_size=MESSAGE_MAX_SIZE)
510 manager.game_ctx.server = Endpoint(socket)
511 logger.info("Connected to Lingo 2!")
512 if manager.client_ctx.auth is not None:
513 manager.game_ctx.send_connected()
514 manager.game_ctx.send_accessible_locations()
515 async for data in manager.game_ctx.server.socket:
516 for msg in decode(data):
517 await process_game_cmd(manager, msg)
518 except ConnectionRefusedError:
519 logger.info("Could not connect to Lingo 2.")
520 finally:
521 manager.game_ctx.server = None
522
523
524async def process_game_cmd(manager: Lingo2Manager, args: dict):
525 cmd = args["cmd"]
526
527 if cmd == "Connect":
528 manager.client_ctx.seed_name = None
529
530 server = args.get("server")
531 player = args.get("player")
532 password = args.get("password")
533
534 if password != "":
535 server_address = f"{player}:{password}@{server}"
536 else:
537 server_address = f"{player}:None@{server}"
538
539 async_start(manager.client_ctx.connect(server_address), name="client connect")
540 elif cmd == "Disconnect":
541 manager.client_ctx.seed_name = None
542
543 async_start(manager.client_ctx.disconnect(), name="client disconnect")
544 elif cmd in ["Sync", "LocationChecks", "Say", "StatusUpdate", "LocationScouts"]:
545 async_start(manager.client_ctx.send_msgs([args]), name="client forward")
546 elif cmd == "UpdateKeyboard":
547 updates = manager.update_keyboard(args["keyboard"])
548 if len(updates) > 0:
549 async_start(manager.client_ctx.update_keyboard(updates), name="client update keyboard")
550 elif cmd == "CheckWorldport":
551 port_id = args["port_id"]
552 worldports = {port_id}
553 if str(port_id) in manager.client_ctx.slot_data["port_pairings"]:
554 worldports.add(manager.client_ctx.slot_data["port_pairings"][str(port_id)])
555
556 updates = manager.update_worldports(worldports)
557 if len(updates) > 0:
558 async_start(manager.client_ctx.update_worldports(updates), name="client update worldports")
559 manager.game_ctx.send_update_worldports(updates)
560 elif cmd == "GetPath":
561 path = None
562
563 if args["type"] == "location":
564 path = manager.tracker.get_path_to_location(args["id"])
565 elif args["type"] == "worldport":
566 path = manager.tracker.get_path_to_port(args["id"])
567 elif args["type"] == "goal":
568 path = manager.tracker.get_path_to_goal()
569
570 manager.game_ctx.send_path_reply(args["type"], args.get("id", None), path)
571 elif cmd == "Quit":
572 manager.client_ctx.exit_event.set()
573
574
575async def run_game():
576 exe_file = settings.get_settings().lingo2_options.exe_file
577
578 # This ensures we can use Steam features without having to open the game
579 # through steam.
580 steam_appid_path = os.path.join(os.path.dirname(exe_file), "steam_appid.txt")
581 with open(steam_appid_path, "w") as said_handle:
582 said_handle.write("2523310")
583
584 if Lingo2World.zip_path is not None:
585 # This is a packaged apworld.
586 init_scene = pkgutil.get_data(__name__, "client/run_from_apworld.tscn")
587 init_path = Utils.local_path("data", "lingo2_init.tscn")
588
589 with open(init_path, "wb") as file_handle:
590 file_handle.write(init_scene)
591
592 subprocess.Popen(
593 [
594 exe_file,
595 "--scene",
596 init_path,
597 "--",
598 str(Lingo2World.zip_path.absolute()),
599 ],
600 cwd=os.path.dirname(exe_file),
601 )
602 else:
603 # The world is unzipped and being run in source.
604 subprocess.Popen(
605 [
606 exe_file,
607 "--scene",
608 Utils.local_path("worlds", "lingo2", "client", "run_from_source.tscn"),
609 "--",
610 Utils.local_path("worlds", "lingo2", "client"),
611 ],
612 cwd=os.path.dirname(exe_file),
613 )
614
615
616def client_main(*launch_args: str) -> None:
617 async def main(args):
618 if settings.get_settings().lingo2_options.start_game:
619 async_start(run_game())
620
621 client_ctx = Lingo2ClientContext(args.connect, args.password)
622 game_ctx = Lingo2GameContext()
623 manager = Lingo2Manager(game_ctx, client_ctx)
624
625 client_ctx.server_task = asyncio.create_task(server_loop(client_ctx), name="ServerLoop")
626
627 if gui_enabled:
628 client_ctx.run_gui()
629 client_ctx.run_cli()
630
631 pipe_task = asyncio.create_task(pipe_loop(manager), name="GameWatcher")
632
633 try:
634 await pipe_task
635 except Exception as e:
636 logger.exception(e)
637
638 await client_ctx.exit_event.wait()
639 client_ctx.ui.stop()
640 await client_ctx.shutdown()
641
642 Utils.init_logging("Lingo2Client", exception_logger="Client")
643 import colorama
644
645 parser = get_base_parser(description="Lingo 2 Archipelago Client")
646 parser.add_argument('--name', default=None, help="Slot Name to connect as.")
647 parser.add_argument("url", nargs="?", help="Archipelago connection url")
648 args = parser.parse_args(launch_args)
649
650 args = handle_url_arg(args, parser=parser)
651
652 colorama.just_fix_windows_console()
653 asyncio.run(main(args))
654 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 d984beb..3d7c9a5 100644 --- a/apworld/options.py +++ b/apworld/options.py
@@ -1,15 +1,144 @@
1from dataclasses import dataclass 1from dataclasses import dataclass
2 2
3from Options import PerGameCommonOptions, Toggle, Choice 3from Options import PerGameCommonOptions, Toggle, Choice, DefaultOnToggle, Range
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
10 10
11class ShuffleControlCenterColors(Toggle):
12 """
13 Some doors open after solving the COLOR panel in the Control Center. If this option is enabled, these doors will
14 instead open upon receiving an item.
15 """
16 display_name = "Shuffle Control Center Colors"
17
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
24class ShuffleLetters(Choice):
25 """
26 Controls how letter unlocks are handled. Note that H1, I1, N1, and T1 will always be present at their vanilla
27 locations in the starting room, even if letters are shuffled remotely.
28
29 - **Vanilla**: All letters will be present at their vanilla locations.
30 - **Unlocked**: Players will start with their keyboards fully unlocked.
31 - **Progressive**: Two items will be added to the pool for every letter (one for H, I, N, and T). Receiving the
32 first item gives you the corresponding level 1 letter, and the second item gives you the corresponding level 2
33 letter.
34 - **Vanilla Cyan**: Players will start with all level 1 (purple) letters unlocked. Level 2 (cyan) letters will be
35 present at their vanilla locations.
36 - **Item Cyan**: Players will start with all level 1 (purple) letters unlocked. One item will be added to the pool
37 for every level 2 (cyan) letter.
38 """
39 display_name = "Shuffle Letters"
40 option_vanilla = 0
41 option_unlocked = 1
42 option_progressive = 2
43 option_vanilla_cyan = 3
44 option_item_cyan = 4
45
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. Connections that depend on placing letters in keyholders are also
60 currently not shuffled.
61
62 NOTE: It is highly recommended that you turn on Shuffle Control Center Colors when using Shuffle Worldports. Not
63 doing so runs the risk of creating an unfinishable seed.
64 """
65 display_name = "Shuffle Worldports"
66
67
68class KeyholderSanity(Toggle):
69 """
70 If enabled, 26 locations will be created for placing each key into its respective Green Ending keyholder.
71
72 NOTE: This does not apply to the two disappearing keyholders in The Congruent, as they are not part of Green Ending.
73 """
74 display_name = "Keyholder Sanity"
75
76
77class CyanDoorBehavior(Choice):
78 """
79 Cyan-colored doors usually only open upon unlocking double letters. Some panels also only appear upon unlocking
80 double letters. This option determines how these unlocks should behave.
81
82 - **Collect H2**: In the base game, H2 is the first double letter you are intended to collect, so cyan doors only
83 open when you collect the H2 pickup in The Repetitive. Collecting the actual pickup is still required even with
84 remote letter shuffle enabled.
85 - **Any Double Letter**: Cyan doors will open when you have unlocked any cyan letter on your keyboard. In letter
86 shuffle, this means receiving a cyan letter, not picking up a cyan letter collectable.
87 - **Item**: Cyan doors will be grouped together in a single item.
88
89 Note that some cyan doors are impacted by door shuffle (e.g. the entrance to The Tower). When door shuffle is
90 enabled, these doors won't be affected by the value of this option.
91 """
92 display_name = "Cyan Door Behavior"
93 option_collect_h2 = 0
94 option_any_double_letter = 1
95 option_item = 2
96
97
98class DaedalusRoofAccess(Toggle):
99 """
100 If enabled, the player will be logically expected to be able to go from the castle entrance to any part of Daedalus
101 that is open to the air. If disabled, the player will only be expected to be able to enter the castle, the moat,
102 Icarus, and the area at the bottom of the stairs. Invisible walls that become opaque as you approach them are added
103 to the level to prevent the player from accidentally breaking logic.
104 """
105 display_name = "Allow Daedalus Roof Access"
106
107
108class StrictPurpleEnding(DefaultOnToggle):
109 """
110 If enabled, the player will be required to have all purple (level 1) letters in order to get Purple Ending.
111 Otherwise, some of the letters may be skippable depending on the other options.
112 """
113 display_name = "Strict Purple Ending"
114
115
116class StrictCyanEnding(DefaultOnToggle):
117 """
118 If enabled, the player will be required to have all cyan (level 2) letters in order to get Cyan Ending. Otherwise,
119 at least J2, Q2, and V2 are skippable. Others may also be skippable depending on the options chosen.
120 """
121 display_name = "Strict Cyan Ending"
122
123
11class VictoryCondition(Choice): 124class VictoryCondition(Choice):
12 """Victory condition.""" 125 """
126 This option determines what your goal is.
127
128 - **Gray Ending** (The Colorful)
129 - **Purple Ending** (The Sun Temple). This ordinarily requires all level 1 (purple) letters.
130 - **Mint Ending** (typing EXIT into the keyholders in Control Center)
131 - **Black Ending** (The Graveyard)
132 - **Blue Ending** (The Words)
133 - **Cyan Ending** (The Parthenon). This ordinarily requires almost all level 2 (cyan) letters.
134 - **Red Ending** (The Tower)
135 - **Plum Ending** (The Wondrous / The Door)
136 - **Orange Ending** (the castle in Daedalus)
137 - **Gold Ending** (The Gold). This involves going through the color rooms in Daedalus.
138 - **Yellow Ending** (The Gallery). This requires unlocking all gallery paintings.
139 - **Green Ending** (The Ancient). This requires filling all keyholders with specific letters.
140 - **White Ending** (Control Center). This combines every other ending.
141 """
13 display_name = "Victory Condition" 142 display_name = "Victory Condition"
14 option_gray_ending = 0 143 option_gray_ending = 0
15 option_purple_ending = 1 144 option_purple_ending = 1
@@ -26,7 +155,26 @@ class VictoryCondition(Choice):
26 option_white_ending = 12 155 option_white_ending = 12
27 156
28 157
158class TrapPercentage(Range):
159 """Replaces junk items with traps, at the specified rate."""
160 display_name = "Trap Percentage"
161 range_start = 0
162 range_end = 100
163 default = 0
164
165
29@dataclass 166@dataclass
30class Lingo2Options(PerGameCommonOptions): 167class Lingo2Options(PerGameCommonOptions):
31 shuffle_doors: ShuffleDoors 168 shuffle_doors: ShuffleDoors
169 shuffle_control_center_colors: ShuffleControlCenterColors
170 shuffle_gallery_paintings: ShuffleGalleryPaintings
171 shuffle_letters: ShuffleLetters
172 shuffle_symbols: ShuffleSymbols
173 shuffle_worldports: ShuffleWorldports
174 keyholder_sanity: KeyholderSanity
175 cyan_door_behavior: CyanDoorBehavior
176 daedalus_roof_access: DaedalusRoofAccess
177 strict_purple_ending: StrictPurpleEnding
178 strict_cyan_ending: StrictCyanEnding
32 victory_condition: VictoryCondition 179 victory_condition: VictoryCondition
180 trap_percentage: TrapPercentage
diff --git a/apworld/player_logic.py b/apworld/player_logic.py index 6feef99..84c93c8 100644 --- a/apworld/player_logic.py +++ b/apworld/player_logic.py
@@ -1,6 +1,11 @@
1from enum import IntEnum, auto
2
1from .generated import data_pb2 as data_pb2 3from .generated import data_pb2 as data_pb2
4from .items import SYMBOL_ITEMS
2from typing import TYPE_CHECKING, NamedTuple 5from typing import TYPE_CHECKING, NamedTuple
3 6
7from .options import ShuffleLetters, CyanDoorBehavior
8
4if TYPE_CHECKING: 9if TYPE_CHECKING:
5 from . import Lingo2World 10 from . import Lingo2World
6 11
@@ -12,64 +17,175 @@ def calculate_letter_histogram(solution: str) -> dict[str, int]:
12 real_l = l.upper() 17 real_l = l.upper()
13 histogram[real_l] = min(histogram.get(real_l, 0) + 1, 2) 18 histogram[real_l] = min(histogram.get(real_l, 0) + 1, 2)
14 19
15 for free_letter in "HINT":
16 if histogram.get(free_letter, 0) == 1:
17 del histogram[free_letter]
18
19 return histogram 20 return histogram
20 21
21 22
22class AccessRequirements: 23class AccessRequirements:
23 items: set[str] 24 items: set[str]
25 progressives: dict[str, int]
24 rooms: set[str] 26 rooms: set[str]
25 symbols: set[str]
26 letters: dict[str, int] 27 letters: dict[str, int]
28 cyans: bool
27 29
28 # This is an AND of ORs. 30 # This is an AND of ORs.
29 or_logic: list[list["AccessRequirements"]] 31 or_logic: list[list["AccessRequirements"]]
30 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
31 def __init__(self): 38 def __init__(self):
32 self.items = set() 39 self.items = set()
40 self.progressives = dict()
33 self.rooms = set() 41 self.rooms = set()
34 self.symbols = set()
35 self.letters = dict() 42 self.letters = dict()
43 self.cyans = False
36 self.or_logic = list() 44 self.or_logic = list()
45 self.complete_at = None
46 self.possibilities = list()
37 47
38 def add_solution(self, solution: str): 48 def copy(self) -> "AccessRequirements":
39 histogram = calculate_letter_histogram(solution) 49 reqs = AccessRequirements()
40 50 reqs.items = self.items.copy()
41 for l, a in histogram.items(): 51 reqs.progressives = self.progressives.copy()
42 self.letters[l] = max(self.letters.get(l, 0), histogram.get(l)) 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
43 59
44 def merge(self, other: "AccessRequirements"): 60 def merge(self, other: "AccessRequirements"):
45 for item in other.items: 61 for item in other.items:
46 self.items.add(item) 62 self.items.add(item)
47 63
64 for item, amount in other.progressives.items():
65 self.progressives[item] = max(amount, self.progressives.get(item, 0))
66
48 for room in other.rooms: 67 for room in other.rooms:
49 self.rooms.add(room) 68 self.rooms.add(room)
50 69
51 for symbol in other.symbols:
52 self.symbols.add(symbol)
53
54 for letter, level in other.letters.items(): 70 for letter, level in other.letters.items():
55 self.letters[letter] = max(self.letters.get(letter, 0), level) 71 self.letters[letter] = max(self.letters.get(letter, 0), level)
56 72
73 self.cyans = self.cyans or other.cyans
74
57 for disjunction in other.or_logic: 75 for disjunction in other.or_logic:
58 self.or_logic.append(disjunction) 76 self.or_logic.append(disjunction)
59 77
78 if other.complete_at is not None:
79 # Merging multiple requirements that use complete_at sucks, and is part of why we want to minimize use of
80 # it. If both requirements use complete_at, we will cheat by using the or_logic field, which supports
81 # conjunctions of requirements.
82 if self.complete_at is not None:
83 print("Merging requirements with complete_at > 1. This is messy and should be avoided!")
84
85 left_req = AccessRequirements()
86 left_req.complete_at = self.complete_at
87 left_req.possibilities = self.possibilities
88 self.or_logic.append([left_req])
89
90 self.complete_at = None
91 self.possibilities = list()
92
93 right_req = AccessRequirements()
94 right_req.complete_at = other.complete_at
95 right_req.possibilities = other.possibilities
96 self.or_logic.append([right_req])
97 else:
98 self.complete_at = other.complete_at
99 self.possibilities = other.possibilities
100
101 def is_empty(self) -> bool:
102 return (len(self.items) == 0 and len(self.progressives) == 0 and len(self.rooms) == 0 and len(self.letters) == 0
103 and not self.cyans and len(self.or_logic) == 0 and self.complete_at is None)
104
105 def __eq__(self, other: "AccessRequirements"):
106 return (self.items == other.items and self.progressives == other.progressives and self.rooms == other.rooms and
107 self.letters == other.letters and self.cyans == other.cyans and self.or_logic == other.or_logic and
108 self.complete_at == other.complete_at and self.possibilities == other.possibilities)
109
110 def simplify(self):
111 resimplify = False
112
113 if len(self.or_logic) > 0:
114 old_or_logic = self.or_logic
115
116 def remove_redundant(sub_reqs: "AccessRequirements"):
117 new_reqs = sub_reqs.copy()
118 new_reqs.letters = {l: v for l, v in new_reqs.letters.items() if self.letters.get(l, 0) < v}
119 if new_reqs != sub_reqs:
120 return new_reqs
121 else:
122 return sub_reqs
123
124 self.or_logic = []
125 for disjunction in old_or_logic:
126 new_disjunction = []
127 for ssr in disjunction:
128 new_ssr = remove_redundant(ssr)
129 if not new_ssr.is_empty():
130 new_disjunction.append(new_ssr)
131 else:
132 new_disjunction.clear()
133 break
134 if len(new_disjunction) == 1:
135 self.merge(new_disjunction[0])
136 resimplify = True
137 elif len(new_disjunction) > 1:
138 if all(cjr == new_disjunction[0] for cjr in new_disjunction):
139 self.merge(new_disjunction[0])
140 resimplify = True
141 else:
142 self.or_logic.append(new_disjunction)
143
144 if resimplify:
145 self.simplify()
146
147 def get_referenced_rooms(self):
148 result = set(self.rooms)
149
150 for disjunction in self.or_logic:
151 for sub_req in disjunction:
152 result = result.union(sub_req.get_referenced_rooms())
153
154 for sub_req in self.possibilities:
155 result = result.union(sub_req.get_referenced_rooms())
156
157 return result
158
159 def remove_room(self, room: str):
160 if room in self.rooms:
161 self.rooms.remove(room)
162
163 for disjunction in self.or_logic:
164 for sub_req in disjunction:
165 sub_req.remove_room(room)
166
167 for sub_req in self.possibilities:
168 sub_req.remove_room(room)
169
60 def __repr__(self): 170 def __repr__(self):
61 parts = [] 171 parts = []
62 if len(self.items) > 0: 172 if len(self.items) > 0:
63 parts.append(f"items={self.items}") 173 parts.append(f"items={self.items}")
174 if len(self.progressives) > 0:
175 parts.append(f"progressives={self.progressives}")
64 if len(self.rooms) > 0: 176 if len(self.rooms) > 0:
65 parts.append(f"rooms={self.rooms}") 177 parts.append(f"rooms={self.rooms}")
66 if len(self.symbols) > 0:
67 parts.append(f"symbols={self.symbols}")
68 if len(self.letters) > 0: 178 if len(self.letters) > 0:
69 parts.append(f"letters={self.letters}") 179 parts.append(f"letters={self.letters}")
180 if self.cyans:
181 parts.append(f"cyans=True")
70 if len(self.or_logic) > 0: 182 if len(self.or_logic) > 0:
71 parts.append(f"or_logic={self.or_logic}") 183 parts.append(f"or_logic={self.or_logic}")
72 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) + ")"
73 189
74 190
75class PlayerLocation(NamedTuple): 191class PlayerLocation(NamedTuple):
@@ -77,13 +193,19 @@ class PlayerLocation(NamedTuple):
77 reqs: AccessRequirements 193 reqs: AccessRequirements
78 194
79 195
196class LetterBehavior(IntEnum):
197 VANILLA = auto()
198 ITEM = auto()
199 UNLOCKED = auto()
200
201
80class Lingo2PlayerLogic: 202class Lingo2PlayerLogic:
81 world: "Lingo2World" 203 world: "Lingo2World"
82 204
83 locations_by_room: dict[int, list[PlayerLocation]] 205 locations_by_room: dict[int, list[PlayerLocation]]
84 event_loc_item_by_room: dict[int, dict[str, str]] 206 event_loc_item_by_room: dict[int, dict[str, str]]
85 207
86 item_by_door: dict[int, str] 208 item_by_door: dict[int, tuple[str, int]]
87 209
88 panel_reqs: dict[int, AccessRequirements] 210 panel_reqs: dict[int, AccessRequirements]
89 proxy_reqs: dict[int, dict[str, AccessRequirements]] 211 proxy_reqs: dict[int, dict[str, AccessRequirements]]
@@ -91,6 +213,9 @@ class Lingo2PlayerLogic:
91 213
92 real_items: list[str] 214 real_items: list[str]
93 215
216 double_letter_amount: dict[str, int]
217 goal_room_id: int
218
94 def __init__(self, world: "Lingo2World"): 219 def __init__(self, world: "Lingo2World"):
95 self.world = world 220 self.world = world
96 self.locations_by_room = {} 221 self.locations_by_room = {}
@@ -100,14 +225,68 @@ class Lingo2PlayerLogic:
100 self.proxy_reqs = dict() 225 self.proxy_reqs = dict()
101 self.door_reqs = dict() 226 self.door_reqs = dict()
102 self.real_items = list() 227 self.real_items = list()
228 self.double_letter_amount = dict()
229
230 if self.world.options.shuffle_doors:
231 for progressive in world.static_logic.objects.progressives:
232 for i in range(0, len(progressive.doors)):
233 self.item_by_door[progressive.doors[i]] = (progressive.name, i + 1)
234 self.real_items.append(progressive.name)
235
236 for door_group in world.static_logic.objects.door_groups:
237 if door_group.type == data_pb2.DoorGroupType.CONNECTOR:
238 if not self.world.options.shuffle_doors or self.world.options.shuffle_worldports:
239 continue
240 elif door_group.type == data_pb2.DoorGroupType.COLOR_CONNECTOR:
241 if not self.world.options.shuffle_control_center_colors or self.world.options.shuffle_worldports:
242 continue
243 elif door_group.type == data_pb2.DoorGroupType.SHUFFLE_GROUP:
244 if not self.world.options.shuffle_doors:
245 continue
246 else:
247 continue
248
249 for door in door_group.doors:
250 self.item_by_door[door] = (door_group.name, 1)
251
252 self.real_items.append(door_group.name)
103 253
104 # We iterate through the doors in two parts because it is essential that we determine which doors are shuffled 254 # We iterate through the doors in two parts because it is essential that we determine which doors are shuffled
105 # before we calculate any access requirements. 255 # before we calculate any access requirements.
106 for door in world.static_logic.objects.doors: 256 for door in world.static_logic.objects.doors:
107 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.ITEM_ONLY] and self.world.options.shuffle_doors: 257 if door.type in [data_pb2.DoorType.EVENT, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]:
108 door_item_name = self.world.static_logic.get_door_item_name(door.id) 258 continue
109 self.item_by_door[door.id] = door_item_name 259
110 self.real_items.append(door_item_name) 260 if door.id in self.item_by_door:
261 continue
262
263 if (door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.ITEM_ONLY] and
264 not self.world.options.shuffle_doors):
265 continue
266
267 if (door.type == data_pb2.DoorType.CONTROL_CENTER_COLOR and
268 not self.world.options.shuffle_control_center_colors):
269 continue
270
271 if door.type == data_pb2.DoorType.GALLERY_PAINTING and not self.world.options.shuffle_gallery_paintings:
272 continue
273
274 door_item_name = self.world.static_logic.get_door_item_name(door)
275 self.item_by_door[door.id] = (door_item_name, 1)
276 self.real_items.append(door_item_name)
277
278 # We handle cyan_door_behavior = Item after door shuffle, because cyan doors that are impacted by door shuffle
279 # should be exempt from cyan_door_behavior.
280 if world.options.cyan_door_behavior == CyanDoorBehavior.option_item:
281 for door_group in world.static_logic.objects.door_groups:
282 if door_group.type != data_pb2.DoorGroupType.CYAN_DOORS:
283 continue
284
285 for door in door_group.doors:
286 if not door in self.item_by_door:
287 self.item_by_door[door] = (door_group.name, 1)
288
289 self.real_items.append(door_group.name)
111 290
112 for door in world.static_logic.objects.doors: 291 for door in world.static_logic.objects.doors:
113 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]: 292 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]:
@@ -117,31 +296,57 @@ class Lingo2PlayerLogic:
117 for letter in world.static_logic.objects.letters: 296 for letter in world.static_logic.objects.letters:
118 self.locations_by_room.setdefault(letter.room_id, []).append(PlayerLocation(letter.ap_id, 297 self.locations_by_room.setdefault(letter.room_id, []).append(PlayerLocation(letter.ap_id,
119 AccessRequirements())) 298 AccessRequirements()))
120 299 behavior = self.get_letter_behavior(letter.key, letter.level2)
121 letter_name = f"{letter.key.upper()}{'2' if letter.level2 else '1'}" 300 if behavior == LetterBehavior.VANILLA:
122 event_name = f"{letter_name} (Collected)" 301 if not world.for_tracker:
123 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper() 302 letter_name = f"{letter.key.upper()}{'2' if letter.level2 else '1'}"
124 303 event_name = f"{letter_name} (Collected)"
125 if letter.level2: 304 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper()
126 event_name = f"{letter_name} (Double Collected)" 305
127 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper() 306 if letter.level2:
307 event_name = f"{letter_name} (Double Collected)"
308 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper()
309 elif behavior == LetterBehavior.ITEM:
310 self.real_items.append(letter.key.upper())
311
312 if behavior != LetterBehavior.UNLOCKED:
313 self.double_letter_amount[letter.key.upper()] = self.double_letter_amount.get(letter.key.upper(), 0) + 1
128 314
129 for mastery in world.static_logic.objects.masteries: 315 for mastery in world.static_logic.objects.masteries:
130 self.locations_by_room.setdefault(mastery.room_id, []).append(PlayerLocation(mastery.ap_id, 316 self.locations_by_room.setdefault(mastery.room_id, []).append(PlayerLocation(mastery.ap_id,
131 AccessRequirements())) 317 AccessRequirements()))
132 318
133 for ending in world.static_logic.objects.endings: 319 for ending in world.static_logic.objects.endings:
134 self.locations_by_room.setdefault(ending.room_id, []).append(PlayerLocation(ending.ap_id, 320 # Don't create a location for your selected ending, and never create a location for White Ending.
135 AccessRequirements())) 321 if world.options.victory_condition.current_key.removesuffix("_ending").upper() != ending.name\
322 and ending.name != "WHITE":
323 self.locations_by_room.setdefault(ending.room_id, []).append(PlayerLocation(ending.ap_id,
324 AccessRequirements()))
136 325
137 event_name = f"{ending.name.capitalize()} Ending (Achieved)" 326 event_name = f"{ending.name.capitalize()} Ending (Achieved)"
138 item_name = event_name 327 item_name = event_name
139 328
140 if world.options.victory_condition.current_key.removesuffix("_ending").upper() == ending.name: 329 if world.options.victory_condition.current_key.removesuffix("_ending").upper() == ending.name:
141 item_name = "Victory" 330 item_name = "Victory"
331 self.goal_room_id = ending.room_id
142 332
143 self.event_loc_item_by_room.setdefault(ending.room_id, {})[event_name] = item_name 333 self.event_loc_item_by_room.setdefault(ending.room_id, {})[event_name] = item_name
144 334
335 if self.world.options.keyholder_sanity:
336 for keyholder in world.static_logic.objects.keyholders:
337 if keyholder.HasField("key"):
338 reqs = AccessRequirements()
339
340 if self.get_letter_behavior(keyholder.key, False) != LetterBehavior.UNLOCKED:
341 reqs.letters[keyholder.key.upper()] = 1
342
343 self.locations_by_room.setdefault(keyholder.room_id, []).append(PlayerLocation(keyholder.ap_id,
344 reqs))
345
346 if self.world.options.shuffle_symbols:
347 for symbol_name in SYMBOL_ITEMS.values():
348 self.real_items.append(symbol_name)
349
145 def get_panel_reqs(self, panel_id: int, answer: str | None) -> AccessRequirements: 350 def get_panel_reqs(self, panel_id: int, answer: str | None) -> AccessRequirements:
146 if answer is None: 351 if answer is None:
147 if panel_id not in self.panel_reqs: 352 if panel_id not in self.panel_reqs:
@@ -161,28 +366,38 @@ class Lingo2PlayerLogic:
161 reqs.rooms.add(self.world.static_logic.get_room_region_name(panel.room_id)) 366 reqs.rooms.add(self.world.static_logic.get_room_region_name(panel.room_id))
162 367
163 if answer is not None: 368 if answer is not None:
164 reqs.add_solution(answer) 369 self.add_solution_reqs(reqs, answer)
165 elif len(panel.proxies) > 0: 370 elif len(panel.proxies) > 0:
166 possibilities = [] 371 possibilities = []
372 already_filled = False
167 373
168 for proxy in panel.proxies: 374 for proxy in panel.proxies:
169 proxy_reqs = AccessRequirements() 375 proxy_reqs = AccessRequirements()
170 proxy_reqs.add_solution(proxy.answer) 376 self.add_solution_reqs(proxy_reqs, proxy.answer)
171 377
172 possibilities.append(proxy_reqs) 378 if not proxy_reqs.is_empty():
379 possibilities.append(proxy_reqs)
380 else:
381 already_filled = True
382 break
173 383
174 if not any(proxy.answer == panel.answer for proxy in panel.proxies): 384 if not already_filled and not any(proxy.answer == panel.answer for proxy in panel.proxies):
175 proxy_reqs = AccessRequirements() 385 proxy_reqs = AccessRequirements()
176 proxy_reqs.add_solution(panel.answer) 386 self.add_solution_reqs(proxy_reqs, panel.answer)
177 387
178 possibilities.append(proxy_reqs) 388 if not proxy_reqs.is_empty():
389 possibilities.append(proxy_reqs)
390 else:
391 already_filled = True
179 392
180 reqs.or_logic.append(possibilities) 393 if not already_filled:
394 reqs.or_logic.append(possibilities)
181 else: 395 else:
182 reqs.add_solution(panel.answer) 396 self.add_solution_reqs(reqs, panel.answer)
183 397
184 for symbol in panel.symbols: 398 if self.world.options.shuffle_symbols:
185 reqs.symbols.add(symbol) 399 for symbol in panel.symbols:
400 reqs.items.add(SYMBOL_ITEMS.get(symbol))
186 401
187 if panel.HasField("required_door"): 402 if panel.HasField("required_door"):
188 door_reqs = self.get_door_open_reqs(panel.required_door) 403 door_reqs = self.get_door_open_reqs(panel.required_door)
@@ -205,22 +420,46 @@ class Lingo2PlayerLogic:
205 door = self.world.static_logic.objects.doors[door_id] 420 door = self.world.static_logic.objects.doors[door_id]
206 reqs = AccessRequirements() 421 reqs = AccessRequirements()
207 422
208 # TODO: control_center_color, switches
209 if not door.HasField("complete_at") or door.complete_at == 0: 423 if not door.HasField("complete_at") or door.complete_at == 0:
210 for proxy in door.panels: 424 for proxy in door.panels:
211 panel_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None) 425 panel_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None)
212 reqs.merge(panel_reqs) 426 reqs.merge(panel_reqs)
213 elif door.complete_at == 1: 427 elif door.complete_at == 1:
214 reqs.or_logic.append([self.get_panel_reqs(proxy.panel, 428 disjunction = []
215 proxy.answer if proxy.HasField("answer") else None) 429 for proxy in door.panels:
216 for proxy in door.panels]) 430 proxy_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None)
431 if proxy_reqs.is_empty():
432 disjunction.clear()
433 break
434 else:
435 disjunction.append(proxy_reqs)
436 if len(disjunction) > 0:
437 reqs.or_logic.append(disjunction)
217 else: 438 else:
218 # TODO: Handle complete_at > 1 439 reqs.complete_at = door.complete_at
219 pass 440 for proxy in door.panels:
441 panel_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None)
442 reqs.possibilities.append(panel_reqs)
443
444 if door.HasField("control_center_color"):
445 # TODO: Logic for ensuring two CC states aren't needed at once.
446 reqs.rooms.add("Control Center - Main Area")
447 self.add_solution_reqs(reqs, door.control_center_color)
448
449 if door.double_letters:
450 if self.world.options.cyan_door_behavior == CyanDoorBehavior.option_collect_h2:
451 reqs.rooms.add("The Repetitive - Main Room")
452 elif self.world.options.cyan_door_behavior == CyanDoorBehavior.option_any_double_letter:
453 if self.world.options.shuffle_letters != ShuffleLetters.option_unlocked:
454 reqs.cyans = True
455 elif self.world.options.cyan_door_behavior == CyanDoorBehavior.option_item:
456 # There shouldn't be any locations that are cyan doors.
457 pass
220 458
221 for keyholder_uses in door.keyholders: 459 for keyholder_uses in door.keyholders:
222 key_name = keyholder_uses.key.upper() 460 key_name = keyholder_uses.key.upper()
223 if key_name not in reqs.letters: 461 if (self.get_letter_behavior(keyholder_uses.key, False) != LetterBehavior.UNLOCKED
462 and key_name not in reqs.letters):
224 reqs.letters[key_name] = 1 463 reqs.letters[key_name] = 1
225 464
226 keyholder = self.world.static_logic.objects.keyholders[keyholder_uses.keyholder] 465 keyholder = self.world.static_logic.objects.keyholders[keyholder_uses.keyholder]
@@ -229,10 +468,20 @@ class Lingo2PlayerLogic:
229 for room in door.rooms: 468 for room in door.rooms:
230 reqs.rooms.add(self.world.static_logic.get_room_region_name(room)) 469 reqs.rooms.add(self.world.static_logic.get_room_region_name(room))
231 470
471 for ending_id in door.endings:
472 ending = self.world.static_logic.objects.endings[ending_id]
473
474 if self.world.options.victory_condition.current_key.removesuffix("_ending").upper() == ending.name:
475 reqs.items.add("Victory")
476 else:
477 reqs.items.add(f"{ending.name.capitalize()} Ending (Achieved)")
478
232 for sub_door_id in door.doors: 479 for sub_door_id in door.doors:
233 sub_reqs = self.get_door_open_reqs(sub_door_id) 480 sub_reqs = self.get_door_open_reqs(sub_door_id)
234 reqs.merge(sub_reqs) 481 reqs.merge(sub_reqs)
235 482
483 reqs.simplify()
484
236 return reqs 485 return reqs
237 486
238 # This gets the requirements to open a door within the world. When a door is shuffled, this means having the item 487 # This gets the requirements to open a door within the world. When a door is shuffled, this means having the item
@@ -240,8 +489,50 @@ class Lingo2PlayerLogic:
240 def get_door_open_reqs(self, door_id: int) -> AccessRequirements: 489 def get_door_open_reqs(self, door_id: int) -> AccessRequirements:
241 if door_id in self.item_by_door: 490 if door_id in self.item_by_door:
242 reqs = AccessRequirements() 491 reqs = AccessRequirements()
243 reqs.items.add(self.item_by_door.get(door_id)) 492
493 item_name, amount = self.item_by_door.get(door_id)
494 if amount == 1:
495 reqs.items.add(item_name)
496 else:
497 reqs.progressives[item_name] = amount
244 498
245 return reqs 499 return reqs
246 else: 500 else:
247 return self.get_door_reqs(door_id) 501 return self.get_door_reqs(door_id)
502
503 def get_letter_behavior(self, letter: str, level2: bool) -> LetterBehavior:
504 if self.world.options.shuffle_letters == ShuffleLetters.option_unlocked:
505 return LetterBehavior.UNLOCKED
506
507 if self.world.options.shuffle_letters in [ShuffleLetters.option_vanilla_cyan, ShuffleLetters.option_item_cyan]:
508 if level2:
509 if self.world.options.shuffle_letters == ShuffleLetters.option_vanilla_cyan:
510 return LetterBehavior.VANILLA
511 else:
512 return LetterBehavior.ITEM
513 else:
514 return LetterBehavior.UNLOCKED
515
516 if not level2 and letter in ["h", "i", "n", "t"]:
517 return LetterBehavior.UNLOCKED
518
519 if self.world.options.shuffle_letters == ShuffleLetters.option_progressive:
520 return LetterBehavior.ITEM
521
522 return LetterBehavior.VANILLA
523
524 def add_solution_reqs(self, reqs: AccessRequirements, solution: str):
525 histogram = calculate_letter_histogram(solution)
526
527 for l, a in histogram.items():
528 needed = min(a, 2)
529 level2 = (needed == 2)
530
531 if level2 and self.get_letter_behavior(l.lower(), True) == LetterBehavior.UNLOCKED:
532 needed = 1
533
534 if self.get_letter_behavior(l.lower(), False) == LetterBehavior.UNLOCKED:
535 needed = needed - 1
536
537 if needed > 0:
538 reqs.letters[l] = max(reqs.letters.get(l, 0), needed)
diff --git a/apworld/regions.py b/apworld/regions.py index fe2c99b..0c3858d 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,16 +56,34 @@ 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:
37 region = create_region(room, world) 65 region = create_region(room, world)
38 regions[region.name] = region 66 regions[region.name] = region
67 region_and_room.append((region, room))
68
69 for (region, room) in region_and_room:
70 create_locations(room, region, world, regions)
39 71
40 regions["Menu"].connect(regions["The Entry - Starting Room"], "Start Game") 72 regions["Menu"].connect(regions["The Entry - Starting Room"], "Start Game")
41 73
42 # TODO: The requirements of the opposite trigger also matter.
43 for connection in world.static_logic.objects.connections: 74 for connection in world.static_logic.objects.connections:
75 if connection.roof_access and not world.options.daedalus_roof_access:
76 continue
77
78 if connection.vanilla_only and world.options.shuffle_doors:
79 continue
80
44 from_region = world.static_logic.get_room_region_name(connection.from_room) 81 from_region = world.static_logic.get_room_region_name(connection.from_room)
45 to_region = world.static_logic.get_room_region_name(connection.to_room) 82 to_region = world.static_logic.get_room_region_name(connection.to_room)
83
84 if from_region not in regions or to_region not in regions:
85 continue
86
46 connection_name = f"{from_region} -> {to_region}" 87 connection_name = f"{from_region} -> {to_region}"
47 88
48 reqs = AccessRequirements() 89 reqs = AccessRequirements()
@@ -56,7 +97,10 @@ def create_regions(world: "Lingo2World"):
56 97
57 if connection.HasField("port"): 98 if connection.HasField("port"):
58 port = world.static_logic.objects.ports[connection.port] 99 port = world.static_logic.objects.ports[connection.port]
59 connection_name = f"{connection_name} (via port {port.name})" 100 connection_name = f"{connection_name} (via {port.display_name})"
101
102 if world.options.shuffle_worldports and not port.no_shuffle:
103 continue
60 104
61 if port.HasField("required_door"): 105 if port.HasField("required_door"):
62 reqs.merge(world.player_logic.get_door_open_reqs(port.required_door)) 106 reqs.merge(world.player_logic.get_door_open_reqs(port.required_door))
@@ -79,14 +123,99 @@ def create_regions(world: "Lingo2World"):
79 else: 123 else:
80 connection_name = f"{connection_name} (via panel {panel.name})" 124 connection_name = f"{connection_name} (via panel {panel.name})"
81 125
82 if from_region in regions and to_region in regions: 126 if connection.HasField("purple_ending") and connection.purple_ending and world.options.strict_purple_ending:
83 connection = Entrance(world.player, connection_name, regions[from_region]) 127 world.player_logic.add_solution_reqs(reqs, "abcdefghijklmnopqrstuvwxyz")
84 connection.access_rule = make_location_lambda(reqs, world) 128
129 if connection.HasField("cyan_ending") and connection.cyan_ending and world.options.strict_cyan_ending:
130 world.player_logic.add_solution_reqs(reqs, "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz")
131
132 reqs.simplify()
133 reqs.remove_room(from_region)
85 134
86 regions[from_region].exits.append(connection) 135 if to_region in reqs.rooms:
87 connection.connect(regions[to_region]) 136 # This connection can't ever increase access because you're required to have access to the other side in
137 # order for it to be usable. We will just not create the connection at all, in order to help GER figure out
138 # what regions are dead ends.
139 continue
88 140
89 for region in reqs.rooms: 141 connection = Entrance(world.player, connection_name, regions[from_region])
90 world.multiworld.register_indirect_condition(regions[region], connection) 142 connection.access_rule = make_location_lambda(reqs, world, regions)
143
144 regions[from_region].exits.append(connection)
145 connection.connect(regions[to_region])
146
147 for region in reqs.get_referenced_rooms():
148 world.multiworld.register_indirect_condition(regions[region], connection)
91 149
92 world.multiworld.regions += regions.values() 150 world.multiworld.regions += regions.values()
151
152
153def shuffle_entrances(world: "Lingo2World"):
154 er_entrances: list[Entrance] = []
155 er_exits: list[Entrance] = []
156
157 port_id_by_name: dict[str, int] = {}
158
159 for port in world.static_logic.objects.ports:
160 if port.no_shuffle:
161 continue
162
163 port_region_name = world.static_logic.get_room_region_name(port.room_id)
164 port_region = world.multiworld.get_region(port_region_name, world.player)
165
166 connection_name = f"{port_region_name} - {port.display_name}"
167 port_id_by_name[connection_name] = port.id
168
169 entrance = port_region.create_er_target(connection_name)
170 entrance.randomization_type = BaseClasses.EntranceType.TWO_WAY
171
172 er_exit = port_region.create_exit(connection_name)
173 er_exit.randomization_type = BaseClasses.EntranceType.TWO_WAY
174
175 if port.HasField("required_door"):
176 door_reqs = world.player_logic.get_door_open_reqs(port.required_door)
177 er_exit.access_rule = make_location_lambda(door_reqs, world, None)
178
179 for region in door_reqs.get_referenced_rooms():
180 world.multiworld.register_indirect_condition(world.multiworld.get_region(region, world.player),
181 er_exit)
182
183 er_entrances.append(entrance)
184 er_exits.append(er_exit)
185
186 result = randomize_entrances(world, True, {0:[0]}, False, er_entrances,
187 er_exits)
188
189 for (f, to) in result.pairings:
190 world.port_pairings[port_id_by_name[f]] = port_id_by_name[to]
191
192
193def connect_ports_from_ut(port_pairings: dict[int, int], world: "Lingo2World"):
194 for fpid, tpid in port_pairings.items():
195 from_port = world.static_logic.objects.ports[fpid]
196 to_port = world.static_logic.objects.ports[tpid]
197
198 from_region_name = world.static_logic.get_room_region_name(from_port.room_id)
199 to_region_name = world.static_logic.get_room_region_name(to_port.room_id)
200
201 from_region = world.multiworld.get_region(from_region_name, world.player)
202 to_region = world.multiworld.get_region(to_region_name, world.player)
203
204 connection = Entrance(world.player, f"{from_region_name} - {from_port.display_name}", from_region)
205
206 reqs = AccessRequirements()
207 if from_port.HasField("required_door"):
208 reqs = world.player_logic.get_door_open_reqs(from_port.required_door).copy()
209
210 if world.for_tracker:
211 reqs.items.add(f"Worldport {fpid} Entered")
212
213 if not reqs.is_empty():
214 connection.access_rule = make_location_lambda(reqs, world, None)
215
216 for region in reqs.get_referenced_rooms():
217 world.multiworld.register_indirect_condition(world.multiworld.get_region(region, world.player),
218 connection)
219
220 from_region.exits.append(connection)
221 connection.connect(to_region)
diff --git a/apworld/requirements.txt b/apworld/requirements.txt index b701d11..dbc395b 100644 --- a/apworld/requirements.txt +++ b/apworld/requirements.txt
@@ -1 +1 @@
protobuf>=5.29.3 \ No newline at end of file protobuf==3.20.3
diff --git a/apworld/rules.py b/apworld/rules.py index 4a84acf..f859e75 100644 --- a/apworld/rules.py +++ b/apworld/rules.py
@@ -1,32 +1,66 @@
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
16 if not all(state.has(item, world.player, amount) for item, amount in reqs.progressives.items()):
17 return False
18
15 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):
16 return False 20 return False
17 21
18 # TODO: symbols 22 if not all(state.can_reach(region) for region in regions):
23 return False
19 24
20 for letter_key, letter_level in reqs.letters.items(): 25 for letter_key, letter_level in reqs.letters.items():
21 if not state.has(letter_key, world.player, letter_level): 26 if not state.has(letter_key, world.player, letter_level):
22 return False 27 return False
23 28
29 if reqs.cyans:
30 if not any(state.has(letter, world.player, amount)
31 for letter, amount in world.player_logic.double_letter_amount.items()):
32 return False
33
24 if len(reqs.or_logic) > 0: 34 if len(reqs.or_logic) > 0:
25 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)
26 for subjunction in reqs.or_logic): 36 for subjunction in reqs.or_logic):
27 return False 37 return False
28 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
29 return True 54 return True
30 55
31def make_location_lambda(reqs: AccessRequirements, world: "Lingo2World") -> Callable[[CollectionState], bool]: 56def make_location_lambda(reqs: AccessRequirements, world: "Lingo2World",
32 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 965ce3e..e59a47d 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]
@@ -8,9 +10,17 @@ class Lingo2StaticLogic:
8 item_name_to_id: dict[str, int] 10 item_name_to_id: dict[str, int]
9 location_name_to_id: dict[str, int] 11 location_name_to_id: dict[str, int]
10 12
13 item_name_groups: dict[str, list[str]]
14 location_name_groups: dict[str, list[str]]
15
16 letter_weights: dict[str, int]
17
11 def __init__(self): 18 def __init__(self):
12 self.item_id_to_name = {} 19 self.item_id_to_name = {}
13 self.location_id_to_name = {} 20 self.location_id_to_name = {}
21 self.item_name_groups = {}
22 self.location_name_groups = {}
23 self.letter_weights = {}
14 24
15 file = pkgutil.get_data(__name__, "generated/data.binpb") 25 file = pkgutil.get_data(__name__, "generated/data.binpb")
16 self.objects = data_pb2.AllObjects() 26 self.objects = data_pb2.AllObjects()
@@ -18,38 +28,125 @@ class Lingo2StaticLogic:
18 28
19 for door in self.objects.doors: 29 for door in self.objects.doors:
20 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]: 30 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]:
21 location_name = f"{self.get_map_object_map_name(door)} - {door.name}" 31 location_name = self.get_door_location_name(door)
22 self.location_id_to_name[door.ap_id] = location_name 32 self.location_id_to_name[door.ap_id] = location_name
23 33
24 if door.type not in [data_pb2.DoorType.EVENT, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]: 34 if door.type not in [data_pb2.DoorType.EVENT, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]:
25 item_name = self.get_door_item_name(door.id) 35 item_name = self.get_door_item_name(door)
26 self.item_id_to_name[door.ap_id] = item_name 36 self.item_id_to_name[door.ap_id] = item_name
27 37
28 for letter in self.objects.letters: 38 for letter in self.objects.letters:
29 letter_name = f"{letter.key.upper()}{'2' if letter.level2 else '1'}" 39 letter_name = f"{letter.key.upper()}{'2' if letter.level2 else '1'}"
30 location_name = f"{self.get_room_object_map_name(letter)} - {letter_name}" 40 location_name = f"{self.get_room_object_map_name(letter)} - {letter_name}"
31 self.location_id_to_name[letter.ap_id] = location_name 41 self.location_id_to_name[letter.ap_id] = location_name
42 self.location_name_groups.setdefault("Letters", []).append(location_name)
32 43
33 if not letter.level2: 44 if not letter.level2:
34 self.item_id_to_name[letter.ap_id] = letter_name 45 self.item_id_to_name[letter.ap_id] = letter.key.upper()
46 self.item_name_groups.setdefault("Letters", []).append(letter.key.upper())
35 47
36 for mastery in self.objects.masteries: 48 for mastery in self.objects.masteries:
37 location_name = f"{self.get_room_object_map_name(mastery)} - Mastery" 49 location_name = f"{self.get_room_object_map_name(mastery)} - Mastery"
38 self.location_id_to_name[mastery.ap_id] = location_name 50 self.location_id_to_name[mastery.ap_id] = location_name
51 self.location_name_groups.setdefault("Masteries", []).append(location_name)
39 52
40 for ending in self.objects.endings: 53 for ending in self.objects.endings:
41 location_name = f"{self.get_room_object_map_name(ending)} - {ending.name.title()} Ending" 54 location_name = f"{self.get_room_object_map_name(ending)} - {ending.name.title()} Ending"
42 self.location_id_to_name[ending.ap_id] = location_name 55 self.location_id_to_name[ending.ap_id] = location_name
56 self.location_name_groups.setdefault("Endings", []).append(location_name)
57
58 for progressive in self.objects.progressives:
59 self.item_id_to_name[progressive.ap_id] = progressive.name
60
61 for door_group in self.objects.door_groups:
62 self.item_id_to_name[door_group.ap_id] = door_group.name
63
64 for keyholder in self.objects.keyholders:
65 if keyholder.HasField("key"):
66 location_name = f"{self.get_room_object_location_prefix(keyholder)} - {keyholder.key.upper()} Keyholder"
67 self.location_id_to_name[keyholder.ap_id] = location_name
68 self.location_name_groups.setdefault("Keyholders", []).append(location_name)
69
70 self.item_id_to_name[self.objects.special_ids["A Job Well Done"]] = "A Job Well Done"
71
72 for symbol_name in SYMBOL_ITEMS.values():
73 self.item_id_to_name[self.objects.special_ids[symbol_name]] = symbol_name
43 74
44 self.item_id_to_name[self.objects.special_ids["Nothing"]] = "Nothing" 75 for trap_name in ANTI_COLLECTABLE_TRAPS:
76 self.item_id_to_name[self.objects.special_ids[trap_name]] = trap_name
45 77
46 self.item_name_to_id = {name: ap_id for ap_id, name in self.item_id_to_name.items()} 78 self.item_name_to_id = {name: ap_id for ap_id, name in self.item_id_to_name.items()}
47 self.location_name_to_id = {name: ap_id for ap_id, name in self.location_id_to_name.items()} 79 self.location_name_to_id = {name: ap_id for ap_id, name in self.location_id_to_name.items()}
48 80
49 def get_door_item_name(self, door_id: int) -> str: 81 for panel in self.objects.panels:
50 door = self.objects.doors[door_id] 82 for letter in panel.answer.upper():
83 self.letter_weights[letter] = self.letter_weights.get(letter, 0) + 1
84
85 def get_door_item_name(self, door: data_pb2.Door) -> str:
51 return f"{self.get_map_object_map_name(door)} - {door.name}" 86 return f"{self.get_map_object_map_name(door)} - {door.name}"
52 87
88 def get_door_item_name_by_id(self, door_id: int) -> str:
89 door = self.objects.doors[door_id]
90 return self.get_door_item_name(door_id)
91
92 def get_door_location_name(self, door: data_pb2.Door) -> str:
93 map_part = self.get_room_object_location_prefix(door)
94
95 if door.HasField("location_name"):
96 return f"{map_part} - {door.location_name}"
97
98 generated_location_name = self.get_generated_door_location_name(door)
99 if generated_location_name is not None:
100 return generated_location_name
101
102 return f"{map_part} - {door.name}"
103
104 def get_generated_door_location_name(self, door: data_pb2.Door) -> str | None:
105 if door.type != data_pb2.DoorType.STANDARD:
106 return None
107
108 if len(door.keyholders) > 0 or len(door.endings) > 0 or door.HasField("complete_at"):
109 return None
110
111 if len(door.panels) > 4:
112 return None
113
114 map_areas = set()
115 for panel_id in door.panels:
116 panel = self.objects.panels[panel_id.panel]
117 panel_room = self.objects.rooms[panel.room_id]
118 # It's okay if panel_display_name is not present because then it's coalesced with other unnamed areas.
119 map_areas.add(panel_room.panel_display_name)
120
121 if len(map_areas) > 1:
122 return None
123
124 game_map = self.objects.maps[door.map_id]
125 map_area = map_areas.pop()
126 if map_area == "":
127 map_part = game_map.display_name
128 else:
129 map_part = f"{game_map.display_name} ({map_area})"
130
131 def get_panel_display_name(panel: data_pb2.ProxyIdentifier) -> str:
132 panel_data = self.objects.panels[panel.panel]
133 panel_name = panel_data.display_name if panel_data.HasField("display_name") else panel_data.name
134
135 if panel.HasField("answer"):
136 return f"{panel_name}/{panel.answer.upper()}"
137 else:
138 return panel_name
139
140 panel_names = [get_panel_display_name(panel_id)
141 for panel_id in door.panels]
142 panel_names.sort()
143
144 return map_part + " - " + ", ".join(panel_names)
145
146 def get_door_location_name_by_id(self, door_id: int) -> str:
147 door = self.objects.doors[door_id]
148 return self.get_door_location_name(door)
149
53 def get_room_region_name(self, room_id: int) -> str: 150 def get_room_region_name(self, room_id: int) -> str:
54 room = self.objects.rooms[room_id] 151 room = self.objects.rooms[room_id]
55 return f"{self.get_map_object_map_name(room)} - {room.name}" 152 return f"{self.get_map_object_map_name(room)} - {room.name}"
@@ -59,3 +156,16 @@ class Lingo2StaticLogic:
59 156
60 def get_room_object_map_name(self, obj) -> str: 157 def get_room_object_map_name(self, obj) -> str:
61 return self.get_map_object_map_name(self.objects.rooms[obj.room_id]) 158 return self.get_map_object_map_name(self.objects.rooms[obj.room_id])
159
160 def get_room_object_location_prefix(self, obj) -> str:
161 room = self.objects.rooms[obj.room_id]
162 game_map = self.objects.maps[room.map_id]
163
164 if room.HasField("panel_display_name"):
165 return f"{game_map.display_name} ({room.panel_display_name})"
166 else:
167 return game_map.display_name
168
169 def get_data_version(self) -> list[int]:
170 version = self.objects.version
171 return [version.major, version.minor, version.patch]
diff --git a/apworld/tracker.py b/apworld/tracker.py new file mode 100644 index 0000000..c65317c --- /dev/null +++ b/apworld/tracker.py
@@ -0,0 +1,143 @@
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 = {int(fp): int(tp) for fp, tp in slot_data["port_pairings"].items()}
51 connect_ports_from_ut(port_pairings, self.world)
52
53 self.refresh_state()
54
55 def set_checked_locations(self, checked_locations: set[int]):
56 self.checked_locations = checked_locations.copy()
57
58 def set_collected_items(self, network_items: list[NetworkItem]):
59 self.collected_items = {}
60
61 for item in network_items:
62 self.collected_items[item.item] = self.collected_items.get(item.item, 0) + 1
63
64 self.refresh_state()
65
66 def refresh_state(self):
67 self.state = CollectionState(self.multiworld)
68
69 for item_id, item_amount in self.collected_items.items():
70 for i in range(item_amount):
71 self.state.collect(Lingo2Item(Lingo2World.static_logic.item_id_to_name.get(item_id),
72 ItemClassification.progression, item_id, PLAYER_NUM), prevent_sweep=True)
73
74 for k, v in self.manager.keyboard.items():
75 # Unless all level 1 letters are pre-unlocked, H1 I1 N1 and T1 act differently between the generator and
76 # game. The generator considers them to be unlocked, which means they are not included in logic
77 # requirements, and only one item/event is needed to unlock their level 2 forms. The game considers them to
78 # be vanilla, which means you still have to pick them up in the Starting Room in order for them to appear on
79 # your keyboard. This also means that whether or not you have the level 1 forms should be synced to the
80 # multiworld. The tracker specifically should collect one fewer item for these letters in this scenario.
81 tv = v
82 if k in "hint" and self.world.options.shuffle_letters in [ShuffleLetters.option_vanilla,
83 ShuffleLetters.option_progressive]:
84 tv = max(0, v - 1)
85
86 if tv > 0:
87 for i in range(tv):
88 self.state.collect(Lingo2Item(k.upper(), ItemClassification.progression, None, PLAYER_NUM),
89 prevent_sweep=True)
90
91 for port_id in self.manager.worldports:
92 self.state.collect(Lingo2Item(f"Worldport {port_id} Entered", ItemClassification.progression, None,
93 PLAYER_NUM), prevent_sweep=True)
94
95 self.state.sweep_for_advancements()
96
97 self.accessible_locations = set()
98 self.accessible_worldports = set()
99 self.goal_accessible = False
100
101 for region in self.state.reachable_regions[PLAYER_NUM]:
102 for location in region.locations:
103 if location.access_rule(self.state):
104 if location.address is not None:
105 if location.address not in self.checked_locations:
106 self.accessible_locations.add(location.address)
107 elif hasattr(location, "port_id"):
108 if location.port_id not in self.manager.worldports:
109 self.accessible_worldports.add(location.port_id)
110 elif hasattr(location, "goal") and location.goal:
111 if not self.manager.goaled:
112 self.goal_accessible = True
113
114 def get_path_to_location(self, location_id: int) -> list[str] | None:
115 location_name = self.world.location_id_to_name.get(location_id)
116 location = self.multiworld.get_location(location_name, PLAYER_NUM)
117 return self.get_logical_path(location.parent_region)
118
119 def get_path_to_port(self, port_id: int) -> list[str] | None:
120 port = self.world.static_logic.objects.ports[port_id]
121 region_name = self.world.static_logic.get_room_region_name(port.room_id)
122 region = self.multiworld.get_region(region_name, PLAYER_NUM)
123 return self.get_logical_path(region)
124
125 def get_path_to_goal(self):
126 room_id = self.world.player_logic.goal_room_id
127 region_name = self.world.static_logic.get_room_region_name(room_id)
128 region = self.multiworld.get_region(region_name, PLAYER_NUM)
129 return self.get_logical_path(region)
130
131 def get_logical_path(self, region: Region) -> list[str] | None:
132 if region not in self.state.path:
133 return None
134
135 def flist_to_iter(path_value) -> Iterator[str]:
136 while path_value:
137 region_or_entrance, path_value = path_value
138 yield region_or_entrance
139
140 reversed_path = self.state.path.get(region)
141 flat_path = reversed(list(map(str, flist_to_iter(reversed_path))))
142
143 return list(flat_path)[1::2]