diff --git a/examples/projectile_attack/README.md b/examples/projectile_attack/README.md new file mode 100644 index 00000000..06447304 --- /dev/null +++ b/examples/projectile_attack/README.md @@ -0,0 +1,40 @@ +# Projectile Attack GAME + +## 1. Overview +A simple 2D projectile game built with Mesa for simulation and Solara for visualization. Control a stationary tank on the left, fire shells with adjustable angle and power to hit the target. Includes an optional moving target, a fixed wall obstacle, and a trajectory trace. + +## 2. Rule +- Only one shell can exist at a time; press Fire to launch when no shell is active. +- Shell motion follows gravity. +- Hitting the ground, leaving the grid, or striking the wall removes the shell; hitting the target ends the round and shows a win message. +- You have at most 5 shots; after all shots are used, the game stops and asks you to reset. +- If target movement is enabled, it moves vertically every 3 steps within bounds (y from 1 to 25, clamped to grid height). +- The wall is fixed at x=17 with height 10 (from y=1 to y=10). + +## 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`. + +## 4. Project Structure +``` +projectile_attack/ +├─ README.md +├─ pyproject.toml +├─ agents.py +├─ model.py +└─ app.py +``` +- `agents.py`: defines Tank, Shell, Target, Wall. +- `model.py`: builds the Mesa model, grid, wall, trajectories, firing mechanics, and win/lose state. +- `run.py`: Solara UI for controls, rendering the grid and overlays, and user interactions. + +## 5. Running the GAME +- From the project root: `solara run app.py` +- First, click the "Pause" button on the left to start the simulation. One simulation can be regarded as one game. In one game, you have five chances to fire the cannonball. Hitting the target is considered a success in this game, otherwise it is a failure. After the game ends, you can click the "Reset" button on the left to reset and start the next game. +- For each launch, you can adjust sliders (angle, power), then press Fire. If the "Fire" button is gray and unclickable, remember to click the "Reloading" button beside it. +- You can also make the target move to increase the difficulty. + +## 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 00000000..5298ef80 --- /dev/null +++ b/examples/projectile_attack/agents.py @@ -0,0 +1,192 @@ +""" +Agent definitions, including Tank, Shell, Target, Wall +""" + +from __future__ import annotations + +from mesa.discrete_space import CellAgent, FixedAgent + +PosF = tuple[float, float] +PosI = tuple[int, int] + + +class Tank(FixedAgent): + # Tank agent - fixed position as the firing origin + def __init__(self, model, pos: PosF = (1.0, 1.0)): + super().__init__(model) + self.pos_f: PosF = pos # floating position + + # Place the tank on the grid + grid_x, grid_y = int(pos[0]), int(pos[1]) + self.cell = self.model.grid[(grid_x, grid_y)] + + # Tank behavior per step - fixed in place, no movement + def step(self) -> None: + return + + +class Wall(FixedAgent): + # Wall agent - fixed obstacle for visualization only + def __init__(self, model, pos: PosI): + super().__init__(model) + grid_x, grid_y = int(pos[0]), int(pos[1]) + self.cell = self.model.grid[(grid_x, grid_y)] + + def step(self) -> None: + return + + +class Shell(CellAgent): + # Shell agent - affected by gravity; can collide + def __init__( + self, + model, + pos_f: PosF, + vx: float = 0.0, + vy: float = 0.0, + ): + """ + Args: + model: Model instance. + pos_f: Floating position (x, y). (Required) + vx: Initial x velocity. + vy: Initial y velocity. + """ + super().__init__(model) + + self.pos_f: PosF = pos_f + self.vx: float = vx + self.vy: float = vy + self.alive: bool = True + + # Initial grid position + grid_x, grid_y = int(pos_f[0]), int(pos_f[1]) + self.cell = self.model.grid[(grid_x, grid_y)] + + def step(self) -> None: + # 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 + ground_y = 1 + + # 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._die(reason="out_of_bounds") + return + + # 2. Ground collision (y < ground_y) + if self.pos_f[1] < ground_y: + self._die(reason="ground") + return + + # 3. Wall collision + if (grid_x, grid_y) in self.model.wall_cells: + self._die(reason="wall") + 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: + self._die() + self.model._handle_target_hit(grid_x, grid_y) + return + + # Update grid position + self.cell = self.model.grid[(grid_x, grid_y)] + + def _die(self, reason: str | None = None) -> None: + if not self.alive: + return + self.alive = False + if reason is not None: + self.model._handle_failure() + if self in self.model.agents: + self.remove() + + +class Target(CellAgent): + # Target agent - fixed position; removed when hit + def __init__(self, model, pos_f: PosF): + super().__init__(model) + + self.pos_f: PosF = pos_f + self.direction: int = 1 + self.move_tick: int = 0 + + grid_x, grid_y = int(pos_f[0]), int(pos_f[1]) + self.cell = self.model.grid[(grid_x, grid_y)] + + # Target behavior per step - optional vertical movement + def step(self) -> None: + 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 + max_y = min(max_y, grid_height - 1) + min_y = max(min_y, 0) + + 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] + self.pos_f = (new_x, new_y) + + self.cell = self.model.grid[(int(new_x), int(new_y))] + + +class ResultText(FixedAgent): + # Result text agent rendered on the grid (win/lose message) + def __init__(self, model, pos: PosI, text: str, kind: str): + super().__init__(model) + self.text = text + self.kind = kind + self.cell = self.model.grid[(int(pos[0]), int(pos[1]))] + + def step(self) -> None: + return diff --git a/examples/projectile_attack/app.py b/examples/projectile_attack/app.py new file mode 100644 index 00000000..7c00202e --- /dev/null +++ b/examples/projectile_attack/app.py @@ -0,0 +1,149 @@ +""" +run.py - solara run run.py +""" + +from __future__ import annotations + +from typing import Any + +import solara + +try: + from .agents import ResultText, Shell, Tank, Target, Wall +except ImportError: + from agents import ResultText, Shell, Tank, Target, Wall +from mesa.visualization import SolaraViz, SpaceRenderer +from mesa.visualization.components import AgentPortrayalStyle, PropertyLayerStyle + +try: + from .model import TankGameModel +except ImportError: + from model import TankGameModel + + +def agent_portrayal(agent: Any) -> AgentPortrayalStyle: + if isinstance(agent, Tank): + return AgentPortrayalStyle(marker="s", size=140, color="tab:green", zorder=5) + if isinstance(agent, Target): + return AgentPortrayalStyle(marker="X", size=170, color="tab:red", zorder=6) + if isinstance(agent, Shell): + return AgentPortrayalStyle(marker="o", size=60, color="black", zorder=7) + if isinstance(agent, Wall): + return AgentPortrayalStyle(marker="s", size=140, color="tab:gray", zorder=1) + if isinstance(agent, ResultText): + text = agent.text.replace("\\", r"\\").replace("{", r"\{").replace("}", r"\}") + color = "tab:green" if agent.kind == "success" else "tab:red" + return AgentPortrayalStyle( + marker=f"$\\text{{{text}}}$", + size=80000, + color=color, + zorder=10, + ) + return AgentPortrayalStyle(marker="o", size=80, zorder=3) + + +def propertylayer_portrayal(layer) -> PropertyLayerStyle | None: + if layer.name == "trajectory": + return PropertyLayerStyle( + color="orange", + alpha=0.7, + vmin=0, + vmax=1, + colorbar=False, + ) + return None + + +@solara.component +def GameControls(model: TankGameModel): + angle, set_angle = solara.use_state(model.angle) + power, set_power = solara.use_state(model.power) + target_movable, set_target_movable = solara.use_state(model.target_movable) + render_tick, set_render_tick = solara.use_state(0) + + def bump_render(): + set_render_tick(render_tick + 1) + + def on_angle_change(value: float) -> None: + set_angle(value) + model.angle = 90.0 - value + + def on_power_change(value: float) -> None: + set_power(value) + model.power = value + + def on_target_movable_change(value: bool) -> None: + set_target_movable(value) + model.target_movable = value + + def fire_with_params() -> None: + model.angle = 90.0 - angle + model.power = power + model.target_movable = target_movable + model.fire() + bump_render() + + with solara.Card("Game Controls"): + solara.SliderFloat( + "Angle", + value=angle, + on_value=on_angle_change, + min=0.0, + max=90.0, + step=1.0, + ) + solara.SliderFloat( + "Power", + value=power, + on_value=on_power_change, + min=0.0, + max=100.0, + step=1.0, + ) + solara.Checkbox( + label="Target movable", + value=target_movable, + on_value=on_target_movable_change, + ) + with solara.Row(gap="12px"): + solara.Button( + "Fire", + on_click=fire_with_params, + disabled=model.shell_exists + or model.game_over + or model.shots_fired >= model.max_shots, + ) + solara.Button( + "Reloading", + on_click=bump_render, + ) + solara.Text( + f"The remaining chances you have: {model.shots_fired}/{model.max_shots}" + ) + + +model_params = { + "width": 35, + "height": 35, +} + +model = TankGameModel() + +renderer = SpaceRenderer(model, backend="matplotlib") +renderer.draw_structure() +if hasattr(renderer, "setup_agents"): + renderer.setup_agents(agent_portrayal).draw_agents() +else: + renderer.draw_agents(agent_portrayal) + +if hasattr(renderer, "setup_propertylayer"): + renderer.setup_propertylayer(propertylayer_portrayal).draw_propertylayer() +else: + renderer.draw_propertylayer(propertylayer_portrayal) + +page = SolaraViz( + model, + renderer=renderer, + model_params=model_params, + components=[(GameControls, 0)], +) diff --git a/examples/projectile_attack/model.py b/examples/projectile_attack/model.py new file mode 100644 index 00000000..0642ff24 --- /dev/null +++ b/examples/projectile_attack/model.py @@ -0,0 +1,218 @@ +""" +Rules definition +""" + +from __future__ import annotations + +import math + +try: + from .agents import ResultText, Shell, Tank, Target, Wall +except ImportError: + from agents import ResultText, Shell, Tank, Target, Wall +from mesa import Model +from mesa.discrete_space import OrthogonalMooreGrid + + +class TankGameModel(Model): + def __init__( + self, + angle: float = 45.0, + power: float = 70.0, + target_movable: bool = False, + width: int = 35, + height: int = 35, + seed: int | None = None, + ): + super().__init__(seed=seed) + + # UI parameters (modifiable) + self.angle = angle + self.power = power + self.target_movable = target_movable + + # Physical constants + self.g = 0.01 # gravitational acceleration + self.vmax = 1.0 # maximum speed + + # Grid + self.grid = OrthogonalMooreGrid((width, height), random=self.random) + self.space = self.grid + + # State variables + self.tank: Tank | None = None + self.target: Target | None = None + self.wall_cells: set[tuple[int, int]] = set() + self.trajectory_cells: set[tuple[int, int]] = set() + self.trajectory_layer = self.grid.create_property_layer( + "trajectory", default_value=0, dtype=int + ) + + # Game result state + self.game_over = False + self.failed = False + self.max_shots = 5 + self.shots_fired = 0 + self.time = 0.0 + + # Fixed wall configuration (no UI control) + self.wall_position = 17 + self.wall_height = 10 + + self._initialize_game() + + def _build_wall_cells(self) -> None: + # 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 _create_wall_agents(self) -> None: + # Create wall agents for visualization (fixed wall) + if self.agents_by_type.get(Wall): + return + for cell in self.wall_cells: + Wall(self, pos=cell) + + def add_trajectory_cell(self, cell: tuple[int, int]) -> None: + # 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)) + self.trajectory_layer.data[x, y] = 1 + + def clear_trajectory(self) -> None: + # Clear current round's shell trajectory + self.trajectory_cells.clear() + self.trajectory_layer.data.fill(0) + + def _clear_result_text(self) -> None: + # Remove result text agents from the grid/model if present + result_agents = list(self.agents_by_type.get(ResultText, [])) + if not result_agents: + return + for agent in result_agents: + if agent in self.agents: + agent.remove() + + def _show_result_text(self, text: str, kind: str) -> None: + # Create one or more result text agents at grid center + self._clear_result_text() + center_x = self.grid.width // 2 + center_y = int(self.grid.height * 0.8) + lines = [line for line in text.split("\n") if line.strip()] + if not lines: + return + spacing = 2 + start_y = center_y + (len(lines) - 1) * spacing // 2 + for idx, line in enumerate(lines): + y = start_y - idx * spacing + ResultText( + self, + pos=(center_x, y), + text=line, + kind=kind, + ) + + @property + def shell_exists(self) -> bool: + # Check whether a live shell exists + shells = self.agents_by_type.get(Shell) + if not shells: + return False + return any(shell.alive for shell in shells) + + @property + def target_exists(self) -> bool: + # Check whether the target exists and is still in the model + return self.target is not None and self.target in self.agents + + def _initialize_game(self) -> None: + # Initialize the game: create tank, target, and wall + # Create tank at (1.0, 1.0) + self.tank = Tank(self, pos=(1.0, 1.0)) + + # Create target + target_y = self.random.randint(1, 25) + self.target = Target(self, pos_f=(self.grid.width - 2, float(target_y))) + + # Build wall cells + self._build_wall_cells() + self._create_wall_agents() + + self.running = True + self.shots_fired = 0 + + def fire(self) -> None: + # Fire a shell + if self.shell_exists: + return + if self.tank is None: + return + if self.shots_fired >= self.max_shots: + if not self.game_over: + self._handle_out_of_shots() + return + self._clear_result_text() + if self.game_over or not self.running: + self.game_over = False + self.failed = False + self.running = True + + speed = self.power / 100.0 + angle_rad = math.radians(self.angle) + + vx = math.sin(angle_rad) * speed + vy = math.cos(angle_rad) * speed + + self.shots_fired += 1 + Shell(self, pos_f=self.tank.pos_f, vx=vx, vy=vy) + + def _handle_target_hit(self, hit_x: int, hit_y: int) -> None: + # Handle target hit: remove target and create explosion effect + self.clear_trajectory() + + # Remove target + if self.target is not None and self.target in self.agents: + self.target.remove() + self.target = None + + self.game_over = True + self.failed = False + self._show_result_text( + "Congratulations! YOU WIN!!!\n\nPerhaps you could make the target move to increase the difficulty.", + "success", + ) + + def _handle_failure(self) -> None: + # Handle failure conditions (missed shot, boundary/wall hit) + if self.game_over: + return + self.clear_trajectory() + if self.shots_fired >= self.max_shots: + self._handle_out_of_shots() + else: + self.game_over = False + self.failed = False + + def _handle_out_of_shots(self) -> None: + # Stop the game when no shots remain + self.game_over = True + self.failed = True + self.running = False + self._show_result_text( + 'You have no chance!\n\nPlease click the left "Reset" button to try again.', + "fail", + ) + + def step(self) -> None: + if self.running and not self.target_exists: + self.running = False + if self.running: + self.agents.shuffle_do("step") + prev_time = self.time + super().step() + if self.time == prev_time: + self.time = prev_time + 1.0 diff --git a/examples/projectile_attack/pyproject.toml b/examples/projectile_attack/pyproject.toml new file mode 100644 index 00000000..c4323fca --- /dev/null +++ b/examples/projectile_attack/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "week4-projectile-attack" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "altair>=6.0.0", + "matplotlib>=3.10.8", + "mesa>=3.3.1", + "networkx>=3.6.1", + "solara>=1.56.0", + "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/tank_game_vis.png b/examples/projectile_attack/tank_game_vis.png new file mode 100644 index 00000000..0d8b95ca Binary files /dev/null and b/examples/projectile_attack/tank_game_vis.png differ