import pygame import random import math from Bosses.Boss3 import BASE_TILE class WormSegment(pygame.sprite.Sprite): """Single 1x1 tile segment of the worm (head or body).""" def __init__(self, x, y, tile_size, is_head: bool = False): super().__init__() self.is_head = is_head self.tile_size = tile_size # load textures: head uses boss5_headTile.png, body uses boss5_bodyTile.png img = None if is_head: paths = ( r"Textures/boss5_headTile.png", r"Textures\boss5_headTile.png", r"../Textures/boss5_headTile.png", r"..\Textures\boss5_headTile.png", ) else: paths = ( r"Textures/boss5_bodyTile.png", r"Textures\boss5_bodyTile.png", r"../Textures/boss5_bodyTile.png", r"..\Textures\boss5_bodyTile.png", ) for p in paths: try: img = pygame.image.load(p).convert_alpha() break except Exception: continue if img is None: # fallback: generic tiles try: alt = r"Textures/block_temp.png" if is_head else r"Textures/block.png" img = pygame.image.load(alt).convert_alpha() except Exception: img = pygame.Surface((BASE_TILE, BASE_TILE), pygame.SRCALPHA) img.fill((255, 0, 0) if is_head else (150, 150, 150)) try: self.base_image = pygame.transform.smoothscale(img, (tile_size, tile_size)) except Exception: self.base_image = pygame.transform.scale(img, (tile_size, tile_size)) # NEW: rotate base image 90 degrees to the right at load time try: self.base_image = pygame.transform.rotate(self.base_image, -90) except Exception: self.base_image = pygame.transform.rotate(self.base_image, -90) self.image = self.base_image self.rect = self.image.get_rect(center=(int(x), int(y))) self.x = float(self.rect.centerx) self.y = float(self.rect.centery) self.facing_angle = 0.0 self._draw_cache = {"scale": None, "angle": None, "image": None} def set_position(self, x, y): """Place segment at pixel center (world space).""" self.x = float(x) self.y = float(y) self.rect.center = (int(round(self.x)), int(round(self.y))) def set_facing_towards(self, tx, ty): """Face from this segment toward target (tx, ty) in world space.""" dx = tx - self.x dy = ty - self.y if dx == 0 and dy == 0: return # screen coords: +x right, +y down self.facing_angle = math.degrees(math.atan2(dy, dx)) def draw(self, screen, scale, off_x=0, off_y=0): # Rotate base_image by facing_angle, then scale and cache angle_key = int(round(self.facing_angle)) % 360 if self._draw_cache["scale"] != scale or self._draw_cache["angle"] != angle_key or self._draw_cache["image"] is None: self._draw_cache["scale"] = scale self._draw_cache["angle"] = angle_key try: rotated = pygame.transform.rotate(self.base_image, -self.facing_angle) except Exception: rotated = pygame.transform.rotate(self.base_image, -float(angle_key)) w = max(1, int(round(rotated.get_width() * scale))) h = max(1, int(round(rotated.get_height() * scale))) try: self._draw_cache["image"] = pygame.transform.smoothscale(rotated, (w, h)) except Exception: self._draw_cache["image"] = pygame.transform.scale(rotated, (w, h)) img = self._draw_cache["image"] sx = int(round(self.rect.x * scale)) + off_x sy = int(round(self.rect.y * scale)) + off_y screen.blit(img, (sx, sy)) class Boss5Projectile(pygame.sprite.Sprite): SPEED = 4 SIZE = (20, 10) fire_sound = None # None = not loaded, False = permanent fail @classmethod def _ensure_sound(cls): if cls.fire_sound is False: return if not pygame.mixer.get_init(): cls.fire_sound = False return if isinstance(cls.fire_sound, pygame.mixer.Sound): return try: cls.fire_sound = pygame.mixer.Sound(r"Sounds/laser.mp3") except Exception: try: cls.fire_sound = pygame.mixer.Sound("Sounds/laser.mp3") except Exception: cls.fire_sound = False @classmethod def _play_fire_sound_safe(cls): if not pygame.mixer.get_init(): return cls._ensure_sound() if not isinstance(cls.fire_sound, pygame.mixer.Sound): return try: cls.fire_sound.play() except Exception: pass @classmethod def apply_master_volume(cls, volume: float): if not pygame.mixer.get_init(): return cls._ensure_sound() if isinstance(cls.fire_sound, pygame.mixer.Sound): try: cls.fire_sound.set_volume(max(0.0, min(1.0, float(volume)))) except Exception: pass def __init__(self, x, y, vx, vy, tile_size=None, group=None): super().__init__() img = None for p in (r"Textures/laser_enemies.png", r"Textures\laser_enemies.png", r"../Textures/laser_enemies.png"): try: img = pygame.image.load(p).convert_alpha() break except Exception: continue if img is None: img = pygame.Surface(self.SIZE, pygame.SRCALPHA) img.fill((255, 80, 20)) base = pygame.transform.scale(img, self.SIZE) angle = math.degrees(math.atan2(-vy, vx)) self.image = pygame.transform.rotate(base, angle) self.rect = self.image.get_rect(center=(int(x), int(y))) self.x = float(x) self.y = float(y) length = math.hypot(vx, vy) s = float(tile_size) / float(BASE_TILE) if tile_size else 1.0 base_speed = float(self.SPEED) * s if length == 0: self.vx = self.vy = 0.0 else: self.vx = vx / length * base_speed self.vy = vy / length * base_speed self._draw_cache = {"scale": None, "image": None} if group is not None: group.add(self) Boss5Projectile._play_fire_sound_safe() def update(self, world): self.x += self.vx self.y += self.vy self.rect.center = (int(round(self.x)), int(round(self.y))) tiles = getattr(world, "collision_rects", None) if tiles is None: tiles = [r for _, r in getattr(world, "tile_list", [])] for r in tiles: if self.rect.colliderect(r): self.kill() return if (self.rect.right < 0 or self.rect.left > world.pixel_width or self.rect.bottom < 0 or self.rect.top > world.pixel_height): self.kill() def draw(self, screen, scale, off_x=0, off_y=0): if self._draw_cache["scale"] != scale: self._draw_cache["scale"] = scale sw = max(1, int(round(self.image.get_width() * scale))) sh = max(1, int(round(self.image.get_height() * scale))) try: self._draw_cache["image"] = pygame.transform.smoothscale(self.image, (sw, sh)) except Exception: self._draw_cache["image"] = pygame.transform.scale(self.image, (sw, sh)) img = self._draw_cache["image"] sx = int(round(self.rect.centerx * scale - img.get_width() / 2)) + off_x sy = int(round(self.rect.centery * scale - img.get_height() / 2)) + off_y screen.blit(img, (sx, sy)) class Boss5(pygame.sprite.Sprite): """ Worm boss: - 25 tiles long (segments); first is head. - Head uses block_temp.png; body uses block.png. - Head moves randomly inside the world (continuous), body follows. - All segments share one HP pool; any segment hit damages boss. - Random body segments occasionally shoot lasers at the player (using laser_enemies.png). - Has a charge+dash attack toward the player after 1.5s prep. - NEW: Every 10s performs an ultimate burst where all segments shoot rapidly. """ def __init__(self, x, y, hp=80, segment_count=25, projectile_group=None, tile_size=BASE_TILE): super().__init__() # HP: now 80 self.max_hp = hp self.hp = hp self.dead = False self.tile_size = tile_size self.segment_count = max(3, int(segment_count)) self.segments = [] # create head head = WormSegment(x, y, tile_size, is_head=True) self.segments.append(head) # body with smaller distance between blocks body_spacing = tile_size * 0.9 for i in range(1, self.segment_count): sx = x - i * body_spacing sy = y seg = WormSegment(sx, sy, tile_size, is_head=False) self.segments.append(seg) self.image = head.base_image self.rect = head.rect # base wandering movement self.head_angle = random.uniform(0, 2 * math.pi) self.head_speed_base = 0.08 # very slow wandering self.noise_angle_change = 0.22 self.follow_distance = body_spacing # --- NEW: dash attack state machine --- # states: "wander", "dash_prep", "dash" # START in dash_prep so first thing he does is charge a dash self.state = "dash_prep" # start by charging a dash immediately self.dash_prep_ms = 1500 # 1.5s charge time self.dash_start_ms = pygame.time.get_ticks() # default dash dir to the right; will be corrected toward player in first update self.dash_dir = (1.0, 0.0) # normalized (dx, dy) toward target self.dash_speed_base = 0.5 # tiles per frame while dashing (faster than wander) self.time_between_dashes_ms = 6000 self.next_dash_allowed_ms = pygame.time.get_ticks() + self.time_between_dashes_ms # projectiles self.bullets = pygame.sprite.Group() self.projectile_group = projectile_group self.last_body_shot_ms = 0 self.body_shot_interval_ms = 950 # NEW: ultimate attack timing self.ultimate_interval_ms = 10000 # every 10s self.last_ultimate_ms = pygame.time.get_ticks() self.ultimate_duration_ms = 1500 # burst lasts 1.5s self.ultimate_shot_interval_ms = 180 # time between ultimate volleys self.ultimate_active = False self.ultimate_end_ms = 0 self.last_ultimate_shot_ms = 0 self._draw_cache = {"scale": None} # --- movement helpers --- def _update_head_wander(self, world): """Random wandering inside the world, clamped to bounds (used in wander state).""" head = self.segments[0] cx, cy = head.x, head.y # random turn self.head_angle += random.uniform(-self.noise_angle_change, self.noise_angle_change) ts = getattr(world, "tile_size", self.tile_size) speed_px = self.head_speed_base * float(ts) dx = math.cos(self.head_angle) * speed_px dy = math.sin(self.head_angle) * speed_px nx = cx + dx ny = cy + dy half = ts * 0.5 clamped_x = max(half, min(world.pixel_width - half, nx)) clamped_y = max(half, min(world.pixel_height - half, ny)) # if we hit a border, bounce angle roughly inward if clamped_x != nx or clamped_y != ny: self.head_angle = self.head_angle + math.pi + random.uniform(-0.5, 0.5) head.set_position(clamped_x, clamped_y) # NEW: head faces its movement direction (head_angle) head.facing_angle = math.degrees(self.head_angle) self.rect = head.rect def _update_head_dash(self, world): """Move head in dash state using precomputed dash_dir.""" head = self.segments[0] cx, cy = head.x, head.y ts = getattr(world, "tile_size", self.tile_size) dash_speed_px = self.dash_speed_base * float(ts) dx = self.dash_dir[0] * dash_speed_px dy = self.dash_dir[1] * dash_speed_px nx = cx + dx ny = cy + dy half = ts * 0.5 clamped_x = max(half, min(world.pixel_width - half, nx)) clamped_y = max(half, min(world.pixel_height - half, ny)) head.set_position(clamped_x, clamped_y) # NEW: head faces dash direction if self.dash_dir != (0.0, 0.0): head.facing_angle = math.degrees(math.atan2(self.dash_dir[1], self.dash_dir[0])) self.rect = head.rect def _update_body_follow(self, world): ts = getattr(world, "tile_size", self.tile_size) half = ts * 0.5 for i in range(1, len(self.segments)): leader = self.segments[i - 1] seg = self.segments[i] dx = leader.x - seg.x dy = leader.y - seg.y dist = math.hypot(dx, dy) if dist > 0 and dist > self.follow_distance: move = dist - self.follow_distance seg_x = seg.x + dx / dist * move seg_y = seg.y + dy / dist * move else: seg_x, seg_y = seg.x, seg.y seg_x = max(half, min(world.pixel_width - half, seg_x)) seg_y = max(half, min(world.pixel_height - half, seg_y)) seg.set_position(seg_x, seg_y) # NEW: each body segment faces toward its leader seg.set_facing_towards(leader.x, leader.y) # --- damage / shooting --- def _handle_player_hits(self, player_projectiles): total_dmg = 0 for proj in list(player_projectiles): try: hit_rect = proj.rect except Exception: continue for seg in self.segments: if seg.rect.colliderect(hit_rect): dmg = getattr(proj, "damage", 1) total_dmg += max(0, int(dmg)) try: proj.kill() except Exception: pass break if total_dmg > 0: self.hp = max(0, self.hp - total_dmg) if self.hp <= 0: self.dead = True for seg in self.segments: seg.kill() for b in list(self.bullets): b.kill() self.kill() def _handle_body_shooting(self, world, player): """Normal body shooting: 25% chance per segment per volley.""" now = pygame.time.get_ticks() if now - self.last_body_shot_ms < self.body_shot_interval_ms: return if len(self.segments) <= 1: return ts = getattr(world, "tile_size", BASE_TILE) any_fired = False body_segments = self.segments[1:] # exclude head # try to fire from each body segment independently (25% chance) for seg in body_segments: if random.random() >= 0.25: continue cx, cy = seg.rect.center try: tx, ty = player.rect.centerx, player.rect.centery except Exception: tx, ty = cx, cy vx = tx - cx vy = ty - cy if vx == 0 and vy == 0: vx, vy = 1, 0 p = Boss5Projectile( cx, cy, vx, vy, tile_size=ts, group=self.bullets, ) if self.projectile_group is not None: self.projectile_group.add(p) any_fired = True # if nobody fired this volley, ensure at least one projectile by # falling back to a single random body segment if not any_fired: seg = random.choice(body_segments) cx, cy = seg.rect.center try: tx, ty = player.rect.centerx, player.rect.centery except Exception: tx, ty = cx, cy vx = tx - cx vy = ty - cy if vx == 0 and vy == 0: vx, vy = 1, 0 p = Boss5Projectile( cx, cy, vx, vy, tile_size=ts, group=self.bullets, ) if self.projectile_group is not None: self.projectile_group.add(p) # record time of this volley (no matter how many segments fired) self.last_body_shot_ms = now def _handle_bullet_hits_player(self, player): for b in list(self.bullets): if b.rect.colliderect(player.rect): try: player.take_damage(1) except Exception: player.health = max(0, getattr(player, "health", 0) - 1) b.kill() # NEW: ultimate attack handler def _handle_ultimate(self, world, player): """Every 10s, all segments enter a rapid-fire burst for a short time.""" now = pygame.time.get_ticks() # Start ultimate if enough time passed and not already active if not self.ultimate_active and now - self.last_ultimate_ms >= self.ultimate_interval_ms: self.ultimate_active = True self.ultimate_end_ms = now + self.ultimate_duration_ms self.last_ultimate_shot_ms = 0 # fire immediately on start if not self.ultimate_active: return # End ultimate after duration if now >= self.ultimate_end_ms: self.ultimate_active = False self.last_ultimate_ms = now return # Fire a volley from ALL segments every ultimate_shot_interval_ms if now - self.last_ultimate_shot_ms < self.ultimate_shot_interval_ms: return self.last_ultimate_shot_ms = now if len(self.segments) == 0: return ts = getattr(world, "tile_size", BASE_TILE) for seg in self.segments: cx, cy = seg.rect.center try: tx, ty = player.rect.centerx, player.rect.centery except Exception: tx, ty = cx, cy vx = tx - cx vy = ty - cy if vx == 0 and vy == 0: # random direction if player exactly on top ang = random.uniform(0, 2 * math.pi) vx = math.cos(ang) vy = math.sin(ang) p = Boss5Projectile( cx, cy, vx, vy, tile_size=ts, group=self.bullets, ) if self.projectile_group is not None: self.projectile_group.add(p) # --- public API --- def update(self, player, player_projectiles, world): self._handle_player_hits(player_projectiles) if self.dead: return self.tile_size = getattr(world, "tile_size", self.tile_size) now = pygame.time.get_ticks() # --- state machine for dash attack --- if self.state == "wander": # normal random movement self._update_head_wander(world) # occasionally start dash prep when cooldown passed if now >= self.next_dash_allowed_ms: self.state = "dash_prep" self.dash_start_ms = now # record direction toward player at start of charge try: head = self.segments[0] dx = player.rect.centerx - head.x dy = player.rect.centery - head.y dist = math.hypot(dx, dy) if dist == 0: self.dash_dir = (1.0, 0.0) else: self.dash_dir = (dx / dist, dy / dist) except Exception: self.dash_dir = (1.0, 0.0) elif self.state == "dash_prep": # ensure dash_dir is set toward player at beginning of prep if player is not None and hasattr(player, "rect"): try: head = self.segments[0] dx = player.rect.centerx - head.x dy = player.rect.centery - head.y dist = math.hypot(dx, dy) if dist != 0: self.dash_dir = (dx / dist, dy / dist) except Exception: pass # stand still and "charge" for 1.5s if now - self.dash_start_ms >= self.dash_prep_ms: self.state = "dash" elif self.state == "dash": # move quickly along precomputed dash_dir self._update_head_dash(world) # simple end condition: dash lasts for a short fixed time if now - self.dash_start_ms >= (self.dash_prep_ms + 600): # ~0.6s dash after prep self.state = "wander" self.next_dash_allowed_ms = now + self.time_between_dashes_ms # body always follows after head updated self._update_body_follow(world) # normal body shots self._handle_body_shooting(world, player) # NEW: ultimate burst handling (may add extra bullets) self._handle_ultimate(world, player) # boss projectiles + damage to player self.bullets.update(world) self._handle_bullet_hits_player(player) def draw(self, screen, scale, off_x=0, off_y=0): # draw body then head (segments already carry their own rotation) for seg in reversed(self.segments): seg.draw(screen, scale, off_x, off_y) head = self.segments[0] if self._draw_cache["scale"] != scale: self._draw_cache["scale"] = scale sx = int(round(head.rect.x * scale)) + off_x sy = int(round(head.rect.y * scale)) + off_y # EVEN SMALLER HP bar for Boss5 # previously: 0.4 * segment_count * tile_size bw = max(40, int(round(self.segment_count * self.tile_size * 0.25 * scale))) bh = max(3, int(round(5 * scale))) bx = sx + int(round(head.rect.width * scale)) // 2 - bw // 2 by = sy - bh - max(2, int(round(6 * scale))) pygame.draw.rect(screen, (40, 40, 40), (bx, by, bw, bh)) ratio = 0 if self.max_hp <= 0 else self.hp / self.max_hp pygame.draw.rect(screen, (220, 40, 40), (bx, by, int(round(bw * ratio)), bh)) pygame.draw.rect(screen, (0, 0, 0), (bx, by, bw, bh), max(1, int(round(2 * scale)))) for b in self.bullets: b.draw(screen, scale, off_x, off_y)