from __future__ import annotations
import math
import random
from dataclasses import dataclass
import pygame
WIDTH, HEIGHT = 960, 640
FPS = 60
ROAD_DEPTH = 120.0
PLAYER_DIST = 8.0
ROAD_BOTTOM_Y = HEIGHT + 28
HORIZON_Y = 108
ROAD_TOP_HALF = 92
ROAD_BOTTOM_HALF = 320
ROAD_CENTER_X = WIDTH * 0.5
LANE_COUNT = 5
LANE_SPAN = (LANE_COUNT - 1) / 2
MAX_SQUAD = 48
BOSS_TRIGGER_DISTANCE = 450.0
SKY_TOP = (68, 145, 255)
SKY_BOTTOM = (184, 228, 255)
SAND_NEAR = (237, 200, 124)
SAND_FAR = (245, 221, 164)
ROAD_COLOR = (63, 66, 77)
ROAD_EDGE = (151, 118, 90)
LANE_COLOR = (245, 245, 245)
PLAYER_BLUE = (57, 140, 255)
PLAYER_RING = (84, 255, 120)
ENEMY_RED = (255, 97, 88)
ENEMY_DARK = (140, 44, 48)
BULLET_GOLD = (255, 198, 88)
BOSS_PURPLE = (159, 92, 255)
BOSS_DARK = (62, 27, 122)
TEXT_DARK = (24, 20, 34)
WHITE = (255, 255, 255)
GOOD_GREEN = (114, 255, 154)
WARN_ORANGE = (255, 162, 90)
BAD_RED = (255, 102, 102)
GATE_LEFT = (255, 110, 96, 128)
GATE_RIGHT = (104, 196, 255, 132)
def clamp(value: float, low: float, high: float) -> float:
return max(low, min(high, value))
def lerp(a: float, b: float, t: float) -> float:
return a + (b - a) * t
def blend(color_a: tuple[int, int, int], color_b: tuple[int, int, int], t: float) -> tuple[int, int, int]:
return (
int(lerp(color_a[0], color_b[0], t)),
int(lerp(color_a[1], color_b[1], t)),
int(lerp(color_a[2], color_b[2], t)),
)
def format_operation(operation: tuple[str, int]) -> str:
kind, value = operation
if kind == "*":
return f"x{value}"
if kind == "/":
return f"/{value}"
return f"{kind}{value}"
@dataclass
class Bullet:
lane: float
dist: float
speed: float
damage: int = 1
@dataclass
class Enemy:
lane: float
dist: float
hp: int
speed: float
kind: str
wobble: float
reward: int
@dataclass
class GatePair:
dist: float
left: tuple[str, int]
right: tuple[str, int]
applied: bool = False
@dataclass
class BossBullet:
lane: float
dist: float
speed: float
@dataclass
class FloatingText:
text: str
x: float
y: float
color: tuple[int, int, int]
timer: float = 0.95
def update(self, dt: float) -> None:
self.y -= 34 * dt
self.timer -= dt
@dataclass
class Boss:
dist: float
target_dist: float
hp: int
max_hp: int
attack_timer: float
minion_timer: float
phase: float = 0.0
lane: float = 0.0
class Player:
def __init__(self) -> None:
self.lane = 0.0
self.squad = 3
self.fire_timer = 0.1
self.invuln = 0.0
self.hit_flash = 0.0
@property
def shot_count(self) -> int:
return int(clamp(1 + self.squad // 3, 1, 8))
@property
def fire_interval(self) -> float:
return clamp(0.42 - self.shot_count * 0.02, 0.24, 0.42)
@property
def formation_width(self) -> float:
return 0.68 + min(1.15, self.squad * 0.045)
def apply_operation(self, operation: tuple[str, int]) -> int:
old_value = self.squad
kind, value = operation
if kind == "+":
self.squad += value
elif kind == "-":
self.squad = max(1, self.squad - value)
elif kind == "*":
self.squad = min(MAX_SQUAD, self.squad * value)
elif kind == "/":
self.squad = max(1, math.ceil(self.squad / value))
self.squad = int(clamp(self.squad, 1, MAX_SQUAD))
return self.squad - old_value
def take_damage(self, amount: int) -> None:
self.squad -= amount
self.invuln = 0.55
self.hit_flash = 0.28
class NumberSquadRaid:
def __init__(self) -> None:
pygame.init()
pygame.display.set_caption("Number Squad Highway")
self.screen = pygame.display.set_mode((WIDTH, HEIGHT))
self.clock = pygame.time.Clock()
self.rng = random.Random()
self.font_small = pygame.font.Font(None, 28)
self.font_medium = pygame.font.Font(None, 38)
self.font_large = pygame.font.Font(None, 72)
self.font_huge = pygame.font.Font(None, 98)
self.state = "title"
self.demo_scroll = 0.0
self.reset_run()
def reset_run(self) -> None:
self.player = Player()
self.bullets: list[Bullet] = []
self.enemies: list[Enemy] = []
self.gates: list[GatePair] = []
self.boss_bullets: list[BossBullet] = []
self.floating_texts: list[FloatingText] = []
self.boss: Boss | None = None
self.travel_distance = 0.0
self.world_speed = 18.0
self.enemy_timer = 0.75
self.gate_timer = 3.6
self.score = 0
self.shake = 0.0
self.flash = 0.0
self.run_time = 0.0
def start_game(self) -> None:
self.reset_run()
self.state = "play"
def project_lane(self, lane: float, dist: float) -> tuple[float, float, float]:
depth_ratio = 1.0 - clamp(dist / ROAD_DEPTH, 0.0, 1.0)
depth_ratio = depth_ratio ** 1.05
half_width = lerp(ROAD_TOP_HALF, ROAD_BOTTOM_HALF, depth_ratio)
y = lerp(HORIZON_Y, ROAD_BOTTOM_Y, depth_ratio)
x = ROAD_CENTER_X + (lane / LANE_SPAN) * half_width * 0.78
scale = lerp(0.28, 1.22, depth_ratio)
return x, y, scale
def project_offset(self, offset_ratio: float, dist: float) -> tuple[float, float, float]:
depth_ratio = 1.0 - clamp(dist / ROAD_DEPTH, 0.0, 1.0)
depth_ratio = depth_ratio ** 1.05
half_width = lerp(ROAD_TOP_HALF, ROAD_BOTTOM_HALF, depth_ratio)
y = lerp(HORIZON_Y, ROAD_BOTTOM_Y, depth_ratio)
x = ROAD_CENTER_X + offset_ratio * half_width
scale = lerp(0.28, 1.22, depth_ratio)
return x, y, scale
def get_player_units(self) -> list[tuple[float, float, float, bool]]:
base_x, base_y, scale = self.project_lane(self.player.lane, PLAYER_DIST)
display_count = min(self.player.squad, 15)
slots_per_row = 5
units: list[tuple[float, float, float, bool]] = []
for index in range(display_count - 1, -1, -1):
row = index // slots_per_row
index_in_row = index % slots_per_row
remaining_in_row = min(slots_per_row, display_count - row * slots_per_row)
offset_x = (index_in_row - (remaining_in_row - 1) / 2) * 22 * scale
offset_y = row * 22 * scale
unit_scale = scale * (0.95 - row * 0.05)
units.append((base_x + offset_x, base_y + offset_y, unit_scale, index == 0))
return units
def ellipse_overlap(
self,
center_a: tuple[float, float],
radius_a: tuple[float, float],
center_b: tuple[float, float],
radius_b: tuple[float, float],
) -> bool:
combined_x = radius_a[0] + radius_b[0]
combined_y = radius_a[1] + radius_b[1]
if combined_x <= 0 or combined_y <= 0:
return False
dx = (center_a[0] - center_b[0]) / combined_x
dy = (center_a[1] - center_b[1]) / combined_y
return dx * dx + dy * dy <= 1.0
def get_player_hitboxes(self) -> list[tuple[tuple[float, float], tuple[float, float]]]:
hitboxes: list[tuple[tuple[float, float], tuple[float, float]]] = []
for x, y, scale, _ in self.get_player_units():
center = (x, y + 5.0 * scale)
radii = (7.5 * scale, 14.0 * scale)
hitboxes.append((center, radii))
return hitboxes
def get_enemy_hitbox(self, enemy: Enemy) -> tuple[tuple[float, float], tuple[float, float]]:
x, y, scale = self.project_lane(enemy.lane, enemy.dist)
scale_multiplier = 1.0 if enemy.kind == "runner" else 1.4
if enemy.kind == "drifter":
scale_multiplier = 1.15
body_scale = scale * scale_multiplier
center = (x, y + 1.5 * body_scale)
radii = (9.0 * body_scale, 15.0 * body_scale)
return center, radii
def get_boss_bullet_hitbox(self, shot: BossBullet) -> tuple[tuple[float, float], tuple[float, float]]:
x, y, scale = self.project_lane(shot.lane, shot.dist)
center = (x, y + 11.0 * scale)
radii = (4.5 * scale, 12.0 * scale)
return center, radii
def add_text(self, text: str, x: float, y: float, color: tuple[int, int, int]) -> None:
self.floating_texts.append(FloatingText(text=text, x=x, y=y, color=color))
def run(self) -> None:
running = True
while running:
dt = min(0.033, self.clock.tick(FPS) / 1000)
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
running = False
if self.state in {"title", "gameover", "win"} and event.key in {
pygame.K_SPACE,
pygame.K_RETURN,
pygame.K_r,
}:
self.start_game()
self.update(dt)
self.draw()
pygame.display.flip()
pygame.quit()
def update(self, dt: float) -> None:
self.demo_scroll += dt * 9.0
self.run_time += dt
if self.state != "play":
for text in self.floating_texts:
text.update(dt)
self.floating_texts = [text for text in self.floating_texts if text.timer > 0.0]
return
self.world_speed = 18.0 + min(8.0, self.travel_distance / 100.0)
if self.boss:
self.world_speed = 13.5
self.travel_distance += self.world_speed * dt
self.player.invuln = max(0.0, self.player.invuln - dt)
self.player.hit_flash = max(0.0, self.player.hit_flash - dt)
self.shake = max(0.0, self.shake - 28 * dt)
self.flash = max(0.0, self.flash - 2.2 * dt)
keys = pygame.key.get_pressed()
move_left = keys[pygame.K_LEFT] or keys[pygame.K_a]
move_right = keys[pygame.K_RIGHT] or keys[pygame.K_d]
move_axis = float(move_right) - float(move_left)
self.player.lane = clamp(self.player.lane + move_axis * 3.3 * dt, -LANE_SPAN, LANE_SPAN)
self.player.fire_timer -= dt
if self.player.fire_timer <= 0.0:
self.fire_player_burst()
self.player.fire_timer = self.player.fire_interval
if not self.boss:
self.enemy_timer -= dt
self.gate_timer -= dt
if self.enemy_timer <= 0.0:
self.spawn_enemy_wave()
difficulty = min(0.32, self.travel_distance / 1800.0)
self.enemy_timer = self.rng.uniform(0.7, 1.15) - difficulty
if self.gate_timer <= 0.0:
self.spawn_gate_pair()
self.gate_timer = self.rng.uniform(4.2, 5.6)
if self.travel_distance >= BOSS_TRIGGER_DISTANCE:
self.start_boss_fight()
self.update_entities(dt)
self.resolve_collisions()
self.cleanup_entities()
for text in self.floating_texts:
text.update(dt)
self.floating_texts = [text for text in self.floating_texts if text.timer > 0.0]
if self.player.squad <= 0:
self.state = "gameover"
def update_entities(self, dt: float) -> None:
for bullet in self.bullets:
bullet.dist += bullet.speed * dt
for enemy in self.enemies:
enemy.dist -= (self.world_speed + enemy.speed) * dt
if enemy.kind == "drifter":
enemy.lane += math.sin(self.run_time * 3.2 + enemy.wobble) * 0.7 * dt
enemy.lane = clamp(enemy.lane, -LANE_SPAN, LANE_SPAN)
for gate in self.gates:
gate.dist -= self.world_speed * dt
for shot in self.boss_bullets:
shot.dist -= shot.speed * dt
if self.boss:
self.boss.phase += dt
if self.boss.dist > self.boss.target_dist:
self.boss.dist -= 20.0 * dt
self.boss.lane = math.sin(self.boss.phase * 1.1) * 1.15
self.boss.attack_timer -= dt
self.boss.minion_timer -= dt
if self.boss.attack_timer <= 0.0 and self.boss.dist <= self.boss.target_dist + 1.0:
self.spawn_boss_attack()
pressure = 1.0 - (self.boss.hp / self.boss.max_hp)
self.boss.attack_timer = 1.15 - pressure * 0.35
if self.boss.minion_timer <= 0.0 and self.boss.dist <= self.boss.target_dist + 1.0:
self.spawn_boss_minions()
self.boss.minion_timer = 2.2
def cleanup_entities(self) -> None:
self.bullets = [bullet for bullet in self.bullets if bullet.dist < ROAD_DEPTH + 10]
self.enemies = [enemy for enemy in self.enemies if enemy.hp > 0 and enemy.dist > -6]
self.gates = [gate for gate in self.gates if gate.dist > -8]
self.boss_bullets = [shot for shot in self.boss_bullets if shot.dist > -6]
def fire_player_burst(self) -> None:
shot_count = self.player.shot_count
if shot_count == 1:
lanes = [self.player.lane]
else:
spread = min(1.8, 0.34 * (shot_count - 1))
lanes = [
clamp(
self.player.lane + lerp(-spread, spread, index / (shot_count - 1)),
-LANE_SPAN,
LANE_SPAN,
)
for index in range(shot_count)
]
for lane in lanes:
self.bullets.append(Bullet(lane=lane, dist=PLAYER_DIST + 3.2, speed=82.0))
def resolve_collisions(self) -> None:
player_hitboxes = self.get_player_hitboxes()
for gate in self.gates:
if not gate.applied and gate.dist <= PLAYER_DIST + 2.0:
operation = gate.left if self.player.lane <= 0 else gate.right
delta = self.player.apply_operation(operation)
x, y, _ = self.project_lane(self.player.lane, PLAYER_DIST + 4.0)
color = GOOD_GREEN if delta >= 0 else BAD_RED
sign = "+" if delta >= 0 else ""
self.add_text(f"{format_operation(operation)} {sign}{delta}", x, y - 46, color)
self.score += max(0, delta) * 3
gate.applied = True
self.flash = 0.22
for bullet in self.bullets[:]:
hit = False
for enemy in self.enemies:
lane_window = 0.34 if enemy.kind != "brute" else 0.5
if abs(bullet.dist - enemy.dist) < 3.2 and abs(bullet.lane - enemy.lane) < lane_window:
enemy.hp -= bullet.damage
hit = True
if enemy.hp <= 0:
self.score += enemy.reward
x, y, _ = self.project_lane(enemy.lane, max(enemy.dist, PLAYER_DIST + 6.0))
self.add_text("+10", x, y - 16, GOOD_GREEN)
break
if not hit and self.boss:
if abs(bullet.dist - self.boss.dist) < 7.0 and abs(bullet.lane - self.boss.lane) < 1.2:
self.boss.hp -= bullet.damage
hit = True
if self.boss.hp <= 0:
self.boss.hp = 0
x, y, _ = self.project_lane(self.boss.lane, self.boss.dist)
self.add_text("Boss Down!", x, y - 60, GOOD_GREEN)
self.score += 500
self.state = "win"
if hit and bullet in self.bullets:
self.bullets.remove(bullet)
for enemy in self.enemies[:]:
enemy_center, enemy_radii = self.get_enemy_hitbox(enemy)
collided = any(
self.ellipse_overlap(player_center, player_radii, enemy_center, enemy_radii)
for player_center, player_radii in player_hitboxes
)
if collided:
if self.player.invuln <= 0.0:
damage = 1 if enemy.kind == "runner" else 2
self.apply_player_damage(damage)
enemy.hp = 0
for shot in self.boss_bullets[:]:
shot_center, shot_radii = self.get_boss_bullet_hitbox(shot)
collided = any(
self.ellipse_overlap(player_center, player_radii, shot_center, shot_radii)
for player_center, player_radii in player_hitboxes
)
if collided:
if self.player.invuln <= 0.0:
self.apply_player_damage(2)
self.boss_bullets.remove(shot)
def apply_player_damage(self, amount: int) -> None:
self.player.take_damage(amount)
x, y, _ = self.project_lane(self.player.lane, PLAYER_DIST)
self.add_text(f"-{amount}", x, y - 58, BAD_RED)
self.shake = 10.0
self.flash = 0.18
def spawn_enemy_wave(self) -> None:
pattern = self.rng.choice(["line", "stream", "brute_wall", "drifters"])
far_dist = ROAD_DEPTH - self.rng.uniform(4.0, 10.0)
if pattern == "line":
lanes = self.rng.sample([-2, -1, 0, 1, 2], k=self.rng.randint(2, 4))
for lane in lanes:
self.enemies.append(
Enemy(lane=lane, dist=far_dist, hp=1, speed=self.rng.uniform(3.0, 5.2), kind="runner", wobble=0.0, reward=10)
)
elif pattern == "stream":
lane = self.rng.choice([-2, -1, 0, 1, 2])
for index in range(5):
self.enemies.append(
Enemy(
lane=lane,
dist=far_dist + index * 10,
hp=1,
speed=4.4,
kind="runner",
wobble=index * 0.5,
reward=10,
)
)
elif pattern == "brute_wall":
lane_choices = self.rng.sample([-2, -1, 0, 1, 2], k=2)
for lane in lane_choices:
self.enemies.append(
Enemy(
lane=lane,
dist=far_dist,
hp=3,
speed=2.6,
kind="brute",
wobble=self.rng.random() * math.tau,
reward=24,
)
)
self.enemies.append(
Enemy(
lane=self.rng.choice([-1, 0, 1]),
dist=far_dist + 8,
hp=1,
speed=4.0,
kind="runner",
wobble=0.0,
reward=10,
)
)
elif pattern == "drifters":
for lane in [-1.5, 0.0, 1.5]:
self.enemies.append(
Enemy(
lane=lane,
dist=far_dist + self.rng.uniform(0.0, 8.0),
hp=2,
speed=3.6,
kind="drifter",
wobble=self.rng.random() * math.tau,
reward=16,
)
)
def gate_operation(self, helpful: bool) -> tuple[str, int]:
safe_choices = [("+", 2), ("+", 3), ("+", 4), ("*", 2)]
mixed_choices = [("+", 1), ("+", 2), ("-", 2), ("-", 3), ("/", 2), ("*", 2)]
if helpful:
if self.player.squad >= 16 and self.rng.random() < 0.35:
return ("+", 5)
return self.rng.choice(safe_choices)
if self.player.squad <= 4:
return self.rng.choice([("+", 1), ("+", 2), ("-", 1), ("*", 2)])
choice = self.rng.choice(mixed_choices)
if choice[0] == "-" and self.player.squad <= 3:
return ("+", 2)
return choice
def spawn_gate_pair(self) -> None:
left = self.gate_operation(helpful=self.rng.random() < 0.7)
right = self.gate_operation(helpful=False)
if self.rng.random() < 0.5:
left, right = right, left
self.gates.append(GatePair(dist=ROAD_DEPTH - 6.0, left=left, right=right))
def start_boss_fight(self) -> None:
self.boss = Boss(
dist=ROAD_DEPTH - 5.0,
target_dist=58.0,
hp=220,
max_hp=220,
attack_timer=1.5,
minion_timer=2.4,
)
self.gates.clear()
x, y, _ = self.project_lane(0.0, 46.0)
self.add_text("Boss Incoming!", x, y - 28, WARN_ORANGE)
def spawn_boss_attack(self) -> None:
if not self.boss:
return
pressure = 1.0 - (self.boss.hp / self.boss.max_hp)
shot_count = 2 + int(pressure * 3)
spread = 0.65 + pressure * 0.8
for index in range(shot_count):
if shot_count == 1:
lane = self.player.lane
else:
lane = clamp(
self.player.lane + lerp(-spread, spread, index / (shot_count - 1)),
-LANE_SPAN,
LANE_SPAN,
)
self.boss_bullets.append(BossBullet(lane=lane, dist=self.boss.dist - 6.0, speed=28.0 + pressure * 4.0))
def spawn_boss_minions(self) -> None:
if not self.boss:
return
lanes = self.rng.sample([-2, -1, 0, 1, 2], k=2)
for lane in lanes:
self.enemies.append(
Enemy(
lane=lane,
dist=self.boss.dist - self.rng.uniform(6.0, 12.0),
hp=2,
speed=3.8,
kind="runner",
wobble=0.0,
reward=14,
)
)
def draw(self) -> None:
scroll_value = self.travel_distance if self.state == "play" else self.demo_scroll
self.draw_background(scroll_value)
self.draw_road(scroll_value)
self.draw_gates()
self.draw_bullets()
self.draw_enemies()
self.draw_boss()
self.draw_boss_bullets()
self.draw_player()
self.draw_floating_texts()
self.draw_hud()
self.draw_overlays()
def draw_background(self, scroll_value: float) -> None:
for y in range(HORIZON_Y):
ratio = y / max(1, HORIZON_Y)
color = blend(SKY_TOP, SKY_BOTTOM, ratio)
pygame.draw.line(self.screen, color, (0, y), (WIDTH, y))
sand_rect = pygame.Rect(0, HORIZON_Y, WIDTH, HEIGHT - HORIZON_Y)
pygame.draw.rect(self.screen, SAND_FAR, sand_rect)
pygame.draw.circle(self.screen, (255, 246, 195), (155, 104), 42)
mesa_left = [(0, HORIZON_Y + 15), (42, 72), (172, 70), (194, HORIZON_Y + 14)]
mesa_mid = [(160, HORIZON_Y + 28), (248, 88), (372, 84), (414, HORIZON_Y + 26)]
mesa_right = [(WIDTH - 348, HORIZON_Y + 24), (WIDTH - 278, 76), (WIDTH - 118, 72), (WIDTH - 32, HORIZON_Y + 22)]
for points, color in (
(mesa_left, (201, 134, 94)),
(mesa_mid, (187, 116, 86)),
(mesa_right, (191, 120, 89)),
):
pygame.draw.polygon(self.screen, color, points)
for cloud_x, cloud_y, size in ((320, 58, 16), (468, 82, 12), (700, 52, 18)):
self.draw_cloud(cloud_x, cloud_y, size)
start_index = int(scroll_value // 26) - 2
end_index = start_index + 10
for world_index in range(start_index, end_index):
dist = world_index * 26 - scroll_value
if not 6 < dist < ROAD_DEPTH:
continue
side = -1 if math.sin(world_index * 1.73) > 0 else 1
offset = 1.22 + 0.18 * abs(math.sin(world_index * 0.71))
x, y, scale = self.project_offset(side * offset, dist)
if world_index % 3 == 0:
self.draw_rock(x, y, scale * 0.9)
else:
self.draw_cactus(x, y, scale * 0.95, side)
fence_step = 14
offset = scroll_value % fence_step
for index in range(-1, int(ROAD_DEPTH / fence_step) + 4):
dist = index * fence_step - offset
if not 4 < dist < ROAD_DEPTH:
continue
for side in (-1, 1):
x1, y1, scale1 = self.project_offset(side * 1.1, dist)
x2, y2, scale2 = self.project_offset(side * 1.13, dist + 5)
post_height = 18 * scale1
pygame.draw.line(self.screen, (147, 100, 72), (x1, y1), (x1, y1 - post_height), max(1, int(2 * scale1)))
pygame.draw.line(
self.screen,
(164, 116, 86),
(x1, y1 - post_height * 0.72),
(x2, y2 - post_height * 0.64),
max(1, int(2 * min(scale1, scale2))),
)
def draw_road(self, scroll_value: float) -> None:
top_left = (ROAD_CENTER_X - ROAD_TOP_HALF, HORIZON_Y)
top_right = (ROAD_CENTER_X + ROAD_TOP_HALF, HORIZON_Y)
bottom_left = (ROAD_CENTER_X - ROAD_BOTTOM_HALF, ROAD_BOTTOM_Y)
bottom_right = (ROAD_CENTER_X + ROAD_BOTTOM_HALF, ROAD_BOTTOM_Y)
pygame.draw.polygon(
self.screen,
ROAD_EDGE,
[
(bottom_left[0] - 14, bottom_left[1]),
(top_left[0] - 14, top_left[1]),
(top_right[0] + 14, top_right[1]),
(bottom_right[0] + 14, bottom_right[1]),
],
)
pygame.draw.polygon(self.screen, ROAD_COLOR, [bottom_left, top_left, top_right, bottom_right])
dash_spacing = 12.0
dash_length = 6.0
lane_offsets = [(index / LANE_COUNT) * 2 - 1 for index in range(1, LANE_COUNT)]
offset = scroll_value % dash_spacing
for dist_index in range(-1, int(ROAD_DEPTH / dash_spacing) + 4):
dist_start = dist_index * dash_spacing - offset
dist_end = dist_start + dash_length
if dist_end <= 0 or dist_start >= ROAD_DEPTH:
continue
for lane_offset in lane_offsets:
x1, y1, scale1 = self.project_offset(lane_offset * 0.78, max(0.01, dist_start))
x2, y2, scale2 = self.project_offset(lane_offset * 0.78, min(ROAD_DEPTH, dist_end))
width = max(1, int(7 * min(scale1, scale2)))
pygame.draw.line(self.screen, LANE_COLOR, (x1, y1), (x2, y2), width)
side_glow = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA)
pygame.draw.polygon(
side_glow,
(255, 255, 255, 20),
[
(bottom_left[0] + 40, bottom_left[1]),
(top_left[0] + 14, top_left[1]),
(top_right[0] - 14, top_right[1]),
(bottom_right[0] - 40, bottom_right[1]),
],
)
self.screen.blit(side_glow, (0, 0))
def draw_gates(self) -> None:
overlay = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA)
for gate in sorted(self.gates, key=lambda item: item.dist, reverse=True):
self.draw_gate_panel(overlay, gate.dist, -1.0, -0.02, gate.left, GATE_LEFT)
self.draw_gate_panel(overlay, gate.dist, 0.02, 1.0, gate.right, GATE_RIGHT)
self.screen.blit(overlay, (0, 0))
for gate in sorted(self.gates, key=lambda item: item.dist, reverse=True):
self.draw_gate_label(gate.dist, -1.0, -0.02, gate.left)
self.draw_gate_label(gate.dist, 0.02, 1.0, gate.right)
def draw_gate_panel(
self,
surface: pygame.Surface,
dist: float,
left_ratio: float,
right_ratio: float,
operation: tuple[str, int],
color: tuple[int, int, int, int],
) -> None:
front_left = self.project_offset(left_ratio, dist)
front_right = self.project_offset(right_ratio, dist)
back_left = self.project_offset(left_ratio, dist + 6.8)
back_right = self.project_offset(right_ratio, dist + 6.8)
points = [
(front_left[0], front_left[1]),
(back_left[0], back_left[1]),
(back_right[0], back_right[1]),
(front_right[0], front_right[1]),
]
pygame.draw.polygon(surface, color, points)
pygame.draw.polygon(surface, (255, 255, 255, 60), points, 2)
def draw_gate_label(
self,
dist: float,
left_ratio: float,
right_ratio: float,
operation: tuple[str, int],
) -> None:
front_left = self.project_offset(left_ratio, dist)
front_right = self.project_offset(right_ratio, dist)
back_left = self.project_offset(left_ratio, dist + 6.8)
back_right = self.project_offset(right_ratio, dist + 6.8)
points = [
(front_left[0], front_left[1]),
(back_left[0], back_left[1]),
(back_right[0], back_right[1]),
(front_right[0], front_right[1]),
]
label_x = sum(point[0] for point in points) / 4
label_y = sum(point[1] for point in points) / 4
label = format_operation(operation)
label_surface = self.font_large.render(label, True, WHITE)
label_surface = pygame.transform.smoothscale(
label_surface,
(
int(label_surface.get_width() * clamp(front_left[2] * 0.92, 0.5, 1.5)),
int(label_surface.get_height() * clamp(front_left[2] * 0.92, 0.5, 1.5)),
),
)
rect = label_surface.get_rect(center=(label_x, label_y))
outline = self.font_large.render(label, True, TEXT_DARK)
outline = pygame.transform.smoothscale(outline, label_surface.get_size())
for dx, dy in ((-2, 0), (2, 0), (0, -2), (0, 2)):
self.screen.blit(outline, outline.get_rect(center=(label_x + dx, label_y + dy)))
self.screen.blit(label_surface, rect)
def draw_bullets(self) -> None:
for bullet in sorted(self.bullets, key=lambda item: item.dist, reverse=True):
x, y, scale = self.project_lane(bullet.lane, bullet.dist)
length = 18 * scale
width = max(2, int(4 * scale))
pygame.draw.line(self.screen, BULLET_GOLD, (x, y), (x, y - length), width)
pygame.draw.circle(self.screen, (255, 238, 163), (int(x), int(y - length)), max(2, int(5 * scale)))
def draw_enemy_body(self, x: float, y: float, scale: float, kind: str) -> None:
scale_multiplier = 1.0 if kind == "runner" else 1.4
if kind == "drifter":
scale_multiplier = 1.15
body_scale = scale * scale_multiplier
shadow_rect = pygame.Rect(0, 0, 36 * body_scale, 12 * body_scale)
shadow_rect.center = (x, y + 8 * body_scale)
pygame.draw.ellipse(self.screen, (0, 0, 0, 70), shadow_rect)
leg_y = y + 16 * body_scale
pygame.draw.line(self.screen, ENEMY_DARK, (x - 7 * body_scale, y + 6 * body_scale), (x - 9 * body_scale, leg_y), max(2, int(3 * body_scale)))
pygame.draw.line(self.screen, ENEMY_DARK, (x + 7 * body_scale, y + 6 * body_scale), (x + 9 * body_scale, leg_y), max(2, int(3 * body_scale)))
pygame.draw.line(self.screen, ENEMY_DARK, (x, y - 4 * body_scale), (x, y + 8 * body_scale), max(3, int(6 * body_scale)))
pygame.draw.line(self.screen, ENEMY_DARK, (x - 9 * body_scale, y + 2 * body_scale), (x + 9 * body_scale, y + 2 * body_scale), max(2, int(3 * body_scale)))
pygame.draw.circle(self.screen, ENEMY_RED, (int(x), int(y - 12 * body_scale)), max(5, int(9 * body_scale)))
def draw_enemies(self) -> None:
for enemy in sorted(self.enemies, key=lambda item: item.dist, reverse=True):
x, y, scale = self.project_lane(enemy.lane, enemy.dist)
self.draw_enemy_body(x, y, scale, enemy.kind)
if enemy.hp > 1:
hp_label = self.font_small.render(str(enemy.hp), True, WHITE)
self.screen.blit(hp_label, hp_label.get_rect(center=(x, y - 24 * scale)))
def draw_boss(self) -> None:
if not self.boss:
return
x, y, scale = self.project_lane(self.boss.lane, self.boss.dist)
width = 200 * scale
height = 128 * scale
shadow = pygame.Rect(0, 0, width * 0.9, height * 0.24)
shadow.center = (x, y + height * 0.3)
pygame.draw.ellipse(self.screen, (0, 0, 0, 80), shadow)
body = pygame.Rect(0, 0, width, height * 0.62)
body.center = (x, y)
turret = pygame.Rect(0, 0, width * 0.56, height * 0.36)
turret.center = (x, y - height * 0.16)
pygame.draw.rect(self.screen, BOSS_DARK, body, border_radius=int(18 * scale))
pygame.draw.rect(self.screen, BOSS_PURPLE, body.inflate(-width * 0.12, -height * 0.18), border_radius=int(16 * scale))
pygame.draw.rect(self.screen, BOSS_DARK, turret, border_radius=int(16 * scale))
pygame.draw.rect(self.screen, (220, 186, 255), turret.inflate(-width * 0.16, -height * 0.18), border_radius=int(12 * scale))
cannon_y = y - height * 0.1
cannon_width = 14 * scale
cannon_length = 72 * scale
pygame.draw.rect(
self.screen,
BOSS_DARK,
pygame.Rect(x - cannon_width / 2, cannon_y - cannon_length, cannon_width, cannon_length),
border_radius=max(4, int(5 * scale)),
)
eye_rect = pygame.Rect(0, 0, width * 0.16, height * 0.07)
eye_rect.center = (x, y - height * 0.16)
pygame.draw.rect(self.screen, (255, 111, 197), eye_rect, border_radius=max(4, int(4 * scale)))
wheel_radius = max(5, int(14 * scale))
for side in (-1, 1):
wheel_center = (int(x + side * width * 0.34), int(y + height * 0.12))
pygame.draw.circle(self.screen, BOSS_DARK, wheel_center, wheel_radius)
pygame.draw.circle(self.screen, (255, 206, 120), wheel_center, max(2, wheel_radius // 3))
def draw_boss_bullets(self) -> None:
for shot in sorted(self.boss_bullets, key=lambda item: item.dist, reverse=True):
x, y, scale = self.project_lane(shot.lane, shot.dist)
pygame.draw.line(self.screen, (255, 111, 197), (x, y), (x, y + 22 * scale), max(3, int(5 * scale)))
pygame.draw.circle(self.screen, (255, 197, 234), (int(x), int(y + 22 * scale)), max(3, int(5 * scale)))
def draw_player_unit(self, x: float, y: float, scale: float, leader: bool) -> None:
helmet_radius = max(6, int(10 * scale))
ring_radius = max(8, int(16 * scale))
body_y = y + 14 * scale
body_color = (228, 240, 255) if self.player.hit_flash > 0 else (222, 234, 255)
helmet_color = (129, 184, 255) if leader else PLAYER_BLUE
ring_rect = pygame.Rect(0, 0, ring_radius * 2.1, ring_radius * 0.9)
ring_rect.center = (x, y + 22 * scale)
pygame.draw.ellipse(self.screen, PLAYER_RING, ring_rect, max(2, int(3 * scale)))
pygame.draw.line(self.screen, body_color, (x, y - 2 * scale), (x, body_y), max(2, int(4 * scale)))
pygame.draw.line(self.screen, body_color, (x - 7 * scale, y + 6 * scale), (x + 7 * scale, y + 3 * scale), max(2, int(3 * scale)))
pygame.draw.line(self.screen, body_color, (x - 4 * scale, body_y), (x - 8 * scale, body_y + 8 * scale), max(2, int(3 * scale)))
pygame.draw.line(self.screen, body_color, (x + 4 * scale, body_y), (x + 8 * scale, body_y + 8 * scale), max(2, int(3 * scale)))
pygame.draw.circle(self.screen, helmet_color, (int(x), int(y - 10 * scale)), helmet_radius)
pygame.draw.line(self.screen, (19, 34, 64), (x + 3 * scale, y + 4 * scale), (x + 14 * scale, y + 1 * scale), max(2, int(3 * scale)))
def draw_player(self) -> None:
units = self.get_player_units()
base_x, base_y, scale = self.project_lane(self.player.lane, PLAYER_DIST)
for unit_x, unit_y, unit_scale, leader in units:
self.draw_player_unit(unit_x, unit_y, unit_scale, leader)
if self.player.squad > len(units):
bubble_text = self.font_medium.render(f"x{self.player.squad}", True, WHITE)
bubble_rect = bubble_text.get_rect(center=(base_x, base_y - 54 * scale))
bubble_back = bubble_rect.inflate(20, 12)
pygame.draw.rect(self.screen, (40, 60, 98), bubble_back, border_radius=16)
pygame.draw.rect(self.screen, (126, 188, 255), bubble_back, 2, border_radius=16)
self.screen.blit(bubble_text, bubble_rect)
def draw_floating_texts(self) -> None:
for text in self.floating_texts:
alpha = int(clamp(text.timer / 0.95, 0.0, 1.0) * 255)
base = self.font_medium.render(text.text, True, text.color)
outline = self.font_medium.render(text.text, True, TEXT_DARK)
base.set_alpha(alpha)
outline.set_alpha(alpha)
for dx, dy in ((-2, 0), (2, 0), (0, -2), (0, 2)):
self.screen.blit(outline, outline.get_rect(center=(text.x + dx, text.y + dy)))
self.screen.blit(base, base.get_rect(center=(text.x, text.y)))
def draw_hud(self) -> None:
hud_panel = pygame.Rect(18, 14, 280, 110)
pygame.draw.rect(self.screen, (18, 28, 50, 185), hud_panel, border_radius=18)
pygame.draw.rect(self.screen, (120, 177, 255), hud_panel, 2, border_radius=18)
stats = [
f"Squad {self.player.squad}",
f"Burst {self.player.shot_count}",
f"Score {self.score}",
]
for index, line in enumerate(stats):
label = self.font_medium.render(line, True, WHITE)
self.screen.blit(label, (34, 28 + index * 28))
progress_label = "Boss Fight" if self.boss else "Boss Inbound"
bar_x, bar_y, bar_w, bar_h = 320, 22, 360, 26
pygame.draw.rect(self.screen, (32, 34, 44), (bar_x, bar_y, bar_w, bar_h), border_radius=14)
if self.boss:
ratio = self.boss.hp / self.boss.max_hp
fill_color = (255, 103, 191)
else:
ratio = clamp(self.travel_distance / BOSS_TRIGGER_DISTANCE, 0.0, 1.0)
fill_color = (255, 108, 108)
pygame.draw.rect(
self.screen,
fill_color,
(bar_x, bar_y, bar_w * ratio, bar_h),
border_radius=14,
)
pygame.draw.rect(self.screen, WHITE, (bar_x, bar_y, bar_w, bar_h), 2, border_radius=14)
label = self.font_medium.render(progress_label, True, WHITE)
self.screen.blit(label, label.get_rect(center=(bar_x + bar_w / 2, bar_y + bar_h / 2)))
if self.state == "play" and self.travel_distance < 90:
tip = self.font_small.render("Move with A / D or Left / Right. Choose a gate side, then survive to the boss.", True, TEXT_DARK)
self.screen.blit(tip, (26, HEIGHT - 34))
def draw_overlays(self) -> None:
if self.flash > 0.0:
overlay = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA)
overlay.fill((255, 255, 255, int(self.flash * 110)))
self.screen.blit(overlay, (0, 0))
if self.state == "title":
overlay = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA)
overlay.fill((8, 12, 24, 96))
self.screen.blit(overlay, (0, 0))
title = self.font_huge.render("NUMBER SQUAD", True, WHITE)
subtitle = self.font_large.render("Highway Raid", True, (255, 220, 147))
desc = self.font_medium.render("Grow your math squad, flood the road with bullets, beat the boss.", True, WHITE)
prompt = self.font_medium.render("Press Space to start", True, GOOD_GREEN)
self.screen.blit(title, title.get_rect(center=(WIDTH / 2, 150)))
self.screen.blit(subtitle, subtitle.get_rect(center=(WIDTH / 2, 214)))
self.screen.blit(desc, desc.get_rect(center=(WIDTH / 2, 282)))
self.screen.blit(prompt, prompt.get_rect(center=(WIDTH / 2, 344)))
tips = [
"Positive gates make your squad larger and your bullet burst wider.",
"Enemies that reach your lane will shrink the squad.",
"The boss spawns after the road sprint phase.",
]
for index, line in enumerate(tips):
tip = self.font_small.render(line, True, WHITE)
self.screen.blit(tip, tip.get_rect(center=(WIDTH / 2, 404 + index * 26)))
elif self.state in {"gameover", "win"}:
overlay = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA)
overlay.fill((10, 12, 20, 154))
self.screen.blit(overlay, (0, 0))
headline = "Stage Clear" if self.state == "win" else "Squad Wiped"
color = GOOD_GREEN if self.state == "win" else BAD_RED
title = self.font_huge.render(headline, True, color)
score = self.font_large.render(f"Final Score {self.score}", True, WHITE)
prompt = self.font_medium.render("Press Space or R to play again", True, WHITE)
self.screen.blit(title, title.get_rect(center=(WIDTH / 2, 220)))
self.screen.blit(score, score.get_rect(center=(WIDTH / 2, 300)))
self.screen.blit(prompt, prompt.get_rect(center=(WIDTH / 2, 360)))
def draw_cloud(self, x: float, y: float, size: int) -> None:
for dx, dy, radius in ((0, 0, size), (size, -5, int(size * 0.8)), (-size, -2, int(size * 0.7))):
pygame.draw.circle(self.screen, (255, 255, 255), (x + dx, y + dy), radius)
def draw_cactus(self, x: float, y: float, scale: float, side: int) -> None:
trunk_width = max(4, int(10 * scale))
trunk_height = max(14, int(34 * scale))
arm_width = max(3, int(7 * scale))
arm_height = max(8, int(18 * scale))
trunk = pygame.Rect(0, 0, trunk_width, trunk_height)
trunk.midbottom = (x, y)
pygame.draw.rect(self.screen, (50, 126, 78), trunk, border_radius=max(3, int(4 * scale)))
arm = pygame.Rect(0, 0, arm_width, arm_height)
arm.midbottom = (x - side * trunk_width, y - trunk_height * 0.35)
pygame.draw.rect(self.screen, (60, 142, 88), arm, border_radius=max(2, int(4 * scale)))
pygame.draw.line(
self.screen,
(60, 142, 88),
(arm.centerx, arm.top + 4 * scale),
(arm.centerx + side * 10 * scale, arm.top + 4 * scale),
max(2, int(3 * scale)),
)
def draw_rock(self, x: float, y: float, scale: float) -> None:
width = 18 * scale
height = 12 * scale
points = [
(x - width, y),
(x - width * 0.5, y - height),
(x + width * 0.3, y - height * 0.8),
(x + width, y),
(x + width * 0.4, y + height * 0.2),
(x - width * 0.8, y + height * 0.1),
]
pygame.draw.polygon(self.screen, (210, 177, 148), points)
if __name__ == "__main__":
NumberSquadRaid().run()