Add Grid Layout Shuffle Customizer options for custom arrangements, restricted positions and split large screens

This commit is contained in:
Catobat
2026-02-08 21:57:46 +01:00
parent f5d547cc5e
commit 78c389e2d5
3 changed files with 312 additions and 11 deletions

View File

@@ -257,10 +257,45 @@ someDescription:
`grid` contains additional options that only have an effect when `ow_layout` is set to `grid`.
#### fixed_arrangements
Use this to dictate the relative positioning between multiple screens (or quadrants of large screens). Screens and quadrants are addressed by their OW Slot ID (independently of their world), ranging from 0x00 to 0x3F. An `arrangement` is given as a list of rows with equal lenghts. If you do not want to specify a full rectangle of screens, you can use `.` as a placeholder to allow the generator to place any screen there. The `world` property can be set to `light`, `dark` or `both` (default value) and determines for which worlds the arrangement applies.
This example forces Death Mountain to stay connected the same as vanilla in both worlds:
```
fixed_arrangements:
- arrangement:
- 0x03 0x04 0x05 0x06 0x07
- 0x0B 0x0C 0x0D 0x0E .
world: both
```
#### restricted_positions
Use this to restrict cells to a specified set of possible positions. The `world` property can be set to `light`, `dark` or `both` (default value) and determines for which worlds the restriction applies.
This example forces the Sanctuary and Link's House screens in both worlds to get placed in corners of the grid:
```
restricted_positions:
- cells:
- 0x13
- 0x2C
positions:
- 0x00
- 0x07
- 0x38
- 0x3F
world: both
```
#### wrap_horizontal / wrap_vertical
Set these to `true` to allow for overworld edge transitions to wrap from one side of a world to the opposite side. With `wrap_horizontal`, there can be east transitions on the eastern edge of the world map that send the player to the western edge of the world. With `wrap_vertical`, there can be south transitions on the southern edge of the world map that send the player to the northern edge of the world.
#### split_large_screens
When set to `true`, the four quadrants of each large screen are placed on the grid independently of each other.
### ow-crossed
This must be defined by player. Each player number should be listed with the appropriate sections and each of these players MUST have `ow_crossed` enabled in the `settings` section in order for any values here to take effect. This section has four primary subsections: `force_crossed`, `force_noncrossed`, `limit_crossed`, and `undefined_chance`. There are also

View File

@@ -504,6 +504,7 @@ class CustomSettings(object):
self.world_rep['ow-whirlpools'] = whirlpools = {}
self.world_rep['ow-tileflips'] = flips = {}
self.world_rep['ow-flutespots'] = flute = {}
self.world_rep['ow-grid'] = owgrid = {}
for p in self.player_range:
connections = edges[p] = {}
connections['two-way'] = {}
@@ -524,6 +525,57 @@ class CustomSettings(object):
else:
flute[p]['force'] = list(HexInt(id) for id in sorted(default_flute_connections))
flute[p]['forbid'] = []
# layout grid
owgrid[p] = {}
grid = world.owgrid[p]
if grid is None:
grid = [
[[HexInt(row * 8 + col) for col in range(8)] for row in range(8)],
[[HexInt(row * 8 + col) for col in range(8)] for row in range(8)]
]
else:
grid = [
[[HexInt(cell & 0xBF) for cell in row] for row in grid[0]],
[[HexInt(cell & 0xBF) for cell in row] for row in grid[1]]
]
# Create fixed_arrangements for both worlds
owgrid[p]['fixed_arrangements'] = [
{
'arrangement': [' '.join(f'0x{cell:02X}' for cell in row) for row in grid[0]],
'world': 'light'
},
{
'arrangement': [' '.join(f'0x{cell:02X}' for cell in row) for row in grid[1]],
'world': 'dark'
}
]
# Pin top left corners to position 0x00
owgrid[p]['restricted_positions'] = [
{
'cells': [HexInt(grid[0][0][0])],
'positions': [HexInt(0x00)],
'world': 'light'
},
{
'cells': [HexInt(grid[1][0][0])],
'positions': [HexInt(0x00)],
'world': 'dark'
}
]
# Set advanced grid options
horizontal_wrap = False
vertical_wrap = False
split_large_screens = False
if world.customizer:
grid_options = world.customizer.get_owgrid()
if grid_options and p in grid_options:
grid_options = grid_options[p]
horizontal_wrap = 'wrap_horizontal' in grid_options and grid_options['wrap_horizontal'] == True
vertical_wrap = 'wrap_vertical' in grid_options and grid_options['wrap_vertical'] == True
split_large_screens = 'split_large_screens' in grid_options and grid_options['split_large_screens'] == True
owgrid[p]['wrap_horizontal'] = horizontal_wrap
owgrid[p]['wrap_vertical'] = vertical_wrap
owgrid[p]['split_large_screens'] = split_large_screens
for key, data in world.spoiler.overworlds.items():
player = data['player'] if 'player' in data else 1
connections = edges[player]
@@ -531,7 +583,7 @@ class CustomSettings(object):
connections[sub][data['entrance']] = data['exit']
for key, data in world.spoiler.whirlpools.items():
player = data['player'] if 'player' in data else 1
whirlconnects = whirlconnects[player]
whirlconnects = whirlpools[player]
sub = 'two-way' if data['direction'] == 'both' else 'one-way'
whirlconnects[sub][data['entrance']] = data['exit']

