about summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--README.md45
-rw-r--r--apworld/CHANGELOG.md13
-rw-r--r--apworld/__init__.py1
-rw-r--r--apworld/options.py6
-rw-r--r--apworld/player_logic.py97
-rw-r--r--apworld/regions.py28
-rw-r--r--apworld/rules.py35
-rw-r--r--apworld/version.py2
-rw-r--r--client/Archipelago/manager.gd13
-rw-r--r--client/Archipelago/settings_screen.gd35
-rw-r--r--client/CHANGELOG.md11
-rw-r--r--client/archipelago.tscn4
-rw-r--r--data/maps/daedalus/doors.txtpb4
-rw-r--r--data/maps/the_gallery/doors.txtpb34
-rw-r--r--data/metadata.txtpb2
-rw-r--r--proto/data.proto3
16 files changed, 292 insertions, 41 deletions
diff --git a/README.md b/README.md index 3b69bbd..b8e3824 100644 --- a/README.md +++ b/README.md
@@ -9,14 +9,43 @@ This is a project that modifies the game
9[Lingo 2](https://www.lingothegame.com/lingo2.html) so that it can be played as 9[Lingo 2](https://www.lingothegame.com/lingo2.html) so that it can be played as
10part of an Archipelago multiworld game. 10part of an Archipelago multiworld game.
11 11
12There are multiple parts of this project: 12## How To Play
13 13
14- [Client](https://code.fourisland.com/lingo2-archipelago/about/client/README.md) 14Here are the components needed in order to play:
15- [Apworld](https://code.fourisland.com/lingo2-archipelago/about/apworld/README.md) 15
16- [Data](https://code.fourisland.com/lingo2-archipelago/about/data/README.md) 16- **Apworld**: This is used by Archipelago to generate randomized Lingo 2
17 worlds.
18 - [Installation & FAQ](https://code.fourisland.com/lingo2-archipelago/about/apworld/README.md)
19 - [Downloads](https://code.fourisland.com/lingo2-archipelago/about/apworld/CHANGELOG.md)
20- **Client**: This is how Lingo 2 connects to an Archipelago multiworld.
21 - [Installation & FAQ](https://code.fourisland.com/lingo2-archipelago/about/client/README.md)
22 - [Downloads](https://code.fourisland.com/lingo2-archipelago/about/client/CHANGELOG.md)
17 23
18## Frequently Asked Questions 24## Frequently Asked Questions
19 25
26### Why aren't the starting room letters shuffled?
27
28The letter requirements for solving puzzles are very restrictive, especially in
29the early game. It is possible for the generator to find some subset of letters
30and doors to place in the starting room such that you are not trapped, but this
31places a lot of strain on generation and leads to significantly more generation
32failures.
33
34As a result, the starting room letters (H1, I1, N1, and T1) are always present
35in the starting room, even when remote letter shuffle is enabled. These letters
36will _also_ count as clearing a check, so you will send out another item at the
37same time as collecting the letter.
38
39### What areas are randomized?
40
41Almost all maps that you can access from the base game are randomized. The
42exceptions are:
43
44- Icarus (this will be randomized at some point, although it will be optional)
45- Demo
46- The Hinterlands (this will probably be repurposed)
47- The beta tester gift maps
48
20### What about wall snipes? 49### What about wall snipes?
21 50
22"Wall sniping" refers to the fact that you are able to solve puzzles on the 51"Wall sniping" refers to the fact that you are able to solve puzzles on the
@@ -64,3 +93,11 @@ disappears. You can retrieve your letter immediately by pressing C or G again
64while standing in the same position, as the keyholder is still there, just 93while standing in the same position, as the keyholder is still there, just
65invisible. If you have already left the room, though, there is an easier way to 94invisible. If you have already left the room, though, there is an easier way to
66get your letters back: just use the Key Return in The Entry. 95get your letters back: just use the Key Return in The Entry.
96
97## Project Details
98
99There are multiple parts of this project:
100
101- [Client](https://code.fourisland.com/lingo2-archipelago/about/client/README.md)
102- [Apworld](https://code.fourisland.com/lingo2-archipelago/about/apworld/README.md)
103- [Data](https://code.fourisland.com/lingo2-archipelago/about/data/README.md)
diff --git a/apworld/CHANGELOG.md b/apworld/CHANGELOG.md new file mode 100644 index 0000000..7db040c --- /dev/null +++ b/apworld/CHANGELOG.md
@@ -0,0 +1,13 @@
1# lingo2-archipelago Apworld Releases
2
3## v3.2 - 2025-09-12
4
5- Initial release for testing. Features include door shuffle, letter shuffle,
6 and symbol shuffle.
7
8Download:
9[lingo2.apworld](https://files.fourisland.com/releases/lingo2-archipelago/apworld/v3.2/lingo2.apworld)<br/>
10Template YAML:
11[Lingo 2.yaml](https://files.fourisland.com/releases/lingo2-archipelago/apworld/v3.2/Lingo%202.yaml)<br/>
12Source:
13[v3.2](https://code.fourisland.com/lingo2-archipelago/tag/?h=apworld-v3.2)
diff --git a/apworld/__init__.py b/apworld/__init__.py index 4044d76..54f870f 100644 --- a/apworld/__init__.py +++ b/apworld/__init__.py
@@ -82,6 +82,7 @@ class Lingo2World(World):
82 "keyholder_sanity", 82 "keyholder_sanity",
83 "shuffle_control_center_colors", 83 "shuffle_control_center_colors",
84 "shuffle_doors", 84 "shuffle_doors",
85 "shuffle_gallery_paintings",
85 "shuffle_letters", 86 "shuffle_letters",
86 "shuffle_symbols", 87 "shuffle_symbols",
87 "victory_condition", 88 "victory_condition",
diff --git a/apworld/options.py b/apworld/options.py index 240f8af..4f0b32a 100644 --- a/apworld/options.py +++ b/apworld/options.py
@@ -16,6 +16,11 @@ class ShuffleControlCenterColors(Toggle):
16 display_name = "Shuffle Control Center Colors" 16 display_name = "Shuffle Control Center Colors"
17 17
18 18
19class ShuffleGalleryPaintings(Toggle):
20 """If enabled, gallery paintings will appear from receiving an item rather than by triggering them normally."""
21 display_name = "Shuffle Gallery Paintings"
22
23
19class ShuffleLetters(Choice): 24class ShuffleLetters(Choice):
20 """ 25 """
21 Controls how letter unlocks are handled. Note that H1, I1, N1, and T1 will always be present at their vanilla 26 Controls how letter unlocks are handled. Note that H1, I1, N1, and T1 will always be present at their vanilla
@@ -125,6 +130,7 @@ class VictoryCondition(Choice):
125class Lingo2Options(PerGameCommonOptions): 130class Lingo2Options(PerGameCommonOptions):
126 shuffle_doors: ShuffleDoors 131 shuffle_doors: ShuffleDoors
127 shuffle_control_center_colors: ShuffleControlCenterColors 132 shuffle_control_center_colors: ShuffleControlCenterColors
133 shuffle_gallery_paintings: ShuffleGalleryPaintings
128 shuffle_letters: ShuffleLetters 134 shuffle_letters: ShuffleLetters
129 shuffle_symbols: ShuffleSymbols 135 shuffle_symbols: ShuffleSymbols
130 keyholder_sanity: KeyholderSanity 136 keyholder_sanity: KeyholderSanity
diff --git a/apworld/player_logic.py b/apworld/player_logic.py index 42b36e6..8e2a523 100644 --- a/apworld/player_logic.py +++ b/apworld/player_logic.py
@@ -30,6 +30,11 @@ class AccessRequirements:
30 # This is an AND of ORs. 30 # This is an AND of ORs.
31 or_logic: list[list["AccessRequirements"]] 31 or_logic: list[list["AccessRequirements"]]
32 32
33 # When complete_at is set, at least that many of the requirements in possibilities must be accessible. This should
34 # only be used for doors with complete_at > 1, as or_logic is more efficient for complete_at == 1.
35 complete_at: int | None
36 possibilities: list["AccessRequirements"]
37
33 def __init__(self): 38 def __init__(self):
34 self.items = set() 39 self.items = set()
35 self.progressives = dict() 40 self.progressives = dict()
@@ -37,6 +42,20 @@ class AccessRequirements:
37 self.letters = dict() 42 self.letters = dict()
38 self.cyans = False 43 self.cyans = False
39 self.or_logic = list() 44 self.or_logic = list()
45 self.complete_at = None
46 self.possibilities = list()
47
48 def copy(self) -> "AccessRequirements":
49 reqs = AccessRequirements()
50 reqs.items = self.items.copy()
51 reqs.progressives = self.progressives.copy()
52 reqs.rooms = self.rooms.copy()
53 reqs.letters = self.letters.copy()
54 reqs.cyans = self.cyans
55 reqs.or_logic = [[other_req.copy() for other_req in disjunction] for disjunction in self.or_logic]
56 reqs.complete_at = self.complete_at
57 reqs.possibilities = self.possibilities.copy()
58 return reqs
40 59
41 def merge(self, other: "AccessRequirements"): 60 def merge(self, other: "AccessRequirements"):
42 for item in other.items: 61 for item in other.items:
@@ -56,9 +75,69 @@ class AccessRequirements:
56 for disjunction in other.or_logic: 75 for disjunction in other.or_logic:
57 self.or_logic.append(disjunction) 76 self.or_logic.append(disjunction)
58 77
78 if other.complete_at is not None:
79 # Merging multiple requirements that use complete_at sucks, and is part of why we want to minimize use of
80 # it. If both requirements use complete_at, we will cheat by using the or_logic field, which supports
81 # conjunctions of requirements.
82 if self.complete_at is not None:
83 print("Merging requirements with complete_at > 1. This is messy and should be avoided!")
84
85 left_req = AccessRequirements()
86 left_req.complete_at = self.complete_at
87 left_req.possibilities = self.possibilities
88 self.or_logic.append([left_req])
89
90 self.complete_at = None
91 self.possibilities = list()
92
93 right_req = AccessRequirements()
94 right_req.complete_at = other.complete_at
95 right_req.possibilities = other.possibilities
96 self.or_logic.append([right_req])
97 else:
98 self.complete_at = other.complete_at
99 self.possibilities = other.possibilities
100
59 def is_empty(self) -> bool: 101 def is_empty(self) -> bool:
60 return (len(self.items) == 0 and len(self.progressives) == 0 and len(self.rooms) == 0 and len(self.letters) == 0 102 return (len(self.items) == 0 and len(self.progressives) == 0 and len(self.rooms) == 0 and len(self.letters) == 0
61 and not self.cyans and len(self.or_logic) == 0) 103 and not self.cyans and len(self.or_logic) == 0 and self.complete_at is None)
104
105 def __eq__(self, other: "AccessRequirements"):
106 return (self.items == other.items and self.progressives == other.progressives and self.rooms == other.rooms and
107 self.letters == other.letters and self.cyans == other.cyans and self.or_logic == other.or_logic and
108 self.complete_at == other.complete_at and self.possibilities == other.possibilities)
109
110 def simplify(self):
111 resimplify = False
112
113 if len(self.or_logic) > 0:
114 old_or_logic = self.or_logic
115
116 def remove_redundant(sub_reqs: "AccessRequirements"):
117 sub_reqs.letters = {l: v for l, v in sub_reqs.letters.items() if self.letters.get(l, 0) < v}
118
119 self.or_logic = []
120 for disjunction in old_or_logic:
121 new_disjunction = []
122 for ssr in disjunction:
123 remove_redundant(ssr)
124 if not ssr.is_empty():
125 new_disjunction.append(ssr)
126 else:
127 new_disjunction.clear()
128 break
129 if len(new_disjunction) == 1:
130 self.merge(new_disjunction[0])
131 resimplify = True
132 elif len(new_disjunction) > 1:
133 if all(cjr == new_disjunction[0] for cjr in new_disjunction):
134 self.merge(new_disjunction[0])
135 resimplify = True
136 else:
137 self.or_logic.append(new_disjunction)
138
139 if resimplify:
140 self.simplify()
62 141
63 def __repr__(self): 142 def __repr__(self):
64 parts = [] 143 parts = []
@@ -74,6 +153,10 @@ class AccessRequirements:
74 parts.append(f"cyans=True") 153 parts.append(f"cyans=True")
75 if len(self.or_logic) > 0: 154 if len(self.or_logic) > 0:
76 parts.append(f"or_logic={self.or_logic}") 155 parts.append(f"or_logic={self.or_logic}")
156 if self.complete_at is not None:
157 parts.append(f"complete_at={self.complete_at}")
158 if len(self.possibilities) > 0:
159 parts.append(f"possibilities={self.possibilities}")
77 return f"AccessRequirements({", ".join(parts)})" 160 return f"AccessRequirements({", ".join(parts)})"
78 161
79 162
@@ -156,6 +239,9 @@ class Lingo2PlayerLogic:
156 not self.world.options.shuffle_control_center_colors): 239 not self.world.options.shuffle_control_center_colors):
157 continue 240 continue
158 241
242 if door.type == data_pb2.DoorType.GALLERY_PAINTING and not self.world.options.shuffle_gallery_paintings:
243 continue
244
159 door_item_name = self.world.static_logic.get_door_item_name(door) 245 door_item_name = self.world.static_logic.get_door_item_name(door)
160 self.item_by_door[door.id] = (door_item_name, 1) 246 self.item_by_door[door.id] = (door_item_name, 1)
161 self.real_items.append(door_item_name) 247 self.real_items.append(door_item_name)
@@ -306,7 +392,6 @@ class Lingo2PlayerLogic:
306 door = self.world.static_logic.objects.doors[door_id] 392 door = self.world.static_logic.objects.doors[door_id]
307 reqs = AccessRequirements() 393 reqs = AccessRequirements()
308 394
309 # TODO: lavender_cubes, endings
310 if not door.HasField("complete_at") or door.complete_at == 0: 395 if not door.HasField("complete_at") or door.complete_at == 0:
311 for proxy in door.panels: 396 for proxy in door.panels:
312 panel_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None) 397 panel_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None)
@@ -323,8 +408,10 @@ class Lingo2PlayerLogic:
323 if len(disjunction) > 0: 408 if len(disjunction) > 0:
324 reqs.or_logic.append(disjunction) 409 reqs.or_logic.append(disjunction)
325 else: 410 else:
326 # TODO: Handle complete_at > 1 411 reqs.complete_at = door.complete_at
327 pass 412 for proxy in door.panels:
413 panel_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None)
414 reqs.possibilities.append(panel_reqs)
328 415
329 if door.HasField("control_center_color"): 416 if door.HasField("control_center_color"):
330 # TODO: Logic for ensuring two CC states aren't needed at once. 417 # TODO: Logic for ensuring two CC states aren't needed at once.
@@ -365,6 +452,8 @@ class Lingo2PlayerLogic:
365 sub_reqs = self.get_door_open_reqs(sub_door_id) 452 sub_reqs = self.get_door_open_reqs(sub_door_id)
366 reqs.merge(sub_reqs) 453 reqs.merge(sub_reqs)
367 454
455 reqs.simplify()
456
368 return reqs 457 return reqs
369 458
370 # This gets the requirements to open a door within the world. When a door is shuffled, this means having the item 459 # This gets the requirements to open a door within the world. When a door is shuffled, this means having the item
diff --git a/apworld/regions.py b/apworld/regions.py index e30493c..4f1dd55 100644 --- a/apworld/regions.py +++ b/apworld/regions.py
@@ -11,12 +11,18 @@ if TYPE_CHECKING:
11 11
12 12
13def create_region(room, world: "Lingo2World") -> Region: 13def create_region(room, world: "Lingo2World") -> Region:
14 new_region = Region(world.static_logic.get_room_region_name(room.id), world.player, world.multiworld) 14 return Region(world.static_logic.get_room_region_name(room.id), world.player, world.multiworld)
15 15
16
17def 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, {}): 18 for location in world.player_logic.locations_by_room.get(room.id, {}):
19 reqs = location.reqs.copy()
20 if new_region.name in reqs.rooms:
21 reqs.rooms.remove(new_region.name)
22
17 new_location = Lingo2Location(world.player, world.static_logic.location_id_to_name[location.code], 23 new_location = Lingo2Location(world.player, world.static_logic.location_id_to_name[location.code],
18 location.code, new_region) 24 location.code, new_region)
19 new_location.access_rule = make_location_lambda(location.reqs, world) 25 new_location.access_rule = make_location_lambda(reqs, world, regions)
20 new_region.locations.append(new_location) 26 new_region.locations.append(new_location)
21 27
22 for event_name, item_name in world.player_logic.event_loc_item_by_room.get(room.id, {}).items(): 28 for event_name, item_name in world.player_logic.event_loc_item_by_room.get(room.id, {}).items():
@@ -25,17 +31,23 @@ def create_region(room, world: "Lingo2World") -> Region:
25 new_location.place_locked_item(event_item) 31 new_location.place_locked_item(event_item)
26 new_region.locations.append(new_location) 32 new_region.locations.append(new_location)
27 33
28 return new_region
29
30
31def create_regions(world: "Lingo2World"): 34def create_regions(world: "Lingo2World"):
32 regions = { 35 regions = {
33 "Menu": Region("Menu", world.player, world.multiworld) 36 "Menu": Region("Menu", world.player, world.multiworld)
34 } 37 }
35 38
39 region_and_room = []
40
41 # Create the regions in two stages. First, make the actual region objects and memoize them. Then, add all of the
42 # locations. This allows us to reference the actual region objects in the access rules for the locations, which is
43 # faster than having to look them up during access checking.
36 for room in world.static_logic.objects.rooms: 44 for room in world.static_logic.objects.rooms:
37 region = create_region(room, world) 45 region = create_region(room, world)
38 regions[region.name] = region 46 regions[region.name] = region
47 region_and_room.append((region, room))
48
49 for (region, room) in region_and_room:
50 create_locations(room, region, world, regions)
39 51
40 regions["Menu"].connect(regions["The Entry - Starting Room"], "Start Game") 52 regions["Menu"].connect(regions["The Entry - Starting Room"], "Start Game")
41 53
@@ -82,14 +94,18 @@ def create_regions(world: "Lingo2World"):
82 else: 94 else:
83 connection_name = f"{connection_name} (via panel {panel.name})" 95 connection_name = f"{connection_name} (via panel {panel.name})"
84 96
97 reqs.simplify()
98
85 if from_region in regions and to_region in regions: 99 if from_region in regions and to_region in regions:
86 connection = Entrance(world.player, connection_name, regions[from_region]) 100 connection = Entrance(world.player, connection_name, regions[from_region])
87 connection.access_rule = make_location_lambda(reqs, world) 101 connection.access_rule = make_location_lambda(reqs, world, regions)
88 102
89 regions[from_region].exits.append(connection) 103 regions[from_region].exits.append(connection)
90 connection.connect(regions[to_region]) 104 connection.connect(regions[to_region])
91 105
92 for region in reqs.rooms: 106 for region in reqs.rooms:
107 if region == from_region:
108 continue
93 world.multiworld.register_indirect_condition(regions[region], connection) 109 world.multiworld.register_indirect_condition(regions[region], connection)
94 110
95 world.multiworld.regions += regions.values() 111 world.multiworld.regions += regions.values()
diff --git a/apworld/rules.py b/apworld/rules.py index 0bff056..c077858 100644 --- a/apworld/rules.py +++ b/apworld/rules.py
@@ -1,14 +1,15 @@
1from collections.abc import Callable 1from collections.abc import Callable
2from typing import TYPE_CHECKING 2from typing import TYPE_CHECKING
3 3
4from BaseClasses import CollectionState 4from BaseClasses import CollectionState, Region
5from .player_logic import AccessRequirements 5from .player_logic import AccessRequirements
6 6
7if TYPE_CHECKING: 7if TYPE_CHECKING:
8 from . import Lingo2World 8 from . import Lingo2World
9 9
10 10
11def lingo2_can_satisfy_requirements(state: CollectionState, reqs: AccessRequirements, world: "Lingo2World") -> bool: 11def lingo2_can_satisfy_requirements(state: CollectionState, reqs: AccessRequirements, regions: list[Region],
12 world: "Lingo2World") -> bool:
12 if not all(state.has(item, world.player) for item in reqs.items): 13 if not all(state.has(item, world.player) for item in reqs.items):
13 return False 14 return False
14 15
@@ -18,6 +19,9 @@ def lingo2_can_satisfy_requirements(state: CollectionState, reqs: AccessRequirem
18 if not all(state.can_reach_region(region_name, world.player) for region_name in reqs.rooms): 19 if not all(state.can_reach_region(region_name, world.player) for region_name in reqs.rooms):
19 return False 20 return False
20 21
22 if not all(state.can_reach(region) for region in regions):
23 return False
24
21 for letter_key, letter_level in reqs.letters.items(): 25 for letter_key, letter_level in reqs.letters.items():
22 if not state.has(letter_key, world.player, letter_level): 26 if not state.has(letter_key, world.player, letter_level):
23 return False 27 return False
@@ -28,11 +32,32 @@ def lingo2_can_satisfy_requirements(state: CollectionState, reqs: AccessRequirem
28 return False 32 return False
29 33
30 if len(reqs.or_logic) > 0: 34 if len(reqs.or_logic) > 0:
31 if not all(any(lingo2_can_satisfy_requirements(state, sub_reqs, world) for sub_reqs in subjunction) 35 if not all(any(lingo2_can_satisfy_requirements(state, sub_reqs, [], world) for sub_reqs in subjunction)
32 for subjunction in reqs.or_logic): 36 for subjunction in reqs.or_logic):
33 return False 37 return False
34 38
39 if reqs.complete_at is not None:
40 completed = 0
41 checked = 0
42 for possibility in reqs.possibilities:
43 checked += 1
44 if lingo2_can_satisfy_requirements(state, possibility, [], world):
45 completed += 1
46 if completed >= reqs.complete_at:
47 break
48 elif len(reqs.possibilities) - checked + completed < reqs.complete_at:
49 # There aren't enough remaining possibilities for the check to pass.
50 return False
51 if completed < reqs.complete_at:
52 return False
53
35 return True 54 return True
36 55
37def make_location_lambda(reqs: AccessRequirements, world: "Lingo2World") -> Callable[[CollectionState], bool]: 56def make_location_lambda(reqs: AccessRequirements, world: "Lingo2World",
38 return lambda state: lingo2_can_satisfy_requirements(state, reqs, world) 57 regions: dict[str, Region]) -> Callable[[CollectionState], bool]:
58 # Replace required rooms with regions for the top level requirement, which saves looking up the regions during rule
59 # checking.
60 required_regions = [regions[room_name] for room_name in reqs.rooms]
61 new_reqs = reqs.copy()
62 new_reqs.rooms.clear()
63 return lambda state: lingo2_can_satisfy_requirements(state, new_reqs, required_regions, world)
diff --git a/apworld/version.py b/apworld/version.py index 12cea7d..87f8797 100644 --- a/apworld/version.py +++ b/apworld/version.py
@@ -1 +1 @@
APWORLD_VERSION = 1 APWORLD_VERSION = 2
diff --git a/client/Archipelago/manager.gd b/client/Archipelago/manager.gd index 25f68c1..8a15728 100644 --- a/client/Archipelago/manager.gd +++ b/client/Archipelago/manager.gd
@@ -1,6 +1,6 @@
1extends Node 1extends Node
2 2
3const MOD_VERSION = 1 3const MOD_VERSION = 2
4 4
5var SCRIPT_client 5var SCRIPT_client
6var SCRIPT_keyboard 6var SCRIPT_keyboard
@@ -41,11 +41,13 @@ const kCYAN_DOOR_BEHAVIOR_H2 = 0
41const kCYAN_DOOR_BEHAVIOR_DOUBLE_LETTER = 1 41const kCYAN_DOOR_BEHAVIOR_DOUBLE_LETTER = 1
42const kCYAN_DOOR_BEHAVIOR_ITEM = 2 42const kCYAN_DOOR_BEHAVIOR_ITEM = 2
43 43
44var apworld_version = [0, 0]
44var cyan_door_behavior = kCYAN_DOOR_BEHAVIOR_H2 45var cyan_door_behavior = kCYAN_DOOR_BEHAVIOR_H2
45var daedalus_roof_access = false 46var daedalus_roof_access = false
46var keyholder_sanity = false 47var keyholder_sanity = false
47var shuffle_control_center_colors = false 48var shuffle_control_center_colors = false
48var shuffle_doors = false 49var shuffle_doors = false
50var shuffle_gallery_paintings = false
49var shuffle_letters = kSHUFFLE_LETTERS_VANILLA 51var shuffle_letters = kSHUFFLE_LETTERS_VANILLA
50var shuffle_symbols = false 52var shuffle_symbols = false
51var victory_condition = -1 53var victory_condition = -1
@@ -361,10 +363,14 @@ func _client_connected(slot_data):
361 keyholder_sanity = bool(slot_data.get("keyholder_sanity", false)) 363 keyholder_sanity = bool(slot_data.get("keyholder_sanity", false))
362 shuffle_control_center_colors = bool(slot_data.get("shuffle_control_center_colors", false)) 364 shuffle_control_center_colors = bool(slot_data.get("shuffle_control_center_colors", false))
363 shuffle_doors = bool(slot_data.get("shuffle_doors", false)) 365 shuffle_doors = bool(slot_data.get("shuffle_doors", false))
366 shuffle_gallery_paintings = bool(slot_data.get("shuffle_gallery_paintings", false))
364 shuffle_letters = int(slot_data.get("shuffle_letters", 0)) 367 shuffle_letters = int(slot_data.get("shuffle_letters", 0))
365 shuffle_symbols = bool(slot_data.get("shuffle_symbols", false)) 368 shuffle_symbols = bool(slot_data.get("shuffle_symbols", false))
366 victory_condition = int(slot_data.get("victory_condition", 0)) 369 victory_condition = int(slot_data.get("victory_condition", 0))
367 370
371 if slot_data.has("version"):
372 apworld_version = [int(slot_data["version"][0]), int(slot_data["version"][1])]
373
368 # Set up item locks. 374 # Set up item locks.
369 _item_locks = {} 375 _item_locks = {}
370 376
@@ -399,6 +405,11 @@ func _client_connected(slot_data):
399 for door in door_group.get_doors(): 405 for door in door_group.get_doors():
400 _item_locks[door] = [door_group.get_ap_id(), 1] 406 _item_locks[door] = [door_group.get_ap_id(), 1]
401 407
408 if shuffle_gallery_paintings:
409 for door in gamedata.objects.get_doors():
410 if door.get_type() == gamedata.SCRIPT_proto.DoorType.GALLERY_PAINTING:
411 _item_locks[door.get_id()] = [door.get_ap_id(), 1]
412
402 if cyan_door_behavior == kCYAN_DOOR_BEHAVIOR_ITEM: 413 if cyan_door_behavior == kCYAN_DOOR_BEHAVIOR_ITEM:
403 for door_group in gamedata.objects.get_door_groups(): 414 for door_group in gamedata.objects.get_door_groups():
404 if door_group.get_type() == gamedata.SCRIPT_proto.DoorGroupType.CYAN_DOORS: 415 if door_group.get_type() == gamedata.SCRIPT_proto.DoorGroupType.CYAN_DOORS:
diff --git a/client/Archipelago/settings_screen.gd b/client/Archipelago/settings_screen.gd index 81e7d3f..14975e5 100644 --- a/client/Archipelago/settings_screen.gd +++ b/client/Archipelago/settings_screen.gd
@@ -100,6 +100,10 @@ func _ready():
100 $Panel/player_box.add_theme_font_size_override("font_size", 36) 100 $Panel/player_box.add_theme_font_size_override("font_size", 36)
101 $Panel/password_box.add_theme_font_size_override("font_size", 36) 101 $Panel/password_box.add_theme_font_size_override("font_size", 36)
102 102
103 # Set up version mismatch dialog.
104 $Panel/VersionMismatch.connect("confirmed", startGame)
105 $Panel/VersionMismatch.get_cancel_button().pressed.connect(versionMismatchDeclined)
106
103 107
104# Adapted from https://gitlab.com/Delta-V-Modding/Mods/-/blob/main/game/ModLoader.gd 108# Adapted from https://gitlab.com/Delta-V-Modding/Mods/-/blob/main/game/ModLoader.gd
105func installScriptExtension(childScript: Resource): 109func installScriptExtension(childScript: Resource):
@@ -129,6 +133,33 @@ func connectionStatus(message):
129 133
130func connectionSuccessful(): 134func connectionSuccessful():
131 var ap = global.get_node("Archipelago") 135 var ap = global.get_node("Archipelago")
136 var gamedata = global.get_node("Gamedata")
137
138 # Check for major version mismatch.
139 if ap.apworld_version[0] != gamedata.objects.get_version():
140 $Panel/AcceptDialog.exclusive = false
141
142 var popup = self.get_node("Panel/VersionMismatch")
143 popup.title = "Version Mismatch!"
144 popup.dialog_text = (
145 "This slot was generated using v%d.%d of the Lingo 2 apworld,\nwhich has a different major version than this client (v%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."
146 % [
147 ap.apworld_version[0],
148 ap.apworld_version[1],
149 gamedata.objects.get_version(),
150 ap.MOD_VERSION
151 ]
152 )
153 popup.exclusive = true
154 popup.popup_centered()
155
156 return
157
158 startGame()
159
160
161func startGame():
162 var ap = global.get_node("Archipelago")
132 163
133 # Save connection details 164 # Save connection details
134 var connection_details = [ap.ap_server, ap.ap_user, ap.ap_pass] 165 var connection_details = [ap.ap_server, ap.ap_user, ap.ap_pass]
@@ -193,6 +224,10 @@ func connectionUnsuccessful(error_message):
193 popup.popup_centered() 224 popup.popup_centered()
194 225
195 226
227func versionMismatchDeclined():
228 $Panel/AcceptDialog.hide()
229
230
196func historySelected(index): 231func historySelected(index):
197 var ap = global.get_node("Archipelago") 232 var ap = global.get_node("Archipelago")
198 var details = ap.connection_history[index] 233 var details = ap.connection_history[index]
diff --git a/client/CHANGELOG.md b/client/CHANGELOG.md new file mode 100644 index 0000000..5818f2d --- /dev/null +++ b/client/CHANGELOG.md
@@ -0,0 +1,11 @@
1# lingo2-archipelago Client Releases
2
3## v3.2 - 2025-09-12
4
5- Initial release for testing. Features include door shuffle, letter shuffle,
6 and symbol shuffle.
7
8Download:
9[lingo2-archipelago-client-v3.2.zip](https://files.fourisland.com/releases/lingo2-archipelago/client/lingo2-archipelago-client-v3.2.zip)<br/>
10Source:
11[v3.2](https://code.fourisland.com/lingo2-archipelago/tag/?h=client-v3.2)
diff --git a/client/archipelago.tscn b/client/archipelago.tscn index 40dd46f..a74c69e 100644 --- a/client/archipelago.tscn +++ b/client/archipelago.tscn
@@ -150,6 +150,10 @@ caret_blink = true
150offset_right = 83.0 150offset_right = 83.0
151offset_bottom = 58.0 151offset_bottom = 58.0
152 152
153[node name="VersionMismatch" type="ConfirmationDialog" parent="Panel"]
154offset_right = 83.0
155offset_bottom = 58.0
156
153[node name="connection_history" type="MenuButton" parent="Panel"] 157[node name="connection_history" type="MenuButton" parent="Panel"]
154offset_left = 1239.0 158offset_left = 1239.0
155offset_top = 276.0 159offset_top = 276.0
diff --git a/data/maps/daedalus/doors.txtpb b/data/maps/daedalus/doors.txtpb index 27cf90e..ace15a1 100644 --- a/data/maps/daedalus/doors.txtpb +++ b/data/maps/daedalus/doors.txtpb
@@ -195,8 +195,8 @@ doors {
195} 195}
196doors { 196doors {
197 name: "Welcome Back Door" 197 name: "Welcome Back Door"
198 type: STANDARD 198 type: LOCATION_ONLY
199 receivers: "Components/Doors/Entry/entry_14" 199 #receivers: "Components/Doors/Entry/entry_14"
200 panels { room: "Welcome Back Area" name: "GREETINGS OLD FRIEND" } 200 panels { room: "Welcome Back Area" name: "GREETINGS OLD FRIEND" }
201 location_room: "Welcome Back Area" 201 location_room: "Welcome Back Area"
202} 202}
diff --git a/data/maps/the_gallery/doors.txtpb b/data/maps/the_gallery/doors.txtpb index a7a5d85..adbc766 100644 --- a/data/maps/the_gallery/doors.txtpb +++ b/data/maps/the_gallery/doors.txtpb
@@ -1,7 +1,7 @@
1# The Gallery is interesting because there's so many cross-map requirements. 1# The Gallery is interesting because there's so many cross-map requirements.
2doors { 2doors {
3 name: "Darkroom Painting" 3 name: "Darkroom Painting"
4 type: ITEM_ONLY 4 type: GALLERY_PAINTING
5 #move_paintings { room: "Main Area" name: "DARKROOM" } 5 #move_paintings { room: "Main Area" name: "DARKROOM" }
6 receivers: "Components/Paintings/darkroom/teleportListener" 6 receivers: "Components/Paintings/darkroom/teleportListener"
7 panels { map: "the_darkroom" room: "First Room" name: "BISON" } 7 panels { map: "the_darkroom" room: "First Room" name: "BISON" }
@@ -27,14 +27,14 @@ doors {
27} 27}
28doors { 28doors {
29 name: "Butterfly Painting" 29 name: "Butterfly Painting"
30 type: ITEM_ONLY 30 type: GALLERY_PAINTING
31 #move_paintings { room: "Main Area" name: "BUTTERFLY" } 31 #move_paintings { room: "Main Area" name: "BUTTERFLY" }
32 receivers: "Components/Paintings/butterfly/teleportListener" 32 receivers: "Components/Paintings/butterfly/teleportListener"
33 rooms { map: "the_butterfly" name: "Main Area" } 33 rooms { map: "the_butterfly" name: "Main Area" }
34} 34}
35doors { 35doors {
36 name: "Between Painting" 36 name: "Between Painting"
37 type: ITEM_ONLY 37 type: GALLERY_PAINTING
38 #move_paintings { room: "Main Area" name: "BETWEEN" } 38 #move_paintings { room: "Main Area" name: "BETWEEN" }
39 receivers: "Components/Paintings/between/teleportListener" 39 receivers: "Components/Paintings/between/teleportListener"
40 panels { map: "the_between" room: "Main Area" name: "SUN" } 40 panels { map: "the_between" room: "Main Area" name: "SUN" }
@@ -70,14 +70,14 @@ doors {
70} 70}
71doors { 71doors {
72 name: "Entry Painting" 72 name: "Entry Painting"
73 type: ITEM_ONLY 73 type: GALLERY_PAINTING
74 #move_paintings { room: "Main Area" name: "ENTRY" } 74 #move_paintings { room: "Main Area" name: "ENTRY" }
75 receivers: "Components/Paintings/eyes/teleportListener" 75 receivers: "Components/Paintings/eyes/teleportListener"
76 panels { map: "the_entry" room: "Eye Room" name: "I" } 76 panels { map: "the_entry" room: "Eye Room" name: "I" }
77} 77}
78doors { 78doors {
79 name: "Wise Painting" 79 name: "Wise Painting"
80 type: ITEM_ONLY 80 type: GALLERY_PAINTING
81 #move_paintings { room: "Main Area" name: "WISE" } 81 #move_paintings { room: "Main Area" name: "WISE" }
82 receivers: "Components/Paintings/triangle/teleportListener" 82 receivers: "Components/Paintings/triangle/teleportListener"
83 panels { map: "the_wise" room: "Entry" name: "INK" } 83 panels { map: "the_wise" room: "Entry" name: "INK" }
@@ -105,7 +105,7 @@ doors {
105} 105}
106doors { 106doors {
107 name: "Tree Painting" 107 name: "Tree Painting"
108 type: ITEM_ONLY 108 type: GALLERY_PAINTING
109 #move_paintings { room: "Main Area" name: "TREE" } 109 #move_paintings { room: "Main Area" name: "TREE" }
110 receivers: "Components/Paintings/Clue Maps/tree/teleportListener" 110 receivers: "Components/Paintings/Clue Maps/tree/teleportListener"
111 panels { map: "the_tree" room: "Main Area" name: "COLOR" } 111 panels { map: "the_tree" room: "Main Area" name: "COLOR" }
@@ -142,35 +142,35 @@ doors {
142} 142}
143doors { 143doors {
144 name: "Unyielding Painting" 144 name: "Unyielding Painting"
145 type: ITEM_ONLY 145 type: GALLERY_PAINTING
146 #move_paintings { room: "Main Area" name: "UNYIELDING" } 146 #move_paintings { room: "Main Area" name: "UNYIELDING" }
147 receivers: "Components/Paintings/Clue Maps/unyielding/teleportListener" 147 receivers: "Components/Paintings/Clue Maps/unyielding/teleportListener"
148 rooms { map: "the_unyielding" name: "Digital Entrance" } 148 rooms { map: "the_unyielding" name: "Digital Entrance" }
149} 149}
150doors { 150doors {
151 name: "Graveyard Painting" 151 name: "Graveyard Painting"
152 type: ITEM_ONLY 152 type: GALLERY_PAINTING
153 #move_paintings { room: "Main Area" name: "GRAVEYARD" } 153 #move_paintings { room: "Main Area" name: "GRAVEYARD" }
154 receivers: "Components/Paintings/Endings/grave/teleportListener" 154 receivers: "Components/Paintings/Endings/grave/teleportListener"
155 rooms { map: "the_graveyard" name: "Outside" } 155 rooms { map: "the_graveyard" name: "Outside" }
156} 156}
157doors { 157doors {
158 name: "Control Center Painting" 158 name: "Control Center Painting"
159 type: ITEM_ONLY 159 type: GALLERY_PAINTING
160 #move_paintings { room: "Main Area" name: "CC" } 160 #move_paintings { room: "Main Area" name: "CC" }
161 receivers: "Components/Paintings/Endings/desert/teleportListener" 161 receivers: "Components/Paintings/Endings/desert/teleportListener"
162 rooms { map: "the_impressive" name: "M2 Room" } 162 rooms { map: "the_impressive" name: "M2 Room" }
163} 163}
164doors { 164doors {
165 name: "Tower Painting" 165 name: "Tower Painting"
166 type: ITEM_ONLY 166 type: GALLERY_PAINTING
167 #move_paintings { room: "Main Area" name: "TOWER" } 167 #move_paintings { room: "Main Area" name: "TOWER" }
168 receivers: "Components/Paintings/Endings/red/teleportListener" 168 receivers: "Components/Paintings/Endings/red/teleportListener"
169 rooms { map: "the_tower" name: "First Floor" } 169 rooms { map: "the_tower" name: "First Floor" }
170} 170}
171doors { 171doors {
172 name: "Wondrous Painting" 172 name: "Wondrous Painting"
173 type: ITEM_ONLY 173 type: GALLERY_PAINTING
174 #move_paintings { room: "Main Area" name: "WONDROUS" } 174 #move_paintings { room: "Main Area" name: "WONDROUS" }
175 receivers: "Components/Paintings/Endings/window/teleportListener" 175 receivers: "Components/Paintings/Endings/window/teleportListener"
176 panels { map: "the_wondrous" room: "Entry" name: "WONDER" } 176 panels { map: "the_wondrous" room: "Entry" name: "WONDER" }
@@ -187,42 +187,42 @@ doors {
187} 187}
188doors { 188doors {
189 name: "Rainbow Painting" 189 name: "Rainbow Painting"
190 type: ITEM_ONLY 190 type: GALLERY_PAINTING
191 #move_paintings { room: "Main Area" name: "RAINBOW" } 191 #move_paintings { room: "Main Area" name: "RAINBOW" }
192 receivers: "Components/Paintings/Endings/rainbow/teleportListener" 192 receivers: "Components/Paintings/Endings/rainbow/teleportListener"
193 rooms { map: "daedalus" name: "Rainbow Start" } 193 rooms { map: "daedalus" name: "Rainbow Start" }
194} 194}
195doors { 195doors {
196 name: "Words Painting" 196 name: "Words Painting"
197 type: ITEM_ONLY 197 type: GALLERY_PAINTING
198 #move_paintings { room: "Main Area" name: "WORDS" } 198 #move_paintings { room: "Main Area" name: "WORDS" }
199 receivers: "Components/Paintings/Endings/words/teleportListener" 199 receivers: "Components/Paintings/Endings/words/teleportListener"
200 rooms { map: "the_words" name: "Main Area" } 200 rooms { map: "the_words" name: "Main Area" }
201} 201}
202doors { 202doors {
203 name: "Colorful Painting" 203 name: "Colorful Painting"
204 type: ITEM_ONLY 204 type: GALLERY_PAINTING
205 #move_paintings { room: "Main Area" name: "COLORFUL" } 205 #move_paintings { room: "Main Area" name: "COLORFUL" }
206 receivers: "Components/Paintings/Endings/colorful/teleportListener" 206 receivers: "Components/Paintings/Endings/colorful/teleportListener"
207 rooms { map: "the_colorful" name: "White Room" } 207 rooms { map: "the_colorful" name: "White Room" }
208} 208}
209doors { 209doors {
210 name: "Castle Painting" 210 name: "Castle Painting"
211 type: ITEM_ONLY 211 type: GALLERY_PAINTING
212 #move_paintings { room: "Main Area" name: "CASTLE" } 212 #move_paintings { room: "Main Area" name: "CASTLE" }
213 receivers: "Components/Paintings/Endings/castle/teleportListener" 213 receivers: "Components/Paintings/Endings/castle/teleportListener"
214 rooms { map: "daedalus" name: "Castle" } 214 rooms { map: "daedalus" name: "Castle" }
215} 215}
216doors { 216doors {
217 name: "Sun Temple Painting" 217 name: "Sun Temple Painting"
218 type: ITEM_ONLY 218 type: GALLERY_PAINTING
219 #move_paintings { room: "Main Area" name: "SUNTEMPLE" } 219 #move_paintings { room: "Main Area" name: "SUNTEMPLE" }
220 receivers: "Components/Paintings/Endings/temple/teleportListener" 220 receivers: "Components/Paintings/Endings/temple/teleportListener"
221 rooms { map: "the_sun_temple" name: "Entrance" } 221 rooms { map: "the_sun_temple" name: "Entrance" }
222} 222}
223doors { 223doors {
224 name: "Ancient Painting" 224 name: "Ancient Painting"
225 type: ITEM_ONLY 225 type: GALLERY_PAINTING
226 #move_paintings { room: "Main Area" name: "ANCIENT" } 226 #move_paintings { room: "Main Area" name: "ANCIENT" }
227 receivers: "Components/Paintings/Endings/cubes/teleportListener" 227 receivers: "Components/Paintings/Endings/cubes/teleportListener"
228 rooms { map: "the_ancient" name: "Outside" } 228 rooms { map: "the_ancient" name: "Outside" }
diff --git a/data/metadata.txtpb b/data/metadata.txtpb index 2f32d00..57255e6 100644 --- a/data/metadata.txtpb +++ b/data/metadata.txtpb
@@ -1,4 +1,4 @@
1version: 2 1version: 3
2# Filler item. 2# Filler item.
3special_names: "A Job Well Done" 3special_names: "A Job Well Done"
4# Symbol items. 4# Symbol items.
diff --git a/proto/data.proto b/proto/data.proto index 827e639..bf216b9 100644 --- a/proto/data.proto +++ b/proto/data.proto
@@ -27,6 +27,9 @@ enum DoorType {
27 27
28 // This door is an item if gravestone shuffle is enabled, and is a location as long as panelsanity is not on. 28 // This door is an item if gravestone shuffle is enabled, and is a location as long as panelsanity is not on.
29 GRAVESTONE = 6; 29 GRAVESTONE = 6;
30
31 // This door is never a location, and is an item as long as gallery painting shuffle is on.
32 GALLERY_PAINTING = 7;
30} 33}
31 34
32enum DoorGroupType { 35enum DoorGroupType {