Pygame 2D Platformer with Advanced Graphics and Mechanics

Below is first the level/physics blueprint, then the full self‑contained Pygame code.


1. Physics and level blueprint

Core physics

These are the assumptions the level layouts are built around:

  • Screen: WIDTH = 960, HEIGHT = 540
  • Tile size: TILE = 48 (20 tiles wide, ~11 tiles high)
  • Player size: 32 x 48 pixels (width x height)
  • Horizontal speed: PLAYER_SPEED = 6 pixels/frame
  • Gravity: GRAVITY = 0.8 pixels/frame²
  • Jump velocity: JUMP_VELOCITY = -16 pixels/frame Approximate jump capabilities:
  • Max jump height: h ≈ v² / (2g) = 16² / (2 * 0.8) = 256 / 1.6 = 160 px ≈ 3.3 tiles
  • Typical safe vertical difference between platforms: ≤ 3 tiles
  • Horizontal reach during a full jump: ~40 frames of air * 6 px ≈ 240 px ≈ 5 tiles Level layouts are built on a 20×10 approximate tile grid, with coordinates (tx, ty):
  • tx in [0..19] from left to right
  • ty in [0..9] from bottom up (0 = just above the void)
  • Platform tile (tx, ty)’s rect top-left in pixels is:
  • x = tx * TILE
  • y = HEIGHT - (ty + 1) * TILE Player start positions are placed slightly above a platform tile’s top.

Level 1 – “Neon Rooftops” (Theme: cyberpunk city night)

Focus: Simple intro, basic jumps, a few spikes. No moving platforms yet. Mostly teaching jump timing.

  • Start:

  • Player spawns on a low, safe rooftop: platform P1 from tiles (1, 1) to (4, 1) (4 tiles wide).

  • Main ascending route:

  • P2: tiles (6, 2) to (8, 2) – slightly higher, small gap from P1.

  • P3: tiles (10, 3) to (12, 3).

  • P4: tiles (14, 4) to (16, 4).

  • P5: tiles (16, 6) to (18, 6) – final high rooftop.

  • Portal:

  • End portal sits above the right half of P5, centered approximately at tile (17, 7).

  • Touching it takes you to Level 2 (and unlocks it).

  • Hazards:

  • Spikes are one-tile wide, sitting on platform tops:

  • On P2: one spike at tile (6, 2) left side; player is encouraged to land near the right edge.

  • On P3: one spike at tile (11, 3) center.

  • On P4: two spikes at tiles (14, 4) and (15, 4) forcing a precise short-hop to the safe right edge.

  • A single slow moving saw:

  • Suspended between P2 and P3, moving horizontally around y-level between them (centered around ty ≈ 2.5).

  • Path roughly from tx=7 to tx=9.

  • Enemies:

  • One small patrolling enemy on P3, moving back and forth between its ends.

  • Design checks:

  • Vertical gaps between consecutive platforms are only 1–2 tiles (≤ 96 px).

  • Horizontal gaps are about 1 tile (48 px), trivially jumpable.

  • Falling off any platform leads to the void and death (offscreen).


Level 2 – “Crystal Cavern” (Theme: glowing underground cave)

Focus: Tighter jumps, first moving platforms, introduction to crumbling platforms and a saw beneath a narrow bridge.

  • Start:

  • Player starts on a wide ledge P1: tiles (1, 3) to (5, 3).

  • Route:

  • P2 static platform: tiles (8, 4) to (10, 4) – slightly higher and to the right.

  • P3 moving platform (horizontal): base at (12, 4), 2 tiles wide, moving between x ranges covering tiles 11 to 14 (so it travels left and right).

  • P4 crumbling platform: tiles (15, 5) to (16, 5) – once stepped on, it drops after a short delay.

  • P5 final ledge: tiles (17, 6) to (19, 6), accessible only if you move quickly off the crumbling platform.

  • Portal:

  • Placed above the center of P5, around tile (18, 7).

  • Hazards:

  • Upward spikes in a pit below the jump from P1 to P2:

  • Tile spikes at (6, 2) and (7, 2).

  • A rotating saw below P3:

  • Center around tx=12, ty=2, moving slightly horizontally or vertically, punishing missed jumps.

  • A few static spikes on P2’s left edge and on P4’s left tile to force careful landings.

  • Enemies:

  • A patrolling cavern creature on P2, moving between its ends.

  • Design checks:

  • Jumps from P1→P2 are ~3 tiles horizontally and 1 tile vertically up: well within capability.

  • P2→P3 is around 2 tiles horizontally; P3→P4 is ~2 tiles horizontally and 1 tile up.

  • P4→P5 must be done quickly due to crumbling; vertical difference is 1 tile, horizontal gap ≈ 2 tiles.


