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

@@ -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,