trianglengin 1.0.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tests/__init__.py +0 -0
- tests/conftest.py +108 -0
- tests/core/__init__.py +2 -0
- tests/core/environment/README.md +47 -0
- tests/core/environment/__init__.py +2 -0
- tests/core/environment/test_action_codec.py +50 -0
- tests/core/environment/test_game_state.py +483 -0
- tests/core/environment/test_grid_data.py +205 -0
- tests/core/environment/test_grid_logic.py +362 -0
- tests/core/environment/test_shape_logic.py +171 -0
- tests/core/environment/test_step.py +372 -0
- tests/core/structs/__init__.py +0 -0
- tests/core/structs/test_shape.py +83 -0
- tests/core/structs/test_triangle.py +97 -0
- tests/utils/__init__.py +0 -0
- tests/utils/test_geometry.py +93 -0
- trianglengin/__init__.py +18 -0
- trianglengin/app.py +110 -0
- trianglengin/cli.py +134 -0
- trianglengin/config/__init__.py +9 -0
- trianglengin/config/display_config.py +47 -0
- trianglengin/config/env_config.py +103 -0
- trianglengin/core/__init__.py +8 -0
- trianglengin/core/environment/__init__.py +31 -0
- trianglengin/core/environment/action_codec.py +37 -0
- trianglengin/core/environment/game_state.py +217 -0
- trianglengin/core/environment/grid/README.md +46 -0
- trianglengin/core/environment/grid/__init__.py +18 -0
- trianglengin/core/environment/grid/grid_data.py +140 -0
- trianglengin/core/environment/grid/line_cache.py +189 -0
- trianglengin/core/environment/grid/logic.py +131 -0
- trianglengin/core/environment/logic/__init__.py +3 -0
- trianglengin/core/environment/logic/actions.py +38 -0
- trianglengin/core/environment/logic/step.py +134 -0
- trianglengin/core/environment/shapes/__init__.py +19 -0
- trianglengin/core/environment/shapes/logic.py +84 -0
- trianglengin/core/environment/shapes/templates.py +587 -0
- trianglengin/core/structs/__init__.py +27 -0
- trianglengin/core/structs/constants.py +28 -0
- trianglengin/core/structs/shape.py +61 -0
- trianglengin/core/structs/triangle.py +48 -0
- trianglengin/interaction/README.md +45 -0
- trianglengin/interaction/__init__.py +17 -0
- trianglengin/interaction/debug_mode_handler.py +96 -0
- trianglengin/interaction/event_processor.py +43 -0
- trianglengin/interaction/input_handler.py +82 -0
- trianglengin/interaction/play_mode_handler.py +141 -0
- trianglengin/utils/__init__.py +9 -0
- trianglengin/utils/geometry.py +73 -0
- trianglengin/utils/types.py +10 -0
- trianglengin/visualization/README.md +44 -0
- trianglengin/visualization/__init__.py +61 -0
- trianglengin/visualization/core/README.md +52 -0
- trianglengin/visualization/core/__init__.py +12 -0
- trianglengin/visualization/core/colors.py +117 -0
- trianglengin/visualization/core/coord_mapper.py +73 -0
- trianglengin/visualization/core/fonts.py +55 -0
- trianglengin/visualization/core/layout.py +101 -0
- trianglengin/visualization/core/visualizer.py +232 -0
- trianglengin/visualization/drawing/README.md +45 -0
- trianglengin/visualization/drawing/__init__.py +30 -0
- trianglengin/visualization/drawing/grid.py +156 -0
- trianglengin/visualization/drawing/highlight.py +30 -0
- trianglengin/visualization/drawing/hud.py +39 -0
- trianglengin/visualization/drawing/previews.py +172 -0
- trianglengin/visualization/drawing/shapes.py +36 -0
- trianglengin-1.0.6.dist-info/METADATA +367 -0
- trianglengin-1.0.6.dist-info/RECORD +72 -0
- trianglengin-1.0.6.dist-info/WHEEL +5 -0
- trianglengin-1.0.6.dist-info/entry_points.txt +2 -0
- trianglengin-1.0.6.dist-info/licenses/LICENSE +22 -0
- trianglengin-1.0.6.dist-info/top_level.txt +2 -0
@@ -0,0 +1,131 @@
|
|
1
|
+
# File: trianglengin/core/environment/grid/logic.py
|
2
|
+
import logging
|
3
|
+
from typing import TYPE_CHECKING, Final
|
4
|
+
|
5
|
+
from trianglengin.core.structs.constants import NO_COLOR_ID
|
6
|
+
|
7
|
+
if TYPE_CHECKING:
|
8
|
+
from trianglengin.core.environment.grid.grid_data import GridData
|
9
|
+
from trianglengin.core.structs.shape import Shape
|
10
|
+
|
11
|
+
|
12
|
+
log = logging.getLogger(__name__)
|
13
|
+
|
14
|
+
# Minimum length a line must have to be eligible for clearing
|
15
|
+
MIN_LINE_LENGTH_TO_CLEAR: Final = 2
|
16
|
+
|
17
|
+
|
18
|
+
def can_place(grid_data: "GridData", shape: "Shape", r: int, c: int) -> bool:
|
19
|
+
"""
|
20
|
+
Checks if a shape can be placed at the specified (r, c) top-left position.
|
21
|
+
|
22
|
+
Args:
|
23
|
+
grid_data: The current grid state.
|
24
|
+
shape: The shape to place.
|
25
|
+
r: Target row for the shape's origin (relative 0,0).
|
26
|
+
c: Target column for the shape's origin (relative 0,0).
|
27
|
+
|
28
|
+
Returns:
|
29
|
+
True if the placement is valid, False otherwise.
|
30
|
+
"""
|
31
|
+
if not shape or not shape.triangles:
|
32
|
+
log.warning("Attempted to check placement for an empty or invalid shape.")
|
33
|
+
return False
|
34
|
+
|
35
|
+
for dr, dc, is_up_shape in shape.triangles:
|
36
|
+
place_r, place_c = r + dr, c + dc
|
37
|
+
|
38
|
+
# 1. Check bounds
|
39
|
+
if not grid_data.valid(place_r, place_c):
|
40
|
+
return False
|
41
|
+
|
42
|
+
# 2. Check death zone
|
43
|
+
if grid_data.is_death(place_r, place_c):
|
44
|
+
return False
|
45
|
+
|
46
|
+
# 3. Check occupancy
|
47
|
+
if grid_data.is_occupied(place_r, place_c):
|
48
|
+
return False
|
49
|
+
|
50
|
+
# 4. Check orientation match
|
51
|
+
is_up_grid = (place_r + place_c) % 2 != 0
|
52
|
+
if is_up_shape != is_up_grid:
|
53
|
+
return False
|
54
|
+
|
55
|
+
return True
|
56
|
+
|
57
|
+
|
58
|
+
def check_and_clear_lines(
|
59
|
+
grid_data: "GridData", newly_occupied_coords: set[tuple[int, int]]
|
60
|
+
) -> tuple[int, set[tuple[int, int]], set[frozenset[tuple[int, int]]]]:
|
61
|
+
"""
|
62
|
+
Checks for completed lines involving the newly occupied coordinates and clears them.
|
63
|
+
|
64
|
+
Uses the precomputed coordinate-to-lines map for efficiency. Only checks
|
65
|
+
maximal lines that contain at least one of the newly occupied cells.
|
66
|
+
|
67
|
+
Args:
|
68
|
+
grid_data: The grid data object (will be modified if lines are cleared).
|
69
|
+
newly_occupied_coords: A set of (r, c) tuples that were just filled.
|
70
|
+
|
71
|
+
Returns:
|
72
|
+
A tuple containing:
|
73
|
+
- lines_cleared_count (int): The number of maximal lines cleared.
|
74
|
+
- unique_coords_cleared_set (set[tuple[int, int]]): A set of unique (r, c)
|
75
|
+
coordinates of all triangles that were cleared.
|
76
|
+
- set_of_cleared_lines_coord_sets (set[frozenset[tuple[int, int]]]): A set
|
77
|
+
containing the frozensets of coordinates for each cleared maximal line.
|
78
|
+
"""
|
79
|
+
if not newly_occupied_coords:
|
80
|
+
return 0, set(), set()
|
81
|
+
|
82
|
+
# Find all candidate maximal lines that include any of the new coordinates
|
83
|
+
candidate_lines_fs: set[frozenset[tuple[int, int]]] = set()
|
84
|
+
for r_new, c_new in newly_occupied_coords:
|
85
|
+
# Check if the coordinate exists in the precomputed map
|
86
|
+
if (r_new, c_new) in grid_data._coord_to_lines_map:
|
87
|
+
candidate_lines_fs.update(grid_data._coord_to_lines_map[(r_new, c_new)])
|
88
|
+
|
89
|
+
if not candidate_lines_fs:
|
90
|
+
return 0, set(), set()
|
91
|
+
|
92
|
+
cleared_lines_fs: set[frozenset[tuple[int, int]]] = set()
|
93
|
+
unique_coords_cleared: set[tuple[int, int]] = set()
|
94
|
+
|
95
|
+
# Check each candidate line for completion
|
96
|
+
for line_fs in candidate_lines_fs:
|
97
|
+
line_coords = tuple(line_fs) # Convert back for iteration/indexing if needed
|
98
|
+
|
99
|
+
# --- Added Check: Ensure line has minimum length ---
|
100
|
+
if len(line_coords) < MIN_LINE_LENGTH_TO_CLEAR:
|
101
|
+
continue
|
102
|
+
# --- End Added Check ---
|
103
|
+
|
104
|
+
# Check if ALL coordinates in this line are now occupied
|
105
|
+
is_line_complete = True
|
106
|
+
for r_line, c_line in line_coords:
|
107
|
+
# Use is_occupied which correctly handles death zones
|
108
|
+
if not grid_data.is_occupied(r_line, c_line):
|
109
|
+
is_line_complete = False
|
110
|
+
break # No need to check further coords in this line
|
111
|
+
|
112
|
+
if is_line_complete:
|
113
|
+
cleared_lines_fs.add(line_fs)
|
114
|
+
unique_coords_cleared.update(line_coords)
|
115
|
+
|
116
|
+
# If any lines were completed, clear the cells
|
117
|
+
if unique_coords_cleared:
|
118
|
+
log.debug(
|
119
|
+
f"Clearing {len(cleared_lines_fs)} lines involving {len(unique_coords_cleared)} unique cells."
|
120
|
+
)
|
121
|
+
for r_clear, c_clear in unique_coords_cleared:
|
122
|
+
# Check bounds just in case, though precomputed lines should be valid
|
123
|
+
if grid_data.valid(r_clear, c_clear):
|
124
|
+
grid_data._occupied_np[r_clear, c_clear] = False
|
125
|
+
grid_data._color_id_np[r_clear, c_clear] = NO_COLOR_ID
|
126
|
+
else:
|
127
|
+
log.warning(
|
128
|
+
f"Attempted to clear out-of-bounds coordinate ({r_clear}, {c_clear}) from line."
|
129
|
+
)
|
130
|
+
|
131
|
+
return len(cleared_lines_fs), unique_coords_cleared, cleared_lines_fs
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# trianglengin/core/environment/logic/actions.py
|
2
|
+
"""
|
3
|
+
Logic for determining valid actions in the game state.
|
4
|
+
"""
|
5
|
+
|
6
|
+
import logging
|
7
|
+
from typing import TYPE_CHECKING # Changed List to Set
|
8
|
+
|
9
|
+
from trianglengin.core.environment.action_codec import ActionType, encode_action
|
10
|
+
from trianglengin.core.environment.grid import logic as GridLogic
|
11
|
+
|
12
|
+
if TYPE_CHECKING:
|
13
|
+
from trianglengin.core.environment.game_state import GameState
|
14
|
+
|
15
|
+
|
16
|
+
logger = logging.getLogger(__name__)
|
17
|
+
|
18
|
+
|
19
|
+
def get_valid_actions(state: "GameState") -> set[ActionType]: # Return Set
|
20
|
+
"""
|
21
|
+
Calculates and returns a set of all valid encoded action indices
|
22
|
+
for the current game state.
|
23
|
+
"""
|
24
|
+
valid_actions: set[ActionType] = set() # Use set directly
|
25
|
+
for shape_idx, shape in enumerate(state.shapes):
|
26
|
+
if shape is None:
|
27
|
+
continue
|
28
|
+
|
29
|
+
# Iterate through potential placement locations (r, c)
|
30
|
+
# Optimization: Could potentially limit r, c range based on shape bbox
|
31
|
+
for r in range(state.env_config.ROWS):
|
32
|
+
for c in range(state.env_config.COLS):
|
33
|
+
# Check if placement is valid using GridLogic
|
34
|
+
if GridLogic.can_place(state.grid_data, shape, r, c):
|
35
|
+
action_index = encode_action(shape_idx, r, c, state.env_config)
|
36
|
+
valid_actions.add(action_index) # Add to set
|
37
|
+
|
38
|
+
return valid_actions
|
@@ -0,0 +1,134 @@
|
|
1
|
+
# trianglengin/core/environment/logic/step.py
|
2
|
+
import logging
|
3
|
+
from typing import TYPE_CHECKING
|
4
|
+
|
5
|
+
# Import shapes module itself for ShapeLogic reference if needed later,
|
6
|
+
# though direct calls are used here.
|
7
|
+
from trianglengin.core.environment.grid import logic as GridLogic
|
8
|
+
from trianglengin.core.structs.constants import COLOR_TO_ID_MAP, NO_COLOR_ID
|
9
|
+
|
10
|
+
if TYPE_CHECKING:
|
11
|
+
# Keep EnvConfig import for reward calculation
|
12
|
+
from trianglengin.config import EnvConfig
|
13
|
+
from trianglengin.core.environment.game_state import GameState
|
14
|
+
from trianglengin.core.environment.grid.line_cache import Coord
|
15
|
+
|
16
|
+
|
17
|
+
logger = logging.getLogger(__name__)
|
18
|
+
|
19
|
+
|
20
|
+
def calculate_reward(
|
21
|
+
placed_count: int,
|
22
|
+
cleared_count: int,
|
23
|
+
is_game_over: bool,
|
24
|
+
config: "EnvConfig",
|
25
|
+
) -> float:
|
26
|
+
"""
|
27
|
+
Calculates the step reward based on the new specification (v3).
|
28
|
+
|
29
|
+
Args:
|
30
|
+
placed_count: Number of triangles successfully placed.
|
31
|
+
cleared_count: Number of unique triangles cleared this step.
|
32
|
+
is_game_over: Boolean indicating if the game ended *after* this step.
|
33
|
+
config: Environment configuration containing reward constants.
|
34
|
+
|
35
|
+
Returns:
|
36
|
+
The calculated step reward.
|
37
|
+
"""
|
38
|
+
reward = 0.0
|
39
|
+
|
40
|
+
# 1. Placement Reward
|
41
|
+
reward += placed_count * config.REWARD_PER_PLACED_TRIANGLE
|
42
|
+
|
43
|
+
# 2. Line Clear Reward
|
44
|
+
reward += cleared_count * config.REWARD_PER_CLEARED_TRIANGLE
|
45
|
+
|
46
|
+
# 3. Survival Reward OR Game Over Penalty
|
47
|
+
if is_game_over:
|
48
|
+
# Apply penalty only if game ended THIS step
|
49
|
+
reward += config.PENALTY_GAME_OVER
|
50
|
+
else:
|
51
|
+
reward += config.REWARD_PER_STEP_ALIVE
|
52
|
+
|
53
|
+
logger.debug(
|
54
|
+
f"Calculated Reward: Placement({placed_count * config.REWARD_PER_PLACED_TRIANGLE:.3f}) "
|
55
|
+
f"+ LineClear({cleared_count * config.REWARD_PER_CLEARED_TRIANGLE:.3f}) "
|
56
|
+
f"+ {'GameOver' if is_game_over else 'Survival'}({config.PENALTY_GAME_OVER if is_game_over else config.REWARD_PER_STEP_ALIVE:.3f}) "
|
57
|
+
f"= {reward:.3f}"
|
58
|
+
)
|
59
|
+
return reward
|
60
|
+
|
61
|
+
|
62
|
+
def execute_placement(
|
63
|
+
game_state: "GameState", shape_idx: int, r: int, c: int
|
64
|
+
) -> tuple[int, int]:
|
65
|
+
"""
|
66
|
+
Places a shape, clears lines, and updates grid/score state.
|
67
|
+
Refill logic and reward calculation are handled by the caller (GameState.step).
|
68
|
+
|
69
|
+
Args:
|
70
|
+
game_state: The current game state (will be modified).
|
71
|
+
shape_idx: Index of the shape to place.
|
72
|
+
r: Target row for placement.
|
73
|
+
c: Target column for placement.
|
74
|
+
|
75
|
+
Returns:
|
76
|
+
Tuple[int, int]: (cleared_triangle_count, placed_triangle_count)
|
77
|
+
Stats needed for reward calculation.
|
78
|
+
Raises:
|
79
|
+
ValueError: If placement is invalid (should be pre-checked).
|
80
|
+
IndexError: If shape_idx is invalid.
|
81
|
+
"""
|
82
|
+
if not (0 <= shape_idx < len(game_state.shapes)):
|
83
|
+
raise IndexError(f"Invalid shape index: {shape_idx}")
|
84
|
+
|
85
|
+
shape = game_state.shapes[shape_idx]
|
86
|
+
if not shape:
|
87
|
+
# This case should ideally be prevented by GameState.step checking valid_actions first.
|
88
|
+
logger.error(f"Attempted to place an empty shape slot: {shape_idx}")
|
89
|
+
raise ValueError("Cannot place an empty shape slot.")
|
90
|
+
|
91
|
+
# Check placement validity using GridLogic - raise error if invalid
|
92
|
+
if not GridLogic.can_place(game_state.grid_data, shape, r, c):
|
93
|
+
# This case should ideally be prevented by GameState.step checking valid_actions first.
|
94
|
+
logger.error(
|
95
|
+
f"Invalid placement attempted in execute_placement: Shape {shape_idx} at ({r},{c}). "
|
96
|
+
"This should have been caught earlier."
|
97
|
+
)
|
98
|
+
raise ValueError("Invalid placement location.")
|
99
|
+
|
100
|
+
# --- Place the shape ---
|
101
|
+
placed_coords: set[Coord] = set()
|
102
|
+
placed_count = 0
|
103
|
+
color_id = COLOR_TO_ID_MAP.get(shape.color, NO_COLOR_ID)
|
104
|
+
if color_id == NO_COLOR_ID:
|
105
|
+
# Use default color ID 0 if the test color isn't found
|
106
|
+
logger.warning(
|
107
|
+
f"Shape color {shape.color} not found in COLOR_TO_ID_MAP! Using ID 0."
|
108
|
+
)
|
109
|
+
color_id = 0
|
110
|
+
|
111
|
+
for dr, dc, _ in shape.triangles:
|
112
|
+
tri_r, tri_c = r + dr, c + dc
|
113
|
+
# Assume valid coordinates as can_place passed and raised error otherwise
|
114
|
+
game_state.grid_data._occupied_np[tri_r, tri_c] = True
|
115
|
+
game_state.grid_data._color_id_np[tri_r, tri_c] = color_id
|
116
|
+
placed_coords.add((tri_r, tri_c))
|
117
|
+
placed_count += 1
|
118
|
+
|
119
|
+
game_state.shapes[shape_idx] = None # Remove shape from slot
|
120
|
+
|
121
|
+
# --- Check and clear lines ---
|
122
|
+
lines_cleared_count, unique_coords_cleared, _ = GridLogic.check_and_clear_lines(
|
123
|
+
game_state.grid_data, placed_coords
|
124
|
+
)
|
125
|
+
cleared_count = len(unique_coords_cleared)
|
126
|
+
|
127
|
+
# --- Update Score ---
|
128
|
+
game_state._game_score += placed_count + cleared_count * 2
|
129
|
+
|
130
|
+
# --- REMOVED REFILL LOGIC ---
|
131
|
+
# --- REMOVED REWARD CALCULATION ---
|
132
|
+
|
133
|
+
# Return stats needed by GameState.step for reward calculation
|
134
|
+
return cleared_count, placed_count
|
@@ -0,0 +1,19 @@
|
|
1
|
+
"""
|
2
|
+
Shapes submodule handling shape generation and management.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from .logic import (
|
6
|
+
generate_random_shape,
|
7
|
+
get_neighbors,
|
8
|
+
is_shape_connected,
|
9
|
+
refill_shape_slots,
|
10
|
+
)
|
11
|
+
from .templates import PREDEFINED_SHAPE_TEMPLATES
|
12
|
+
|
13
|
+
__all__ = [
|
14
|
+
"generate_random_shape",
|
15
|
+
"refill_shape_slots",
|
16
|
+
"is_shape_connected",
|
17
|
+
"get_neighbors",
|
18
|
+
"PREDEFINED_SHAPE_TEMPLATES",
|
19
|
+
]
|
@@ -0,0 +1,84 @@
|
|
1
|
+
# File: trianglengin/trianglengin/core/environment/shapes/logic.py
|
2
|
+
import logging
|
3
|
+
import random
|
4
|
+
from typing import TYPE_CHECKING
|
5
|
+
|
6
|
+
from ...structs import SHAPE_COLORS, Shape # Import from library's structs
|
7
|
+
from .templates import PREDEFINED_SHAPE_TEMPLATES
|
8
|
+
|
9
|
+
if TYPE_CHECKING:
|
10
|
+
# Use relative import for GameState within the library
|
11
|
+
from ..game_state import GameState
|
12
|
+
|
13
|
+
logger = logging.getLogger(__name__)
|
14
|
+
|
15
|
+
|
16
|
+
def generate_random_shape(rng: random.Random) -> Shape:
|
17
|
+
"""Generates a random shape from predefined templates and colors."""
|
18
|
+
template = rng.choice(PREDEFINED_SHAPE_TEMPLATES)
|
19
|
+
color = rng.choice(SHAPE_COLORS)
|
20
|
+
return Shape(template, color)
|
21
|
+
|
22
|
+
|
23
|
+
def refill_shape_slots(game_state: "GameState", rng: random.Random) -> None:
|
24
|
+
"""
|
25
|
+
Refills ALL empty shape slots in the GameState, but ONLY if ALL slots are currently empty.
|
26
|
+
This implements batch refilling.
|
27
|
+
"""
|
28
|
+
if all(shape is None for shape in game_state.shapes):
|
29
|
+
logger.debug("All shape slots are empty. Refilling all slots.")
|
30
|
+
for i in range(game_state.env_config.NUM_SHAPE_SLOTS):
|
31
|
+
game_state.shapes[i] = generate_random_shape(rng)
|
32
|
+
logger.debug(f"Refilled slot {i} with {game_state.shapes[i]}")
|
33
|
+
else:
|
34
|
+
logger.debug("Not all shape slots are empty. Skipping refill.")
|
35
|
+
|
36
|
+
|
37
|
+
def get_neighbors(r: int, c: int, is_up: bool) -> list[tuple[int, int]]:
|
38
|
+
"""Gets potential neighbor coordinates for connectivity check."""
|
39
|
+
if is_up:
|
40
|
+
# Up triangle neighbors: (r, c-1), (r, c+1), (r+1, c)
|
41
|
+
return [(r, c - 1), (r, c + 1), (r + 1, c)]
|
42
|
+
else:
|
43
|
+
# Down triangle neighbors: (r, c-1), (r, c+1), (r-1, c)
|
44
|
+
return [(r, c - 1), (r, c + 1), (r - 1, c)]
|
45
|
+
|
46
|
+
|
47
|
+
def is_shape_connected(shape: Shape) -> bool:
|
48
|
+
"""Checks if all triangles in a shape are connected using BFS."""
|
49
|
+
if not shape.triangles or len(shape.triangles) <= 1:
|
50
|
+
return True
|
51
|
+
|
52
|
+
# --- CORRECTED BFS LOGIC V2 ---
|
53
|
+
# Store the actual triangle tuples (r, c, is_up) in a set for quick lookup
|
54
|
+
all_triangles_set = set(shape.triangles)
|
55
|
+
# Also store just the coordinates for quick neighbor checking
|
56
|
+
all_coords_set = {(r, c) for r, c, _ in shape.triangles}
|
57
|
+
|
58
|
+
start_triangle = shape.triangles[0] # (r, c, is_up)
|
59
|
+
|
60
|
+
visited: set[tuple[int, int, bool]] = set()
|
61
|
+
queue: list[tuple[int, int, bool]] = [start_triangle]
|
62
|
+
visited.add(start_triangle)
|
63
|
+
|
64
|
+
while queue:
|
65
|
+
current_r, current_c, current_is_up = queue.pop(0)
|
66
|
+
|
67
|
+
# Check neighbors based on the current triangle's orientation
|
68
|
+
for nr, nc in get_neighbors(current_r, current_c, current_is_up):
|
69
|
+
# Check if the neighbor *coordinate* exists in the shape
|
70
|
+
if (nr, nc) in all_coords_set:
|
71
|
+
# Find the full neighbor triangle tuple (r, c, is_up)
|
72
|
+
neighbor_triangle: tuple[int, int, bool] | None = None
|
73
|
+
for tri_tuple in all_triangles_set:
|
74
|
+
if tri_tuple[0] == nr and tri_tuple[1] == nc:
|
75
|
+
neighbor_triangle = tri_tuple
|
76
|
+
break
|
77
|
+
|
78
|
+
# If the neighbor exists in the shape and hasn't been visited
|
79
|
+
if neighbor_triangle and neighbor_triangle not in visited:
|
80
|
+
visited.add(neighbor_triangle)
|
81
|
+
queue.append(neighbor_triangle)
|
82
|
+
# --- END CORRECTED BFS LOGIC V2 ---
|
83
|
+
|
84
|
+
return len(visited) == len(all_triangles_set)
|