Level 3 – “Clockwork Factory” (Theme: mechanical gears and pipes)

Focus: Moving platforms, rotating saws along tracks, first turret. Much more precise movement around hazards.

  • Start:

  • Player starts on P1: tiles (1, 2) to (4, 2).

  • Route:

  • P2 static platform: (6, 3) to (9, 3) – higher and right.

  • P3 moving platform (horizontal): (11, 4) to (13, 4); moves between tile 11 and 15.

  • P4 static narrow beam: (15, 5) to (16, 5) – one tile wide walkway.

  • P5 static platform: (17, 6) to (19, 6) – upper right.

  • Portal:

  • End portal above P5, on roughly (18, 7).

  • Hazards:

  • Rotating saw on a horizontal track under P2:

  • Path from tx=6 to tx=9 at ty=2.

  • Rotating saw on a vertical track near P3:

  • Moves from ty=3 to ty=5 at tx=13.

  • Timed disappearing platforms:

  • Two small platforms under P3: (10, 2) and (14, 2) that flicker on/off with a period.

  • Used as recovery spots if you miss P3, but dangerous if they vanish mid-flight.

  • Static spikes on P4’s left edge and on a small platform off to the side to punish sloppy jumps.

  • Enemies:

  • One turret mounted on a high background ledge (e.g., around tiles (8, 7)), firing bullets horizontally across part of the level.

  • One patrolling robot on P2.

  • Design checks:

  • P1→P2: 2–3 tiles horizontally, 1 tile up.

  • P2→P3: 2–3 tiles horizontally, 1 tile up; moving platform’s cycle is tuned so that a patient player can time the jump.

  • P3→P4: 2 tiles horizontally, 1 tile up; narrow walkway and saw beneath makes landing precise.

  • P4→P5: around 2 tiles horizontally, 1 tile up.


Level 4 – “Sky Ruins” (Theme: ruins floating in the clouds)

Focus: Many small ledges, wind (horizontal force) in upper section, moving spikes, disappearing platforms. Very precision-heavy.

  • Start:

  • Player starts on P1: tiles (2, 1) to (5, 1) – low left floating ruins.

  • Route:

  • P2 static: (7, 3) to (8, 3).

  • P3 static: (10, 4) to (11, 4).

  • P4 horizontal moving platform: (13, 5) to (14, 5), moving between tiles 12 and 16.

  • P5 timed disappearing platform: (15, 7) (1 tile wide), flickering.

  • P6 final ruined balcony: (17, 8) to (19, 8).

  • Portal:

  • Above P6, at tile (18, 9).

  • Hazards:

  • Stationary spikes beneath many jumps:

  • Spikes at (6, 2), (9, 3), (12, 4), (14, 4) etc., forming deadly rows just under platforms.

  • Moving spike (horizontal) between P2 and P3:

  • Travels between tx=7 and tx=10 at ty=3.

  • Rotating saw moving horizontally below P4, punishing misjudged jumps.

  • Wind zone:

  • Above y-level ty ≥ 6, a constant horizontal force pushes the player slightly rightward every frame, making landings on P5 and P6 trickier.

  • Enemies:

  • One turret on a column far below (decorative but still shooting upward horizontally across a section).

  • Design checks:

  • Platforms from P1 to P6 each differ height by only 1–2 tiles and 2–3 tiles horizontally.

  • Wind is mild enough not to make jumps impossible, but noticeable; movement is still humanly possible with slight counter-steering.


Level 5 – “Void Core” (Theme: surreal void, boss arena)