View File

@@ -148,8 +148,7 @@ class LayoutGeneratorOptions:
"""
Configuration options for layout generation.
"""
__slots__ = ('horizontal_wrap', 'vertical_wrap', 'split_large_screens',
'large_screen_pool', 'distortion_chance', 'random_order',
__slots__ = ('horizontal_wrap', 'vertical_wrap', 'split_large_screens', 'distortion_chance', 'random_order',
'multi_choice', 'max_delay', 'first_ignore_bonus_points',
'penalty_full_edge_mismatch', 'penalty_partial_edge_mismatch', 'bonus_partial_edge_match',
'bonus_full_edge_match', 'bonus_crossed_group_match', 'bonus_fill_parallel',
@@ -163,7 +162,6 @@ class LayoutGeneratorOptions:
horizontal_wrap: bool = True,
vertical_wrap: bool = True,
split_large_screens = False,
large_screen_pool: bool = False,
distortion_chance: float = 0.0,
random_order: int = 0,
multi_choice: int = 1,
@@ -190,7 +188,6 @@ class LayoutGeneratorOptions:
self.horizontal_wrap = horizontal_wrap
self.vertical_wrap = vertical_wrap
self.split_large_screens = split_large_screens
self.large_screen_pool = large_screen_pool
self.distortion_chance = distortion_chance
self.random_order = random_order
self.multi_choice = multi_choice
@@ -428,15 +425,14 @@ def create_piece_list(world: World, player: int, options: LayoutGeneratorOptions
# Create 4 pieces for large screen quadrants
for offset in [0x00, 0x01, 0x08, 0x09]:
piece = create_piece(world, player, [[screen.id + offset]], overworld_screens)
if options.large_screen_pool:
piece.restriction = [large_id + offset for large_id in [0x00, 0x03, 0x05, 0x18, 0x1B, 0x1E, 0x30, 0x35]]
piece_list.append(piece)
else:
piece = create_piece(world, player, [[screen.id]], overworld_screens)
if options.large_screen_pool:
piece.restriction = [s.id for s in overworld_screens.values() if not s.big]
piece_list.append(piece)
# Apply position restrictions from Customizer
piece_list = apply_position_restrictions(world, player, piece_list, overworld_screens)
# Phase 2: Apply options via merging
# Merge large screens if not split
@@ -449,6 +445,9 @@ def create_piece_list(world: World, player: int, options: LayoutGeneratorOptions
if world.mode[player] == 'standard':
piece_list = merge_pieces(piece_list, [[0x23, 0x24], [0x2B, 0x2C]], world, player, overworld_screens)
# Apply fixed arrangement restrictions from Customizer
piece_list = apply_arrangement_restrictions(world, player, piece_list, overworld_screens)
# Trim pieces by removing empty rows/columns on edges
piece_list = [trim_piece(piece) for piece in piece_list]
@@ -533,6 +532,211 @@ def create_piece(world: World, player: int, grid: List[List[int]], overworld_scr
return piece
def apply_position_restrictions(world: World, player: int, piece_list: List[Piece], overworld_screens: Dict[int, Screen]) -> List[Piece]:
"""
Apply position restrictions from Customizer to pieces at the end of phase 1.
Position restrictions specify that certain cells can only be placed at certain positions.
The Customizer format is:
restricted_positions:
- cells: [0x13, 0x2C]
positions: [0x00, 0x07, 0x38, 0x3F]
world: both # or 'light' or 'dark'
Note: At the end of phase 1, all pieces are 1x1 (one piece per cell).
The world bit (0x40) in user input is ignored. The actual cell ID is determined by:
- The world where the restriction applies (light=0, dark=1)
- The mixed_state of the screen containing that cell (swapped screens flip the world bit)
"""
if not world.customizer:
return piece_list
grid_options = world.customizer.get_owgrid()
if not grid_options or player not in grid_options:
return piece_list
grid_options = grid_options[player]
restricted_positions = grid_options.get('restricted_positions', [])
if not restricted_positions:
return piece_list
# Build a mapping from cell ID to piece for quick lookup
cell_to_piece = {piece.main.grid[0][0]: piece for piece in piece_list}
for restriction_idx, restriction in enumerate(restricted_positions):
cells = restriction.get('cells', [])
positions = restriction.get('positions', [])
restriction_world = restriction.get('world', 'both')
# Validate input
if not cells:
raise GenerationException(f"Invalid restriction in restricted_positions[{restriction_idx}]: No cells provided")
for cell_id in cells:
validate_cell_id(cell_id, f"restricted_positions[{restriction_idx}].cells")
if not positions:
raise GenerationException(f"Invalid restriction in restricted_positions[{restriction_idx}]: No positions provided")
for pos in positions:
validate_cell_id(pos, f"restricted_positions[{restriction_idx}].positions")
validate_world_value(restriction_world, f"restricted_positions[{restriction_idx}]")
position_set = set(positions)
for user_cell_id in cells:
# Ignore the world bit in user input
base_cell_id = user_cell_id & 0xBF
# Determine which worlds this restriction applies to
worlds_to_check = []
if restriction_world == 'light' or restriction_world == 'both':
worlds_to_check.append(0) # Light World
if restriction_world == 'dark' or restriction_world == 'both':
worlds_to_check.append(1) # Dark World
for target_world in worlds_to_check:
# Determine the actual cell ID based on the target world and mixed state
screen_id = get_screen_id_from_cell(base_cell_id)
screen = overworld_screens.get(screen_id)
is_swapped = screen.mixed_state == "swapped"
# Calculate the actual cell ID:
# - In Light World (target_world=0): use base_cell_id if normal, base_cell_id|0x40 if swapped
# - In Dark World (target_world=1): use base_cell_id|0x40 if normal, base_cell_id if swapped
if target_world == 0:
# Light World
actual_cell_id = (base_cell_id | 0x40) if is_swapped else base_cell_id
else:
# Dark World
actual_cell_id = base_cell_id if is_swapped else (base_cell_id | 0x40)
piece = cell_to_piece.get(actual_cell_id)
# Apply the position restriction
if piece.restriction is None:
piece.restriction = list(position_set)
else:
# Intersect with existing restrictions
piece.restriction = [p for p in piece.restriction if p in position_set]
return piece_list
def apply_arrangement_restrictions(world: World, player: int, piece_list: List[Piece], overworld_screens: Dict[int, Screen]) -> List[Piece]:
"""
Apply fixed arrangement restrictions from Customizer to pieces at the end of phase 2.
Fixed arrangements specify the relative positioning between multiple screens.
The Customizer format is:
fixed_arrangements:
- arrangement:
- 0x03 0x04 0x05 0x06 0x07
- 0x0B 0x0C 0x0D 0x0E .
world: both # or 'light' or 'dark'
The '.' character is a placeholder that allows any screen to be placed there.
The world bit (0x40) in user input is ignored. The actual cell ID is determined by:
- The world where the restriction applies (light=0, dark=1)
- The mixed_state of the screen containing that cell (swapped screens flip the world bit)
"""
if not world.customizer:
return piece_list
grid_options = world.customizer.get_owgrid()
if not grid_options or player not in grid_options:
return piece_list
grid_options = grid_options[player]
fixed_arrangements = grid_options.get('fixed_arrangements', [])
if not fixed_arrangements:
return piece_list
for arrangement_idx, arrangement_config in enumerate(fixed_arrangements):
arrangement_rows = arrangement_config.get('arrangement', [])
arrangement_world = arrangement_config.get('world', 'both')
# Validate world value
validate_world_value(arrangement_world, f"fixed_arrangements[{arrangement_idx}]")
if not arrangement_rows:
raise GenerationException(f"Invalid arrangement in fixed_arrangements[{arrangement_idx}]: No arrangement provided")
# Pre-validate the arrangement: check row lengths and entry validity
expected_row_length = None
for row_idx, row_str in enumerate(arrangement_rows):
parts = str(row_str).split()
if expected_row_length is None:
expected_row_length = len(parts)
elif len(parts) != expected_row_length:
raise GenerationException(f"Invalid arrangement in fixed_arrangements[{arrangement_idx}]: row {row_idx} has {len(parts)} entries but expected {expected_row_length} (all rows must have the same number of entries)")
# Validate each entry
for part_idx, part in enumerate(parts):
part = part.strip()
if part == '.':
continue
# Try to parse as cell ID
try:
if part.startswith('0x') or part.startswith('0X'):
cell_id = int(part, 16)
else:
cell_id = int(part)
validate_cell_id(cell_id, f"fixed_arrangements[{arrangement_idx}].arrangement[{row_idx}][{part_idx}]")
except ValueError:
raise GenerationException(f"Invalid entry '{part}' in fixed_arrangements[{arrangement_idx}].arrangement[{row_idx}][{part_idx}]: must be a cell ID (0x00-0x7F) or '.'")
# Determine which worlds this arrangement applies to
worlds_to_apply = []
if arrangement_world == 'light' or arrangement_world == 'both':
worlds_to_apply.append(0) # Light World
if arrangement_world == 'dark' or arrangement_world == 'both':
worlds_to_apply.append(1) # Dark World
for target_world in worlds_to_apply:
# Parse the arrangement into a 2D list of cell IDs, translating based on world and mixed state
# Each row is a string like "0x03 0x04 0x05 0x06 0x07" or contains '.' for wildcards
arrangement = []
for row_str in arrangement_rows:
row = []
# Split by whitespace
parts = str(row_str).split()
for part in parts:
part = part.strip()
if part == '.':
row.append(-1) # -1 represents wildcard
else:
# Parse as hex or decimal (already validated above)
if part.startswith('0x') or part.startswith('0X'):
user_cell_id = int(part, 16)
else:
user_cell_id = int(part)
# Ignore the world bit in user input
base_cell_id = user_cell_id & 0xBF
# Get the screen that contains this cell
screen_id = get_screen_id_from_cell(base_cell_id)
screen = overworld_screens.get(screen_id)
is_swapped = screen.mixed_state == "swapped"
# Calculate the actual cell ID:
# - In Light World (target_world=0): use base_cell_id if normal, base_cell_id|0x40 if swapped
# - In Dark World (target_world=1): use base_cell_id|0x40 if normal, base_cell_id if swapped
if target_world == 0:
# Light World
actual_cell_id = (base_cell_id | 0x40) if is_swapped else base_cell_id
else:
# Dark World
actual_cell_id = base_cell_id if is_swapped else (base_cell_id | 0x40)
row.append(actual_cell_id)
if row:
arrangement.append(row)
# Merge the pieces according to the arrangement
piece_list = merge_pieces(piece_list, arrangement, world, player, overworld_screens)
return piece_list
def get_piece_cells(piece: Piece) -> Set[int]:
"""Get all cell IDs contained in a piece."""
cells = set()
@@ -542,6 +746,15 @@ def get_piece_cells(piece: Piece) -> Set[int]:
cells.add(cell)
return cells
def validate_cell_id(cell_id: int, context: str) -> None:
if not isinstance(cell_id, int) or cell_id < 0x00 or cell_id > 0x7F:
raise GenerationException(f"Invalid cell ID 0x{cell_id:02X} in {context}: must be in range 0x00-0x7F")
def validate_world_value(world_value: str, context: str) -> None:
allowed_values = {'light', 'dark', 'both'}
if world_value not in allowed_values:
raise GenerationException(f"Invalid world value '{world_value}' in {context}: must be one of {allowed_values}")
def trim_piece(piece: Piece) -> Piece:
"""
Trim a piece by removing any full rows or columns on the edges that only consist of -1.
@@ -1828,12 +2041,14 @@ def generate_random_grid_layout(world: World, player: int, connected_edges: List
horizontal_wrap = False
vertical_wrap = False
split_large_screens = False
if world.customizer:
grid_options = world.customizer.get_owgrid()
if grid_options and player in grid_options:
grid_options = grid_options[player]
horizontal_wrap = 'wrap_horizontal' in grid_options and grid_options['wrap_horizontal'] == True
vertical_wrap = 'wrap_vertical' in grid_options and grid_options['wrap_vertical'] == True
split_large_screens = 'split_large_screens' in grid_options and grid_options['split_large_screens'] == True
first_ignore_bonus = 2
if not world.owParallel[player]:
@@ -1843,8 +2058,7 @@ def generate_random_grid_layout(world: World, player: int, connected_edges: List
options = LayoutGeneratorOptions(
horizontal_wrap=horizontal_wrap,
vertical_wrap=vertical_wrap,
split_large_screens=False,
large_screen_pool=False,
split_large_screens=split_large_screens,
distortion_chance=0.0,
random_order=6 if world.owParallel[player] else 12,
multi_choice=1,