Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions examples/projectile_attack/README.md
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
![Tank Game UI](tank_game_vis.png)

192 changes: 192 additions & 0 deletions examples/projectile_attack/agents.py
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
149 changes: 149 additions & 0 deletions examples/projectile_attack/app.py
Copy link
Member

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.py for Solara visualisations.

Copy link
Author

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.

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)],
)
Loading