Focus: Short precision section into boss arena, then a proper bossfight with bullets. Portal appears only after boss is defeated.

  • Start:

  • Player starts on P1: tiles (1, 2) to (3, 2).

  • Pre-boss section:

  • P2: (5, 3) to (7, 3) with a row of spikes beneath at (6, 1).

  • P3: moving platform (9, 4) to (10, 4) moving between tiles 9 and 12.

  • P4: static (13, 4) to (15, 4) leading to a wide boss arena P5.

  • Boss arena:

  • P5 main arena floor: tiles (5, 1) to (19, 1) – wide, near bottom, giving space to dodge.

  • Two small raised platforms:

  • Left: (7, 3) to (8, 3)

  • Right: (16, 3) to (17, 3)

  • Boss:

  • Appears centered on P5, around tiles (12, 2)(13, 2).

  • Moves left-right across the arena, sometimes jumping slightly, and fires bullets at the player.

  • Has a visible health bar.

  • Portal:

  • Not present at start.

  • After boss HP drops to 0, portal appears above a high platform:

  • Platform (12, 5) to (13, 5) and portal above that at (12.5, 6).

  • Touching the portal ends the game with a victory screen and unlocks all levels for replay.

  • Hazards:

  • Several static spikes at the far edges of P5 to keep player in the arena.

  • Occasional saw making a short horizontal movement above the arena as an added threat (kept moderate to keep fight fair).

  • Design checks:

  • Pre-boss path P1→P4 has gaps similar to previous levels (2–3 tiles horizontally, ≤ 2 tiles vertically) and some hazards, fitting difficulty spike.

  • Boss arena is flat and wide so dodging bullets and simple jumps between small raised platforms is very manageable.


Extra features beyond the user’s explicit list

These are implemented in the code as additional unique touches:

  • Coyote time (small grace period to still jump after leaving an edge).
  • Jump buffering (press jump slightly before landing and it still triggers).
  • Moving platforms (horizontal).
  • Crumbling platforms that fall away shortly after you step on them.
  • Timed disappearing/reappearing platforms.
  • Parallax, animated, per-level backgrounds using only pygame.draw (buildings, gears, clouds, void patterns).
  • Particle effects for jumps, landings, deaths, and portal effects.
  • Turret enemies that automatically shoot bullets.
  • Level timer and per-level best time display on the level-select screen.
  • Screen shake effect on death and boss taking damage.
  • Cosmetic change of player outline color per level theme.

2. Full Pygame code

Single-file, drop-in, with everything requested.
Requirement: pygame installed (pip install pygame).

python

