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__.py119
-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.gd265
-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.gd286
-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.gd603
-rw-r--r--apworld/client/messages.gd74
-rw-r--r--apworld/client/minimap.gd175
-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.gd346
-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.gd153
-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.gd310
-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.py630
-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.py171
-rw-r--r--apworld/player_logic.py431
-rw-r--r--apworld/regions.py151
-rw-r--r--apworld/requirements.txt2
-rw-r--r--apworld/rules.py53
-rw-r--r--apworld/static_logic.py122
-rw-r--r--apworld/tracker.py112
53 files changed, 5589 insertions, 75 deletions
diff --git a/apworld/__init__.py b/apworld/__init__.py index 07e3982..96f6804 100644 --- a/apworld/__init__.py +++ b/apworld/__init__.py
@@ -1,18 +1,38 @@
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 settings import Group, UserFilePath
5from worlds.AutoWorld import WebWorld, World 8from worlds.AutoWorld import WebWorld, World
6from .items import Lingo2Item 9from .items import Lingo2Item, ANTI_COLLECTABLE_TRAPS
7from .options import Lingo2Options 10from .options import Lingo2Options
8from .player_logic import Lingo2PlayerLogic 11from .player_logic import Lingo2PlayerLogic
9from .regions import create_regions 12from .regions import create_regions, shuffle_entrances, connect_ports_from_ut
10from .static_logic import Lingo2StaticLogic 13from .static_logic import Lingo2StaticLogic
14from ..LauncherComponents import Component, Type, components, launch as launch_component, icon_paths
11 15
12 16
13class Lingo2WebWorld(WebWorld): 17class Lingo2WebWorld(WebWorld):
14 rich_text_options_doc = True 18 rich_text_options_doc = True
15 theme = "grass" 19 theme = "grass"
20 tutorials = [Tutorial(
21 "Multiworld Setup Guide",
22 "A guide to playing Lingo 2 with Archipelago.",
23 "English",
24 "en_Lingo_2.md",
25 "setup/en",
26 ["hatkirby"]
27 )]
28
29
30class Lingo2Settings(Group):
31 class ExecutableFile(UserFilePath):
32 """Path to the Lingo 2 executable"""
33 is_exe = True
34
35 exe_file: ExecutableFile = ExecutableFile()
16 36
17 37
18class Lingo2World(World): 38class Lingo2World(World):
@@ -24,21 +44,43 @@ class Lingo2World(World):
24 game = "Lingo 2" 44 game = "Lingo 2"
25 web = Lingo2WebWorld() 45 web = Lingo2WebWorld()
26 46
47 settings: ClassVar[Lingo2Settings]
48 settings_key = "lingo2_options"
49
50 topology_present = True
51
27 options_dataclass = Lingo2Options 52 options_dataclass = Lingo2Options
28 options: Lingo2Options 53 options: Lingo2Options
29 54
30 static_logic = Lingo2StaticLogic() 55 static_logic = Lingo2StaticLogic()
31 item_name_to_id = static_logic.item_name_to_id 56 item_name_to_id = static_logic.item_name_to_id
32 location_name_to_id = static_logic.location_name_to_id 57 location_name_to_id = static_logic.location_name_to_id
58 item_name_groups = static_logic.item_name_groups
59 location_name_groups = static_logic.location_name_groups
60
61 for_tracker: ClassVar[bool] = False
33 62
34 player_logic: Lingo2PlayerLogic 63 player_logic: Lingo2PlayerLogic
35 64
65 port_pairings: dict[int, int]
66
36 def generate_early(self): 67 def generate_early(self):
37 self.player_logic = Lingo2PlayerLogic(self) 68 self.player_logic = Lingo2PlayerLogic(self)
69 self.port_pairings = {}
38 70
39 def create_regions(self): 71 def create_regions(self):
40 create_regions(self) 72 create_regions(self)
41 73
74 def connect_entrances(self):
75 if self.options.shuffle_worldports:
76 if hasattr(self.multiworld, "re_gen_passthrough") and "Lingo 2" in self.multiworld.re_gen_passthrough:
77 slot_value = self.multiworld.re_gen_passthrough["Lingo 2"]["port_pairings"]
78 self.port_pairings = {int(fp): int(tp) for fp, tp in slot_value.items()}
79
80 connect_ports_from_ut(self.port_pairings, self)
81 else:
82 shuffle_entrances(self)
83
42 from Utils import visualize_regions 84 from Utils import visualize_regions
43 85
44 visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml") 86 visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml")
@@ -49,11 +91,78 @@ class Lingo2World(World):
49 total_locations = sum(len(locs) for locs in self.player_logic.locations_by_room.values()) 91 total_locations = sum(len(locs) for locs in self.player_logic.locations_by_room.values())
50 92
51 item_difference = total_locations - len(pool) 93 item_difference = total_locations - len(pool)
94
95 if self.options.trap_percentage > 0:
96 num_traps = int(item_difference * self.options.trap_percentage / 100)
97 item_difference = item_difference - num_traps
98
99 trap_names = []
100 trap_weights = []
101 for letter_name, weight in self.static_logic.letter_weights.items():
102 trap_names.append(f"Anti {letter_name}")
103 trap_weights.append(weight)
104
105 bad_letters = self.random.choices(trap_names, weights=trap_weights, k=num_traps)
106 pool += [self.create_item(trap_name) for trap_name in bad_letters]
107
52 for i in range(0, item_difference): 108 for i in range(0, item_difference):
53 pool.append(self.create_item("Nothing")) 109 pool.append(self.create_item(self.get_filler_item_name()))
54 110
55 self.multiworld.itempool += pool 111 self.multiworld.itempool += pool
56 112
57 def create_item(self, name: str) -> Item: 113 def create_item(self, name: str) -> Item:
58 return Lingo2Item(name, ItemClassification.filler if name == "Nothing" else ItemClassification.progression, 114 return Lingo2Item(name, ItemClassification.filler if name == self.get_filler_item_name() else
115 ItemClassification.trap if name in ANTI_COLLECTABLE_TRAPS else
116 ItemClassification.progression,
59 self.item_name_to_id.get(name), self.player) 117 self.item_name_to_id.get(name), self.player)
118
119 def set_rules(self):
120 self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player)
121
122 def fill_slot_data(self):
123 slot_options = [
124 "cyan_door_behavior",
125 "daedalus_roof_access",
126 "keyholder_sanity",
127 "shuffle_control_center_colors",
128 "shuffle_doors",
129 "shuffle_gallery_paintings",
130 "shuffle_letters",
131 "shuffle_symbols",
132 "shuffle_worldports",
133 "strict_cyan_ending",
134 "strict_purple_ending",
135 "victory_condition",
136 ]
137
138 slot_data: dict[str, object] = {
139 **self.options.as_dict(*slot_options),
140 "version": self.static_logic.get_data_version(),
141 }
142
143 if self.options.shuffle_worldports:
144 slot_data["port_pairings"] = self.port_pairings
145
146 return slot_data
147
148 def get_filler_item_name(self) -> str:
149 return "A Job Well Done"
150
151 # for the universal tracker, doesn't get called in standard gen
152 # docs: https://github.com/FarisTheAncient/Archipelago/blob/tracker/worlds/tracker/docs/re-gen-passthrough.md
153 @staticmethod
154 def interpret_slot_data(slot_data: dict[str, object]) -> dict[str, object]:
155 # returning slot_data so it regens, giving it back in multiworld.re_gen_passthrough
156 # we are using re_gen_passthrough over modifying the world here due to complexities with ER
157 return slot_data
158
159
160def launch_client(*args):
161 from .context import client_main
162 launch_component(client_main, name="Lingo2Client", args=args)
163
164
165icon_paths["lingo2_ico"] = f"ap:{__name__}/logo.png"
166component = Component("Lingo 2 Client", component_type=Type.CLIENT, func=launch_client,
167 description="Open Lingo 2.", supports_uri=True, game_name="Lingo 2", icon="lingo2_ico")
168components.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..62d7fd8 --- /dev/null +++ b/apworld/client/client.gd
@@ -0,0 +1,265 @@
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(message["checked_locations"]))
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"]),
162 loc["item"],
163 loc["player"],
164 int(loc["flags"]),
165 int(loc["for_self"])
166 )
167
168 elif cmd == "AccessibleLocations":
169 _accessible_locations.clear()
170 _accessible_worldports.clear()
171
172 for loc in message["locations"]:
173 _accessible_locations.append(int(loc))
174
175 if "worldports" in message:
176 for port_id in message["worldports"]:
177 _accessible_worldports.append(int(port_id))
178
179 _goal_accessible = bool(message.get("goal", false))
180
181 accessible_locations_updated.emit()
182
183 elif cmd == "UpdateKeyboard":
184 var updates = {}
185 for k in message["updates"]:
186 updates[k] = int(message["updates"][k])
187
188 keyboard_update_received.emit(updates)
189
190
191func connectToServer(server, un, pw):
192 sendMessage([{"cmd": "Connect", "server": server, "player": un, "password": pw}])
193
194 ap_server = server
195 ap_user = un
196 ap_pass = pw
197
198 _should_process = true
199
200 connect_status.emit("Connecting...")
201
202
203func sendMessage(msg):
204 var payload = JSON.stringify(msg)
205 _server.send(0, payload)
206
207
208func connectToRoom():
209 connect_status.emit("Authenticating...")
210
211 sendMessage(
212 [
213 {
214 "cmd": "Connect",
215 "password": ap_pass,
216 "game": "Lingo 2",
217 "name": ap_user,
218 }
219 ]
220 )
221
222
223func requestSync():
224 sendMessage([{"cmd": "Sync"}])
225
226
227func sendLocation(loc_id):
228 sendMessage([{"cmd": "LocationChecks", "locations": [loc_id]}])
229
230
231func sendLocations(loc_ids):
232 sendMessage([{"cmd": "LocationChecks", "locations": loc_ids}])
233
234
235func say(textdata):
236 sendMessage([{"cmd": "Say", "text": textdata}])
237
238
239func completedGoal():
240 sendMessage([{"cmd": "StatusUpdate", "status": 30}]) # CLIENT_GOAL
241
242
243func scoutLocations(loc_ids):
244 sendMessage([{"cmd": "LocationScouts", "locations": loc_ids}])
245
246
247func updateKeyboard(updates):
248 sendMessage([{"cmd": "UpdateKeyboard", "keyboard": updates}])
249
250
251func checkWorldport(port_id):
252 if not _checked_worldports.has(port_id):
253 sendMessage([{"cmd": "CheckWorldport", "port_id": port_id}])
254
255
256func sendQuit():
257 sendMessage([{"cmd": "Quit"}])
258
259
260func hasItem(item_id):
261 return _received_items.has(item_id)
262
263
264func getItemAmount(item_id):
265 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..334d42a --- /dev/null +++ b/apworld/client/gamedata.gd
@@ -0,0 +1,286 @@
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 location_name_by_id[door.get_ap_id()] = _get_door_location_name(door)
76
77 for painting in objects.get_paintings():
78 var room = objects.get_rooms()[painting.get_room_id()]
79 var map = objects.get_maps()[room.get_map_id()]
80
81 if not map.get_name() in painting_id_by_map_node_path:
82 painting_id_by_map_node_path[map.get_name()] = {}
83
84 var _map_data = painting_id_by_map_node_path[map.get_name()]
85
86 for port in objects.get_ports():
87 var room = objects.get_rooms()[port.get_room_id()]
88 var map = objects.get_maps()[room.get_map_id()]
89
90 if not map.get_name() in port_id_by_map_node_path:
91 port_id_by_map_node_path[map.get_name()] = {}
92
93 var map_data = port_id_by_map_node_path[map.get_name()]
94 map_data[port.get_path()] = port.get_id()
95
96 for progressive in objects.get_progressives():
97 progressive_id_by_ap_id[progressive.get_ap_id()] = progressive.get_id()
98
99 for letter in objects.get_letters():
100 letter_id_by_ap_id[letter.get_ap_id()] = letter.get_id()
101 location_name_by_id[letter.get_ap_id()] = _get_letter_location_name(letter)
102
103 for mastery in objects.get_masteries():
104 location_name_by_id[mastery.get_ap_id()] = _get_mastery_location_name(mastery)
105
106 for ending in objects.get_endings():
107 var location_name = _get_ending_location_name(ending)
108 location_name_by_id[ending.get_ap_id()] = location_name
109 ending_display_name_by_name[ending.get_name()] = location_name
110
111 for keyholder in objects.get_keyholders():
112 if keyholder.has_key():
113 location_name_by_id[keyholder.get_ap_id()] = _get_keyholder_location_name(keyholder)
114
115 for panel in objects.get_panels():
116 var room = objects.get_rooms()[panel.get_room_id()]
117 var map = objects.get_maps()[room.get_map_id()]
118
119 if not map.get_name() in panel_id_by_map_node_path:
120 panel_id_by_map_node_path[map.get_name()] = {}
121
122 var map_data = panel_id_by_map_node_path[map.get_name()]
123 map_data[panel.get_path()] = panel.get_id()
124
125 for symbol_name in kSYMBOL_ITEMS.values():
126 symbol_item_ids.append(objects.get_special_ids()[symbol_name])
127
128 for special_name in objects.get_special_ids().keys():
129 if special_name.begins_with("Anti "):
130 anti_trap_ids[objects.get_special_ids()[special_name]] = (
131 special_name.substr(5).to_lower()
132 )
133
134
135func get_door_for_map_node_path(map_name, node_path):
136 if not door_id_by_map_node_path.has(map_name):
137 return null
138
139 var map_data = door_id_by_map_node_path[map_name]
140 return map_data.get(node_path, null)
141
142
143func get_panel_for_map_node_path(map_name, node_path):
144 if not panel_id_by_map_node_path.has(map_name):
145 return null
146
147 var map_data = panel_id_by_map_node_path[map_name]
148 return map_data.get(node_path, null)
149
150
151func get_port_for_map_node_path(map_name, node_path):
152 if not port_id_by_map_node_path.has(map_name):
153 return null
154
155 var map_data = port_id_by_map_node_path[map_name]
156 return map_data.get(node_path, null)
157
158
159func get_door_ap_id(door_id):
160 var door = objects.get_doors()[door_id]
161 if door.has_ap_id():
162 return door.get_ap_id()
163 else:
164 return null
165
166
167func get_door_map_name(door_id):
168 var door = objects.get_doors()[door_id]
169 var room = objects.get_rooms()[door.get_room_id()]
170 var map = objects.get_maps()[room.get_map_id()]
171 return map.get_name()
172
173
174func get_door_receivers(door_id):
175 var door = objects.get_doors()[door_id]
176 return door.get_receivers()
177
178
179func get_worldport_display_name(port_id):
180 var port = objects.get_ports()[port_id]
181 return "%s - %s" % [_get_room_object_map_name(port), port.get_display_name()]
182
183
184func _get_map_object_map_name(obj):
185 return objects.get_maps()[obj.get_map_id()].get_display_name()
186
187
188func _get_room_object_map_name(obj):
189 return _get_map_object_map_name(objects.get_rooms()[obj.get_room_id()])
190
191
192func _get_room_object_location_prefix(obj):
193 var room = objects.get_rooms()[obj.get_room_id()]
194 var game_map = objects.get_maps()[room.get_map_id()]
195
196 if room.has_panel_display_name():
197 return "%s (%s)" % [game_map.get_display_name(), room.get_panel_display_name()]
198 else:
199 return game_map.get_display_name()
200
201
202func _get_door_location_name(door):
203 var map_part = _get_room_object_location_prefix(door)
204
205 if door.has_location_name():
206 return "%s - %s" % [map_part, door.get_location_name()]
207
208 var generated_location_name = _get_generated_door_location_name(door)
209 if generated_location_name != null:
210 return generated_location_name
211
212 return "%s - %s" % [map_part, door.get_name()]
213
214
215func _get_generated_door_location_name(door):
216 if door.get_type() != SCRIPT_proto.DoorType.STANDARD:
217 return null
218
219 if door.get_keyholders().size() > 0 or door.get_endings().size() > 0 or door.has_complete_at():
220 return null
221
222 if door.get_panels().size() > 4:
223 return null
224
225 var map_areas = []
226 for panel_id in door.get_panels():
227 var panel = objects.get_panels()[panel_id.get_panel()]
228 var panel_room = objects.get_rooms()[panel.get_room_id()]
229 # It's okay if panel_display_name is not present because then it's coalesced with other unnamed areas.
230 if not map_areas.has(panel_room.get_panel_display_name()):
231 map_areas.append(panel_room.get_panel_display_name())
232
233 if map_areas.size() > 1:
234 return null
235
236 var game_map = objects.get_maps()[door.get_map_id()]
237 var map_area = map_areas[0]
238 var map_part
239 if map_area == "":
240 map_part = game_map.get_display_name()
241 else:
242 map_part = "%s (%s)" % [game_map.get_display_name(), map_area]
243
244 var panel_names = []
245 for panel_id in door.get_panels():
246 var panel_data = objects.get_panels()[panel_id.get_panel()]
247 var panel_name
248 if panel_data.has_display_name():
249 panel_name = panel_data.get_display_name()
250 else:
251 panel_name = panel_data.get_name()
252
253 var location_part
254 if panel_id.has_answer():
255 location_part = "%s/%s" % [panel_name, panel_id.get_answer().to_upper()]
256 else:
257 location_part = panel_name
258
259 panel_names.append(location_part)
260
261 panel_names.sort()
262
263 return map_part + " - " + ", ".join(panel_names)
264
265
266func _get_letter_location_name(letter):
267 var letter_level = 2 if letter.get_level2() else 1
268 var letter_name = "%s%d" % [letter.get_key().to_upper(), letter_level]
269 return "%s - %s" % [_get_room_object_map_name(letter), letter_name]
270
271
272func _get_mastery_location_name(mastery):
273 return "%s - Mastery" % _get_room_object_map_name(mastery)
274
275
276func _get_ending_location_name(ending):
277 return (
278 "%s - %s Ending" % [_get_room_object_map_name(ending), ending.get_name().to_pascal_case()]
279 )
280
281
282func _get_keyholder_location_name(keyholder):
283 return (
284 "%s - %s Keyholder"
285 % [_get_room_object_location_prefix(keyholder), keyholder.get_key().to_upper()]
286 )
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..9212233 --- /dev/null +++ b/apworld/client/manager.gd
@@ -0,0 +1,603 @@
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
78signal could_not_connect
79signal connect_status
80signal ap_connected
81
82
83func _init():
84 # Read AP settings from file, if there are any
85 if FileAccess.file_exists("user://ap_settings"):
86 var file = FileAccess.open("user://ap_settings", FileAccess.READ)
87 var data = file.get_var(true)
88 file.close()
89
90 if typeof(data) != TYPE_ARRAY:
91 global._print("AP settings file is corrupted")
92 data = []
93
94 if data.size() > 0:
95 ap_server = data[0]
96
97 if data.size() > 1:
98 ap_user = data[1]
99
100 if data.size() > 2:
101 ap_pass = data[2]
102
103 if data.size() > 3:
104 connection_history = data[3]
105
106 if data.size() > 4:
107 show_compass = data[4]
108
109 if data.size() > 5:
110 show_locations = data[5]
111
112 if data.size() > 6:
113 show_minimap = data[6]
114
115
116func _ready():
117 client = SCRIPT_client.new()
118 client.SCRIPT_websocketserver = SCRIPT_websocketserver
119
120 client.item_received.connect(_process_item)
121 client.location_scout_received.connect(_process_location_scout)
122 client.text_message_received.connect(_process_text_message)
123 client.item_sent_notification.connect(_process_item_sent_notification)
124 client.hint_received.connect(_process_hint_received)
125 client.accessible_locations_updated.connect(_on_accessible_locations_updated)
126 client.checked_locations_updated.connect(_on_checked_locations_updated)
127 client.checked_worldports_updated.connect(_on_checked_worldports_updated)
128
129 client.could_not_connect.connect(_client_could_not_connect)
130 client.connect_status.connect(_client_connect_status)
131 client.client_connected.connect(_client_connected)
132
133 add_child(client)
134
135 keyboard = SCRIPT_keyboard.new()
136 add_child(keyboard)
137 client.keyboard_update_received.connect(keyboard.remote_keyboard_updated)
138
139
140func saveSettings():
141 # Save the AP settings to disk.
142 var path = "user://ap_settings"
143 var file = FileAccess.open(path, FileAccess.WRITE)
144
145 var data = [
146 ap_server,
147 ap_user,
148 ap_pass,
149 connection_history,
150 show_compass,
151 show_locations,
152 show_minimap,
153 ]
154 file.store_var(data, true)
155 file.close()
156
157
158func saveLocaldata():
159 # Save the MW/slot specific settings to disk.
160 var dir = DirAccess.open("user://")
161 var folder = "archipelago_data"
162 if not dir.dir_exists(folder):
163 dir.make_dir(folder)
164
165 var file = FileAccess.open(_localdata_file, FileAccess.WRITE)
166
167 var data = [
168 _last_new_item,
169 ]
170 file.store_var(data, true)
171 file.close()
172
173
174func connectToServer():
175 _last_new_item = -1
176 _batch_locations = false
177 _held_locations = []
178 _held_location_scouts = []
179 _location_scouts = {}
180 _letters_setup = false
181 _held_letters = {}
182 _already_connected = false
183
184 client.connectToServer(ap_server, ap_user, ap_pass)
185
186
187func getSaveFileName():
188 return "zzAP_%s_%d" % [client._seed, client._slot]
189
190
191func disconnect_from_ap():
192 _already_connected = false
193
194 var effects = global.get_node("Effects")
195 effects.set_connection_lost(false)
196
197 client.disconnect_from_ap()
198
199
200func get_item_id_for_door(door_id):
201 return _item_locks.get(door_id, null)
202
203
204func _process_item(item, amount):
205 var gamedata = global.get_node("Gamedata")
206
207 var item_id = int(item["id"])
208 var prog_id = null
209 if _inverse_item_locks.has(item_id):
210 for lock in _inverse_item_locks.get(item_id):
211 if lock[1] != amount:
212 continue
213
214 if gamedata.progressive_id_by_ap_id.has(item_id):
215 prog_id = lock[0]
216
217 if gamedata.get_door_map_name(lock[0]) != global.map:
218 continue
219
220 # TODO: fix doors opening from door groups
221 var receivers = gamedata.get_door_receivers(lock[0])
222 var scene = get_tree().get_root().get_node_or_null("scene")
223 if scene != null:
224 for receiver in receivers:
225 var rnode = scene.get_node_or_null(receiver)
226 if rnode != null:
227 rnode.handleTriggered()
228
229 var letter_id = gamedata.letter_id_by_ap_id.get(item_id, null)
230 if letter_id != null:
231 var letter = gamedata.objects.get_letters()[letter_id]
232 if not letter.has_level2() or not letter.get_level2():
233 _process_key_item(letter.get_key(), amount)
234
235 if gamedata.symbol_item_ids.has(item_id):
236 var player = get_tree().get_root().get_node_or_null("scene/player")
237 if player != null:
238 player.evaluate_solvability.emit()
239
240 # Show a message about the item if it's new.
241 if int(item["index"]) > _last_new_item:
242 _last_new_item = int(item["index"])
243 saveLocaldata()
244
245 var full_item_name = item["text"]
246 if prog_id != null:
247 var door = gamedata.objects.get_doors()[prog_id]
248 full_item_name = "%s (%s)" % [full_item_name, door.get_name()]
249
250 var message
251 if "sender" in item:
252 message = (
253 "Received %s from %s"
254 % [wrapInItemColorTags(full_item_name, item["flags"]), item["sender"]]
255 )
256 else:
257 message = "Found %s" % wrapInItemColorTags(full_item_name, item["flags"])
258
259 if gamedata.anti_trap_ids.has(item):
260 keyboard.block_letter(gamedata.anti_trap_ids[item])
261
262 global._print(message)
263
264 global.get_node("Messages").showMessage(message)
265
266
267func _process_item_sent_notification(message):
268 var sentMsg = (
269 "Sent %s to %s"
270 % [
271 wrapInItemColorTags(message["item_name"], message["item_flags"]),
272 message["receiver_name"]
273 ]
274 )
275 #if _hinted_locations.has(message["item"]["location"]):
276 # sentMsg += " ([color=#fafad2]Hinted![/color])"
277 global.get_node("Messages").showMessage(sentMsg)
278
279
280func _process_hint_received(message):
281 var is_for = ""
282 if message["self"] == 0:
283 is_for = " for %s" % message["receiver_name"]
284
285 global.get_node("Messages").showMessage(
286 (
287 "Hint: %s%s is on %s"
288 % [
289 wrapInItemColorTags(message["item_name"], message["item_flags"]),
290 is_for,
291 message["location_name"]
292 ]
293 )
294 )
295
296
297func _process_text_message(message):
298 var parts = []
299 for message_part in message:
300 if message_part["type"] == "text":
301 parts.append(message_part["text"])
302 elif message_part["type"] == "player":
303 if message_part["self"] == 1:
304 parts.append("[color=#ee00ee]%s[/color]" % message_part["text"])
305 else:
306 parts.append("[color=#fafad2]%s[/color]" % message_part["text"])
307 elif message_part["type"] == "item":
308 parts.append(wrapInItemColorTags(message_part["text"], int(message_part["flags"])))
309 elif message_part["type"] == "location":
310 parts.append("[color=#00ff7f]%s[/color]" % message_part["text"])
311
312 var textclient_node = global.get_node("Textclient")
313 if textclient_node != null:
314 textclient_node.parse_printjson("".join(parts))
315
316
317func _process_location_scout(location_id, item_name, player_name, flags, for_self):
318 _location_scouts[location_id] = {
319 "item": item_name, "player": player_name, "flags": flags, "for_self": for_self
320 }
321
322 if for_self and flags & 4 != 0:
323 # This is a trap for us, so let's not display it.
324 return
325
326 var gamedata = global.get_node("Gamedata")
327 var map_id = gamedata.map_id_by_name.get(global.map)
328
329 var letter_id = gamedata.letter_id_by_ap_id.get(location_id, null)
330 if letter_id != null:
331 var letter = gamedata.objects.get_letters()[letter_id]
332 var room = gamedata.objects.get_rooms()[letter.get_room_id()]
333 if room.get_map_id() == map_id:
334 var collectable = get_tree().get_root().get_node("scene").get_node_or_null(
335 letter.get_path()
336 )
337 if collectable != null:
338 collectable.setScoutedText(item_name)
339
340
341func _on_accessible_locations_updated():
342 var textclient_node = global.get_node("Textclient")
343 if textclient_node != null:
344 textclient_node.update_locations()
345
346
347func _on_checked_locations_updated():
348 var textclient_node = global.get_node("Textclient")
349 if textclient_node != null:
350 textclient_node.update_locations()
351
352
353func _on_checked_worldports_updated():
354 var textclient_node = global.get_node("Textclient")
355 if textclient_node != null:
356 textclient_node.update_locations()
357 textclient_node.update_worldports()
358
359
360func _client_could_not_connect(message):
361 could_not_connect.emit(message)
362
363 if global.loaded:
364 var effects = global.get_node("Effects")
365 effects.set_connection_lost(true)
366
367 var messages = global.get_node("Messages")
368 messages.showMessage("Connection to multiworld lost.")
369
370
371func _client_connect_status(message):
372 connect_status.emit(message)
373
374
375func _client_connected(slot_data):
376 var effects = global.get_node("Effects")
377 effects.set_connection_lost(false)
378
379 if _already_connected:
380 var messages = global.get_node("Messages")
381 messages.showMessage("Reconnected to multiworld!")
382 return
383
384 _already_connected = true
385
386 var gamedata = global.get_node("Gamedata")
387
388 _localdata_file = "user://archipelago_data/%s_%d" % [client._seed, client._slot]
389 _last_new_item = -1
390
391 if FileAccess.file_exists(_localdata_file):
392 var ap_file = FileAccess.open(_localdata_file, FileAccess.READ)
393 var localdata = []
394 if ap_file != null:
395 localdata = ap_file.get_var(true)
396 ap_file.close()
397
398 if typeof(localdata) != TYPE_ARRAY:
399 print("AP localdata file is corrupted")
400 localdata = []
401
402 if localdata.size() > 0:
403 _last_new_item = localdata[0]
404
405 # Read slot data.
406 cyan_door_behavior = int(slot_data.get("cyan_door_behavior", 0))
407 daedalus_roof_access = bool(slot_data.get("daedalus_roof_access", false))
408 keyholder_sanity = bool(slot_data.get("keyholder_sanity", false))
409 shuffle_control_center_colors = bool(slot_data.get("shuffle_control_center_colors", false))
410 shuffle_doors = bool(slot_data.get("shuffle_doors", false))
411 shuffle_gallery_paintings = bool(slot_data.get("shuffle_gallery_paintings", false))
412 shuffle_letters = int(slot_data.get("shuffle_letters", 0))
413 shuffle_symbols = bool(slot_data.get("shuffle_symbols", false))
414 shuffle_worldports = bool(slot_data.get("shuffle_worldports", false))
415 strict_cyan_ending = bool(slot_data.get("strict_cyan_ending", false))
416 strict_purple_ending = bool(slot_data.get("strict_purple_ending", false))
417 victory_condition = int(slot_data.get("victory_condition", 0))
418
419 if slot_data.has("version"):
420 var version_msg = slot_data["version"]
421 apworld_version = [int(version_msg[0]), int(version_msg[1]), 0]
422 if version_msg.size() > 2:
423 apworld_version[2] = int(version_msg[2])
424
425 port_pairings.clear()
426 if slot_data.has("port_pairings"):
427 var raw_pp = slot_data.get("port_pairings")
428
429 for p1 in raw_pp.keys():
430 port_pairings[int(p1)] = int(raw_pp[p1])
431
432 # Set up item locks.
433 _item_locks = {}
434
435 if shuffle_doors:
436 for door in gamedata.objects.get_doors():
437 if (
438 door.get_type() == gamedata.SCRIPT_proto.DoorType.STANDARD
439 or door.get_type() == gamedata.SCRIPT_proto.DoorType.ITEM_ONLY
440 ):
441 _item_locks[door.get_id()] = [door.get_ap_id(), 1]
442
443 for progressive in gamedata.objects.get_progressives():
444 for i in range(0, progressive.get_doors().size()):
445 var door = gamedata.objects.get_doors()[progressive.get_doors()[i]]
446 _item_locks[door.get_id()] = [progressive.get_ap_id(), i + 1]
447
448 for door_group in gamedata.objects.get_door_groups():
449 if door_group.get_type() == gamedata.SCRIPT_proto.DoorGroupType.CONNECTOR:
450 if shuffle_worldports:
451 continue
452 elif door_group.get_type() != gamedata.SCRIPT_proto.DoorGroupType.SHUFFLE_GROUP:
453 continue
454
455 for door in door_group.get_doors():
456 _item_locks[door] = [door_group.get_ap_id(), 1]
457
458 if shuffle_control_center_colors:
459 for door in gamedata.objects.get_doors():
460 if door.get_type() == gamedata.SCRIPT_proto.DoorType.CONTROL_CENTER_COLOR:
461 _item_locks[door.get_id()] = [door.get_ap_id(), 1]
462
463 for door_group in gamedata.objects.get_door_groups():
464 if (
465 door_group.get_type() == gamedata.SCRIPT_proto.DoorGroupType.COLOR_CONNECTOR
466 and not shuffle_worldports
467 ):
468 for door in door_group.get_doors():
469 _item_locks[door] = [door_group.get_ap_id(), 1]
470
471 if shuffle_gallery_paintings:
472 for door in gamedata.objects.get_doors():
473 if door.get_type() == gamedata.SCRIPT_proto.DoorType.GALLERY_PAINTING:
474 _item_locks[door.get_id()] = [door.get_ap_id(), 1]
475
476 if cyan_door_behavior == kCYAN_DOOR_BEHAVIOR_ITEM:
477 for door_group in gamedata.objects.get_door_groups():
478 if door_group.get_type() == gamedata.SCRIPT_proto.DoorGroupType.CYAN_DOORS:
479 for door in door_group.get_doors():
480 if not _item_locks.has(door):
481 _item_locks[door] = [door_group.get_ap_id(), 1]
482
483 # Create a reverse item locks map for processing items.
484 _inverse_item_locks = {}
485
486 for door_id in _item_locks.keys():
487 var lock = _item_locks.get(door_id)
488
489 if not _inverse_item_locks.has(lock[0]):
490 _inverse_item_locks[lock[0]] = []
491
492 _inverse_item_locks[lock[0]].append([door_id, lock[1]])
493
494 if shuffle_worldports:
495 var textclient = global.get_node("Textclient")
496 textclient.setup_worldports()
497
498 ap_connected.emit()
499
500
501func start_batching_locations():
502 _batch_locations = true
503
504
505func send_location(loc_id):
506 if _batch_locations:
507 _held_locations.append(loc_id)
508 else:
509 client.sendLocation(loc_id)
510
511
512func scout_location(loc_id):
513 if _location_scouts.has(loc_id):
514 return _location_scouts.get(loc_id)
515
516 if _batch_locations:
517 _held_location_scouts.append(loc_id)
518 else:
519 client.scoutLocation(loc_id)
520
521 return null
522
523
524func stop_batching_locations():
525 _batch_locations = false
526
527 if not _held_locations.is_empty():
528 client.sendLocations(_held_locations)
529 _held_locations.clear()
530
531 if not _held_location_scouts.is_empty():
532 client.scoutLocations(_held_location_scouts)
533 _held_location_scouts.clear()
534
535
536func colorForItemType(flags):
537 var int_flags = int(flags)
538 if int_flags & 1: # progression
539 if int_flags & 2: # proguseful
540 return "#f0d200"
541 else:
542 return "#bc51e0"
543 elif int_flags & 2: # useful
544 return "#2b67ff"
545 elif int_flags & 4: # trap
546 return "#d63a22"
547 else: # filler
548 return "#14de9e"
549
550
551func wrapInItemColorTags(text, flags):
552 var int_flags = int(flags)
553 if int_flags & 1 and int_flags & 2: # proguseful
554 return "[rainbow]%s[/rainbow]" % text
555 else:
556 return "[color=%s]%s[/color]" % [colorForItemType(flags), text]
557
558
559func get_letter_behavior(key, level2):
560 if shuffle_letters == kSHUFFLE_LETTERS_UNLOCKED:
561 return kLETTER_BEHAVIOR_UNLOCKED
562
563 if [kSHUFFLE_LETTERS_VANILLA_CYAN, kSHUFFLE_LETTERS_ITEM_CYAN].has(shuffle_letters):
564 if level2:
565 if shuffle_letters == kSHUFFLE_LETTERS_VANILLA_CYAN:
566 return kLETTER_BEHAVIOR_VANILLA
567 else:
568 return kLETTER_BEHAVIOR_ITEM
569 else:
570 return kLETTER_BEHAVIOR_UNLOCKED
571
572 if not level2 and ["h", "i", "n", "t"].has(key):
573 # This differs from the equivalent function in the apworld. Logically it is
574 # the same as UNLOCKED since they are in the starting room, but VANILLA
575 # means the player still has to actually pick up the letters.
576 return kLETTER_BEHAVIOR_VANILLA
577
578 if shuffle_letters == kSHUFFLE_LETTERS_PROGRESSIVE:
579 return kLETTER_BEHAVIOR_ITEM
580
581 return kLETTER_BEHAVIOR_VANILLA
582
583
584func setup_keys():
585 keyboard.load_seed()
586
587 _letters_setup = true
588
589 for k in _held_letters.keys():
590 _process_key_item(k, _held_letters[k])
591
592 _held_letters.clear()
593
594
595func _process_key_item(key, level):
596 if not _letters_setup:
597 _held_letters[key] = max(_held_letters.get(key, 0), level)
598 return
599
600 if shuffle_letters == kSHUFFLE_LETTERS_ITEM_CYAN:
601 level += 1
602
603 keyboard.collect_remote_letter(key, level)
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..5640716 --- /dev/null +++ b/apworld/client/minimap.gd
@@ -0,0 +1,175 @@
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 heights = {}
130
131 var rendered = Image.create_empty(cell_width, cell_height, false, Image.FORMAT_RGBA8)
132 rendered.fill(Color.TRANSPARENT)
133
134 var meshes_node = get_tree().get_root().get_node("scene/Meshes")
135 if meshes_node != null:
136 _renderMeshNode(gridmap, meshes_node, rendered)
137
138 for pos in gridmap.get_used_cells():
139 var in_plane = Vector2i(pos.x, pos.z)
140
141 if in_plane in heights and heights[in_plane] > pos.y:
142 continue
143
144 heights[in_plane] = pos.y
145
146 var cell_item = gridmap.get_cell_item(pos)
147 var mesh = gridmap.mesh_library.get_item_mesh(cell_item)
148 var material = mesh.surface_get_material(0)
149 var color = material.albedo_color
150
151 rendered.set_pixel(pos.x - cell_left, pos.z - cell_top, color)
152
153 return rendered
154
155
156func _renderMeshNode(gridmap, mesh, rendered):
157 if mesh is MeshInstance3D:
158 var local_tl = gridmap.map_to_local(Vector3i(cell_left, 0, cell_top))
159 var global_tl = gridmap.to_global(local_tl)
160 var mesh_material = mesh.get_surface_override_material(0)
161 if mesh_material != null:
162 var mesh_color = mesh_material.albedo_color
163
164 for y in range(
165 max(mesh.position.z - mesh.scale.z / 2 - global_tl.z, 0),
166 min(mesh.position.z + mesh.scale.z / 2 - global_tl.z, cell_height)
167 ):
168 for x in range(
169 max(mesh.position.x - mesh.scale.x / 2 - global_tl.x, 0),
170 min(mesh.position.x + mesh.scale.x / 2 - global_tl.x, cell_width)
171 ):
172 rendered.set_pixel(x, y, mesh_color)
173
174 for child in mesh.get_children():
175 _renderMeshNode(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..5417a48 --- /dev/null +++ b/apworld/client/player.gd
@@ -0,0 +1,346 @@
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 ):
35 continue
36
37 var locationListener = ap.SCRIPT_locationListener.new()
38 locationListener.location_id = door.get_ap_id()
39 locationListener.name = "locationListener_%d" % door.get_ap_id()
40
41 for panel_ref in door.get_panels():
42 var panel_data = gamedata.objects.get_panels()[panel_ref.get_panel()]
43 var panel_path = panel_data.get_path()
44
45 if panel_ref.has_answer():
46 for proxy in panel_data.get_proxies():
47 if proxy.get_answer() == panel_ref.get_answer():
48 panel_path = proxy.get_path()
49 break
50
51 locationListener.senders.append(NodePath("/root/scene/" + panel_path))
52
53 for keyholder_ref in door.get_keyholders():
54 var keyholder_data = gamedata.objects.get_keyholders()[keyholder_ref.get_keyholder()]
55
56 var khl = khl_script.new()
57 khl.name = (
58 "location_%d_keyholder_%d" % [door.get_ap_id(), keyholder_ref.get_keyholder()]
59 )
60 khl.answer = keyholder_ref.get_key()
61 khl.senders.append(NodePath("/root/scene/" + keyholder_data.get_path()))
62 get_parent().add_child.call_deferred(khl)
63
64 locationListener.senders.append(NodePath("../" + khl.name))
65
66 for sender in door.get_senders():
67 locationListener.senders.append(NodePath("/root/scene/" + sender))
68
69 if door.has_complete_at():
70 locationListener.complete_at = door.get_complete_at()
71
72 get_parent().add_child.call_deferred(locationListener)
73
74 # Set up letter locations.
75 for letter in gamedata.objects.get_letters():
76 var room = gamedata.objects.get_rooms()[letter.get_room_id()]
77 if room.get_map_id() != map_id:
78 continue
79
80 var locationListener = ap.SCRIPT_locationListener.new()
81 locationListener.location_id = letter.get_ap_id()
82 locationListener.name = "locationListener_%d" % letter.get_ap_id()
83 locationListener.senders.append(NodePath("/root/scene/" + letter.get_path()))
84
85 get_parent().add_child.call_deferred(locationListener)
86
87 if (
88 ap.get_letter_behavior(letter.get_key(), letter.has_level2() and letter.get_level2())
89 != ap.kLETTER_BEHAVIOR_VANILLA
90 ):
91 var scout = ap.scout_location(letter.get_ap_id())
92 if scout != null and not (scout["for_self"] and scout["flags"] & 4 != 0):
93 var collectable = get_tree().get_root().get_node("scene").get_node_or_null(
94 letter.get_path()
95 )
96 if collectable != null:
97 collectable.setScoutedText.call_deferred(scout["item"])
98
99 # Set up mastery locations.
100 for mastery in gamedata.objects.get_masteries():
101 var room = gamedata.objects.get_rooms()[mastery.get_room_id()]
102 if room.get_map_id() != map_id:
103 continue
104
105 var locationListener = ap.SCRIPT_locationListener.new()
106 locationListener.location_id = mastery.get_ap_id()
107 locationListener.name = "locationListener_%d" % mastery.get_ap_id()
108 locationListener.senders.append(NodePath("/root/scene/" + mastery.get_path()))
109
110 get_parent().add_child.call_deferred(locationListener)
111
112 # Set up ending locations.
113 for ending in gamedata.objects.get_endings():
114 var room = gamedata.objects.get_rooms()[ending.get_room_id()]
115 if room.get_map_id() != map_id:
116 continue
117
118 var locationListener = ap.SCRIPT_locationListener.new()
119 locationListener.location_id = ending.get_ap_id()
120 locationListener.name = "locationListener_%d" % ending.get_ap_id()
121 locationListener.senders.append(NodePath("/root/scene/" + ending.get_path()))
122
123 get_parent().add_child.call_deferred(locationListener)
124
125 if ap.kEndingNameByVictoryValue.get(ap.victory_condition, null) == ending.get_name():
126 var victoryListener = ap.SCRIPT_victoryListener.new()
127 victoryListener.name = "victoryListener"
128 victoryListener.senders.append(NodePath("/root/scene/" + ending.get_path()))
129
130 get_parent().add_child.call_deferred(victoryListener)
131
132 # Set up keyholder locations, in keyholder sanity.
133 if ap.keyholder_sanity:
134 for keyholder in gamedata.objects.get_keyholders():
135 if not keyholder.has_key():
136 continue
137
138 var room = gamedata.objects.get_rooms()[keyholder.get_room_id()]
139 if room.get_map_id() != map_id:
140 continue
141
142 var locationListener = ap.SCRIPT_locationListener.new()
143 locationListener.location_id = keyholder.get_ap_id()
144 locationListener.name = "locationListener_%d" % keyholder.get_ap_id()
145
146 var khl = khl_script.new()
147 khl.name = "location_%d_keyholder" % keyholder.get_ap_id()
148 khl.answer = keyholder.get_key()
149 khl.senders.append(NodePath("/root/scene/" + keyholder.get_path()))
150 get_parent().add_child.call_deferred(khl)
151
152 locationListener.senders.append(NodePath("../" + khl.name))
153
154 get_parent().add_child.call_deferred(locationListener)
155
156 # Block off roof access in Daedalus.
157 if global.map == "daedalus" and not ap.daedalus_roof_access:
158 _set_up_invis_wall(75.5, 11, -24.5, 1, 10, 49)
159 _set_up_invis_wall(51.5, 11, -17, 16, 10, 1)
160 _set_up_invis_wall(46, 10, -9.5, 1, 10, 10)
161 _set_up_invis_wall(67.5, 11, 17, 16, 10, 1)
162 _set_up_invis_wall(50.5, 11, 14, 10, 10, 1)
163 _set_up_invis_wall(39, 10, 18.5, 1, 10, 22)
164 _set_up_invis_wall(20, 15, 18.5, 1, 10, 16)
165 _set_up_invis_wall(11.5, 15, 3, 32, 10, 1)
166 _set_up_invis_wall(11.5, 16, -20, 14, 20, 1)
167 _set_up_invis_wall(14, 16, -26.5, 1, 20, 4)
168 _set_up_invis_wall(28.5, 20.5, -26.5, 1, 15, 25)
169 _set_up_invis_wall(40.5, 20.5, -11, 30, 15, 1)
170 _set_up_invis_wall(50.5, 15, 5.5, 7, 10, 1)
171 _set_up_invis_wall(83.5, 33.5, 5.5, 1, 7, 11)
172 _set_up_invis_wall(83.5, 33.5, -5.5, 1, 7, 11)
173
174 var warp_exit_prefab = preload("res://objects/nodes/exit.tscn")
175 var warp_exit = warp_exit_prefab.instantiate()
176 warp_exit.name = "roof_access_blocker_warp_exit"
177 warp_exit.position = Vector3(58, 10, 0)
178 warp_exit.rotation_degrees.y = 90
179 get_parent().add_child.call_deferred(warp_exit)
180
181 var warp_enter_prefab = preload("res://objects/nodes/teleportAuto.tscn")
182 var warp_enter = warp_enter_prefab.instantiate()
183 warp_enter.target = warp_exit
184 warp_enter.position = Vector3(76.5, 30, 1)
185 warp_enter.scale = Vector3(4, 1.5, 1)
186 warp_enter.rotation_degrees.y = 90
187 get_parent().add_child.call_deferred(warp_enter)
188
189 if global.map == "the_entry":
190 # Remove door behind X1.
191 var door_node = get_tree().get_root().get_node("/root/scene/Components/Doors/exit_1")
192 door_node.handleTriggered()
193
194 # Display win condition.
195 var sign_prefab = preload("res://objects/nodes/sign.tscn")
196 var sign1 = sign_prefab.instantiate()
197 sign1.position = Vector3(-7, 5, -15.01)
198 sign1.text = "victory"
199 get_parent().add_child.call_deferred(sign1)
200
201 var sign2 = sign_prefab.instantiate()
202 sign2.position = Vector3(-7, 4, -15.01)
203 sign2.text = "%s ending" % ap.kEndingNameByVictoryValue.get(ap.victory_condition, "?")
204
205 var sign2_color = ap.kEndingNameByVictoryValue.get(ap.victory_condition, "coral").to_lower()
206 if sign2_color == "white":
207 sign2_color = "silver"
208
209 sign2.material = load("res://assets/materials/%s.material" % sign2_color)
210 get_parent().add_child.call_deferred(sign2)
211
212 # Add the strict purple ending validation.
213 if global.map == "the_sun_temple" and ap.strict_purple_ending:
214 var panel_prefab = preload("res://objects/nodes/panel.tscn")
215 var tpl_prefab = preload("res://objects/nodes/listeners/teleportListener.tscn")
216 var reverse_prefab = preload("res://objects/nodes/listeners/reversingListener.tscn")
217
218 var previous_panel = null
219 var next_y = -100
220 var words = ["quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"]
221 for word in words:
222 var panel = panel_prefab.instantiate()
223 panel.position = Vector3(0, next_y, 0)
224 next_y -= 10
225 panel.clue = word
226 panel.symbol = ""
227 panel.answer = word
228 panel.name = "EndCheck_%s" % word
229
230 var tpl = tpl_prefab.instantiate()
231 tpl.teleport_point = Vector3(0, 1, 0)
232 tpl.teleport_rotate = Vector3(-45, 180, 0)
233 tpl.target_path = panel
234 tpl.name = "Teleport"
235
236 if previous_panel == null:
237 tpl.senders.append(NodePath("/root/scene/Panels/End/panel_24"))
238 else:
239 tpl.senders.append(NodePath("../../%s" % previous_panel.name))
240
241 var reversing = reverse_prefab.instantiate()
242 reversing.senders.append(NodePath(".."))
243 reversing.name = "Reversing"
244 tpl.senders.append(NodePath("../Reversing"))
245
246 panel.add_child.call_deferred(tpl)
247 panel.add_child.call_deferred(reversing)
248 get_parent().get_node("Panels").add_child.call_deferred(panel)
249
250 previous_panel = panel
251
252 # Duplicate the doors that usually wait on EQUINOX. We can't set the senders
253 # here for some reason so we actually set them in the door ready function.
254 var endplat = get_node("/root/scene/Components/Doors/EndPlatform")
255 var endplat2 = endplat.duplicate()
256 endplat2.name = "spe_EndPlatform"
257 endplat.get_parent().add_child.call_deferred(endplat2)
258 endplat.queue_free()
259
260 var entry2 = get_node("/root/scene/Components/Doors/entry_2")
261 var entry22 = entry2.duplicate()
262 entry22.name = "spe_entry_2"
263 entry2.get_parent().add_child.call_deferred(entry22)
264 entry2.queue_free()
265
266 # Add the strict cyan ending validation.
267 if global.map == "the_parthenon" and ap.strict_cyan_ending:
268 var panel_prefab = preload("res://objects/nodes/panel.tscn")
269 var tpl_prefab = preload("res://objects/nodes/listeners/teleportListener.tscn")
270 var reverse_prefab = preload("res://objects/nodes/listeners/reversingListener.tscn")
271
272 var previous_panel = null
273 var next_y = -100
274 var words = ["quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"]
275 for word in words:
276 var panel = panel_prefab.instantiate()
277 panel.position = Vector3(0, next_y, 0)
278 next_y -= 10
279 panel.clue = word
280 panel.symbol = "."
281 panel.answer = "%s%s" % [word, word]
282 panel.name = "EndCheck_%s" % word
283
284 var tpl = tpl_prefab.instantiate()
285 tpl.teleport_point = Vector3(0, 1, -11)
286 tpl.teleport_rotate = Vector3(-45, 0, 0)
287 tpl.target_path = panel
288 tpl.name = "Teleport"
289
290 if previous_panel == null:
291 tpl.senderGroup.append(NodePath("/root/scene/Panels/Rulers"))
292 else:
293 tpl.senders.append(NodePath("../../%s" % previous_panel.name))
294
295 var reversing = reverse_prefab.instantiate()
296 reversing.senders.append(NodePath(".."))
297 reversing.name = "Reversing"
298 tpl.senders.append(NodePath("../Reversing"))
299
300 panel.add_child.call_deferred(tpl)
301 panel.add_child.call_deferred(reversing)
302 get_parent().get_node("Panels").add_child.call_deferred(panel)
303
304 previous_panel = panel
305
306 # Duplicate the door that usually waits on the rulers. We can't set the
307 # senders here for some reason so we actually set them in the door ready
308 # function.
309 var entry1 = get_node("/root/scene/Components/Doors/entry_1")
310 var entry12 = entry1.duplicate()
311 entry12.name = "spe_entry_1"
312 entry1.get_parent().add_child.call_deferred(entry12)
313 entry1.queue_free()
314
315 var minimap = ap.SCRIPT_minimap.new()
316 minimap.name = "Minimap"
317 minimap.visible = ap.show_minimap
318 get_parent().add_child.call_deferred(minimap)
319
320 super._ready()
321
322 await get_tree().process_frame
323 await get_tree().process_frame
324
325 ap.stop_batching_locations()
326
327
328func _set_up_invis_wall(x, y, z, sx, sy, sz):
329 var prefab = preload("res://objects/nodes/block.tscn")
330 var newwall = prefab.instantiate()
331 newwall.position.x = x
332 newwall.position.y = y
333 newwall.position.z = z
334 newwall.scale.x = sz
335 newwall.scale.y = sy
336 newwall.scale.z = sx
337 newwall.set_surface_override_material(0, preload("res://assets/materials/blackMatte.material"))
338 newwall.visibility_range_end = 3
339 newwall.visibility_range_end_margin = 1
340 newwall.visibility_range_fade_mode = RenderingServer.VISIBILITY_RANGE_FADE_SELF
341 newwall.skeleton = ".."
342 get_parent().add_child.call_deferred(newwall)
343
344
345func _process(_dt):
346 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..b430b17 --- /dev/null +++ b/apworld/client/settings_screen.gd
@@ -0,0 +1,153 @@
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.horizontal_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.horizontal_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.horizontal_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 accept_dialog.offset_right = 83.0
130 accept_dialog.offset_bottom = 58.0
131 panel.add_child(accept_dialog)
132
133 var version_mismatch = ConfirmationDialog.new()
134 version_mismatch.name = "VersionMismatch"
135 version_mismatch.offset_right = 83.0
136 version_mismatch.offset_bottom = 58.0
137 panel.add_child(version_mismatch)
138
139 var connection_history = MenuButton.new()
140 connection_history.name = "connection_history"
141 connection_history.offset_left = 1239.0
142 connection_history.offset_top = 276.0
143 connection_history.offset_right = 1829.0
144 connection_history.offset_bottom = 372.0
145 connection_history.text = "connection history"
146 connection_history.flat = false
147 panel.add_child(connection_history)
148
149 var runtime = global.get_node("Runtime")
150 var main_script = runtime.load_script("main.gd")
151 var main_node = main_script.new()
152 main_node.name = "Main"
153 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..530eddb --- /dev/null +++ b/apworld/client/textclient.gd
@@ -0,0 +1,310 @@
1extends CanvasLayer
2
3var tabs
4var panel
5var label
6var entry
7var tracker_label
8var is_open = false
9
10var locations_overlay
11var location_texture
12var worldport_texture
13var goal_texture
14
15var worldports_tab
16var worldports_tree
17var port_tree_item_by_map = {}
18var port_tree_item_by_map_port = {}
19
20
21func _ready():
22 process_mode = ProcessMode.PROCESS_MODE_ALWAYS
23 layer = 2
24
25 locations_overlay = RichTextLabel.new()
26 locations_overlay.name = "LocationsOverlay"
27 locations_overlay.offset_top = 220
28 locations_overlay.offset_bottom = 720
29 locations_overlay.offset_left = 20
30 locations_overlay.anchor_right = 1.0
31 locations_overlay.offset_right = -10
32 locations_overlay.scroll_active = false
33 locations_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
34 locations_overlay.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
35 add_child(locations_overlay)
36 update_locations_visibility()
37
38 tabs = TabContainer.new()
39 tabs.name = "Tabs"
40 tabs.offset_left = 100
41 tabs.offset_right = 1820
42 tabs.offset_top = 100
43 tabs.offset_bottom = 980
44 tabs.visible = false
45 tabs.theme = preload("res://assets/themes/baseUI.tres")
46 tabs.add_theme_font_size_override("font_size", 36)
47 add_child(tabs)
48
49 panel = MarginContainer.new()
50 panel.name = "Text Client"
51 panel.add_theme_constant_override("margin_top", 60)
52 panel.add_theme_constant_override("margin_left", 60)
53 panel.add_theme_constant_override("margin_right", 60)
54 panel.add_theme_constant_override("margin_bottom", 60)
55 tabs.add_child(panel)
56
57 label = RichTextLabel.new()
58 label.set_name("Label")
59 label.scroll_following = true
60 label.selection_enabled = true
61 label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
62 label.size_flags_vertical = Control.SIZE_EXPAND_FILL
63 label.push_font(preload("res://assets/fonts/Lingo2.ttf"))
64 label.push_font_size(36)
65
66 var entry_style = StyleBoxFlat.new()
67 entry_style.bg_color = Color(0.9, 0.9, 0.9, 1)
68
69 entry = LineEdit.new()
70 entry.set_name("Entry")
71 entry.add_theme_font_override("font", preload("res://assets/fonts/Lingo2.ttf"))
72 entry.add_theme_font_size_override("font_size", 36)
73 entry.add_theme_color_override("font_color", Color(0, 0, 0, 1))
74 entry.add_theme_color_override("cursor_color", Color(0, 0, 0, 1))
75 entry.add_theme_stylebox_override("focus", entry_style)
76 entry.text_submitted.connect(text_entered)
77
78 var tc_arranger = VBoxContainer.new()
79 tc_arranger.add_child(label)
80 tc_arranger.add_child(entry)
81 tc_arranger.add_theme_constant_override("separation", 40)
82 panel.add_child(tc_arranger)
83
84 var tracker_margins = MarginContainer.new()
85 tracker_margins.name = "Locations"
86 tracker_margins.add_theme_constant_override("margin_top", 60)
87 tracker_margins.add_theme_constant_override("margin_left", 60)
88 tracker_margins.add_theme_constant_override("margin_right", 60)
89 tracker_margins.add_theme_constant_override("margin_bottom", 60)
90 tabs.add_child(tracker_margins)
91
92 tracker_label = RichTextLabel.new()
93 tracker_margins.add_child(tracker_label)
94
95 worldports_tab = MarginContainer.new()
96 worldports_tab.name = "Worldports"
97 worldports_tab.add_theme_constant_override("margin_top", 60)
98 worldports_tab.add_theme_constant_override("margin_left", 60)
99 worldports_tab.add_theme_constant_override("margin_right", 60)
100 worldports_tab.add_theme_constant_override("margin_bottom", 60)
101 tabs.add_child(worldports_tab)
102 tabs.set_tab_hidden(2, true)
103
104 worldports_tree = Tree.new()
105 worldports_tree.columns = 2
106 worldports_tree.hide_root = true
107 worldports_tree.theme = preload("res://assets/themes/baseUI.tres")
108 worldports_tree.add_theme_font_size_override("font_size", 24)
109 worldports_tab.add_child(worldports_tree)
110
111 var runtime = global.get_node("Runtime")
112 var location_image = Image.new()
113 location_image.load_png_from_buffer(runtime.read_path("assets/location.png"))
114 location_texture = ImageTexture.create_from_image(location_image)
115
116 var worldport_image = Image.new()
117 worldport_image.load_png_from_buffer(runtime.read_path("assets/worldport.png"))
118 worldport_texture = ImageTexture.create_from_image(worldport_image)
119
120 var goal_image = Image.new()
121 goal_image.load_png_from_buffer(runtime.read_path("assets/goal.png"))
122 goal_texture = ImageTexture.create_from_image(goal_image)
123
124
125func _input(event):
126 if global.loaded and event is InputEventKey and event.pressed:
127 if event.keycode == KEY_TAB and !Input.is_key_pressed(KEY_SHIFT):
128 if !get_tree().paused:
129 is_open = true
130 get_tree().paused = true
131 Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
132 tabs.visible = true
133 entry.grab_focus()
134 get_viewport().set_input_as_handled()
135 else:
136 dismiss()
137 elif event.keycode == KEY_ESCAPE:
138 if is_open:
139 dismiss()
140 get_viewport().set_input_as_handled()
141
142
143func dismiss():
144 if is_open:
145 get_tree().paused = false
146 Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
147 tabs.visible = false
148 is_open = false
149
150
151func parse_printjson(text):
152 label.append_text("[p]" + text + "[/p]")
153
154
155func text_entered(text):
156 var ap = global.get_node("Archipelago")
157 var cmd = text.trim_suffix("\n")
158 entry.text = ""
159 if OS.is_debug_build():
160 if cmd.begins_with("/tp_map "):
161 var new_map = cmd.substr(8)
162 global.map = new_map
163 global.sets_entry_point = false
164 switcher.switch_map("res://objects/scenes/%s.tscn" % new_map)
165 return
166
167 ap.client.say(cmd)
168
169
170func update_locations():
171 var ap = global.get_node("Archipelago")
172 var gamedata = global.get_node("Gamedata")
173
174 tracker_label.clear()
175 tracker_label.push_font(preload("res://assets/fonts/Lingo2.ttf"))
176 tracker_label.push_font_size(24)
177
178 locations_overlay.clear()
179 locations_overlay.push_font(preload("res://assets/fonts/Lingo2.ttf"))
180 locations_overlay.push_font_size(24)
181 locations_overlay.push_color(Color(0.9, 0.9, 0.9, 1))
182 locations_overlay.push_outline_color(Color(0, 0, 0, 1))
183 locations_overlay.push_outline_size(2)
184
185 const kLocation = 0
186 const kWorldport = 1
187 const kGoal = 2
188
189 var location_names = []
190 var type_by_name = {}
191 for location_id in ap.client._accessible_locations:
192 if not ap.client._checked_locations.has(location_id):
193 var location_name = gamedata.location_name_by_id.get(location_id, "(Unknown)")
194 location_names.append(location_name)
195 type_by_name[location_name] = kLocation
196
197 for port_id in ap.client._accessible_worldports:
198 if not ap.client._checked_worldports.has(port_id):
199 var port_name = gamedata.get_worldport_display_name(port_id)
200 location_names.append(port_name)
201 type_by_name[port_name] = kWorldport
202
203 location_names.sort()
204
205 if ap.client._goal_accessible:
206 var location_name = gamedata.ending_display_name_by_name[ap.kEndingNameByVictoryValue[
207 ap.victory_condition
208 ]]
209 location_names.push_front(location_name)
210 type_by_name[location_name] = kGoal
211
212 var count = 0
213 for location_name in location_names:
214 tracker_label.append_text("[p]%s[/p]" % location_name)
215 if count < 18:
216 locations_overlay.push_paragraph(HORIZONTAL_ALIGNMENT_RIGHT)
217 locations_overlay.append_text(location_name)
218 locations_overlay.append_text(" ")
219 if type_by_name[location_name] == kLocation:
220 locations_overlay.add_image(location_texture)
221 elif type_by_name[location_name] == kWorldport:
222 locations_overlay.add_image(worldport_texture)
223 elif type_by_name[location_name] == kGoal:
224 locations_overlay.add_image(goal_texture)
225 locations_overlay.pop()
226 count += 1
227
228 if count > 18:
229 locations_overlay.append_text("[p align=right][lb]...[rb][/p]")
230
231
232func update_locations_visibility():
233 var ap = global.get_node("Archipelago")
234 locations_overlay.visible = ap.show_locations
235
236
237func setup_worldports():
238 tabs.set_tab_hidden(2, false)
239
240 var root_ti = worldports_tree.create_item(null)
241
242 var ports_by_map_id = {}
243 var display_names_by_map_id = {}
244 var display_names_by_port_id = {}
245
246 var ap = global.get_node("Archipelago")
247 var gamedata = global.get_node("Gamedata")
248 for fpid in ap.port_pairings:
249 var port = gamedata.objects.get_ports()[fpid]
250 var room = gamedata.objects.get_rooms()[port.get_room_id()]
251
252 if not ports_by_map_id.has(room.get_map_id()):
253 ports_by_map_id[room.get_map_id()] = []
254
255 var map = gamedata.objects.get_maps()[room.get_map_id()]
256 display_names_by_map_id[map.get_id()] = map.get_display_name()
257
258 ports_by_map_id[room.get_map_id()].append(fpid)
259 display_names_by_port_id[fpid] = port.get_display_name()
260
261 var sorted_map_ids = ports_by_map_id.keys().duplicate()
262 sorted_map_ids.sort_custom(
263 func(a, b): return display_names_by_map_id[a] < display_names_by_map_id[b]
264 )
265
266 for map_id in sorted_map_ids:
267 var map_ti = root_ti.create_child()
268 map_ti.set_text(0, display_names_by_map_id[map_id])
269 map_ti.visible = false
270 map_ti.collapsed = true
271 port_tree_item_by_map[map_id] = map_ti
272 port_tree_item_by_map_port[map_id] = {}
273
274 var port_ids = ports_by_map_id[map_id]
275 port_ids.sort_custom(
276 func(a, b): return display_names_by_port_id[a] < display_names_by_port_id[b]
277 )
278
279 for port_id in port_ids:
280 var port_ti = map_ti.create_child()
281 port_ti.set_text(0, display_names_by_port_id[port_id])
282 port_ti.set_text(1, gamedata.get_worldport_display_name(ap.port_pairings[port_id]))
283 port_ti.visible = false
284 port_tree_item_by_map_port[map_id][port_id] = port_ti
285
286 update_worldports()
287
288
289func update_worldports():
290 var ap = global.get_node("Archipelago")
291
292 for map_id in port_tree_item_by_map_port.keys():
293 var map_visible = false
294
295 for port_id in port_tree_item_by_map_port[map_id].keys():
296 var ti = port_tree_item_by_map_port[map_id][port_id]
297 ti.visible = ap.client._checked_worldports.has(port_id)
298
299 if ti.visible:
300 map_visible = true
301
302 port_tree_item_by_map[map_id].visible = map_visible
303
304
305func reset():
306 locations_overlay.clear()
307 tabs.set_tab_hidden(2, true)
308 port_tree_item_by_map.clear()
309 port_tree_item_by_map_port.clear()
310 worldports_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..5c2faff --- /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 == "menus/credits":
6 return
7
8 super.handleTriggered()
diff --git a/apworld/context.py b/apworld/context.py new file mode 100644 index 0000000..63645a4 --- /dev/null +++ b/apworld/context.py
@@ -0,0 +1,630 @@
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 async def send_msgs(self, msgs: list[Any]) -> None:
233 """ `msgs` JSON serializable """
234 if not self.server or not self.server.socket.open or self.server.socket.closed:
235 return
236 await self.server.socket.send(encode(msgs))
237
238
239class Lingo2ClientContext(CommonContext):
240 manager: Lingo2Manager
241
242 game = "Lingo 2"
243 items_handling = 0b111
244
245 slot_data: dict[str, Any] | None
246 victory_data_storage_key: str
247
248 def __init__(self, server_address: str | None = None, password: str | None = None):
249 super().__init__(server_address, password)
250
251 def make_gui(self):
252 ui = super().make_gui()
253 ui.base_title = "Archipelago Lingo 2 Client"
254 return ui
255
256 async def server_auth(self, password_requested: bool = False):
257 if password_requested:
258 if self.password is None:
259 self.manager.game_ctx.send_connection_refused("Slot requires a password.")
260 else:
261 self.manager.game_ctx.send_connection_refused("Invalid password.")
262 else:
263 self.auth = self.username
264 await self.send_connect()
265
266 def handle_connection_loss(self, msg: str):
267 super().handle_connection_loss(msg)
268
269 exc_info = sys.exc_info()
270 self.manager.game_ctx.send_connection_refused(str(exc_info[1]))
271
272 def on_package(self, cmd: str, args: dict):
273 if cmd == "RoomInfo":
274 self.seed_name = args.get("seed_name", None)
275 elif cmd == "Connected":
276 self.slot_data = args.get("slot_data", None)
277
278 self.manager.reset()
279
280 self.manager.game_ctx.send_connected()
281
282 self.manager.tracker.setup_slot(self.slot_data)
283 self.manager.tracker.set_checked_locations(self.checked_locations)
284 self.manager.game_ctx.send_accessible_locations()
285
286 self.victory_data_storage_key = f"_read_client_status_{self.team}_{self.slot}"
287
288 self.set_notify(self.get_datastorage_key("keyboard1"), self.get_datastorage_key("keyboard2"),
289 self.victory_data_storage_key)
290 msg_batch = [{
291 "cmd": "Set",
292 "key": self.get_datastorage_key("keyboard1"),
293 "default": 0,
294 "want_reply": True,
295 "operations": [{"operation": "default", "value": 0}]
296 }, {
297 "cmd": "Set",
298 "key": self.get_datastorage_key("keyboard2"),
299 "default": 0,
300 "want_reply": True,
301 "operations": [{"operation": "default", "value": 0}]
302 }]
303
304 if self.slot_data.get("shuffle_worldports", False):
305 self.set_notify(self.get_datastorage_key("worldports"))
306 msg_batch.append({
307 "cmd": "Set",
308 "key": self.get_datastorage_key("worldports"),
309 "default": [],
310 "want_reply": True,
311 "operations": [{"operation": "default", "value": []}]
312 })
313
314 async_start(self.send_msgs(msg_batch), name="default keys")
315 elif cmd == "RoomUpdate":
316 if "checked_locations" in args:
317 self.manager.tracker.set_checked_locations(self.checked_locations)
318 self.manager.game_ctx.send_update_locations(args["checked_locations"])
319 elif cmd == "ReceivedItems":
320 self.manager.tracker.set_collected_items(self.items_received)
321
322 cur_index = 0
323 items = []
324
325 for item in args["items"]:
326 index = cur_index + args["index"]
327 cur_index += 1
328
329 item_msg = {
330 "id": item.item,
331 "index": index,
332 "flags": item.flags,
333 "text": self.item_names.lookup_in_slot(item.item, self.slot),
334 }
335
336 if item.player != self.slot:
337 item_msg["sender"] = self.player_names.get(item.player)
338
339 items.append(item_msg)
340
341 self.manager.game_ctx.send_item_received(items)
342
343 if any(ItemClassification.progression in ItemClassification(item.flags) for item in args["items"]):
344 self.manager.game_ctx.send_accessible_locations()
345 elif cmd == "PrintJSON":
346 if "receiving" in args and "item" in args and args["item"].player == self.slot:
347 item_name = self.item_names.lookup_in_slot(args["item"].item, args["receiving"])
348 location_name = self.location_names.lookup_in_slot(args["item"].location, args["item"].player)
349 receiver_name = self.player_names.get(args["receiving"])
350
351 if args["type"] == "Hint" and not args.get("found", False):
352 self.manager.game_ctx.send_hint_received(item_name, location_name, receiver_name, args["item"].flags,
353 int(args["receiving"]) == self.slot)
354 elif args["receiving"] != self.slot:
355 self.manager.game_ctx.send_item_sent_notification(item_name, receiver_name, args["item"].flags)
356
357 parts = []
358 for message_part in args["data"]:
359 if "type" not in message_part and "text" in message_part:
360 parts.append({"type": "text", "text": message_part["text"]})
361 elif message_part["type"] == "player_id":
362 parts.append({
363 "type": "player",
364 "text": self.player_names.get(int(message_part["text"])),
365 "self": int(int(message_part["text"]) == self.slot),
366 })
367 elif message_part["type"] == "item_id":
368 parts.append({
369 "type": "item",
370 "text": self.item_names.lookup_in_slot(int(message_part["text"]), message_part["player"]),
371 "flags": message_part["flags"],
372 })
373 elif message_part["type"] == "location_id":
374 parts.append({
375 "type": "location",
376 "text": self.location_names.lookup_in_slot(int(message_part["text"]),
377 message_part["player"])
378 })
379 elif "text" in message_part:
380 parts.append({"type": "text", "text": message_part["text"]})
381
382 self.manager.game_ctx.send_text_message(parts)
383 elif cmd == "LocationInfo":
384 locations = []
385
386 for location in args["locations"]:
387 locations.append({
388 "id": location.location,
389 "item": self.item_names.lookup_in_slot(location.item, location.player),
390 "player": self.player_names.get(location.player),
391 "flags": location.flags,
392 "self": int(location.player) == self.slot,
393 })
394
395 self.manager.game_ctx.send_location_info(locations)
396 elif cmd == "Retrieved":
397 for k, v in args["keys"].items():
398 if k == self.victory_data_storage_key:
399 self.handle_status_update(v)
400 elif cmd == "SetReply":
401 if args["key"] == self.get_datastorage_key("keyboard1"):
402 self.handle_keyboard_update(1, args)
403 elif args["key"] == self.get_datastorage_key("keyboard2"):
404 self.handle_keyboard_update(2, args)
405 elif args["key"] == self.get_datastorage_key("worldports"):
406 updates = self.manager.update_worldports(set(args["value"]))
407 if len(updates) > 0:
408 self.manager.game_ctx.send_update_worldports(updates)
409 elif args["key"] == self.victory_data_storage_key:
410 self.handle_status_update(args["value"])
411
412 def get_datastorage_key(self, name: str):
413 return f"Lingo2_{self.slot}_{name}"
414
415 async def update_keyboard(self, updates: dict[str, int]):
416 kb1 = 0
417 kb2 = 0
418
419 for k, v in updates.items():
420 if v == 0:
421 continue
422
423 effect = 0
424 if v >= 1:
425 effect |= 1
426 if v == 2:
427 effect |= 2
428
429 pos = KEY_STORAGE_MAPPING[k]
430 if pos[0] == 1:
431 kb1 |= (effect << pos[1] * 2)
432 else:
433 kb2 |= (effect << pos[1] * 2)
434
435 msgs = []
436
437 if kb1 != 0:
438 msgs.append({
439 "cmd": "Set",
440 "key": self.get_datastorage_key("keyboard1"),
441 "want_reply": True,
442 "operations": [{
443 "operation": "or",
444 "value": kb1
445 }]
446 })
447
448 if kb2 != 0:
449 msgs.append({
450 "cmd": "Set",
451 "key": self.get_datastorage_key("keyboard2"),
452 "want_reply": True,
453 "operations": [{
454 "operation": "or",
455 "value": kb2
456 }]
457 })
458
459 if len(msgs) > 0:
460 await self.send_msgs(msgs)
461
462 def handle_keyboard_update(self, field: int, args: dict[str, Any]):
463 keys = {}
464 value = args["value"]
465
466 for i in range(0, 13):
467 if (value & (1 << (i * 2))) != 0:
468 keys[REVERSE_KEY_STORAGE_MAPPING[(field, i)]] = 1
469 if (value & (1 << (i * 2 + 1))) != 0:
470 keys[REVERSE_KEY_STORAGE_MAPPING[(field, i)]] = 2
471
472 updates = self.manager.update_keyboard(keys)
473 if len(updates) > 0:
474 self.manager.game_ctx.send_update_keyboard(updates)
475
476 async def update_worldports(self, updates: set[int]):
477 await self.send_msgs([{
478 "cmd": "Set",
479 "key": self.get_datastorage_key("worldports"),
480 "want_reply": True,
481 "operations": [{
482 "operation": "update",
483 "value": updates
484 }]
485 }])
486
487 def handle_status_update(self, value: int):
488 self.manager.goaled = (value == ClientStatus.CLIENT_GOAL)
489 self.manager.tracker.refresh_state()
490 self.manager.game_ctx.send_accessible_locations()
491
492
493async def pipe_loop(manager: Lingo2Manager):
494 while not manager.client_ctx.exit_event.is_set():
495 try:
496 socket = await websockets.connect("ws://localhost", port=PORT, ping_timeout=None, ping_interval=None,
497 max_size=MESSAGE_MAX_SIZE)
498 manager.game_ctx.server = Endpoint(socket)
499 logger.info("Connected to Lingo 2!")
500 if manager.client_ctx.auth is not None:
501 manager.game_ctx.send_connected()
502 manager.game_ctx.send_accessible_locations()
503 async for data in manager.game_ctx.server.socket:
504 for msg in decode(data):
505 await process_game_cmd(manager, msg)
506 except ConnectionRefusedError:
507 logger.info("Could not connect to Lingo 2.")
508 finally:
509 manager.game_ctx.server = None
510
511
512async def process_game_cmd(manager: Lingo2Manager, args: dict):
513 cmd = args["cmd"]
514
515 if cmd == "Connect":
516 manager.client_ctx.seed_name = None
517
518 server = args.get("server")
519 player = args.get("player")
520 password = args.get("password")
521
522 if password != "":
523 server_address = f"{player}:{password}@{server}"
524 else:
525 server_address = f"{player}:None@{server}"
526
527 async_start(manager.client_ctx.connect(server_address), name="client connect")
528 elif cmd == "Disconnect":
529 manager.client_ctx.seed_name = None
530
531 async_start(manager.client_ctx.disconnect(), name="client disconnect")
532 elif cmd in ["Sync", "LocationChecks", "Say", "StatusUpdate", "LocationScouts"]:
533 async_start(manager.client_ctx.send_msgs([args]), name="client forward")
534 elif cmd == "UpdateKeyboard":
535 updates = manager.update_keyboard(args["keyboard"])
536 if len(updates) > 0:
537 async_start(manager.client_ctx.update_keyboard(updates), name="client update keyboard")
538 elif cmd == "CheckWorldport":
539 port_id = args["port_id"]
540 worldports = {port_id}
541 if str(port_id) in manager.client_ctx.slot_data["port_pairings"]:
542 worldports.add(manager.client_ctx.slot_data["port_pairings"][str(port_id)])
543
544 updates = manager.update_worldports(worldports)
545 if len(updates) > 0:
546 async_start(manager.client_ctx.update_worldports(updates), name="client update worldports")
547 manager.game_ctx.send_update_worldports(updates)
548 elif cmd == "Quit":
549 manager.client_ctx.exit_event.set()
550
551
552async def run_game():
553 exe_file = settings.get_settings().lingo2_options.exe_file
554
555 # This ensures we can use Steam features without having to open the game
556 # through steam.
557 steam_appid_path = os.path.join(os.path.dirname(exe_file), "steam_appid.txt")
558 with open(steam_appid_path, "w") as said_handle:
559 said_handle.write("2523310")
560
561 if Lingo2World.zip_path is not None:
562 # This is a packaged apworld.
563 init_scene = pkgutil.get_data(__name__, "client/run_from_apworld.tscn")
564 init_path = Utils.local_path("data", "lingo2_init.tscn")
565
566 with open(init_path, "wb") as file_handle:
567 file_handle.write(init_scene)
568
569 subprocess.Popen(
570 [
571 exe_file,
572 "--scene",
573 init_path,
574 "--",
575 str(Lingo2World.zip_path.absolute()),
576 ],
577 cwd=os.path.dirname(exe_file),
578 )
579 else:
580 # The world is unzipped and being run in source.
581 subprocess.Popen(
582 [
583 exe_file,
584 "--scene",
585 Utils.local_path("worlds", "lingo2", "client", "run_from_source.tscn"),
586 "--",
587 Utils.local_path("worlds", "lingo2", "client"),
588 ],
589 cwd=os.path.dirname(exe_file),
590 )
591
592
593def client_main(*launch_args: str) -> None:
594 async def main(args):
595 async_start(run_game())
596
597 client_ctx = Lingo2ClientContext(args.connect, args.password)
598 game_ctx = Lingo2GameContext()
599 manager = Lingo2Manager(game_ctx, client_ctx)
600
601 client_ctx.server_task = asyncio.create_task(server_loop(client_ctx), name="ServerLoop")
602
603 if gui_enabled:
604 client_ctx.run_gui()
605 client_ctx.run_cli()
606
607 pipe_task = asyncio.create_task(pipe_loop(manager), name="GameWatcher")
608
609 try:
610 await pipe_task
611 except Exception as e:
612 logger.exception(e)
613
614 await client_ctx.exit_event.wait()
615 client_ctx.ui.stop()
616 await client_ctx.shutdown()
617
618 Utils.init_logging("Lingo2Client", exception_logger="Client")
619 import colorama
620
621 parser = get_base_parser(description="Lingo 2 Archipelago Client")
622 parser.add_argument('--name', default=None, help="Slot Name to connect as.")
623 parser.add_argument("url", nargs="?", help="Archipelago connection url")
624 args = parser.parse_args(launch_args)
625
626 args = handle_url_arg(args, parser=parser)
627
628 colorama.just_fix_windows_console()
629 asyncio.run(main(args))
630 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 77f0ae3..3d7c9a5 100644 --- a/apworld/options.py +++ b/apworld/options.py
@@ -1,13 +1,180 @@
1from dataclasses import dataclass 1from dataclasses import dataclass
2 2
3from Options import PerGameCommonOptions, Toggle 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
124class VictoryCondition(Choice):
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 """
142 display_name = "Victory Condition"
143 option_gray_ending = 0
144 option_purple_ending = 1
145 option_mint_ending = 2
146 option_black_ending = 3
147 option_blue_ending = 4
148 option_cyan_ending = 5
149 option_red_ending = 6
150 option_plum_ending = 7
151 option_orange_ending = 8
152 option_gold_ending = 9
153 option_yellow_ending = 10
154 option_green_ending = 11
155 option_white_ending = 12
156
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
11@dataclass 166@dataclass
12class Lingo2Options(PerGameCommonOptions): 167class Lingo2Options(PerGameCommonOptions):
13 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
179 victory_condition: VictoryCondition
180 trap_percentage: TrapPercentage
diff --git a/apworld/player_logic.py b/apworld/player_logic.py index edf8c4f..5be066d 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 VictoryCondition, ShuffleLetters, CyanDoorBehavior
8
4if TYPE_CHECKING: 9if TYPE_CHECKING:
5 from . import Lingo2World 10 from . import Lingo2World
6 11
@@ -10,60 +15,197 @@ def calculate_letter_histogram(solution: str) -> dict[str, int]:
10 for l in solution: 15 for l in solution:
11 if l.isalpha(): 16 if l.isalpha():
12 real_l = l.upper() 17 real_l = l.upper()
13 histogram[real_l] = min(histogram.get(l, 0) + 1, 2) 18 histogram[real_l] = min(histogram.get(real_l, 0) + 1, 2)
14 19
15 return histogram 20 return histogram
16 21
17 22
18class AccessRequirements: 23class AccessRequirements:
19 items: set[str] 24 items: set[str]
25 progressives: dict[str, int]
20 rooms: set[str] 26 rooms: set[str]
21 symbols: set[str]
22 letters: dict[str, int] 27 letters: dict[str, int]
28 cyans: bool
23 29
24 # This is an AND of ORs. 30 # This is an AND of ORs.
25 or_logic: list[list["AccessRequirements"]] 31 or_logic: list[list["AccessRequirements"]]
26 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
27 def __init__(self): 38 def __init__(self):
28 self.items = set() 39 self.items = set()
40 self.progressives = dict()
29 self.rooms = set() 41 self.rooms = set()
30 self.symbols = set()
31 self.letters = dict() 42 self.letters = dict()
43 self.cyans = False
32 self.or_logic = list() 44 self.or_logic = list()
45 self.complete_at = None
46 self.possibilities = list()
33 47
34 def add_solution(self, solution: str): 48 def copy(self) -> "AccessRequirements":
35 histogram = calculate_letter_histogram(solution) 49 reqs = AccessRequirements()
36 50 reqs.items = self.items.copy()
37 for l, a in histogram.items(): 51 reqs.progressives = self.progressives.copy()
38 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
39 59
40 def merge(self, other: "AccessRequirements"): 60 def merge(self, other: "AccessRequirements"):
41 for item in other.items: 61 for item in other.items:
42 self.items.add(item) 62 self.items.add(item)
43 63
64 for item, amount in other.progressives.items():
65 self.progressives[item] = max(amount, self.progressives.get(item, 0))
66
44 for room in other.rooms: 67 for room in other.rooms:
45 self.rooms.add(room) 68 self.rooms.add(room)
46 69
47 for symbol in other.symbols:
48 self.symbols.add(symbol)
49
50 for letter, level in other.letters.items(): 70 for letter, level in other.letters.items():
51 self.letters[letter] = max(self.letters.get(letter, 0), level) 71 self.letters[letter] = max(self.letters.get(letter, 0), level)
52 72
73 self.cyans = self.cyans or other.cyans
74
53 for disjunction in other.or_logic: 75 for disjunction in other.or_logic:
54 self.or_logic.append(disjunction) 76 self.or_logic.append(disjunction)
55 77
78 if other.complete_at is not None:
79 # Merging multiple requirements that use complete_at sucks, and is part of why we want to minimize use of
80 # it. If both requirements use complete_at, we will cheat by using the or_logic field, which supports
81 # conjunctions of requirements.
82 if self.complete_at is not None:
83 print("Merging requirements with complete_at > 1. This is messy and should be avoided!")
84
85 left_req = AccessRequirements()
86 left_req.complete_at = self.complete_at
87 left_req.possibilities = self.possibilities
88 self.or_logic.append([left_req])
89
90 self.complete_at = None
91 self.possibilities = list()
92
93 right_req = AccessRequirements()
94 right_req.complete_at = other.complete_at
95 right_req.possibilities = other.possibilities
96 self.or_logic.append([right_req])
97 else:
98 self.complete_at = other.complete_at
99 self.possibilities = other.possibilities
100
101 def is_empty(self) -> bool:
102 return (len(self.items) == 0 and len(self.progressives) == 0 and len(self.rooms) == 0 and len(self.letters) == 0
103 and not self.cyans and len(self.or_logic) == 0 and self.complete_at is None)
104
105 def __eq__(self, other: "AccessRequirements"):
106 return (self.items == other.items and self.progressives == other.progressives and self.rooms == other.rooms and
107 self.letters == other.letters and self.cyans == other.cyans and self.or_logic == other.or_logic and
108 self.complete_at == other.complete_at and self.possibilities == other.possibilities)
109
110 def simplify(self):
111 resimplify = False
112
113 if len(self.or_logic) > 0:
114 old_or_logic = self.or_logic
115
116 def remove_redundant(sub_reqs: "AccessRequirements"):
117 new_reqs = sub_reqs.copy()
118 new_reqs.letters = {l: v for l, v in new_reqs.letters.items() if self.letters.get(l, 0) < v}
119 if new_reqs != sub_reqs:
120 return new_reqs
121 else:
122 return sub_reqs
123
124 self.or_logic = []
125 for disjunction in old_or_logic:
126 new_disjunction = []
127 for ssr in disjunction:
128 new_ssr = remove_redundant(ssr)
129 if not new_ssr.is_empty():
130 new_disjunction.append(new_ssr)
131 else:
132 new_disjunction.clear()
133 break
134 if len(new_disjunction) == 1:
135 self.merge(new_disjunction[0])
136 resimplify = True
137 elif len(new_disjunction) > 1:
138 if all(cjr == new_disjunction[0] for cjr in new_disjunction):
139 self.merge(new_disjunction[0])
140 resimplify = True
141 else:
142 self.or_logic.append(new_disjunction)
143
144 if resimplify:
145 self.simplify()
146
147 def get_referenced_rooms(self):
148 result = set(self.rooms)
149
150 for disjunction in self.or_logic:
151 for sub_req in disjunction:
152 result = result.union(sub_req.get_referenced_rooms())
153
154 for sub_req in self.possibilities:
155 result = result.union(sub_req.get_referenced_rooms())
156
157 return result
158
159 def remove_room(self, room: str):
160 if room in self.rooms:
161 self.rooms.remove(room)
162
163 for disjunction in self.or_logic:
164 for sub_req in disjunction:
165 sub_req.remove_room(room)
166
167 for sub_req in self.possibilities:
168 sub_req.remove_room(room)
169
170 def __repr__(self):
171 parts = []
172 if len(self.items) > 0:
173 parts.append(f"items={self.items}")
174 if len(self.progressives) > 0:
175 parts.append(f"progressives={self.progressives}")
176 if len(self.rooms) > 0:
177 parts.append(f"rooms={self.rooms}")
178 if len(self.letters) > 0:
179 parts.append(f"letters={self.letters}")
180 if self.cyans:
181 parts.append(f"cyans=True")
182 if len(self.or_logic) > 0:
183 parts.append(f"or_logic={self.or_logic}")
184 if self.complete_at is not None:
185 parts.append(f"complete_at={self.complete_at}")
186 if len(self.possibilities) > 0:
187 parts.append(f"possibilities={self.possibilities}")
188 return "AccessRequirements(" + ", ".join(parts) + ")"
189
56 190
57class PlayerLocation(NamedTuple): 191class PlayerLocation(NamedTuple):
58 code: int | None 192 code: int | None
59 reqs: AccessRequirements 193 reqs: AccessRequirements
60 194
61 195
196class LetterBehavior(IntEnum):
197 VANILLA = auto()
198 ITEM = auto()
199 UNLOCKED = auto()
200
201
62class Lingo2PlayerLogic: 202class Lingo2PlayerLogic:
63 world: "Lingo2World" 203 world: "Lingo2World"
64 204
65 locations_by_room: dict[int, list[PlayerLocation]] 205 locations_by_room: dict[int, list[PlayerLocation]]
66 item_by_door: dict[int, str] 206 event_loc_item_by_room: dict[int, dict[str, str]]
207
208 item_by_door: dict[int, tuple[str, int]]
67 209
68 panel_reqs: dict[int, AccessRequirements] 210 panel_reqs: dict[int, AccessRequirements]
69 proxy_reqs: dict[int, dict[str, AccessRequirements]] 211 proxy_reqs: dict[int, dict[str, AccessRequirements]]
@@ -71,22 +213,79 @@ class Lingo2PlayerLogic:
71 213
72 real_items: list[str] 214 real_items: list[str]
73 215
216 double_letter_amount: dict[str, int]
217
74 def __init__(self, world: "Lingo2World"): 218 def __init__(self, world: "Lingo2World"):
75 self.world = world 219 self.world = world
76 self.locations_by_room = {} 220 self.locations_by_room = {}
221 self.event_loc_item_by_room = {}
77 self.item_by_door = {} 222 self.item_by_door = {}
78 self.panel_reqs = dict() 223 self.panel_reqs = dict()
79 self.proxy_reqs = dict() 224 self.proxy_reqs = dict()
80 self.door_reqs = dict() 225 self.door_reqs = dict()
81 self.real_items = list() 226 self.real_items = list()
227 self.double_letter_amount = dict()
228
229 if self.world.options.shuffle_doors:
230 for progressive in world.static_logic.objects.progressives:
231 for i in range(0, len(progressive.doors)):
232 self.item_by_door[progressive.doors[i]] = (progressive.name, i + 1)
233 self.real_items.append(progressive.name)
234
235 for door_group in world.static_logic.objects.door_groups:
236 if door_group.type == data_pb2.DoorGroupType.CONNECTOR:
237 if not self.world.options.shuffle_doors or self.world.options.shuffle_worldports:
238 continue
239 elif door_group.type == data_pb2.DoorGroupType.COLOR_CONNECTOR:
240 if not self.world.options.shuffle_control_center_colors or self.world.options.shuffle_worldports:
241 continue
242 elif door_group.type == data_pb2.DoorGroupType.SHUFFLE_GROUP:
243 if not self.world.options.shuffle_doors:
244 continue
245 else:
246 continue
247
248 for door in door_group.doors:
249 self.item_by_door[door] = (door_group.name, 1)
250
251 self.real_items.append(door_group.name)
82 252
83 # We iterate through the doors in two parts because it is essential that we determine which doors are shuffled 253 # We iterate through the doors in two parts because it is essential that we determine which doors are shuffled
84 # before we calculate any access requirements. 254 # before we calculate any access requirements.
85 for door in world.static_logic.objects.doors: 255 for door in world.static_logic.objects.doors:
86 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.ITEM_ONLY] and self.world.options.shuffle_doors: 256 if door.type in [data_pb2.DoorType.EVENT, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]:
87 door_item_name = self.world.static_logic.get_door_item_name(door.id) 257 continue
88 self.item_by_door[door.id] = door_item_name 258
89 self.real_items.append(door_item_name) 259 if door.id in self.item_by_door:
260 continue
261
262 if (door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.ITEM_ONLY] and
263 not self.world.options.shuffle_doors):
264 continue
265
266 if (door.type == data_pb2.DoorType.CONTROL_CENTER_COLOR and
267 not self.world.options.shuffle_control_center_colors):
268 continue
269
270 if door.type == data_pb2.DoorType.GALLERY_PAINTING and not self.world.options.shuffle_gallery_paintings:
271 continue
272
273 door_item_name = self.world.static_logic.get_door_item_name(door)
274 self.item_by_door[door.id] = (door_item_name, 1)
275 self.real_items.append(door_item_name)
276
277 # We handle cyan_door_behavior = Item after door shuffle, because cyan doors that are impacted by door shuffle
278 # should be exempt from cyan_door_behavior.
279 if world.options.cyan_door_behavior == CyanDoorBehavior.option_item:
280 for door_group in world.static_logic.objects.door_groups:
281 if door_group.type != data_pb2.DoorGroupType.CYAN_DOORS:
282 continue
283
284 for door in door_group.doors:
285 if not door in self.item_by_door:
286 self.item_by_door[door] = (door_group.name, 1)
287
288 self.real_items.append(door_group.name)
90 289
91 for door in world.static_logic.objects.doors: 290 for door in world.static_logic.objects.doors:
92 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]: 291 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]:
@@ -96,14 +295,55 @@ class Lingo2PlayerLogic:
96 for letter in world.static_logic.objects.letters: 295 for letter in world.static_logic.objects.letters:
97 self.locations_by_room.setdefault(letter.room_id, []).append(PlayerLocation(letter.ap_id, 296 self.locations_by_room.setdefault(letter.room_id, []).append(PlayerLocation(letter.ap_id,
98 AccessRequirements())) 297 AccessRequirements()))
298 behavior = self.get_letter_behavior(letter.key, letter.level2)
299 if behavior == LetterBehavior.VANILLA:
300 if not world.for_tracker:
301 letter_name = f"{letter.key.upper()}{'2' if letter.level2 else '1'}"
302 event_name = f"{letter_name} (Collected)"
303 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper()
304
305 if letter.level2:
306 event_name = f"{letter_name} (Double Collected)"
307 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper()
308 elif behavior == LetterBehavior.ITEM:
309 self.real_items.append(letter.key.upper())
310
311 if behavior != LetterBehavior.UNLOCKED:
312 self.double_letter_amount[letter.key.upper()] = self.double_letter_amount.get(letter.key.upper(), 0) + 1
99 313
100 for mastery in world.static_logic.objects.masteries: 314 for mastery in world.static_logic.objects.masteries:
101 self.locations_by_room.setdefault(mastery.room_id, []).append(PlayerLocation(mastery.ap_id, 315 self.locations_by_room.setdefault(mastery.room_id, []).append(PlayerLocation(mastery.ap_id,
102 AccessRequirements())) 316 AccessRequirements()))
103 317
104 for ending in world.static_logic.objects.endings: 318 for ending in world.static_logic.objects.endings:
105 self.locations_by_room.setdefault(ending.room_id, []).append(PlayerLocation(ending.ap_id, 319 # Don't create a location for your selected ending, and never create a location for White Ending.
106 AccessRequirements())) 320 if world.options.victory_condition.current_key.removesuffix("_ending").upper() != ending.name\
321 and ending.name != "WHITE":
322 self.locations_by_room.setdefault(ending.room_id, []).append(PlayerLocation(ending.ap_id,
323 AccessRequirements()))
324
325 event_name = f"{ending.name.capitalize()} Ending (Achieved)"
326 item_name = event_name
327
328 if world.options.victory_condition.current_key.removesuffix("_ending").upper() == ending.name:
329 item_name = "Victory"
330
331 self.event_loc_item_by_room.setdefault(ending.room_id, {})[event_name] = item_name
332
333 if self.world.options.keyholder_sanity:
334 for keyholder in world.static_logic.objects.keyholders:
335 if keyholder.HasField("key"):
336 reqs = AccessRequirements()
337
338 if self.get_letter_behavior(keyholder.key, False) != LetterBehavior.UNLOCKED:
339 reqs.letters[keyholder.key.upper()] = 1
340
341 self.locations_by_room.setdefault(keyholder.room_id, []).append(PlayerLocation(keyholder.ap_id,
342 reqs))
343
344 if self.world.options.shuffle_symbols:
345 for symbol_name in SYMBOL_ITEMS.values():
346 self.real_items.append(symbol_name)
107 347
108 def get_panel_reqs(self, panel_id: int, answer: str | None) -> AccessRequirements: 348 def get_panel_reqs(self, panel_id: int, answer: str | None) -> AccessRequirements:
109 if answer is None: 349 if answer is None:
@@ -124,18 +364,38 @@ class Lingo2PlayerLogic:
124 reqs.rooms.add(self.world.static_logic.get_room_region_name(panel.room_id)) 364 reqs.rooms.add(self.world.static_logic.get_room_region_name(panel.room_id))
125 365
126 if answer is not None: 366 if answer is not None:
127 reqs.add_solution(answer) 367 self.add_solution_reqs(reqs, answer)
128 elif len(panel.proxies) > 0: 368 elif len(panel.proxies) > 0:
369 possibilities = []
370 already_filled = False
371
129 for proxy in panel.proxies: 372 for proxy in panel.proxies:
130 proxy_reqs = AccessRequirements() 373 proxy_reqs = AccessRequirements()
131 proxy_reqs.add_solution(proxy.answer) 374 self.add_solution_reqs(proxy_reqs, proxy.answer)
375
376 if not proxy_reqs.is_empty():
377 possibilities.append(proxy_reqs)
378 else:
379 already_filled = True
380 break
381
382 if not already_filled and not any(proxy.answer == panel.answer for proxy in panel.proxies):
383 proxy_reqs = AccessRequirements()
384 self.add_solution_reqs(proxy_reqs, panel.answer)
132 385
133 reqs.or_logic.append([proxy_reqs]) 386 if not proxy_reqs.is_empty():
387 possibilities.append(proxy_reqs)
388 else:
389 already_filled = True
390
391 if not already_filled:
392 reqs.or_logic.append(possibilities)
134 else: 393 else:
135 reqs.add_solution(panel.answer) 394 self.add_solution_reqs(reqs, panel.answer)
136 395
137 for symbol in panel.symbols: 396 if self.world.options.shuffle_symbols:
138 reqs.symbols.add(symbol) 397 for symbol in panel.symbols:
398 reqs.items.add(SYMBOL_ITEMS.get(symbol))
139 399
140 if panel.HasField("required_door"): 400 if panel.HasField("required_door"):
141 door_reqs = self.get_door_open_reqs(panel.required_door) 401 door_reqs = self.get_door_open_reqs(panel.required_door)
@@ -158,32 +418,67 @@ class Lingo2PlayerLogic:
158 door = self.world.static_logic.objects.doors[door_id] 418 door = self.world.static_logic.objects.doors[door_id]
159 reqs = AccessRequirements() 419 reqs = AccessRequirements()
160 420
161 use_item = False 421 if not door.HasField("complete_at") or door.complete_at == 0:
162 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.ITEM_ONLY] and self.world.options.shuffle_doors: 422 for proxy in door.panels:
163 use_item = True 423 panel_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None)
164 424 reqs.merge(panel_reqs)
165 if use_item: 425 elif door.complete_at == 1:
166 reqs.items.add(self.world.static_logic.get_door_item_name(door.id)) 426 disjunction = []
427 for proxy in door.panels:
428 proxy_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None)
429 if proxy_reqs.is_empty():
430 disjunction.clear()
431 break
432 else:
433 disjunction.append(proxy_reqs)
434 if len(disjunction) > 0:
435 reqs.or_logic.append(disjunction)
167 else: 436 else:
168 # TODO: complete_at, control_center_color, switches, keyholders 437 reqs.complete_at = door.complete_at
169 if not door.HasField("complete_at") or door.complete_at == 0: 438 for proxy in door.panels:
170 for proxy in door.panels: 439 panel_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None)
171 panel_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None) 440 reqs.possibilities.append(panel_reqs)
172 reqs.merge(panel_reqs) 441
173 elif door.complete_at == 1: 442 if door.HasField("control_center_color"):
174 reqs.or_logic.append([self.get_panel_reqs(proxy.panel, 443 # TODO: Logic for ensuring two CC states aren't needed at once.
175 proxy.answer if proxy.HasField("answer") else None) 444 reqs.rooms.add("Control Center - Main Area")
176 for proxy in door.panels]) 445 self.add_solution_reqs(reqs, door.control_center_color)
177 else: 446
178 # TODO: Handle complete_at > 1 447 if door.double_letters:
448 if self.world.options.cyan_door_behavior == CyanDoorBehavior.option_collect_h2:
449 reqs.rooms.add("The Repetitive - Main Room")
450 elif self.world.options.cyan_door_behavior == CyanDoorBehavior.option_any_double_letter:
451 if self.world.options.shuffle_letters != ShuffleLetters.option_unlocked:
452 reqs.cyans = True
453 elif self.world.options.cyan_door_behavior == CyanDoorBehavior.option_item:
454 # There shouldn't be any locations that are cyan doors.
179 pass 455 pass
180 456
181 for room in door.rooms: 457 for keyholder_uses in door.keyholders:
182 reqs.rooms.add(self.world.static_logic.get_room_region_name(room)) 458 key_name = keyholder_uses.key.upper()
459 if (self.get_letter_behavior(keyholder_uses.key, False) != LetterBehavior.UNLOCKED
460 and key_name not in reqs.letters):
461 reqs.letters[key_name] = 1
462
463 keyholder = self.world.static_logic.objects.keyholders[keyholder_uses.keyholder]
464 reqs.rooms.add(self.world.static_logic.get_room_region_name(keyholder.room_id))
465
466 for room in door.rooms:
467 reqs.rooms.add(self.world.static_logic.get_room_region_name(room))
468
469 for ending_id in door.endings:
470 ending = self.world.static_logic.objects.endings[ending_id]
471
472 if self.world.options.victory_condition.current_key.removesuffix("_ending").upper() == ending.name:
473 reqs.items.add("Victory")
474 else:
475 reqs.items.add(f"{ending.name.capitalize()} Ending (Achieved)")
476
477 for sub_door_id in door.doors:
478 sub_reqs = self.get_door_open_reqs(sub_door_id)
479 reqs.merge(sub_reqs)
183 480
184 for sub_door_id in door.doors: 481 reqs.simplify()
185 sub_reqs = self.get_door_open_reqs(sub_door_id)
186 reqs.merge(sub_reqs)
187 482
188 return reqs 483 return reqs
189 484
@@ -192,8 +487,50 @@ class Lingo2PlayerLogic:
192 def get_door_open_reqs(self, door_id: int) -> AccessRequirements: 487 def get_door_open_reqs(self, door_id: int) -> AccessRequirements:
193 if door_id in self.item_by_door: 488 if door_id in self.item_by_door:
194 reqs = AccessRequirements() 489 reqs = AccessRequirements()
195 reqs.items.add(self.item_by_door.get(door_id)) 490
491 item_name, amount = self.item_by_door.get(door_id)
492 if amount == 1:
493 reqs.items.add(item_name)
494 else:
495 reqs.progressives[item_name] = amount
196 496
197 return reqs 497 return reqs
198 else: 498 else:
199 return self.get_door_reqs(door_id) 499 return self.get_door_reqs(door_id)
500
501 def get_letter_behavior(self, letter: str, level2: bool) -> LetterBehavior:
502 if self.world.options.shuffle_letters == ShuffleLetters.option_unlocked:
503 return LetterBehavior.UNLOCKED
504
505 if self.world.options.shuffle_letters in [ShuffleLetters.option_vanilla_cyan, ShuffleLetters.option_item_cyan]:
506 if level2:
507 if self.world.options.shuffle_letters == ShuffleLetters.option_vanilla_cyan:
508 return LetterBehavior.VANILLA
509 else:
510 return LetterBehavior.ITEM
511 else:
512 return LetterBehavior.UNLOCKED
513
514 if not level2 and letter in ["h", "i", "n", "t"]:
515 return LetterBehavior.UNLOCKED
516
517 if self.world.options.shuffle_letters == ShuffleLetters.option_progressive:
518 return LetterBehavior.ITEM
519
520 return LetterBehavior.VANILLA
521
522 def add_solution_reqs(self, reqs: AccessRequirements, solution: str):
523 histogram = calculate_letter_histogram(solution)
524
525 for l, a in histogram.items():
526 needed = min(a, 2)
527 level2 = (needed == 2)
528
529 if level2 and self.get_letter_behavior(l.lower(), True) == LetterBehavior.UNLOCKED:
530 needed = 1
531
532 if self.get_letter_behavior(l.lower(), False) == LetterBehavior.UNLOCKED:
533 needed = needed - 1
534
535 if needed > 0:
536 reqs.letters[l] = max(reqs.letters.get(l, 0), needed)
diff --git a/apworld/regions.py b/apworld/regions.py index 1c8e672..3735858 100644 --- a/apworld/regions.py +++ b/apworld/regions.py
@@ -1,6 +1,9 @@
1from typing import TYPE_CHECKING 1from typing import TYPE_CHECKING
2 2
3from BaseClasses import Region 3import BaseClasses
4from BaseClasses import Region, ItemClassification, Entrance
5from entrance_rando import randomize_entrances
6from .items import Lingo2Item
4from .locations import Lingo2Location 7from .locations import Lingo2Location
5from .player_logic import AccessRequirements 8from .player_logic import AccessRequirements
6from .rules import make_location_lambda 9from .rules import make_location_lambda
@@ -10,15 +13,42 @@ if TYPE_CHECKING:
10 13
11 14
12def create_region(room, world: "Lingo2World") -> Region: 15def create_region(room, world: "Lingo2World") -> Region:
13 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)
14 17
18
19def create_locations(room, new_region: Region, world: "Lingo2World", regions: dict[str, Region]):
15 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
16 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],
17 location.code, new_region) 25 location.code, new_region)
18 new_location.access_rule = make_location_lambda(location.reqs, world) 26 new_location.access_rule = make_location_lambda(reqs, world, regions)
27 new_region.locations.append(new_location)
28
29 for event_name, item_name in world.player_logic.event_loc_item_by_room.get(room.id, {}).items():
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
34 event_item = Lingo2Item(item_name, ItemClassification.progression, None, world.player)
35 new_location.place_locked_item(event_item)
19 new_region.locations.append(new_location) 36 new_region.locations.append(new_location)
20 37
21 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)
22 52
23 53
24def create_regions(world: "Lingo2World"): 54def create_regions(world: "Lingo2World"):
@@ -26,16 +56,34 @@ def create_regions(world: "Lingo2World"):
26 "Menu": Region("Menu", world.player, world.multiworld) 56 "Menu": Region("Menu", world.player, world.multiworld)
27 } 57 }
28 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.
29 for room in world.static_logic.objects.rooms: 64 for room in world.static_logic.objects.rooms:
30 region = create_region(room, world) 65 region = create_region(room, world)
31 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)
32 71
33 regions["Menu"].connect(regions["The Entry - Starting Room"], "Start Game") 72 regions["Menu"].connect(regions["The Entry - Starting Room"], "Start Game")
34 73
35 # TODO: The requirements of the opposite trigger also matter.
36 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
37 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)
38 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
39 connection_name = f"{from_region} -> {to_region}" 87 connection_name = f"{from_region} -> {to_region}"
40 88
41 reqs = AccessRequirements() 89 reqs = AccessRequirements()
@@ -51,6 +99,9 @@ def create_regions(world: "Lingo2World"):
51 port = world.static_logic.objects.ports[connection.port] 99 port = world.static_logic.objects.ports[connection.port]
52 connection_name = f"{connection_name} (via port {port.name})" 100 connection_name = f"{connection_name} (via port {port.name})"
53 101
102 if world.options.shuffle_worldports and not port.no_shuffle:
103 continue
104
54 if port.HasField("required_door"): 105 if port.HasField("required_door"):
55 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))
56 107
@@ -72,7 +123,93 @@ def create_regions(world: "Lingo2World"):
72 else: 123 else:
73 connection_name = f"{connection_name} (via panel {panel.name})" 124 connection_name = f"{connection_name} (via panel {panel.name})"
74 125
75 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:
76 regions[from_region].connect(regions[to_region], connection_name, make_location_lambda(reqs, world)) 127 world.player_logic.add_solution_reqs(reqs, "abcdefghijklmnopqrstuvwxyz")
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)
134
135 connection = Entrance(world.player, connection_name, regions[from_region])
136 connection.access_rule = make_location_lambda(reqs, world, regions)
137
138 regions[from_region].exits.append(connection)
139 connection.connect(regions[to_region])
140
141 for region in reqs.get_referenced_rooms():
142 world.multiworld.register_indirect_condition(regions[region], connection)
77 143
78 world.multiworld.regions += regions.values() 144 world.multiworld.regions += regions.values()
145
146
147def shuffle_entrances(world: "Lingo2World"):
148 er_entrances: list[Entrance] = []
149 er_exits: list[Entrance] = []
150
151 port_id_by_name: dict[str, int] = {}
152
153 for port in world.static_logic.objects.ports:
154 if port.no_shuffle:
155 continue
156
157 port_region_name = world.static_logic.get_room_region_name(port.room_id)
158 port_region = world.multiworld.get_region(port_region_name, world.player)
159
160 connection_name = f"{port_region_name} - {port.name}"
161 port_id_by_name[connection_name] = port.id
162
163 entrance = port_region.create_er_target(connection_name)
164 entrance.randomization_type = BaseClasses.EntranceType.TWO_WAY
165
166 er_exit = port_region.create_exit(connection_name)
167 er_exit.randomization_type = BaseClasses.EntranceType.TWO_WAY
168
169 if port.HasField("required_door"):
170 door_reqs = world.player_logic.get_door_open_reqs(port.required_door)
171 er_exit.access_rule = make_location_lambda(door_reqs, world, None)
172
173 for region in door_reqs.get_referenced_rooms():
174 world.multiworld.register_indirect_condition(world.multiworld.get_region(region, world.player),
175 er_exit)
176
177 er_entrances.append(entrance)
178 er_exits.append(er_exit)
179
180 result = randomize_entrances(world, True, {0:[0]}, False, er_entrances,
181 er_exits)
182
183 for (f, to) in result.pairings:
184 world.port_pairings[port_id_by_name[f]] = port_id_by_name[to]
185
186
187def connect_ports_from_ut(port_pairings: dict[int, int], world: "Lingo2World"):
188 for fpid, tpid in port_pairings.items():
189 from_port = world.static_logic.objects.ports[fpid]
190 to_port = world.static_logic.objects.ports[tpid]
191
192 from_region_name = world.static_logic.get_room_region_name(from_port.room_id)
193 to_region_name = world.static_logic.get_room_region_name(to_port.room_id)
194
195 from_region = world.multiworld.get_region(from_region_name, world.player)
196 to_region = world.multiworld.get_region(to_region_name, world.player)
197
198 connection = Entrance(world.player, f"{from_region_name} - {from_port.name}", from_region)
199
200 reqs = AccessRequirements()
201 if from_port.HasField("required_door"):
202 reqs = world.player_logic.get_door_open_reqs(from_port.required_door).copy()
203
204 if world.for_tracker:
205 reqs.items.add(f"Worldport {fpid} Entered")
206
207 if not reqs.is_empty():
208 connection.access_rule = make_location_lambda(reqs, world, None)
209
210 for region in reqs.get_referenced_rooms():
211 world.multiworld.register_indirect_condition(world.multiworld.get_region(region, world.player),
212 connection)
213
214 from_region.exits.append(connection)
215 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 05689e9..f859e75 100644 --- a/apworld/rules.py +++ b/apworld/rules.py
@@ -1,27 +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, letters 22 if not all(state.can_reach(region) for region in regions):
23 return False
24
25 for letter_key, letter_level in reqs.letters.items():
26 if not state.has(letter_key, world.player, letter_level):
27 return False
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
34 if len(reqs.or_logic) > 0:
35 if not all(any(lingo2_can_satisfy_requirements(state, sub_reqs, [], world) for sub_reqs in subjunction)
36 for subjunction in reqs.or_logic):
37 return False
19 38
20 for disjunction in reqs.or_logic: 39 if reqs.complete_at is not None:
21 if not any(lingo2_can_satisfy_requirements(state, sub_reqs, world) for sub_reqs in disjunction): 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:
22 return False 52 return False
23 53
24 return True 54 return True
25 55
26def make_location_lambda(reqs: AccessRequirements, world: "Lingo2World") -> Callable[[CollectionState], bool]: 56def make_location_lambda(reqs: AccessRequirements, world: "Lingo2World",
27 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..7239b65 --- /dev/null +++ b/apworld/tracker.py
@@ -0,0 +1,112 @@
1from typing import TYPE_CHECKING
2
3from BaseClasses import MultiWorld, CollectionState, ItemClassification
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