-
-
Notifications
You must be signed in to change notification settings - Fork 209
Add a new example: Tank Game #310
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
LuHaot1an
wants to merge
3
commits into
mesa:main
Choose a base branch
from
LuHaot1an:add_example_projectile_attack
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|  | ||
|
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)], | ||
| ) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The convention is to use
app.pyfor Solara visualisations.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have already modified the file name.