import pygame import math import random import sys # ========================= # CONFIG / CONSTANTS # ========================= WIDTH, HEIGHT = 960, 540 FPS = 60 TILE = 48 GRAVITY = 0.8 JUMP_VELOCITY = -16 PLAYER_SPEED = 6 COYOTE_FRAMES = 8 JUMP_BUFFER_FRAMES = 8 PLAYER_WIDTH = 32 PLAYER_HEIGHT = 48 BULLET_SPEED = 12 PLAYER_BULLET_COOLDOWN = 200 # ms ENEMY_BULLET_SPEED = 6 BOSS_MAX_HP = 20 # Game states STATE_TITLE = "TITLE" STATE_LEVEL_SELECT = "LEVEL_SELECT" STATE_PLAYING = "PLAYING" STATE_GAME_OVER = "GAME_OVER" STATE_VICTORY = "VICTORY" # ========================= # HELPER FUNCTIONS # ========================= def tile_rect(tx, ty, w=1, h=1): """Return a pygame.Rect for a tile rectangle in grid coords.""" x = tx * TILE y = HEIGHT - (ty + h) * TILE return pygame.Rect(x, y, w * TILE, h * TILE) def portal_rect_from_tile(tx, ty): """Portal is a tall rectangle, 1 tile wide, 2 tiles tall.""" rect = tile_rect(tx, ty, 1, 2) return rect def lerp_color(c1, c2, t): return ( int(c1[0] + (c2[0] - c1[0]) * t), int(c1[1] + (c2[1] - c1[1]) * t), int(c1[2] + (c2[2] - c1[2]) * t), ) def draw_vertical_gradient(surface, top_color, bottom_color): h = surface.get_height() for i in range(h): t = i / h color = lerp_color(top_color, bottom_color, t) pygame.draw.line(surface, color, (0, i), (surface.get_width(), i)) # ========================= # CORE CLASSES # ========================= class Platform: def __init__(self, rect, kind='static', move_range=None, speed=0, period_ms=1000, crumble_delay=600): """ kind: 'static', 'moving', 'timed', 'crumble' move_range: (min_x, max_x) for horizontal moving platforms speed: pixels per frame (for moving platforms) period_ms: for timed platforms (visible / invisible) crumble_delay: ms before crumble starts after stepped on """ self.rect = rect self.kind = kind self.move_range = move_range self.speed = speed self.period_ms = period_ms self.crumble_delay = crumble_delay self.direction = 1 self.crumble_start_time = None self.falling = False self.broken = False def on_step(self, time_ms): if self.kind == 'crumble' and self.crumble_start_time is None: self.crumble_start_time = time_ms def update(self, time_ms): if self.kind == 'moving' and self.move_range is not None: self.rect.x += self.speed * self.direction if self.rect.left self.move_range[1]: self.rect.right = self.move_range[1] self.direction *= -1 elif self.kind == 'crumble': if self.crumble_start_time is not None and not self.falling: if time_ms - self.crumble_start_time >= self.crumble_delay: self.falling = True if self.falling: self.rect.y += 6 if self.rect.top > HEIGHT + 200: self.broken = True # 'timed' handled in is_solid logic def is_solid(self, time_ms): if self.kind == 'timed': if self.period_ms self.move_max: self.move_dir *= -1 elif self.move_axis == 'vertical': self.rect.y += self.speed * self.move_dir if self.rect.top self.move_max: self.move_dir *= -1 class Saw: def __init__(self, cx, cy, radius, axis=None, move_min=None, move_max=None, speed=0): self.cx = cx self.cy = cy self.radius = radius self.axis = axis # None, 'horizontal', 'vertical' self.move_min = move_min self.move_max = move_max self.speed = speed self.direction = 1 self.angle = 0 def update(self): if self.axis == 'horizontal': self.cx += self.speed * self.direction if self.cx - self.radius self.move_max: self.direction *= -1 elif self.axis == 'vertical': self.cy += self.speed * self.direction if self.cy - self.radius self.move_max: self.direction *= -1 self.angle = (self.angle + 5) % 360 def get_rect(self): return pygame.Rect(self.cx - self.radius, self.cy - self.radius, self.radius * 2, self.radius * 2) class Enemy: def __init__(self, rect, move_range=None, speed=2): self.rect = rect self.move_range = move_range self.speed = speed self.direction = 1 def update(self): if self.move_range is not None: self.rect.x += self.speed * self.direction if self.rect.left self.move_range[1]: self.rect.right = self.move_range[1] self.direction *= -1 class Turret: def __init__(self, x, y, facing=1, cooldown_ms=1200): self.x = x self.y = y self.facing = facing # 1 = right, -1 = left self.cooldown_ms = cooldown_ms self.last_shot_time = 0 def can_shoot(self, time_ms): return time_ms - self.last_shot_time >= self.cooldown_ms def shoot(self, time_ms): self.last_shot_time = time_ms vx = ENEMY_BULLET_SPEED * self.facing vy = 0 rect = pygame.Rect(self.x, self.y, 10, 4) return Bullet(rect, vx, vy, owner='enemy') class Bullet: def __init__(self, rect, vx, vy, owner='player'): self.rect = rect self.vx = vx self.vy = vy self.owner = owner # 'player' or 'enemy' self.alive = True def update(self): self.rect.x += self.vx self.rect.y += self.vy if (self.rect.right WIDTH or self.rect.bottom HEIGHT): self.alive = False class Particle: def __init__(self, x, y, vx, vy, life_ms, color): self.x = x self.y = y self.vx = vx self.vy = vy self.life_ms = life_ms self.color = color self.birth = pygame.time.get_ticks() def update(self, time_ms): dt = time_ms - self.birth if dt >= self.life_ms: return False self.x += self.vx self.y += self.vy self.vy += 0.1 return True def draw(self, surface): pygame.draw.circle(surface, self.color, (int(self.x), int(self.y)), 2) class Player: def __init__(self, x, y): self.rect = pygame.Rect(x, y, PLAYER_WIDTH, PLAYER_HEIGHT) self.vel_x = 0 self.vel_y = 0 self.on_ground = False self.coyote_frames = 0 self.jump_buffer_frames = 0 self.current_platform = None self.last_shot_time = 0 def reset_for_level(self, x, y): self.rect.x = x self.rect.y = y self.vel_x = 0 self.vel_y = 0 self.on_ground = False self.coyote_frames = 0 self.jump_buffer_frames = 0 self.current_platform = None self.last_shot_time = 0 def queue_jump(self): self.jump_buffer_frames = JUMP_BUFFER_FRAMES def handle_run_input(self, keys): self.vel_x = 0 if keys[pygame.K_LEFT] or keys[pygame.K_a]: self.vel_x = -PLAYER_SPEED elif keys[pygame.K_RIGHT] or keys[pygame.K_d]: self.vel_x = PLAYER_SPEED def try_jump(self): if self.jump_buffer_frames > 0: if self.on_ground or self.coyote_frames > 0: self.vel_y = JUMP_VELOCITY self.on_ground = False self.coyote_frames = 0 self.jump_buffer_frames = 0 def update(self, platforms, time_ms): if self.on_ground: self.coyote_frames = COYOTE_FRAMES elif self.coyote_frames > 0: self.coyote_frames -= 1 self.try_jump() # Horizontal movement self.rect.x += self.vel_x for plat in platforms: if not plat.is_solid(time_ms): continue if self.rect.colliderect(plat.rect): if self.vel_x > 0: self.rect.right = plat.rect.left elif self.vel_x 20: self.vel_y = 20 # Vertical movement self.rect.y += int(self.vel_y) self.on_ground = False self.current_platform = None for plat in platforms: if not plat.is_solid(time_ms): continue if self.rect.colliderect(plat.rect): if self.vel_y > 0: # falling self.rect.bottom = plat.rect.top self.vel_y = 0 self.on_ground = True plat.on_step(time_ms) self.current_platform = plat elif self.vel_y 0: self.jump_buffer_frames -= 1 # Move with moving platform if self.current_platform and self.current_platform.kind == 'moving': self.rect.x += self.current_platform.speed * self.current_platform.direction def can_shoot(self, time_ms): return (time_ms - self.last_shot_time) >= PLAYER_BULLET_COOLDOWN def shoot(self, time_ms, facing_dir): self.last_shot_time = time_ms bullet_width = 12 bullet_height = 4 if facing_dir == 0: facing_dir = 1 if facing_dir > 0: bx = self.rect.right else: bx = self.rect.left - bullet_width by = self.rect.centery - bullet_height // 2 rect = pygame.Rect(bx, by, bullet_width, bullet_height) vx = BULLET_SPEED * facing_dir vy = 0 return Bullet(rect, vx, vy, owner='player') class Boss: def __init__(self, rect): self.rect = rect self.hp = BOSS_MAX_HP self.alive = True self.direction = 1 self.speed = 3 self.jump_timer = 0 self.bullet_cooldown = 700 self.last_shot_time = 0 def update(self, time_ms, arena_rect, player): if not self.alive: return # Move horizontally self.rect.x += self.speed * self.direction if self.rect.left arena_rect.right: self.rect.right = arena_rect.right self.direction *= -1 # Simple bobbing/jumping effect # Use sine over time for vertical small motion bob = int(math.sin(time_ms / 200.0) * 4) self.rect.y = arena_rect.top - self.rect.height + 80 + bob # Phase change when low HP if self.hp = self.bullet_cooldown def shoot(self, time_ms, player): self.last_shot_time = time_ms # Aim roughly at player dx = player.rect.centerx - self.rect.centerx dy = player.rect.centery - self.rect.centery length = math.hypot(dx, dy) if length == 0: length = 1 vx = ENEMY_BULLET_SPEED * dx / length vy = ENEMY_BULLET_SPEED * dy / length rect = pygame.Rect(self.rect.centerx, self.rect.centery, 12, 6) return Bullet(rect, vx, vy, owner='enemy') def hit(self, amount): self.hp -= amount if self.hp P2 gap lvl2.spikes.append(Spike(tile_rect(6, 2, 1, 1), direction='up')) lvl2.spikes.append(Spike(tile_rect(7, 2, 1, 1), direction='up')) # Spikes on P2 and P4 edge lvl2.spikes.append(Spike(tile_rect(8, 5, 1, 1), direction='up')) lvl2.spikes.append(Spike(tile_rect(15, 6, 1, 1), direction='up')) # Saw below P3 saw_y = HEIGHT - int(2 * TILE) saw_x_min = 11 * TILE saw_x_max = 14 * TILE lvl2.saws.append(Saw((saw_x_min + saw_x_max) // 2, saw_y, 18, axis='horizontal', move_min=saw_x_min, move_max=saw_x_max, speed=2)) # Enemy on P2 e_rect = tile_rect(8, 5, 1, 1).inflate(-10, -10) lvl2.enemies.append(Enemy(e_rect, move_range=(8 * TILE, 11 * TILE), speed=2)) levels.append(lvl2) # Level 3: Clockwork Factory start_rect = tile_rect(1, 3, 1, 1) start_pos = (start_rect.x, start_rect.y - PLAYER_HEIGHT + 8) portal = portal_rect_from_tile(18, 7) lvl3 = Level(2, "Clockwork Factory", "factory", start_pos, portal_rect=portal) # Platforms lvl3.platforms.append(Platform(tile_rect(1, 2, 4, 1))) # P1 lvl3.platforms.append(Platform(tile_rect(6, 3, 4, 1))) # P2 lvl3.platforms.append(Platform(tile_rect(11, 4, 3, 1), kind='moving', move_range=(11 * TILE, 15 * TILE), speed=2)) # P3 lvl3.platforms.append(Platform(tile_rect(15, 5, 2, 1))) # P4 lvl3.platforms.append(Platform(tile_rect(17, 6, 3, 1))) # P5 # Timed platforms under P3 lvl3.platforms.append(Platform(tile_rect(10, 2, 1, 1), kind='timed', period_ms=900)) lvl3.platforms.append(Platform(tile_rect(14, 2, 1, 1), kind='timed', period_ms=900)) # Rotating saw under P2 saw_y = HEIGHT - int(2 * TILE) saw_x_min = 6 * TILE saw_x_max = 9 * TILE lvl3.saws.append(Saw((saw_x_min + saw_x_max) // 2, saw_y, 20, axis='horizontal', move_min=saw_x_min, move_max=saw_x_max, speed=2)) # Vertical saw near P3 v_saw_x = 13 * TILE v_saw_y_min = HEIGHT - int(3 * TILE) v_saw_y_max = HEIGHT - int(5 * TILE) lvl3.saws.append(Saw(v_saw_x, (v_saw_y_min + v_saw_y_max) // 2, 20, axis='vertical', move_min=v_saw_y_max - 2 * 20, move_max=v_saw_y_min + 2 * 20, speed=2)) # Spikes on P4 edge lvl3.spikes.append(Spike(tile_rect(15, 6, 1, 1), direction='up')) # Turret on high ledge turret_x = 8 * TILE + TILE // 2 turret_y = HEIGHT - int(7 * TILE) lvl3.turrets.append(Turret(turret_x, turret_y, facing=1, cooldown_ms=1400)) # Enemy on P2 e_rect = tile_rect(6, 4, 1, 1).inflate(-12, -10) lvl3.enemies.append(Enemy(e_rect, move_range=(6 * TILE, 9 * TILE), speed=2)) levels.append(lvl3) # Level 4: Sky Ruins start_rect = tile_rect(2, 2, 1, 1) start_pos = (start_rect.x, start_rect.y - PLAYER_HEIGHT + 8) portal = portal_rect_from_tile(18, 9) lvl4 = Level(3, "Sky Ruins", "sky", start_pos, portal_rect=portal) # Platforms lvl4.platforms.append(Platform(tile_rect(2, 1, 4, 1))) # P1 lvl4.platforms.append(Platform(tile_rect(7, 3, 2, 1))) # P2 lvl4.platforms.append(Platform(tile_rect(10, 4, 2, 1))) # P3 lvl4.platforms.append(Platform(tile_rect(13, 5, 2, 1), kind='moving', move_range=(12 * TILE, 16 * TILE), speed=2)) # P4 lvl4.platforms.append(Platform(tile_rect(15, 7, 1, 1), kind='timed', period_ms=800)) # P5 lvl4.platforms.append(Platform(tile_rect(17, 8, 3, 1))) # P6 # Spikes under many jumps lvl4.spikes.append(Spike(tile_rect(6, 2, 1, 1), direction='up')) lvl4.spikes.append(Spike(tile_rect(9, 3, 1, 1), direction='up')) lvl4.spikes.append(Spike(tile_rect(12, 4, 1, 1), direction='up')) lvl4.spikes.append(Spike(tile_rect(14, 4, 1, 1), direction='up')) # Moving spike between P2 and P3 spike_rect = tile_rect(7, 4, 1, 1) lvl4.spikes.append(Spike(spike_rect, direction='up', move_axis='horizontal', move_min=7 * TILE, move_max=10 * TILE, speed=2)) # Saw below P4 saw_y = HEIGHT - int(4 * TILE) saw_x_min = 12 * TILE saw_x_max = 16 * TILE lvl4.saws.append(Saw((saw_x_min + saw_x_max) // 2, saw_y, 18, axis='horizontal', move_min=saw_x_min, move_max=saw_x_max, speed=2)) # Turret lower column turret_x = 4 * TILE turret_y = HEIGHT - int(4 * TILE) lvl4.turrets.append(Turret(turret_x, turret_y, facing=1, cooldown_ms=1600)) levels.append(lvl4) # Level 5: Void Core (Boss) start_rect = tile_rect(1, 3, 1, 1) start_pos = (start_rect.x, start_rect.y - PLAYER_HEIGHT + 8) portal = None # appears later # Arena floor roughly at ty=1 from tx=5..19 arena_rect = tile_rect(5, 1, 15, 1) lvl5 = Level(4, "Void Core", "void", start_pos, portal_rect=portal, has_boss=True, boss_arena_rect=arena_rect) # Pre-boss platforms lvl5.platforms.append(Platform(tile_rect(1, 2, 3, 1))) # P1 lvl5.platforms.append(Platform(tile_rect(5, 3, 3, 1))) # P2 lvl5.platforms.append(Platform(tile_rect(9, 4, 2, 1), kind='moving', move_range=(9 * TILE, 12 * TILE), speed=2)) # P3 lvl5.platforms.append(Platform(tile_rect(13, 4, 3, 1))) # P4 # Arena floor P5 lvl5.platforms.append(Platform(tile_rect(5, 1, 15, 1))) # P5 # Raised platforms lvl5.platforms.append(Platform(tile_rect(7, 3, 2, 1))) # left small lvl5.platforms.append(Platform(tile_rect(16, 3, 2, 1))) # right small # Spikes at arena edges lvl5.spikes.append(Spike(tile_rect(5, 2, 1, 1), direction='up')) lvl5.spikes.append(Spike(tile_rect(19, 2, 1, 1), direction='up')) # Small saw above arena saw_x_min = 8 * TILE saw_x_max = 16 * TILE saw_y = HEIGHT - int(3 * TILE) lvl5.saws.append(Saw((saw_x_min + saw_x_max) // 2, saw_y, 16, axis='horizontal', move_min=saw_x_min, move_max=saw_x_max, speed=2)) # Boss boss_rect = pygame.Rect(arena_rect.centerx - 40, arena_rect.top - 60, 80, 60) lvl5.boss = Boss(boss_rect) levels.append(lvl5) return levels # ========================= # GAME LOOP / UI # ========================= def main(): pygame.init() screen = pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption("Precision Void - Drawn Platformer") clock = pygame.time.Clock() font_big = pygame.font.SysFont("consolas", 60) font_med = pygame.font.SysFont("consolas", 30) font_small = pygame.font.SysFont("consolas", 20) levels = build_levels() unlocked_levels = [True, False, False, False, False] best_times = [None] * 5 player = Player(*levels[0].start_pos) # Attach player reference to each level (for boss) for lvl in levels: lvl.game = type("dummy", (), {})() lvl.game.player = player state = STATE_TITLE current_level_index = 0 current_level = levels[current_level_index] bullets = [] enemy_bullets = [] particles = [] death_reason = "" level_start_time = 0 last_level_time = 0 selected_level_index = 0 facing_dir = 1 screen_shake_frames = 0 running = True while running: dt_ms = clock.tick(FPS) time_ms = pygame.time.get_ticks() # ========================= # EVENT HANDLING # ========================= for event in pygame.event.get(): if event.type == pygame.QUIT: running = False if state == STATE_TITLE: if event.type == pygame.KEYDOWN: if event.key in (pygame.K_RETURN, pygame.K_SPACE): state = STATE_LEVEL_SELECT elif state == STATE_LEVEL_SELECT: if event.type == pygame.KEYDOWN: if event.key in (pygame.K_LEFT, pygame.K_a): selected_level_index = max(0, selected_level_index - 1) elif event.key in (pygame.K_RIGHT, pygame.K_d): selected_level_index = min(4, selected_level_index + 1) elif event.key == pygame.K_ESCAPE: state = STATE_TITLE elif event.key in (pygame.K_RETURN, pygame.K_SPACE): if unlocked_levels[selected_level_index]: current_level_index = selected_level_index current_level = levels[current_level_index] player.reset_for_level(*current_level.start_pos) bullets.clear() enemy_bullets.clear() particles.clear() if current_level.has_boss and current_level.boss: current_level.boss.hp = BOSS_MAX_HP current_level.boss.alive = True current_level.portal_rect = current_level.portal_rect # unchanged, level5 will spawn later level_start_time = time_ms state = STATE_PLAYING elif state == STATE_PLAYING: if event.type == pygame.KEYDOWN: if event.key in (pygame.K_SPACE, pygame.K_w, pygame.K_UP): player.queue_jump() elif event.key in (pygame.K_j, pygame.K_k, pygame.K_f): if player.can_shoot(time_ms): bullets.append(player.shoot(time_ms, facing_dir)) elif event.key == pygame.K_ESCAPE: state = STATE_LEVEL_SELECT # no other special handling elif state == STATE_GAME_OVER: if event.type == pygame.KEYDOWN: if event.key == pygame.K_r: # restart current level current_level = levels[current_level_index] player.reset_for_level(*current_level.start_pos) bullets.clear() enemy_bullets.clear() particles.clear() if current_level.has_boss and current_level.boss: current_level.boss.hp = BOSS_MAX_HP current_level.boss.alive = True # Hide boss portal again if current_level_index == 4: current_level.portal_rect = None level_start_time = time_ms state = STATE_PLAYING elif event.key == pygame.K_ESCAPE: state = STATE_LEVEL_SELECT elif state == STATE_VICTORY: if event.type == pygame.KEYDOWN: if event.key in (pygame.K_RETURN, pygame.K_SPACE): state = STATE_LEVEL_SELECT elif event.key == pygame.K_ESCAPE: running = False # ========================= # GAME LOGIC # ========================= keys = pygame.key.get_pressed() if state == STATE_PLAYING: # Track facing direction if keys[pygame.K_LEFT] or keys[pygame.K_a]: facing_dir = -1 elif keys[pygame.K_RIGHT] or keys[pygame.K_d]: facing_dir = 1 player.handle_run_input(keys) current_level.update(time_ms) solid_platforms = current_level.get_solid_platforms(time_ms) player.update(solid_platforms, time_ms) # Wind effect in Sky Ruins upper region if current_level.theme == "sky": if player.rect.top HEIGHT + 50: dead = True death_reason = "Fell" if dead: screen_shake_frames = 15 # death particles for _ in range(30): px = player.rect.centerx py = player.rect.centery vx = random.uniform(-3, 3) vy = random.uniform(-3, 0) particles.append(Particle(px, py, vx, vy, 600, (255, 255, 255))) state = STATE_GAME_OVER # Check for boss defeat portal spawn if (current_level_index == 4 and current_level.has_boss and current_level.boss and not current_level.boss.alive and current_level.portal_rect is None): # spawn portal high current_level.portal_rect = portal_rect_from_tile(12, 6) # Player vs portal (level completion) if current_level.portal_rect and player.rect.colliderect(current_level.portal_rect): last_level_time = (time_ms - level_start_time) / 1000.0 old_time = best_times[current_level_index] if old_time is None or last_level_time 0: screen_shake_frames -= 1 offset_x = random.randint(-5, 5) offset_y = random.randint(-5, 5) else: offset_x = 0 offset_y = 0 if offset_x != 0 or offset_y != 0: shaken = pygame.Surface((WIDTH, HEIGHT)) shaken.blit(screen, (0, 0)) screen.fill((0, 0, 0)) screen.blit(shaken, (offset_x, offset_y)) pygame.display.flip() pygame.quit() sys.exit() if __name__ == "__main__": main()