import math
import random
import pygame
import numpy as np
# ============================================================
# Global parameters
# ============================================================
DT = 0.01
FPS = 60
FORWARD_SPEED = 800.0
TOPVIEW_SPAN = 8000.0 # map shows x,y in [-8000, +8000]
TARGET_HALF_SIZE = 500
# Dynamics coefficients
PITCH_DAMP = 1.5
PITCH_GAIN = 10.0
ROLL_DAMP = 1.5
ROLL_GAIN = 12.0
TURN_GAIN = -0.5
# Random disturbances
PITCH_DISTURB = 2
ROLL_DISTURB = 2
# FBW control gains
KP_THETA = 2.5
KD_THETA = 1.2
KP_PHI = 2.5
KD_PHI = 1.2
MAX_DEFLECTION = 1.0
# Gravity
G = 9.81 * 50
BOMB_DROP_ALT = 2000
# Target corridor and airspace
TARGET_X = 8000.0
TARGET_Y = 0.0
TARGET_LENGTH = 1000.0
TARGET_WIDTH = 1000.0
AIRSPACE_RADIUS = 6000.0 # kept, but not used anymore in rectangle logic
# Missile parameters
MISSILE_SPEED = 2000.0 # faster than aircraft so it can catch you
MISSILE_KILL_RADIUS = 250.0
MISSILE_BACK_DISTANCE = 8000.0
# Flare parameters
FLARE_LIFETIME = 5.0 # seconds
FLARE_SPAWN_SPACING = 200.0
FLARE_MISSILE_RADIUS = 2500.0
FLARE_SUCCESS_PROB = 1.0 # 100% effective now
class Aircraft:
def __init__(self):
self.theta = 0.0
self.q = 0.0
self.phi = 0.0
self.p = 0.0
self.h = 2000.0
# Start bottom-center of the top view, pointing up
self.x = random.uniform(TOPVIEW_SPAN * -0.9, TOPVIEW_SPAN * 0.9)
# self.x = 0
self.y = -TOPVIEW_SPAN # bottom edge
self.psi = 0.0 # 0 rad = forward (up)
def step(self, delta_e, delta_a, dt):
# --- pitch dynamics with disturbances ---
theta_dot = self.q
q_dot = -PITCH_DAMP * self.q + PITCH_GAIN * delta_e
q_dot += PITCH_DISTURB * random.gauss(0.0, 1.0)
# --- roll dynamics with disturbances ---
phi_dot = self.p
p_dot = -ROLL_DAMP * self.p + ROLL_GAIN * delta_a
p_dot += ROLL_DISTURB * random.gauss(0.0, 1.0)
# integrate attitude
self.theta += theta_dot * dt
self.q += q_dot * dt
self.phi += phi_dot * dt
self.p += p_dot * dt
# altitude
h_dot = FORWARD_SPEED * math.sin(self.theta)
self.h += h_dot * dt
if self.h < 0.0:
self.h = 0.0
# --- heading & ground track ---
psi_dot = -TURN_GAIN * self.phi # sign chosen so right roll → right turn
self.psi += psi_dot * dt
x_dot = FORWARD_SPEED * math.sin(self.psi)
y_dot = FORWARD_SPEED * math.cos(self.psi)
self.x += x_dot * dt
self.y += y_dot * dt
def get_sensors(self):
return {
"theta": self.theta,
"q": self.q,
"phi": self.phi,
"p": self.p,
"h": self.h,
"x": self.x,
"y": self.y,
"psi": self.psi,
}
class FBWController:
def __init__(self):
self.theta_cmd = 0.0
self.phi_cmd = 0.0
def update_commands_from_keys(self, keys, dt):
cmd_rate_deg = 20.0
if keys[pygame.K_UP]:
self.theta_cmd += math.radians(cmd_rate_deg) * dt
if keys[pygame.K_DOWN]:
self.theta_cmd -= math.radians(cmd_rate_deg) * dt
if keys[pygame.K_RIGHT]:
self.phi_cmd += math.radians(cmd_rate_deg) * dt
if keys[pygame.K_LEFT]:
self.phi_cmd -= math.radians(cmd_rate_deg) * dt
max_cmd = math.radians(30.0)
self.theta_cmd = max(-max_cmd, min(max_cmd, self.theta_cmd))
self.phi_cmd = max(-max_cmd, min(max_cmd, self.phi_cmd))
def compute_controls(self, sensors):
theta = sensors["theta"]
q = sensors["q"]
phi = sensors["phi"]
p = sensors["p"]
e_theta = self.theta_cmd - theta
delta_e = KP_THETA * e_theta - KD_THETA * q
e_phi = self.phi_cmd - phi
delta_a = KP_PHI * e_phi - KD_PHI * p
delta_e = max(-MAX_DEFLECTION, min(MAX_DEFLECTION, delta_e))
delta_a = max(-MAX_DEFLECTION, min(MAX_DEFLECTION, delta_a))
return delta_e, delta_a
class Bomb:
def __init__(self, x, y, h, psi):
# Start where the aircraft was when B was pressed
self.x = x
self.y = y
# Effective altitude: use real h, but cap so impact isn't too slow
self.h = min(h, BOMB_DROP_ALT)
# Bomb inherits the aircraft's horizontal velocity at release
self.vx = FORWARD_SPEED * math.sin(psi)
self.vy = FORWARD_SPEED * math.cos(psi)
# Start with zero vertical speed, then accelerate downward
self.vz = 0.0
self.active = True
self.impact_pos = None
self.reported = False # used to only report impact once
def step(self, dt):
if not self.active:
return
# Gravity acts downward
self.vz -= G * dt
# Update position
self.x += self.vx * dt
self.y += self.vy * dt
self.h += self.vz * dt
# Check for ground impact
if self.h <= 0.0:
self.h = 0.0
self.active = False
self.impact_pos = (self.x, self.y)
class Missile:
def __init__(self, x, y, h, target_aircraft):
self.x = x
self.y = y
self.h = h
self.target = target_aircraft
self.active = True
def distance_to_target(self):
dx = self.target.x - self.x
dy = self.target.y - self.y
dz = self.target.h - self.h
return math.sqrt(dx * dx + dy * dy + dz * dz)
def step(self, dt):
if not self.active:
return None
dx = self.target.x - self.x
dy = self.target.y - self.y
dz = self.target.h - self.h
dist = math.sqrt(dx * dx + dy * dy + dz * dz) + 1e-6
ux = dx / dist
uy = dy / dist
uz = dz / dist
self.x += MISSILE_SPEED * ux * dt
self.y += MISSILE_SPEED * uy * dt
self.h += MISSILE_SPEED * uz * dt
if dist < MISSILE_KILL_RADIUS:
self.active = False
return "hit"
return None
class Flare:
def __init__(self, x, y, h, spawn_time):
self.x = x
self.y = y
self.h = h
self.spawn_time = spawn_time
self.active = True
def update(self, current_time):
if current_time - self.spawn_time > FLARE_LIFETIME:
self.active = False
class FBWSim:
def __init__(self):
pygame.init()
self.width = 1100
self.height = 650
self.screen = pygame.display.set_mode((self.width, self.height))
pygame.display.set_caption("B-2 Style FBW Mission")
self.fbw_mode_text = "FBW: ON (attitude hold)"
self.track_points = []
self.clock = pygame.time.Clock()
self.font = pygame.font.SysFont("consolas", 18)
self.aircraft = Aircraft()
self.controller = FBWController()
self.running = True
self.sim_time = 0.0
# Bomb state (supports 2 bombs)
self.bombs = []
self.max_bombs = 2
self.bombs_remaining = self.max_bombs
self.bomb_hit_target = False
self.bomb_miss = False
self.bomb_result_time = None
self.BOMB_MSG_DURATION = 8.0
self.bomb_away_time = None
self.BOMB_AWAY_DURATION = 3.0
# Enemy airspace and missile
self.entered_airspace = False
self.next_missile_spawn_time = None # for continuous spawns
self.missile = None
self.missile_launched = False
self.missile_hit = False
# Flares
self.flares = []
self.flare_message_time = None
self.FLARE_MSG_DURATION = 3.0
self.missile_spoofed = False
self.missile_spoofed_time = None
self.SPOOF_MSG_DURATION = 5.0
self.flare_used = False # track if player ever used flares
# FBW mode
self.fbw_on = True
self.fbw_mode_text = "FBW: ON (attitude hold)"
# Game-over state when missile hits and no flares were used
self.game_over = False
self.game_over_time = None
self.GAME_OVER_DURATION = 3.0
def in_target_corridor(self, x, y):
# HIT if impact is inside the central 2 km x 2 km square
return (-TARGET_HALF_SIZE <= x <= TARGET_HALF_SIZE and
-TARGET_HALF_SIZE <= y <= TARGET_HALF_SIZE)
def in_enemy_airspace(self, x, y):
# Make entire 8k x 8k world box "enemy airspace"
# so missile timer starts right away
return (-TOPVIEW_SPAN <= x <= TOPVIEW_SPAN and
-TOPVIEW_SPAN <= y <= TOPVIEW_SPAN)
def draw_horizon(self, theta, phi):
center_x = self.width // 2
center_y = self.height // 2
size = 300
pitch_scale = 10.0
y_offset = theta * pitch_scale
half = size // 2
pts = np.array([[-half, 0.0], [half, 0.0]])
c = math.cos(phi)
s = math.sin(phi)
R = np.array([[c, -s], [s, c]])
pts_rot = pts @ R.T
sky_color = (80, 120, 200)
ground_color = (100, 80, 60)
self.screen.fill(sky_color)
pygame.draw.rect(
self.screen,
ground_color,
pygame.Rect(0, center_y + y_offset, self.width, self.height)
)
pts_screen = []
for x, y in pts_rot:
sx = int(center_x + x)
sy = int(center_y + y + y_offset)
pts_screen.append((sx, sy))
pygame.draw.line(self.screen, (255, 255, 255), pts_screen[0], pts_screen[1], 3)
pygame.draw.line(
self.screen,
(255, 255, 0),
(center_x - 20, center_y),
(center_x + 20, center_y),
3,
)
pygame.draw.line(
self.screen,
(255, 255, 0),
(center_x, center_y - 10),
(center_x, center_y + 10),
2,
)
def draw_ground_map(self, sensors):
map_width = 350
map_height = 200
margin = 20
x0 = margin
y0 = self.height - map_height - margin
# Outline box
pygame.draw.rect(self.screen, (30, 30, 30), (x0, y0, map_width, map_height), 1)
# World region: x,y in [-TOPVIEW_SPAN, +TOPVIEW_SPAN]
span = TOPVIEW_SPAN
def world_to_map(wx, wy):
# Normalized to [-1, +1]
nx = wx / span
ny = wy / span
# Clamp to keep dot inside
nx = max(-1.0, min(1.0, nx))
ny = max(-1.0, min(1.0, ny))
# Map:
# nx = -1 -> left , +1 -> right
# ny = -1 -> bottom , +1 -> top
sx = x0 + int((nx + 1.0) * 0.5 * map_width)
sy = y0 + map_height - int((ny + 1.0) * 0.5 * map_height)
return sx, sy
# --- draw target square (1k x 1k) centered at (0,0) ---
tl = world_to_map(-TARGET_HALF_SIZE, +TARGET_HALF_SIZE)
br = world_to_map(+TARGET_HALF_SIZE, -TARGET_HALF_SIZE)
rect_w = br[0] - tl[0]
rect_h = br[1] - tl[1]
pygame.draw.rect(self.screen, (200, 50, 50), (tl[0], tl[1], rect_w, rect_h), 2)
# --- draw aircraft ground track (tracer) ---
if len(self.track_points) > 1:
prev_screen = None
for wx, wy in self.track_points:
sx, sy = world_to_map(wx, wy)
if prev_screen is not None:
pygame.draw.line(self.screen, (0, 180, 0), prev_screen, (sx, sy), 1)
prev_screen = (sx, sy)
# --- draw aircraft ---
ax, ay = world_to_map(sensors["x"], sensors["y"])
pygame.draw.circle(self.screen, (0, 255, 0), (ax, ay), 4)
# --- draw bomb impacts (all bombs) ---
for bomb in self.bombs:
if bomb.impact_pos is not None:
bx, by = world_to_map(bomb.impact_pos[0], bomb.impact_pos[1])
pygame.draw.circle(self.screen, (255, 200, 0), (bx, by), 4)
text = self.font.render("Top view (target + track)", True, (255, 255, 255))
self.screen.blit(text, (x0, y0 - 20))
def draw_rear_view(self, sensors):
rv_width = 350
rv_height = 200
margin = 20
x0 = self.width - rv_width - margin
y0 = self.height - rv_height - margin
pygame.draw.rect(self.screen, (10, 10, 10), (x0, y0, rv_width, rv_height))
pygame.draw.rect(self.screen, (200, 200, 200), (x0, y0, rv_width, rv_height), 1)
cx = x0 + rv_width // 2
cy = y0 + rv_height - 30
pygame.draw.polygon(
self.screen,
(0, 255, 0),
[(cx, cy),
(cx - 10, cy + 20),
(cx + 10, cy + 20)]
)
label = self.font.render("Rear view (missile + flares)", True, (255, 255, 255))
self.screen.blit(label, (x0 + 5, y0 + 5))
# Missile
if self.missile and (self.missile.active or self.missile_hit or self.missile_spoofed):
dx = self.missile.x - sensors["x"]
dy = self.missile.y - sensors["y"]
dz = self.missile.h - sensors["h"]
psi = sensors["psi"]
c = math.cos(-psi)
s = math.sin(-psi)
fx = c * dx - s * dy
fy = s * dx + c * dy
rx = -fx
ry = dz
scale_x = 0.01
scale_y = 0.01
mx = cx + int(rx * scale_x)
my = cy - int(ry * scale_y)
mx = max(x0 + 10, min(x0 + rv_width - 10, mx))
my = max(y0 + 20, min(y0 + rv_height - 10, my))
if self.missile.active:
color = (255, 50, 50)
elif self.missile_hit:
color = (255, 150, 150)
else:
color = (180, 180, 255)
pygame.draw.circle(self.screen, color, (mx, my), 5)
# Flares
for flare in self.flares:
if not flare.active:
continue
dx = flare.x - sensors["x"]
dy = flare.y - sensors["y"]
dz = flare.h - sensors["h"]
psi = sensors["psi"]
c = math.cos(-psi)
s = math.sin(-psi)
fx = c * dx - s * dy
fy = s * dx + c * dy
rx = -fx
ry = dz
scale_x = 0.01
scale_y = 0.01
fx_screen = cx + int(rx * scale_x)
fy_screen = cy - int(ry * scale_y)
fx_screen = max(x0 + 10, min(x0 + rv_width - 10, fx_screen))
fy_screen = max(y0 + 20, min(y0 + rv_height - 10, fy_screen))
pygame.draw.circle(self.screen, (255, 140, 0), (fx_screen, fy_screen), 4)
def draw_hud_text(self, sensors):
theta_deg = math.degrees(sensors["theta"])
phi_deg = math.degrees(sensors["phi"])
psi_deg = math.degrees(sensors["psi"])
h = sensors["h"]
x = sensors["x"]
y = sensors["y"]
dist_to_target = math.sqrt((x - TARGET_X) ** 2 + (y - TARGET_Y) ** 2)
lines = [
f"Yaw : {psi_deg:6.1f} deg",
f"Pitch: {theta_deg:6.1f} deg Cmd: {math.degrees(self.controller.theta_cmd):6.1f} deg",
f"Roll : {phi_deg:6.1f} deg Cmd: {math.degrees(self.controller.phi_cmd):6.1f} deg",
f"Alt : {h:7.1f} m",
f"Range to target: {dist_to_target:7.1f} m",
self.fbw_mode_text,
f"Bombs (B): {self.bombs_remaining}/{self.max_bombs}",
"Flares (F): unlimited",
"Controls: Arrows = pitch/roll, B = bomb, F = flares, C = toggle FBW, ESC = quit",
]
if self.in_target_corridor(x, y) and self.bombs_remaining > 0 and not self.game_over:
lines.append(f"Over target corridor: press B to drop bomb ({self.bombs_remaining}/{self.max_bombs})")
if self.bomb_away_time is not None and self.sim_time - self.bomb_away_time < self.BOMB_AWAY_DURATION:
lines.append("Bomb away")
if self.bomb_result_time is not None and self.sim_time - self.bomb_result_time < self.BOMB_MSG_DURATION:
if self.bomb_hit_target:
lines.append("Bomb impact: TARGET HIT")
elif self.bomb_miss:
lines.append("Bomb impact: MISS")
if self.missile_launched and self.missile and self.missile.active and not self.missile_hit:
lines.append("Missile inbound")
if self.missile_hit:
lines.append("Missile impact: AIRCRAFT HIT")
if self.flare_message_time is not None and self.sim_time - self.flare_message_time < self.FLARE_MSG_DURATION:
lines.append("Flares deployed")
if self.missile_spoofed and self.missile_spoofed_time is not None:
if self.sim_time - self.missile_spoofed_time < self.SPOOF_MSG_DURATION:
lines.append("Missile spoofed by flares")
if self.game_over:
lines.append("GAME OVER: Missile hit you")
x0 = 20
y0 = 20
for line in lines:
surf = self.font.render(line, True, (255, 255, 255))
self.screen.blit(surf, (x0, y0))
y0 += 22
def spawn_missile_if_time(self, sensors):
x = sensors["x"]
y = sensors["y"]
# First time entering enemy airspace starts the missile logic
if not self.entered_airspace:
if self.in_enemy_airspace(x, y):
self.entered_airspace = True
self.next_missile_spawn_time = self.sim_time + random.uniform(1.0, 4.0)
return
# If we're "out" of airspace (shouldn't really happen with whole box), do nothing
if not self.in_enemy_airspace(x, y):
return
if self.missile_hit:
return
# If no next spawn time set, schedule one
if self.next_missile_spawn_time is None:
self.next_missile_spawn_time = self.sim_time + random.uniform(1.0, 4.0)
# Wait until it's time and there's no active missile
if self.sim_time >= self.next_missile_spawn_time:
if not (self.missile and self.missile.active):
back_dist = MISSILE_BACK_DISTANCE
psi = sensors["psi"]
mx = x - back_dist * math.sin(psi)
my = y - back_dist * math.cos(psi)
mh = max(sensors["h"], 300.0)
self.missile = Missile(mx, my, mh, self.aircraft)
self.missile_launched = True
self.missile_spoofed = False
self.missile_spoofed_time = None
# Schedule the next missile spawn
self.next_missile_spawn_time = self.sim_time + random.uniform(1.0, 4.0)
def update_bomb_logic(self):
# Step all active bombs
for bomb in self.bombs:
if bomb.active:
bomb.step(DT)
# Process impacts once
for bomb in self.bombs:
if (not bomb.active) and bomb.impact_pos is not None and not bomb.reported:
bx, by = bomb.impact_pos
if self.in_target_corridor(bx, by):
self.bomb_hit_target = True
self.bomb_miss = False
else:
self.bomb_hit_target = False
self.bomb_miss = True
self.bomb_result_time = self.sim_time
bomb.reported = True
if self.bomb_result_time is not None:
if self.sim_time - self.bomb_result_time > self.BOMB_MSG_DURATION:
self.bomb_result_time = None
def update_missile_logic(self):
if self.missile and self.missile.active:
result = self.missile.step(DT)
if result == "hit":
self.missile_hit = True
# If hit and no flares were ever used, trigger game over
if not self.game_over:
self.game_over = True
self.game_over_time = self.sim_time
def update_flares(self):
# Age out flares
for flare in self.flares:
flare.update(self.sim_time)
# Keep only active
self.flares = [f for f in self.flares if f.active]
# Check missile spoofing
if self.missile and self.missile.active and self.flares:
for flare in self.flares:
dx = self.missile.x - flare.x
dy = self.missile.y - flare.y
dz = self.missile.h - flare.h
dist = math.sqrt(dx * dx + dy * dy + dz * dz)
if dist < FLARE_MISSILE_RADIUS:
# 100% success
if random.random() < FLARE_SUCCESS_PROB:
self.missile.active = False
self.missile_spoofed = True
self.missile_spoofed_time = self.sim_time
break
def deploy_flares(self, sensors):
# Spawn a short string of flares behind aircraft
psi = sensors["psi"]
back_dir_x = -math.cos(psi)
back_dir_y = -math.sin(psi)
for k in range(3):
fx = sensors["x"] + back_dir_x * (100.0 + k * FLARE_SPAWN_SPACING)
fy = sensors["y"] + back_dir_y * (100.0 + k * FLARE_SPAWN_SPACING)
fh = sensors["h"]
self.flares.append(Flare(fx, fy, fh, self.sim_time))
self.flare_message_time = self.sim_time
self.flare_used = True
def run(self):
while self.running:
self.sim_time += DT
# 1) Handle events (key presses, quit, etc.)
for event in pygame.event.get():
if event.type == pygame.QUIT:
self.running = False
if event.type == pygame.KEYDOWN and not self.game_over:
if event.key == pygame.K_b and self.bombs_remaining > 0:
sensors = self.aircraft.get_sensors()
new_bomb = Bomb(sensors["x"], sensors["y"], sensors["h"], sensors["psi"])
self.bombs.append(new_bomb)
self.bombs_remaining -= 1
self.bomb_away_time = self.sim_time
if event.key == pygame.K_c:
self.fbw_on = not self.fbw_on
if self.fbw_on:
self.fbw_mode_text = "FBW: ON (attitude hold)"
else:
self.fbw_mode_text = "FBW: OFF (direct surfaces)"
if event.key == pygame.K_f:
sensors = self.aircraft.get_sensors()
self.deploy_flares(sensors)
# 2) Poll held keys
keys = pygame.key.get_pressed()
if keys[pygame.K_ESCAPE]:
self.running = False
# If game over, just show frozen scene + message, then exit after some time
if self.game_over:
sensors = self.aircraft.get_sensors()
self.draw_horizon(sensors["theta"], sensors["phi"])
self.draw_hud_text(sensors)
self.draw_ground_map(sensors)
self.draw_rear_view(sensors)
pygame.display.flip()
self.clock.tick(FPS)
if self.game_over_time is not None:
if self.sim_time - self.game_over_time > self.GAME_OVER_DURATION:
self.running = False
continue
# 3) Compute control inputs (FBW ON vs OFF)
if self.fbw_on:
self.controller.update_commands_from_keys(keys, DT)
sensors = self.aircraft.get_sensors()
delta_e, delta_a = self.controller.compute_controls(sensors)
else:
delta_e = 0.0
delta_a = 0.0
step = 0.5
if keys[pygame.K_UP]:
delta_e += step
if keys[pygame.K_DOWN]:
delta_e -= step
if keys[pygame.K_RIGHT]:
delta_a += step
if keys[pygame.K_LEFT]:
delta_a -= step
delta_e = max(-MAX_DEFLECTION, min(MAX_DEFLECTION, delta_e))
delta_a = max(-MAX_DEFLECTION, min(MAX_DEFLECTION, delta_a))
# 4) Step aircraft physics
self.aircraft.step(delta_e, delta_a, DT)
sensors = self.aircraft.get_sensors()
# 5) Record position for tracer
self.track_points.append((sensors["x"], sensors["y"]))
if len(self.track_points) > 2000:
self.track_points.pop(0)
# 6) Update game logic
self.update_bomb_logic()
self.spawn_missile_if_time(sensors)
self.update_missile_logic()
self.update_flares()
# 7) Draw everything
self.draw_horizon(sensors["theta"], sensors["phi"])
self.draw_hud_text(sensors)
self.draw_ground_map(sensors)
self.draw_rear_view(sensors)
pygame.display.flip()
self.clock.tick(FPS)
pygame.quit()
if __name__ == "__main__":
sim = FBWSim()
sim.run()