diff --git a/examples/projectile_attack/.gitignore b/examples/projectile_attack/.gitignore new file mode 100644 index 000000000..e83f3cf7d --- /dev/null +++ b/examples/projectile_attack/.gitignore @@ -0,0 +1,13 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv + +# test files +.ruff_cache \ No newline at end of file diff --git a/examples/projectile_attack/README.md b/examples/projectile_attack/README.md new file mode 100644 index 000000000..accb4d116 --- /dev/null +++ b/examples/projectile_attack/README.md @@ -0,0 +1,42 @@ +# Projectile Attack GAME + +## 1. Overview +- A simple 2D projectile game built with Mesa for the simulation core and tkinter for the GUI. +- Control a stationary tank on the left, fire shells with adjustable angle, power, wind, and wall settings to hit the target. +- Includes wind-drifted clouds, optional moving targets, wall wind-blocking logic, and explosion visuals. + +## 2. Rule +- Only one shell can exist at a time; press Fire to launch when no shell is active. +- Shell motion follows gravity, wind acceleration, and a speed cap; walls block collision and can optionally block wind. + - Wind blocking: when enabled, any cell at the wall column and the cells on the wind-upwind side within the wall height (ground y=1 up to `wall_height`, inclusive) ignore wind. If wind < 0, cells left of the wall are shielded; if wind > 0, cells right of the wall are shielded. +- Hitting the ground, leaving the grid, or striking the wall removes the shell; hitting the target ends the round and shows an explosion before auto-reset. +- If target movement is enabled, it oscillates vertically within bounds. + +## 3. Installation +Prerequisites: Python 3.11+. +- Clone the repo, then install dependencies defined in `pyproject.toml`. +- With uv (recommended): `pip install uv` then `uv sync`. +- With pip: `pip install -e .` (installs `mesa` and typing stubs from `pyproject.toml`). + +## 4. Project Structure +``` +WEEK4_Projectile_Attack/ +├─ README.md +├─ pyproject.toml +├─ uv.lock +├─ agents.py +├─ model.py +├─ run.py +└─ tank_game_vis.png +``` +- `agents.py`: defines Tank, Shell, Target, and Cloud behaviors (physics, movement, collisions). +- `model.py`: builds the Mesa model, grid, scheduler, wall logic, explosions, and firing mechanics. +- `run.py`: tkinter UI for controls, rendering the grid, handling simulation loop, and user interactions. + +## 5. Running the GAME +- From the project root: `python run.py` +- Adjust sliders (angle, power, wind, wall position/height), toggle wind blocking and target movement, then press Fire. Use Reset to restart with defaults. + +## 6. Game interface +![Tank Game UI](tank_game_vis.png) + diff --git a/examples/projectile_attack/agents.py b/examples/projectile_attack/agents.py new file mode 100644 index 000000000..b69427703 --- /dev/null +++ b/examples/projectile_attack/agents.py @@ -0,0 +1,263 @@ +""" +Agent definitions: Tank, Shell, Target, Cloud +""" + +from mesa import Agent + + +class Tank(Agent): + """Tank agent - fixed position as the firing origin""" + + def __init__(self, model, unique_id=None, pos: tuple[float, float] = (1.0, 1.0)): + """ + Initialize a tank. + Args: + model: Model instance. + unique_id: Unique ID of the agent (Mesa 3.x autogenerates; kept for compatibility). + pos: Position tuple (x, y), default (1.0, 1.0). + """ + super().__init__(model) + self.pos_f = pos # floating position + # Place the tank on the grid + grid_x = int(pos[0]) + grid_y = int(pos[1]) + self.model.grid.place_agent(self, (grid_x, grid_y)) + + def step(self): + """Tank behavior per step - fixed in place, no movement""" + + +class Shell(Agent): + """Shell agent - affected by gravity and wind; can collide""" + + def __init__( + self, + model, + unique_id=None, + pos_f: tuple[float, float] | None = None, + vx: float = 0.0, + vy: float = 0.0, + ): + """ + Initialize a shell. + Args: + model: Model instance. + unique_id: Unique ID of the agent (Mesa 3.x autogenerates; kept for compatibility). + pos_f: Floating position (x, y). + vx: Initial x velocity. + vy: Initial y velocity. + """ + super().__init__(model) + self.pos_f = pos_f # floating position + self.vx = vx # x-axis velocity + self.vy = vy # y-axis velocity + self.alive = True # alive flag + + # Initial grid position + grid_x = int(pos_f[0]) + grid_y = int(pos_f[1]) + self.model.grid.place_agent(self, (grid_x, grid_y)) + + def step(self): + """Shell behavior per step: update position, apply physics, check collisions""" + if not self.alive: + return + + # Record current grid for trajectory display (treated as passed through next step) + prev_grid = (int(self.pos_f[0]), int(self.pos_f[1])) + self.model.add_trajectory_cell(prev_grid) + + # Get physical constants + g = self.model.g + vmax = self.model.vmax + wind_acc = self.model.wind_acc + wall_block_wind = self.model.wall_block_wind + wall_position = self.model.wall_position + wall_height = self.model.wall_height + ground_y = 1 + + # Current grid position (integers for wind shadow check) + grid_x = int(self.pos_f[0]) + grid_y = int(self.pos_f[1]) + + # Check whether wind is blocked by wall. + # Rule: if wind < 0, cells left of the wall within wall height ignore wind; + # if wind > 0, cells right of the wall within wall height ignore wind. + # The wall itself also ignores wind; height range includes the top. + in_wall_shadow = False + if wall_block_wind: + within_wall_height = ground_y <= grid_y <= ground_y + wall_height + if within_wall_height and ( + grid_x == wall_position + or (wind_acc < 0 and grid_x < wall_position) + or (wind_acc > 0 and grid_x > wall_position) + ): + in_wall_shadow = True + + # Apply wind if not in shadow + if not in_wall_shadow: + self.vx += wind_acc + + # Apply gravity + self.vy -= g + + # Speed clamp: normalize velocity vector so |v| ≤ vmax + speed = (self.vx**2 + self.vy**2) ** 0.5 + if speed > vmax: + scale = vmax / speed + self.vx *= scale + self.vy *= scale + + # Update position + self.pos_f = (self.pos_f[0] + self.vx, self.pos_f[1] + self.vy) + + # Compute new grid position + grid_x = int(self.pos_f[0]) + grid_y = int(self.pos_f[1]) + + # Collision checks + # 1. Out of grid bounds + if ( + grid_x < 0 + or grid_x >= self.model.grid.width + or grid_y < 0 + or grid_y >= self.model.grid.height + ): + self.alive = False + self.model.grid.remove_agent(self) + return + + # 2. Ground collision (y < ground_y) + if self.pos_f[1] < ground_y: + self.alive = False + self.model.grid.remove_agent(self) + return + + # 3. Wall collision + if (grid_x, grid_y) in self.model.wall_cells: + self.alive = False + self.model.grid.remove_agent(self) + return + + # 4. Target collision + target = self.model.target + if target is not None: + target_grid_x = int(target.pos_f[0]) + target_grid_y = int(target.pos_f[1]) + if grid_x == target_grid_x and grid_y == target_grid_y: + # Target hit - model handles explosion and removal + self.alive = False + self.model.grid.remove_agent(self) + # Notify model to handle target hit + self.model._handle_target_hit(grid_x, grid_y) + return + + # Update grid position + self.model.grid.move_agent(self, (grid_x, grid_y)) + + +class Target(Agent): + """Target agent - fixed position; removed when hit""" + + def __init__(self, model, unique_id=None, pos_f: tuple[float, float] | None = None): + """ + Initialize a target. + Args: + model: Model instance. + unique_id: Unique ID of the agent (Mesa 3.x autogenerates; kept for compatibility). + pos_f: Position tuple (x, y). + """ + super().__init__(model) + self.pos_f = pos_f # floating position (fixed but kept consistent) + self.direction = 1 # vertical direction: 1 up, -1 down + self.move_tick = 0 # movement cadence counter; move every 3 steps + + # Place target on the grid + grid_x = int(pos_f[0]) + grid_y = int(pos_f[1]) + self.model.grid.place_agent(self, (grid_x, grid_y)) + + def step(self): + """Target behavior per step - optional vertical movement""" + if not getattr(self.model, "target_movable", False): + return + + # Throttle: move every 3 steps + self.move_tick = (self.move_tick + 1) % 3 + if self.move_tick != 0: + return + + # Bounce vertically within y ∈ [1, 25] + min_y, max_y = 1, 25 + grid_height = self.model.grid.height + # Guard for smaller grids to avoid overflow + max_y = min(max_y, grid_height - 1) + min_y = max(min_y, 0) + + # Compute new position + new_y = self.pos_f[1] + self.direction + if new_y >= max_y: + new_y = max_y + self.direction = -1 + elif new_y <= min_y: + new_y = min_y + self.direction = 1 + + new_x = self.pos_f[0] # x fixed + self.pos_f = (new_x, new_y) + + # Update grid position + grid_x = int(new_x) + grid_y = int(new_y) + self.model.grid.move_agent(self, (grid_x, grid_y)) + + +class Cloud(Agent): + """Cloud agent - moves horizontally; affected by wind""" + + def __init__( + self, model, unique_id=None, pos_f: tuple[float, float] = (17.0, 30.0) + ): + """ + Initialize a cloud. + Args: + model: Model instance. + unique_id: Unique ID of the agent (Mesa 3.x autogenerates; kept for compatibility). + pos_f: Position tuple (x, y), default (17.0, 30.0). + """ + super().__init__(model) + self.pos_f = pos_f # floating position + + # Place cloud on the grid + grid_x = int(pos_f[0]) + grid_y = int(pos_f[1]) + self.model.grid.place_agent(self, (grid_x, grid_y)) + + def step(self): + """Cloud behavior per step: horizontal movement with wind, wraps horizontally""" + # Get wind + wind = self.model.wind + cloud_factor = 0.01 # cloud movement factor + + # Horizontal movement + new_x = self.pos_f[0] + wind * cloud_factor + + # Wrap horizontally (re-enter from other side when leaving grid) + grid_width = self.model.grid.width + if new_x < 0: + new_x += grid_width + elif new_x >= grid_width: + new_x -= grid_width + + # Update position + self.pos_f = (new_x, self.pos_f[1]) + + # Compute new grid position + grid_x = int(self.pos_f[0]) + grid_y = int(self.pos_f[1]) + + # Ensure grid_x stays valid (wraparound should guarantee validity) + grid_x = grid_x % grid_width + + # Update grid position + self.model.grid.move_agent(self, (grid_x, grid_y)) diff --git a/examples/projectile_attack/model.py b/examples/projectile_attack/model.py new file mode 100644 index 000000000..8fc004a07 --- /dev/null +++ b/examples/projectile_attack/model.py @@ -0,0 +1,317 @@ +""" +TankGameModel definition: game model with physics, collision, and explosion effects +""" + +import math +import random + +from agents import Cloud, Shell, Tank, Target +from mesa import Model +from mesa.space import MultiGrid + + +class RandomActivation: + """Simple random activation scheduler (Mesa 3.x compatible)""" + + def __init__(self, model): + self.model = model + self._agents: list = [] + + def add(self, agent): + """Add agent to the scheduler""" + self._agents.append(agent) + + def remove(self, agent): + """Remove agent from the scheduler""" + if agent in self._agents: + self._agents.remove(agent) + + def step(self): + """Execute all agents' step methods in random order""" + random.shuffle(self._agents) + for agent in self._agents[ + : + ]: # use slice copy to avoid mutation during iteration + if agent in self._agents: # double-check in case removed during step + agent.step() + + @property + def agents(self): + """Return the agent list (for compatibility)""" + return self._agents + + +class TankGameModel(Model): + """Tank game model - manages game state, physics rules, and agent interaction""" + + def __init__( + self, + angle: float = 45.0, + power: float = 70.0, + wind: float = 0.0, + wall_position: int = 17, + wall_height: int = 10, + wall_block_wind: bool = False, + target_movable: bool = False, + width: int = 35, + height: int = 35, + seed: int | None = None, + ): + """ + Initialize the game model. + Args: + angle: Launch angle (degrees), default 45. + power: Launch power, default 70. + wind: Wind strength, default 0. + wall_position: X position of the wall, default 17. + wall_height: Height of the wall, default 10. + wall_block_wind: Whether the wall blocks wind, default False. + target_movable: Whether the target moves vertically, default False (static). + width: Grid width, default 35. + height: Grid height, default 35. + seed: Random seed, default None. + """ + super().__init__(seed=seed) + + # UI parameters (modifiable) + self.angle = angle + self.power = power + self.wind = wind + self.wall_position = wall_position + self.wall_height = wall_height + self.wall_block_wind = wall_block_wind + self.target_movable = target_movable + + # Physical constants + self.g = 0.01 # gravitational acceleration + self.vmax = 1.0 # maximum speed + self._update_wind_acc() # compute wind acceleration + + # Grid and scheduler + self.grid = MultiGrid(width, height, torus=False) + self.schedule = RandomActivation(self) + + # State variables + self.tank = None + self.target = None + self.cloud = None + self.wall_cells: set[tuple[int, int]] = set() + self.explosion_cells: dict[tuple[int, int], int] = {} # {(x, y): ttl} + self.explosion_centers: list[tuple[int, int]] = [] # explosion centers + self.trajectory_cells: set[tuple[int, int]] = set() # shell trajectory cells + + # Step counter + self.step_count = 0 + + # Agent ID counter + self._agent_id_counter = 0 + + # For detecting wall parameter changes + self._last_wall_position = wall_position + self._last_wall_height = wall_height + + # Initialize game + self._initialize_game() + + def next_id(self): + """Generate the next unique agent ID""" + self._agent_id_counter += 1 + return self._agent_id_counter + + def _update_wind_acc(self): + """Update wind acceleration""" + self.wind_acc = self.wind / 10000.0 + + def _build_wall_cells(self): + """Build the set of wall cells""" + self.wall_cells.clear() + ground_y = 1 + for y in range(ground_y, ground_y + self.wall_height): + if 0 <= self.wall_position < self.grid.width and 0 <= y < self.grid.height: + self.wall_cells.add((self.wall_position, y)) + + def reset(self): + """Reset game state: remove shells, recreate target and cloud""" + # Clear trajectory + self.clear_trajectory() + + # Remove all shells + for agent in self.schedule.agents[:]: + if isinstance(agent, Shell): + self.grid.remove_agent(agent) + self.schedule.remove(agent) + + # Remove existing target if present + if self.target is not None: + self.grid.remove_agent(self.target) + self.schedule.remove(self.target) + self.target = None + + # Remove existing cloud if present + if self.cloud is not None: + self.grid.remove_agent(self.cloud) + self.schedule.remove(self.cloud) + self.cloud = None + + # Clear explosion effects + self.explosion_cells.clear() + self.explosion_centers.clear() + + # Recreate cloud at (17.0, 30.0) + cloud_id = self.next_id() + self.cloud = Cloud(self, cloud_id, pos_f=(17.0, 30.0)) + self.schedule.add(self.cloud) + + # Recreate target at (width-2, random(10,25)) + target_y = random.randint(10, 25) + target_id = self.next_id() + self.target = Target( + self, target_id, pos_f=(self.grid.width - 2, float(target_y)) + ) + self.schedule.add(self.target) + + # Resume running state + self.running = True + + # Optional: reset step counter + # self.step_count = 0 + + def _initialize_game(self): + """Initialize the game: create tank, cloud, target, and wall""" + # Create tank at (1.0, 1.0) + # Mesa 3.x: Agent(model, *args, **kwargs), unique_id passed via *args + tank_id = self.next_id() + self.tank = Tank(self, tank_id, pos=(1.0, 1.0)) + self.schedule.add(self.tank) + + # Create cloud at (17.0, 30.0) + cloud_id = self.next_id() + self.cloud = Cloud(self, cloud_id, pos_f=(17.0, 32.0)) + self.schedule.add(self.cloud) + + # Create target at (width-2, random(10,25)) + target_y = random.randint(1, 25) + target_id = self.next_id() + self.target = Target( + self, target_id, pos_f=(self.grid.width - 2, float(target_y)) + ) + self.schedule.add(self.target) + + # Build wall cells + self._build_wall_cells() + + def add_trajectory_cell(self, cell: tuple[int, int]): + """Record grid cells traversed by shells (only within bounds)""" + x, y = cell + if 0 <= x < self.grid.width and 0 <= y < self.grid.height: + self.trajectory_cells.add((x, y)) + + def clear_trajectory(self): + """Clear current round's shell trajectory""" + self.trajectory_cells.clear() + + @property + def shell_exists(self) -> bool: + """Check whether a shell exists""" + for agent in self.schedule.agents: + if isinstance(agent, Shell) and agent.alive: + return True + return False + + @property + def target_exists(self) -> bool: + """Check whether a target exists""" + return self.target is not None and self.target in self.schedule.agents + + def fire(self): + """Fire a shell""" + # Only fire when no shell exists + if self.shell_exists: + return + + # Compute initial velocity + speed = self.power / 100.0 + angle_rad = math.radians(self.angle) + vx = math.sin(angle_rad) * speed + vy = math.cos(angle_rad) * speed + + # Create shell at tank position + # Mesa 3.x: Agent(model, *args, **kwargs), unique_id passed via *args + shell_id = self.next_id() + shell = Shell(self, shell_id, pos_f=self.tank.pos_f, vx=vx, vy=vy) + self.schedule.add(shell) + + def _handle_target_hit(self, hit_x: int, hit_y: int): + """Handle target hit: remove target and shell, create explosion effect""" + # Clear trajectory immediately after a hit + self.clear_trajectory() + + # Remove target + if self.target is not None: + self.grid.remove_agent(self.target) + self.schedule.remove(self.target) + self.target = None + + # Record explosion center + self.explosion_centers.append((hit_x, hit_y)) + + # Create explosion effect + # Radius 1 (Manhattan distance ≤ 1): red, TTL=10 + # Radius 2 (Manhattan distance ≤ 2 but >1): yellow, TTL=10 + for dx in range(-2, 3): + for dy in range(-2, 3): + manhattan_dist = abs(dx) + abs(dy) + if manhattan_dist <= 2: + x = hit_x + dx + y = hit_y + dy + # Check if within grid bounds + if 0 <= x < self.grid.width and 0 <= y < self.grid.height: + self.explosion_cells[(x, y)] = 10 # TTL=10 + + def _update_explosion_cells(self): + """Update explosion effects: decrement TTL, remove when zero""" + cells_to_remove = [] + for cell, ttl in self.explosion_cells.items(): + new_ttl = ttl - 1 + if new_ttl <= 0: + cells_to_remove.append(cell) + else: + self.explosion_cells[cell] = new_ttl + + for cell in cells_to_remove: + del self.explosion_cells[cell] + + # If all explosion effects are gone, clear explosion centers + if not self.explosion_cells: + self.explosion_centers.clear() + + def step(self): + """Model step logic""" + # 1. Check if target exists; if not, stop running (but still update explosions) + if not self.target_exists: + self.running = False + # Even without target, update explosion TTL + if self.explosion_cells: + self._update_explosion_cells() + return + + # 2. If wall params changed, rebuild wall cells + if ( + self.wall_position != self._last_wall_position + or self.wall_height != self._last_wall_height + ): + self._build_wall_cells() + self._last_wall_position = self.wall_position + self._last_wall_height = self.wall_height + + # 3. Update wind acceleration (if wind changed) + self._update_wind_acc() + + # 4. Call all agents' step() + self.schedule.step() + + # 5. Update explosion cell TTL + self._update_explosion_cells() + + # 6. Increment step counter + self.step_count += 1 diff --git a/examples/projectile_attack/pyproject.toml b/examples/projectile_attack/pyproject.toml new file mode 100644 index 000000000..84df6148c --- /dev/null +++ b/examples/projectile_attack/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "week4-projectile-attack" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "mesa>=3.3.1", + "typing>=3.10.0.0", +] + +[dependency-groups] +dev = [ + "black>=25.12.0", + "pre-commit>=4.5.1", + "ruff>=0.14.10", +] diff --git a/examples/projectile_attack/run.py b/examples/projectile_attack/run.py new file mode 100644 index 000000000..567554913 --- /dev/null +++ b/examples/projectile_attack/run.py @@ -0,0 +1,646 @@ +""" +Tank game visualization and entry point. +Uses tkinter to create an interactive UI. +""" + +import math +import threading +import time +import tkinter as tk +from tkinter import ttk + +from agents import Cloud, Shell, Tank, Target +from model import TankGameModel + + +class TankGameVisualization: + """Tank game visualization UI""" + + def __init__(self, root): + self.root = root + self.root.title("Tank Projectile Attack Game") + + # Default parameters (used to reset consistently) + self.default_params = { + "angle": 45.0, + "power": 70.0, + "wind": 0.0, + "wall_position": 17, + "wall_height": 10, + "wall_block_wind": False, + "target_movable": False, + } + + # Create model with default parameters + self.model = TankGameModel(**self.default_params) + # Precompute pixel offsets for the "cloud" word + self.cloud_word_cells = [] # relative offsets + self.cloud_word_width = 0 + self._build_cloud_word_cells() + + # Running flag + self.running = False + self.simulation_thread = None + + # Auto-reset controls + self.auto_reset_scheduled = False # whether auto-reset is scheduled + self.auto_reset_after_id = None # after callback ID for cancellation + + # Build UI + self._create_ui() + + # Initial render + self.update_display() + + def _create_ui(self): + """Create the user interface""" + # Main container + main_frame = ttk.Frame(self.root, padding="10") + main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + + # Configure grid weights + self.root.columnconfigure(0, weight=1) + self.root.rowconfigure(0, weight=1) + main_frame.columnconfigure(0, weight=1) + main_frame.rowconfigure(0, weight=1) + + # Left: control panel + control_frame = ttk.LabelFrame(main_frame, text="Control Panel", padding="10") + control_frame.grid( + row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 10) + ) + + # Right: game grid + grid_frame = ttk.Frame(main_frame, padding="10") + grid_frame.grid(row=0, column=1, sticky=(tk.W, tk.E, tk.N, tk.S)) + main_frame.columnconfigure(1, weight=1) + main_frame.rowconfigure(0, weight=1) + + # Build controls + self._create_controls(control_frame) + + # Build grid canvas + self._create_grid(grid_frame) + + def _create_controls(self, parent): + """Create control widgets""" + # Angle slider + angle_frame = ttk.Frame(parent) + angle_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=5) + ttk.Label(angle_frame, text="Angle (deg):").grid(row=0, column=0, sticky=tk.W) + self.angle_var = tk.DoubleVar(value=self.default_params["angle"]) + angle_scale = ttk.Scale( + angle_frame, + from_=0, + to=90, + variable=self.angle_var, + orient=tk.HORIZONTAL, + command=self._on_angle_change, + ) + angle_scale.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5) + angle_frame.columnconfigure(1, weight=1) + self.angle_label = ttk.Label( + angle_frame, text=f"{self.default_params['angle']:.1f}" + ) + self.angle_label.grid(row=0, column=2, sticky=tk.W) + + # Power slider + power_frame = ttk.Frame(parent) + power_frame.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=5) + ttk.Label(power_frame, text="Power:").grid(row=0, column=0, sticky=tk.W) + self.power_var = tk.DoubleVar(value=self.default_params["power"]) + power_scale = ttk.Scale( + power_frame, + from_=0, + to=100, + variable=self.power_var, + orient=tk.HORIZONTAL, + command=self._on_power_change, + ) + power_scale.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5) + power_frame.columnconfigure(1, weight=1) + self.power_label = ttk.Label( + power_frame, text=f"{self.default_params['power']:.1f}" + ) + self.power_label.grid(row=0, column=2, sticky=tk.W) + + # Wind slider + wind_frame = ttk.Frame(parent) + wind_frame.grid(row=2, column=0, sticky=(tk.W, tk.E), pady=5) + ttk.Label(wind_frame, text="Wind:").grid(row=0, column=0, sticky=tk.W) + self.wind_var = tk.DoubleVar(value=self.default_params["wind"]) + wind_scale = ttk.Scale( + wind_frame, + from_=-100, + to=100, + variable=self.wind_var, + orient=tk.HORIZONTAL, + command=self._on_wind_change, + ) + wind_scale.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5) + wind_frame.columnconfigure(1, weight=1) + self.wind_label = ttk.Label( + wind_frame, text=f"{self.default_params['wind']:.1f}" + ) + self.wind_label.grid(row=0, column=2, sticky=tk.W) + + # Wall position slider + wall_pos_frame = ttk.Frame(parent) + wall_pos_frame.grid(row=3, column=0, sticky=(tk.W, tk.E), pady=5) + ttk.Label(wall_pos_frame, text="Wall Position:").grid( + row=0, column=0, sticky=tk.W + ) + self.wall_pos_var = tk.IntVar(value=self.default_params["wall_position"]) + wall_pos_scale = ttk.Scale( + wall_pos_frame, + from_=0, + to=34, + variable=self.wall_pos_var, + orient=tk.HORIZONTAL, + command=self._on_wall_pos_change, + ) + wall_pos_scale.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5) + wall_pos_frame.columnconfigure(1, weight=1) + self.wall_pos_label = ttk.Label( + wall_pos_frame, text=str(self.default_params["wall_position"]) + ) + self.wall_pos_label.grid(row=0, column=2, sticky=tk.W) + + # Wall height slider + wall_height_frame = ttk.Frame(parent) + wall_height_frame.grid(row=4, column=0, sticky=(tk.W, tk.E), pady=5) + ttk.Label(wall_height_frame, text="Wall Height:").grid( + row=0, column=0, sticky=tk.W + ) + self.wall_height_var = tk.IntVar(value=self.default_params["wall_height"]) + wall_height_scale = ttk.Scale( + wall_height_frame, + from_=0, + to=20, + variable=self.wall_height_var, + orient=tk.HORIZONTAL, + command=self._on_wall_height_change, + ) + wall_height_scale.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5) + wall_height_frame.columnconfigure(1, weight=1) + self.wall_height_label = ttk.Label( + wall_height_frame, text=str(self.default_params["wall_height"]) + ) + self.wall_height_label.grid(row=0, column=2, sticky=tk.W) + + # Wall blocks wind checkbox + self.wall_block_wind_var = tk.BooleanVar( + value=self.default_params["wall_block_wind"] + ) + wall_block_check = ttk.Checkbutton( + parent, + text="Wall Blocks Wind", + variable=self.wall_block_wind_var, + command=self._on_wall_block_wind_change, + ) + wall_block_check.grid(row=5, column=0, sticky=tk.W, pady=5) + + # Target movable checkbox + self.target_movable_var = tk.BooleanVar( + value=self.default_params["target_movable"] + ) + target_move_check = ttk.Checkbutton( + parent, + text="Target Moves Up/Down", + variable=self.target_movable_var, + command=self._on_target_movable_change, + ) + target_move_check.grid(row=6, column=0, sticky=tk.W, pady=5) + + # Button frame + button_frame = ttk.Frame(parent) + button_frame.grid(row=7, column=0, sticky=(tk.W, tk.E), pady=10) + + # Fire button + fire_button = ttk.Button(button_frame, text="Fire", command=self._on_fire) + fire_button.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=5) + + # Reset button + reset_button = ttk.Button(button_frame, text="Reset", command=self._on_reset) + reset_button.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5) + + button_frame.columnconfigure(0, weight=1) + button_frame.columnconfigure(1, weight=1) + + def _create_grid(self, parent): + """Create the game grid canvas""" + # Canvas frame + canvas_frame = ttk.Frame(parent) + canvas_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + parent.columnconfigure(0, weight=1) + parent.rowconfigure(0, weight=1) + + # Canvas + self.canvas_size = 600 # canvas size in pixels + self.canvas = tk.Canvas( + canvas_frame, width=self.canvas_size, height=self.canvas_size, bg="white" + ) + self.canvas.grid(row=0, column=0) + + # Compute cell size + self.cell_size = self.canvas_size / self.model.grid.width + + def _build_cloud_word_cells(self): + """Generate relative pixel offsets for the word 'cloud'""" + # 5x5 pixel-style lowercase letters, width=4, spacing=1 + pattern = [ + " ### # ### # # ###", + "# # # # # # # #", + "# # # # # # # #", + "# # # # # # # #", + " ### #### ### #### ###", + ] + self.cloud_word_width = len(pattern[0]) + cells = [] + for row_idx, row in enumerate(pattern): + for col_idx, ch in enumerate(row): + if ch != " ": + # Use negative y offset so increasing row_idx moves downward + cells.append((col_idx, -row_idx)) + self.cloud_word_cells = cells + + def _apply_default_params_to_ui(self): + """Reset controls and model params to defaults""" + defaults = self.default_params + + # Update UI variables + self.angle_var.set(defaults["angle"]) + self.power_var.set(defaults["power"]) + self.wind_var.set(defaults["wind"]) + self.wall_pos_var.set(defaults["wall_position"]) + self.wall_height_var.set(defaults["wall_height"]) + self.wall_block_wind_var.set(defaults["wall_block_wind"]) + self.target_movable_var.set(defaults["target_movable"]) + + # Update labels + self.angle_label.config(text=f"{defaults['angle']:.1f}") + self.power_label.config(text=f"{defaults['power']:.1f}") + self.wind_label.config(text=f"{defaults['wind']:.1f}") + self.wall_pos_label.config(text=str(defaults["wall_position"])) + self.wall_height_label.config(text=str(defaults["wall_height"])) + + # Sync model parameters + self.model.angle = defaults["angle"] + self.model.power = defaults["power"] + self.model.wind = defaults["wind"] + self.model.wall_position = defaults["wall_position"] + self.model.wall_height = defaults["wall_height"] + self.model.wall_block_wind = defaults["wall_block_wind"] + self.model.target_movable = defaults["target_movable"] + + def _on_angle_change(self, value): + """Angle change callback""" + ui_angle = float(value) + model_angle = 90.0 - ui_angle + # angle = float(value) + # self.model.angle = angle + # self.angle_label.config(text=f"{angle:.1f}") + self.model.angle = model_angle # model receives complementary angle + self.angle_label.config(text=f"{ui_angle:.1f}") # label shows slider value + + def _on_power_change(self, value): + """Power change callback""" + power = float(value) + self.model.power = power + self.power_label.config(text=f"{power:.1f}") + + def _on_wind_change(self, value): + """Wind change callback""" + wind = float(value) + self.model.wind = wind + self.wind_label.config(text=f"{wind:.1f}") + + def _on_wall_pos_change(self, value): + """Wall position change callback""" + wall_pos = int(float(value)) + self.model.wall_position = wall_pos + self.wall_pos_label.config(text=str(wall_pos)) + + def _on_wall_height_change(self, value): + """Wall height change callback""" + wall_height = int(float(value)) + self.model.wall_height = wall_height + self.wall_height_label.config(text=str(wall_height)) + + def _on_wall_block_wind_change(self): + """Wall blocks wind checkbox change""" + self.model.wall_block_wind = self.wall_block_wind_var.get() + + def _on_target_movable_change(self): + """Target movable checkbox change""" + movable = self.target_movable_var.get() + self.model.target_movable = movable + # When enabling movement, reset direction upward to avoid sticking at edges + if self.model.target is not None: + self.model.target.direction = 1 + self.model.target.move_tick = 0 + + def _on_fire(self): + """Fire button callback""" + self.model.fire() + if not self.running: + self.start_simulation() + + def _auto_reset_after_hit(self): + """Auto reset after target hit""" + # Clear after callback ID so future scheduling works + self.auto_reset_after_id = None + self._do_reset() + + def _cancel_auto_reset(self): + """Cancel any scheduled auto-reset""" + if self.auto_reset_after_id is not None: + self.root.after_cancel(self.auto_reset_after_id) + self.auto_reset_after_id = None + self.auto_reset_scheduled = False + + def _do_reset(self): + """Internal reset implementation""" + # Stop current simulation loop + self.running = False + if hasattr(self.model, "running"): + self.model.running = False + + # Wait for simulation loop to finish if running + if self.simulation_thread and self.simulation_thread.is_alive(): + self.simulation_thread.join(timeout=0.1) + self.simulation_thread = None + + # Force-cancel any scheduled auto-reset + self._cancel_auto_reset() + + # Recreate model and restore defaults + self.model = TankGameModel(**self.default_params) + + # Update grid scaling for new model + self.cell_size = self.canvas_size / self.model.grid.width + # Rebuild cloud word coordinates to match new grid + self._build_cloud_word_cells() + + # Sync UI controls to defaults + self._apply_default_params_to_ui() + + # Update display immediately + self.update_display() + + # Restart simulation loop to keep game running (cloud keeps moving) + self.start_simulation() + + def _on_reset(self): + """Reset button callback - restart game""" + self._do_reset() + + def start_simulation(self): + """Start simulation loop""" + if self.running: + return + self.running = True + self.model.running = True + self.simulation_thread = threading.Thread( + target=self._simulation_loop, daemon=True + ) + self.simulation_thread.start() + + def _simulation_loop(self): + """Simulation loop running in background thread""" + while self.running: + # Keep updating while model runs or explosions remain + if self.model.running or self.model.explosion_cells: + self.model.step() + self.root.after(0, self.update_display) + time.sleep(0.05) # control simulation speed + else: + # Model stopped and no explosions, break loop + break + self.running = False + + def update_display(self): + """Render current state to canvas""" + self.canvas.delete("all") + + # Draw grid background + cell_size = self.cell_size + width = self.model.grid.width + height = self.model.grid.height + + # Draw grass (bottom row in green) + grass_color = "#b6e388" + y_ground = 0 + for x in range(width): + x1 = x * cell_size + y1 = (height - 1 - y_ground) * cell_size + x2 = (x + 1) * cell_size + y2 = (height - y_ground) * cell_size + self.canvas.create_rectangle(x1, y1, x2, y2, fill=grass_color, outline="") + + # Draw optional grid lines + for i in range(width + 1): + x = i * cell_size + self.canvas.create_line( + x, 0, x, height * cell_size, fill="lightgray", width=1 + ) + for i in range(height + 1): + y = i * cell_size + self.canvas.create_line( + 0, y, width * cell_size, y, fill="lightgray", width=1 + ) + + # Draw "cloud" word (light gray pixels moving with cloud, wrapping horizontally) + cloud_agent = self.model.cloud + if cloud_agent is not None: + anchor_x = int(cloud_agent.pos_f[0]) - self.cloud_word_width // 2 + anchor_y = int(cloud_agent.pos_f[1]) + for dx, dy in self.cloud_word_cells: + x = (anchor_x + dx) % width # horizontal wrap + y = anchor_y + dy + if 0 <= y < height: + x1 = x * cell_size + y1 = (height - 1 - y) * cell_size + x2 = (x + 1) * cell_size + y2 = (height - y) * cell_size + self.canvas.create_rectangle( + x1, y1, x2, y2, fill="#dcdcdc", outline="" + ) + + # Draw shell trajectory (light red, stipple for semi-transparency, above background) + for x, y in self.model.trajectory_cells: + x1 = x * cell_size + y1 = (height - 1 - y) * cell_size + x2 = (x + 1) * cell_size + y2 = (height - y) * cell_size + self.canvas.create_rectangle( + x1, y1, x2, y2, fill="#ff9e9e", outline="", stipple="gray50" + ) + + # Draw wall (gray blocks) + for x, y in self.model.wall_cells: + x1 = x * cell_size + y1 = (height - 1 - y) * cell_size # flip Y-axis (grid Y is up) + x2 = (x + 1) * cell_size + y2 = (height - y) * cell_size + self.canvas.create_rectangle( + x1, y1, x2, y2, fill="gray", outline="darkgray" + ) + + # Draw explosion effects (overlay others) + if self.model.explosion_centers: # only draw when explosion centers exist + for (x, y), _ttl in self.model.explosion_cells.items(): + x1 = x * cell_size + y1 = (height - 1 - y) * cell_size + x2 = (x + 1) * cell_size + y2 = (height - y) * cell_size + + # Determine color by Manhattan distance to closest explosion center + min_dist = float("inf") + for center_x, center_y in self.model.explosion_centers: + manhattan_dist = abs(x - center_x) + abs(y - center_y) + min_dist = min(min_dist, manhattan_dist) + + # Radius 1 (distance ≤1): red + # Radius 2 (distance ≤2 and >1): yellow + if min_dist <= 1: + color = "red" + elif min_dist <= 2: + color = "yellow" + else: + color = "orange" # shouldn't happen but keep fallback + + self.canvas.create_rectangle( + x1, y1, x2, y2, fill=color, outline="darkorange" + ) + + # Draw agents + # Iterate all cells + for x in range(width): + for y in range(height): + cell_agents = self.model.grid.get_cell_list_contents([(x, y)]) + if not cell_agents: + continue + + # Compute draw position (flip Y-axis) + x1 = x * cell_size + y1 = (height - 1 - y) * cell_size + x2 = (x + 1) * cell_size + y2 = (height - y) * cell_size + center_x = (x1 + x2) / 2 + center_y = (y1 + y2) / 2 + + # Priority: explosion > Shell > Target > Tank > Cloud > Wall + # Wall and explosions already drawn above + for agent in cell_agents: + if isinstance(agent, Tank): + # Tank: rectangle hull + round turret + barrel line + hull_margin = cell_size * 0.12 + hull_height = cell_size * 0.45 + hull_x1 = x1 + hull_margin + hull_x2 = x2 - hull_margin + hull_y2 = y2 - hull_margin + hull_y1 = hull_y2 - hull_height + hull_fill = "#6ca965" + hull_outline = "#3f6b3d" + self.canvas.create_rectangle( + hull_x1, + hull_y1, + hull_x2, + hull_y2, + fill=hull_fill, + outline=hull_outline, + width=2, + ) + + # Turret + turret_r = cell_size * 0.18 + turret_cx = center_x + turret_cy = hull_y1 - cell_size * 0.05 + self.canvas.create_oval( + turret_cx - turret_r, + turret_cy - turret_r, + turret_cx + turret_r, + turret_cy + turret_r, + fill=hull_fill, + outline=hull_outline, + width=2, + ) + + # Barrel (direction follows current UI angle) + ui_angle = self.angle_var.get() + angle_rad = math.radians(ui_angle) + barrel_len = cell_size * 0.6 + # Canvas y-axis is down, so invert y direction + barrel_dx = math.cos(angle_rad) * barrel_len + barrel_dy = -math.sin(angle_rad) * barrel_len + barrel_end_x = turret_cx + barrel_dx + barrel_end_y = turret_cy + barrel_dy + self.canvas.create_line( + turret_cx, + turret_cy, + barrel_end_x, + barrel_end_y, + fill=hull_outline, + width=4, + capstyle=tk.ROUND, + ) + elif isinstance(agent, Shell): + # Shell: black + self.canvas.create_oval( + x1 + 1, + y1 + 1, + x2 - 1, + y2 - 1, + fill="black", + outline="black", + ) + elif isinstance(agent, Target): + # Target: blue + self.canvas.create_rectangle( + x1 + 2, + y1 + 2, + x2 - 2, + y2 - 2, + fill="blue", + outline="darkblue", + width=2, + ) + elif isinstance(agent, Cloud): + # Cloud is drawn via pixel font; skip separate dots + continue + + # Show victory text when target destroyed, before reset + if not self.model.target_exists: + self.canvas.create_text( + self.canvas_size / 2, + self.canvas_size / 2, + text="YOU WIN", + font=("Helvetica", 48, "bold"), + fill="darkgreen", + ) + + # Check whether to auto-reset (after hit and explosions vanish) + if not self.model.running and not self.model.target_exists: + # If explosions are gone, schedule auto-reset + if not self.model.explosion_cells and not self.auto_reset_scheduled: + self.auto_reset_scheduled = True + # Delay 0.5s so player sees explosions fade + self.auto_reset_after_id = self.root.after( + 500, self._auto_reset_after_hit + ) + elif self.model.running: + # If model running, cancel scheduled auto-reset + self._cancel_auto_reset() + + +def main(): + """Main function""" + root = tk.Tk() + # Keep reference on root to avoid unused-var lint and allow future access + root.app = TankGameVisualization(root) + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/examples/projectile_attack/tank_game_vis.png b/examples/projectile_attack/tank_game_vis.png new file mode 100644 index 000000000..500b1f351 Binary files /dev/null and b/examples/projectile_attack/tank_game_vis.png differ