Add comprehensive test suite for game mechanics and level handling

- Introduced `test_final_level_flow.py` to validate final level transitions and game end scenarios.
- Created `test_game_over_flow.py` to ensure game over conditions trigger correctly based on rat counts.
- Implemented `test_keybindings.py` to verify keybinding configurations and their context-specific actions.
- Developed `test_level_editor.py` to assess level editor functionalities and layout computations.
- Added `test_level_io.py` for testing level data serialization and deserialization.
- Established `test_loop_logic_parity.py` to ensure consistent game state across multiple simulation runs.
- Created `test_non_regression.py` to simulate game behavior and capture states for future verification.
- Implemented `test_verify.py` to compare current game states against a golden master for regression detection.
This commit is contained in:
2026-05-19 22:18:43 +02:00
parent 486cd6b7c5
commit c7ed24483d
53 changed files with 10169 additions and 3886 deletions
+98 -328
View File
@@ -1,27 +1,14 @@
"""
Optimized collision detection system using NumPy for vectorized operations.
Native Python collision detection system using Spatial Hashing.
This module provides efficient collision detection for games with many entities (200+).
Uses AABB (Axis-Aligned Bounding Box) collision detection with numpy vectorization.
HYBRID APPROACH:
- For < 50 units: Uses simple dictionary-based approach (low overhead)
- For >= 50 units: Uses NumPy vectorization (scales better)
Performance improvements:
- O(n²) → O(n) for spatial queries using grid-based hashing
- Vectorized AABB checks for large unit counts
- Minimal overhead for small unit counts
This module provides efficient collision detection without NumPy.
It uses a grid-based approach (buckets) to ensure O(1) or O(n) complexity.
This structure is designed to be easily portable to Nim.
"""
import numpy as np
from typing import Dict, List, Tuple, Set
from dataclasses import dataclass
# Threshold for switching to NumPy mode
NUMPY_THRESHOLD = 50
@dataclass
class CollisionLayer:
"""Define which types of units can collide with each other."""
@@ -35,16 +22,7 @@ class CollisionLayer:
class CollisionSystem:
"""
Manages collision detection for all game units using NumPy vectorization.
Attributes
----------
cell_size : int
Size of each grid cell in pixels
grid_width : int
Number of cells in grid width
grid_height : int
Number of cells in grid height
Manages collision detection using a Spatial Grid.
"""
def __init__(self, cell_size: int, grid_width: int, grid_height: int):
@@ -52,350 +30,142 @@ class CollisionSystem:
self.grid_width = grid_width
self.grid_height = grid_height
# Spatial grid for fast lookups
self.spatial_grid: Dict[Tuple[int, int], List] = {}
self.spatial_grid_before: Dict[Tuple[int, int], List] = {}
# Grid: Maps (x, y) coordinates to list of Unit objects/IDs
self.grid: Dict[Tuple[int, int], List[int]] = {}
self.grid_before: Dict[Tuple[int, int], List[int]] = {}
# Arrays for vectorized operations
self.unit_ids = []
self.bboxes = np.array([], dtype=np.float32).reshape(0, 4) # (x1, y1, x2, y2)
self.positions = np.array([], dtype=np.int32).reshape(0, 2) # (x, y)
self.positions_before = np.array([], dtype=np.int32).reshape(0, 2)
self.layers = np.array([], dtype=np.int8)
# Unit storage
self.units_data: Dict[int, dict] = {}
self.unit_ids: List[int] = [] # Stable list of IDs for parity
# Pre-allocation tracking
self._capacity = 0
self._size = 0
# Collision matrix: which layers collide with which
self.collision_matrix = np.zeros((6, 6), dtype=bool)
# Collision matrix (Native Python dict of sets for speed)
self._setup_collision_matrix()
def _setup_collision_matrix(self):
"""Define which collision layers interact with each other."""
L = CollisionLayer
# Interaction rules: layer -> set of target layers
self.interaction_map = {
L.RAT: {L.RAT, L.GAS, L.MINE, L.POINT, L.EXPLOSION},
L.GAS: {L.RAT},
L.MINE: {L.RAT},
L.POINT: {L.RAT},
L.EXPLOSION: {L.RAT},
L.BOMB: set() # Bombs are passive until they explode
}
# Rats collide with: Rats, Bombs, Gas, Mines, Points
self.collision_matrix[L.RAT, L.RAT] = True
self.collision_matrix[L.RAT, L.BOMB] = False # Bombs don't kill on contact
self.collision_matrix[L.RAT, L.GAS] = True
self.collision_matrix[L.RAT, L.MINE] = True
self.collision_matrix[L.RAT, L.POINT] = True
self.collision_matrix[L.RAT, L.EXPLOSION] = True
# Gas affects rats
self.collision_matrix[L.GAS, L.RAT] = True
# Mines trigger on rats
self.collision_matrix[L.MINE, L.RAT] = True
# Points collected by rats (handled in point logic)
self.collision_matrix[L.POINT, L.RAT] = True
# Explosions kill rats
self.collision_matrix[L.EXPLOSION, L.RAT] = True
# Make matrix symmetric
self.collision_matrix = np.logical_or(self.collision_matrix,
self.collision_matrix.T)
def clear(self):
"""Clear all collision data for new frame."""
self.spatial_grid.clear()
self.spatial_grid_before.clear()
self.unit_ids = []
self.bboxes = np.array([], dtype=np.float32).reshape(0, 4)
self.positions = np.array([], dtype=np.int32).reshape(0, 2)
self.positions_before = np.array([], dtype=np.int32).reshape(0, 2)
self.layers = np.array([], dtype=np.int8)
self.grid.clear()
self.grid_before.clear()
self.units_data.clear()
self.unit_ids.clear()
def register_unit(self, unit_id, bbox: Tuple[float, float, float, float],
position: Tuple[int, int], position_before: Tuple[int, int],
layer: int):
"""
Register a unit for collision detection this frame.
Parameters
----------
unit_id : UUID
Unique identifier for the unit
bbox : tuple
Bounding box (x1, y1, x2, y2)
position : tuple
Current grid position (x, y)
position_before : tuple
Previous grid position (x, y)
layer : int
Collision layer (from CollisionLayer enum)
Register a unit in the spatial grid.
"""
idx = len(self.unit_ids)
self.unit_ids.append(unit_id)
self.units_data[unit_id] = {
"bbox": bbox,
"pos": position,
"pos_before": position_before,
"layer": layer
}
# Pre-allocate arrays in batches to reduce overhead
if len(self.bboxes) == 0:
# Initialize with reasonable capacity
self.bboxes = np.empty((100, 4), dtype=np.float32)
self.positions = np.empty((100, 2), dtype=np.int32)
self.positions_before = np.empty((100, 2), dtype=np.int32)
self.layers = np.empty(100, dtype=np.int8)
self._capacity = 100
self._size = 0
elif self._size >= self._capacity:
# Expand capacity
new_capacity = self._capacity * 2
self.bboxes = np.resize(self.bboxes, (new_capacity, 4))
self.positions = np.resize(self.positions, (new_capacity, 2))
self.positions_before = np.resize(self.positions_before, (new_capacity, 2))
self.layers = np.resize(self.layers, new_capacity)
self._capacity = new_capacity
# Add to spatial buckets
if position not in self.grid:
self.grid[position] = []
self.grid[position].append(unit_id)
# Add data
self.bboxes[self._size] = bbox
self.positions[self._size] = position
self.positions_before[self._size] = position_before
self.layers[self._size] = layer
self._size += 1
# Add to spatial grids
self.spatial_grid.setdefault(position, []).append(idx)
self.spatial_grid_before.setdefault(position_before, []).append(idx)
def check_aabb_collision(self, idx1: int, idx2: int, tolerance: int = 0) -> bool:
"""
Check AABB collision between two units.
Parameters
----------
idx1, idx2 : int
Indices in the arrays
tolerance : int
Overlap tolerance in pixels (reduces detection zone)
Returns
-------
bool
True if bounding boxes overlap
"""
bbox1 = self.bboxes[idx1]
bbox2 = self.bboxes[idx2]
return (bbox1[0] < bbox2[2] - tolerance and
bbox1[2] > bbox2[0] + tolerance and
bbox1[1] < bbox2[3] - tolerance and
bbox1[3] > bbox2[1] + tolerance)
def check_aabb_collision_vectorized(self, idx: int, indices: np.ndarray,
tolerance: int = 0) -> np.ndarray:
"""
Vectorized AABB collision check between one unit and many others.
Parameters
----------
idx : int
Index of the unit to check
indices : ndarray
Array of indices to check against
tolerance : int
Overlap tolerance in pixels
Returns
-------
ndarray
Boolean array indicating collisions
"""
if len(indices) == 0:
return np.array([], dtype=bool)
# Slice actual data size, not full capacity
bbox = self.bboxes[idx]
other_bboxes = self.bboxes[indices]
# Vectorized AABB check
collisions = (
(bbox[0] < other_bboxes[:, 2] - tolerance) &
(bbox[2] > other_bboxes[:, 0] + tolerance) &
(bbox[1] < other_bboxes[:, 3] - tolerance) &
(bbox[3] > other_bboxes[:, 1] + tolerance)
)
return collisions
if position_before not in self.grid_before:
self.grid_before[position_before] = []
self.grid_before[position_before].append(unit_id)
def get_collisions_for_unit(self, unit_id, layer: int,
tolerance: int = 0) -> List[Tuple[int, any]]:
"""
Get all units colliding with the specified unit.
Uses hybrid approach: simple method for few units, numpy for many.
Parameters
----------
unit_id : UUID
ID of the unit to check
layer : int
Collision layer of the unit
tolerance : int
Overlap tolerance
Returns
-------
list
List of tuples (index, unit_id) for colliding units
Get all units colliding with the specified unit using grid lookup.
"""
if unit_id not in self.unit_ids:
if unit_id not in self.units_data:
return []
data = self.units_data[unit_id]
bbox = data["bbox"]
pos = data["pos"]
pos_before = data["pos_before"]
idx = self.unit_ids.index(unit_id)
position = tuple(self.positions[idx])
position_before = tuple(self.positions_before[idx])
colliding_units = []
target_layers = self.interaction_map.get(layer, set())
# Get candidate indices from spatial grid
# Candidate search: look in current and previous grid buckets
# This covers units that moved into our space or were there before
candidates = set()
for pos in [position, position_before]:
candidates.update(self.spatial_grid.get(pos, []))
candidates.update(self.spatial_grid_before.get(pos, []))
for p in [pos, pos_before]:
if p in self.grid:
candidates.update(self.grid[p])
if p in self.grid_before:
candidates.update(self.grid_before[p])
# Remove self and out-of-bounds indices
candidates.discard(idx)
candidates = {c for c in candidates if c < self._size}
candidates.discard(unit_id)
if not candidates:
return []
# HYBRID APPROACH: Use simple method for few candidates
if len(candidates) < 10:
return self._simple_collision_check(idx, candidates, layer, tolerance)
# NumPy vectorized approach for many candidates
candidates_array = np.array(list(candidates), dtype=np.int32)
candidate_layers = self.layers[candidates_array]
# Check collision matrix
can_collide = self.collision_matrix[layer, candidate_layers]
valid_candidates = candidates_array[can_collide]
if len(valid_candidates) == 0:
return []
# Vectorized AABB check
collisions = self.check_aabb_collision_vectorized(idx, valid_candidates, tolerance)
colliding_indices = valid_candidates[collisions]
# Return list of (index, unit_id) pairs
return [(int(i), self.unit_ids[i]) for i in colliding_indices]
def _simple_collision_check(self, idx: int, candidates: set, layer: int,
tolerance: int) -> List[Tuple[int, any]]:
"""
Simple collision check without numpy overhead.
Used when there are few candidates.
"""
results = []
bbox = self.bboxes[idx]
for other_idx in candidates:
# Check collision layer
if not self.collision_matrix[layer, self.layers[other_idx]]:
continue
for other_id in candidates:
other_data = self.units_data[other_id]
# AABB check
other_bbox = self.bboxes[other_idx]
# 1. Filter by layer
if other_data["layer"] not in target_layers:
continue
# 2. AABB Check
other_bbox = other_data["bbox"]
if (bbox[0] < other_bbox[2] - tolerance and
bbox[2] > other_bbox[0] + tolerance and
bbox[1] < other_bbox[3] - tolerance and
bbox[3] > other_bbox[1] + tolerance):
results.append((int(other_idx), self.unit_ids[other_idx]))
return results
# Return dummy index (for parity) and ID
colliding_units.append((0, other_id))
return colliding_units
def get_units_in_cell(self, position: Tuple[int, int],
use_before: bool = False) -> List[any]:
"""
Get all unit IDs in a specific grid cell.
Parameters
----------
position : tuple
Grid position (x, y)
use_before : bool
If True, use position_before instead of position
Returns
-------
list
List of unit IDs in that cell
"""
grid = self.spatial_grid_before if use_before else self.spatial_grid
indices = grid.get(position, [])
return [self.unit_ids[i] for i in indices]
"""Get all unit IDs in a specific grid cell."""
target_grid = self.grid_before if use_before else self.grid
return target_grid.get(position, [])
def get_units_in_area(self, positions: List[Tuple[int, int]],
layer_filter: int = None) -> Set[any]:
"""
Get all units in multiple grid cells (useful for explosions).
Parameters
----------
positions : list
List of grid positions to check
layer_filter : int, optional
If provided, only return units of this layer
Returns
-------
set
Set of unique unit IDs in the area
"""
unit_set = set()
"""Get all units in multiple grid cells (vectorized lookup replacement)."""
found = set()
for pos in positions:
# Check both current and previous positions
for grid in [self.spatial_grid, self.spatial_grid_before]:
indices = grid.get(pos, [])
for idx in indices:
if layer_filter is None or self.layers[idx] == layer_filter:
unit_set.add(self.unit_ids[idx])
return unit_set
# Check current grid
if pos in self.grid:
for uid in self.grid[pos]:
if layer_filter is None or self.units_data[uid]["layer"] == layer_filter:
found.add(uid)
# Check previous grid
if pos in self.grid_before:
for uid in self.grid_before[pos]:
if layer_filter is None or self.units_data[uid]["layer"] == layer_filter:
found.add(uid)
return found
def check_partial_move_collision(self, unit_id, partial_move: float,
threshold: float = 0.5) -> List[any]:
"""
Check collisions considering partial movement progress.
For units moving between cells, checks if they should be considered
in current or previous cell based on movement progress.
Parameters
----------
unit_id : UUID
Unit to check
partial_move : float
Movement progress (0.0 to 1.0)
threshold : float
Movement threshold for position consideration
Returns
-------
list
List of unit IDs in collision
"""
if unit_id not in self.unit_ids:
"""Collision check considering movement progress."""
if unit_id not in self.units_data:
return []
data = self.units_data[unit_id]
pos = data["pos"] if partial_move >= threshold else data["pos_before"]
idx = self.unit_ids.index(unit_id)
# Choose position based on partial move
if partial_move >= threshold:
position = tuple(self.positions[idx])
else:
position = tuple(self.positions_before[idx])
# Get units in that position
indices = self.spatial_grid.get(position, []) + \
self.spatial_grid_before.get(position, [])
# Remove duplicates and self
indices = list(set(indices))
if idx in indices:
indices.remove(idx)
return [self.unit_ids[i] for i in indices]
found = set()
if pos in self.grid:
found.update(self.grid[pos])
if pos in self.grid_before:
found.update(self.grid_before[pos])
found.discard(unit_id)
return list(found)
+26 -8
View File
@@ -199,6 +199,26 @@ class KeyBindings:
def __init__(self, game):
self.game = game
self.bindings = {}
# Explicit action mapping for static-friendly dispatch (Nim-ready)
self.action_dispatcher = {
"spawn_rat": self.spawn_rat,
"spawn_new_bomb": self.spawn_new_bomb,
"spawn_new_mine": self.spawn_new_mine,
"spawn_new_nuclear_bomb": self.spawn_new_nuclear_bomb,
"spawn_new_gas": self.spawn_new_gas,
"toggle_audio": self.toggle_audio,
"toggle_pause": self.toggle_pause,
"toggle_full_screen": self.toggle_full_screen,
"quit_game": self.quit_game,
"menu_up": self.game.menu_up,
"menu_down": self.game.menu_down,
"menu_left": self.game.menu_left,
"menu_right": self.game.menu_right,
"reset_game": self.game.reset_game,
"start_scrolling": self.start_scrolling,
"stop_scrolling": self.stop_scrolling,
}
def _binding_sections_for_action(self):
game_end_active, game_end_reason = getattr(self.game, "game_end", (False, None))
@@ -259,16 +279,14 @@ class KeyBindings:
continue
method_name = value.split("|", 1)[0]
# Check both self (KeyBindings) and self.game (MiceMaze)
method = getattr(self, method_name, getattr(self.game, method_name, None))
if callable(method):
if method_name in self.action_dispatcher:
validated[section_name][action] = value
continue
invalid_bindings += 1
print(
f"[input] ignoring binding {section_name}.{action} -> {value}: "
f"missing method {method_name}"
f"missing method {method_name} in dispatcher"
)
if invalid_bindings:
@@ -291,13 +309,13 @@ class KeyBindings:
if "|" in value:
method_name, *args = value.split("|")
method = getattr(self, method_name, getattr(self.game, method_name, None))
if callable(method):
method = self.action_dispatcher.get(method_name)
if method:
method(*args)
return None
method = getattr(self, value, getattr(self.game, value, None))
if callable(method):
method = self.action_dispatcher.get(value)
if method:
method()
return None
+21 -5
View File
@@ -4,6 +4,8 @@ from units import gas, rat, bomb, mine
from units.unit import UnitType
class UnitManager:
def __init__(self, game):
self.game = game
@@ -24,11 +26,10 @@ class UnitManager:
def has_weapon_at(self, position):
"""Check if there's a weapon (bomb, gas, mine) at the given position"""
weapon_types = {UnitType.BOMB_TIMER, UnitType.BOMB_NUCLEAR, UnitType.GAS, UnitType.MINE}
for unit in self.game.units.values():
if unit.position == position:
# Check if it's a weapon type (not a rat or points)
if isinstance(unit, (bomb.Timer, bomb.NuclearBomb, gas.Gas, mine.Mine)):
return True
if unit.position == position and unit.type in weapon_types:
return True
return False
def can_place_weapon_at(self, position):
@@ -43,11 +44,26 @@ class UnitManager:
def count_rats(self):
count = 0
rat_types = {UnitType.RAT_MALE, UnitType.RAT_FEMALE}
for unit in self.game.units.values():
if isinstance(unit, rat.Rat):
if unit.type in rat_types:
count += 1
return count
def refill_ammo(self):
"""Randomly refill ammo during gameplay."""
import random
for ammo_type, data in self.game.ammo.items():
if ammo_type == "bomb":
if random.random() < 0.02:
data["count"] = min(data["count"] + 1, data["max"])
elif ammo_type == "mine":
if random.random() < 0.05:
data["count"] = min(data["count"] + 1, data["max"])
elif ammo_type == "gas":
if random.random() < 0.01:
data["count"] = min(data["count"] + 1, data["max"])
def spawn_gas(self, parent_id=None):
if not self.can_place_weapon_at(self.game.pointer):
return