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