diff --git a/light_and_dark/data/config/colors.json b/light_and_dark/data/config/colors.json index 71ca63c..ed9804f 100644 --- a/light_and_dark/data/config/colors.json +++ b/light_and_dark/data/config/colors.json @@ -24,7 +24,9 @@ "boss": [240, 220, 80], "boss_hit": [255, 245, 160], "boss_flyer": [150, 90, 220], - "boss_flyer_hit": [210, 170, 255] + "boss_flyer_hit": [210, 170, 255], + "swooper": [90, 180, 220], + "swooper_hit": [170, 225, 255] }, "healing": { diff --git a/light_and_dark/data/config/fairy.json b/light_and_dark/data/config/fairy.json index 105c39e..e9cb741 100644 --- a/light_and_dark/data/config/fairy.json +++ b/light_and_dark/data/config/fairy.json @@ -34,7 +34,7 @@ "shade": "Ooh, shiny. Even the dark likes this." }, "dust_2": { - "light": "Another pinch of dust - keep going!", + "light": "A pinch of dust - keep going!", "shade": "The dark tastes sweeter with this." }, "dust_3": { @@ -50,7 +50,7 @@ "shade": "All 5! My sting is yours. Press the Fairy Mode key." }, "quest_return": { - "light": "That's all 5! Return to me at the fairy spot - I have something to show you!", + "light": "That's all 5! Return to the fairy spot - I have something to show you!", "shade": "All 5 gathered. Come back to my spot - I am ready to change." }, "quest_unlock": { diff --git a/light_and_dark/data/config/ranged.json b/light_and_dark/data/config/ranged.json index 0d2c4bc..1a77e36 100644 --- a/light_and_dark/data/config/ranged.json +++ b/light_and_dark/data/config/ranged.json @@ -8,8 +8,8 @@ "size": [14, 6] }, "fairy": { - "charge_cost": 5.0, - "cooldown": 0.7, + "charge_cost": 10.0, + "cooldown": 1.4, "range": 560.0, "voice_every": 3, "damage_mult": 0.5, diff --git a/light_and_dark/data/config/ui_layout.json b/light_and_dark/data/config/ui_layout.json index 73369d8..b9632c0 100644 --- a/light_and_dark/data/config/ui_layout.json +++ b/light_and_dark/data/config/ui_layout.json @@ -28,6 +28,7 @@ "pad_y": 6, "line_gap": 4, "x": 14, + "center_lift": 60, "y_offset_from_center": -10 } } diff --git a/light_and_dark/data/enemies/enemy_types.json b/light_and_dark/data/enemies/enemy_types.json index 31484bd..21b1291 100644 --- a/light_and_dark/data/enemies/enemy_types.json +++ b/light_and_dark/data/enemies/enemy_types.json @@ -88,6 +88,26 @@ "detection_range": 320, "contact_damage": 1, "drop_profile": "normal" + }, + + "swooper": { + "rect": [40, 40], + "health": 3, + "speed": 2.0, + "air_speed": 2.0, + "detection_range": 340, + "contact_damage": 1, + "drop_profile": "normal", + "behavior": "swooper", + "swooper": { + "bob_amplitude": 6, + "bob_speed": 2.4, + "charge_speed_mult": 3.4, + "charge_duration": 0.8, + "charge_cooldown": 1.8, + "return_speed": 2.2, + "windup": 0.0 + } } } diff --git a/light_and_dark/data/sprites/enemies/boss_flyer_minion.png b/light_and_dark/data/sprites/enemies/boss_flyer_minion.png new file mode 100644 index 0000000..29fdb7c Binary files /dev/null and b/light_and_dark/data/sprites/enemies/boss_flyer_minion.png differ diff --git a/light_and_dark/data/sprites/enemies/flying_boss_1.png b/light_and_dark/data/sprites/enemies/flying_boss_1.png new file mode 100644 index 0000000..4147f78 Binary files /dev/null and b/light_and_dark/data/sprites/enemies/flying_boss_1.png differ diff --git a/light_and_dark/data/sprites/enemies/flying_enemy_1.png b/light_and_dark/data/sprites/enemies/flying_enemy_1.png new file mode 100644 index 0000000..9310e3a Binary files /dev/null and b/light_and_dark/data/sprites/enemies/flying_enemy_1.png differ diff --git a/light_and_dark/data/sprites/enemies/ground_boss_1.png b/light_and_dark/data/sprites/enemies/ground_boss_1.png new file mode 100644 index 0000000..0c1f0c6 Binary files /dev/null and b/light_and_dark/data/sprites/enemies/ground_boss_1.png differ diff --git a/light_and_dark/data/sprites/enemies/ground_enemy_1.png b/light_and_dark/data/sprites/enemies/ground_enemy_1.png new file mode 100644 index 0000000..f5a5467 Binary files /dev/null and b/light_and_dark/data/sprites/enemies/ground_enemy_1.png differ diff --git a/light_and_dark/data/sprites/enemies/ground_miniboss_1.png b/light_and_dark/data/sprites/enemies/ground_miniboss_1.png new file mode 100644 index 0000000..93702f4 Binary files /dev/null and b/light_and_dark/data/sprites/enemies/ground_miniboss_1.png differ diff --git a/light_and_dark/data/world/rooms/room_09.json b/light_and_dark/data/world/rooms/room_09.json index 276c650..4dd94d1 100644 --- a/light_and_dark/data/world/rooms/room_09.json +++ b/light_and_dark/data/world/rooms/room_09.json @@ -22,6 +22,10 @@ "room9_bottom_right": [ 2660, 600 + ], + "entry_diary": [ + 1774, + 540 ] }, "platforms": [ diff --git a/light_and_dark/level_editor_v43.html b/light_and_dark/level_editor_v43.html index b3997e8..529eb35 100644 --- a/light_and_dark/level_editor_v43.html +++ b/light_and_dark/level_editor_v43.html @@ -303,6 +303,13 @@ } }, boss_minion: { rect: [34, 50], health: 4, speed: 2.4, air_speed: 2.2, detection_range: 320, contact_damage: 1, drop_profile: "normal" }, + swooper: { + rect: [40, 40], health: 3, speed: 2.0, air_speed: 2.0, detection_range: 340, contact_damage: 1, drop_profile: "normal", behavior: "swooper", + swooper: { + bob_amplitude: 6, bob_speed: 2.4, charge_speed_mult: 3.4, + charge_duration: 0.8, charge_cooldown: 1.8, return_speed: 2.2, windup: 0.0 + } + }, }; function seedDefaultObjectCatalog(){ diff --git a/light_and_dark/main.py b/light_and_dark/main.py index d68b528..f82f685 100644 --- a/light_and_dark/main.py +++ b/light_and_dark/main.py @@ -450,6 +450,9 @@ BOSS_HIT_COLOR = _color(["enemy", "boss_hit"]) FLYER_BOSS_COLOR = _color(["enemy", "boss_flyer"], (150, 90, 220)) FLYER_BOSS_HIT_COLOR = _color(["enemy", "boss_flyer_hit"], (210, 170, 255)) +SWOOPER_COLOR = _color(["enemy", "swooper"], (90, 180, 220)) +SWOOPER_HIT_COLOR = _color(["enemy", "swooper_hit"], (170, 225, 255)) + PLAYER_HIT_COLOR = (255, 255, 0) HEAL_DARK = _color(["healing", "dark"]) @@ -521,8 +524,8 @@ PROJECTILE_LIFETIME = float(_ranged(["projectile", "lifetime"], PROJECTILE_LIFET PROJECTILE_SIZE = tuple(_ranged(["projectile", "size"], list(PROJECTILE_SIZE))) # --- Fairy combat (granted by the Fairy Dust quest) --- -FAIRY_SHOT_CHARGE_COST = float(_ranged(["fairy", "charge_cost"], RANGED_CHARGE_COST)) -FAIRY_SHOT_COOLDOWN = float(_ranged(["fairy", "cooldown"], 0.7)) +FAIRY_SHOT_CHARGE_COST = float(_ranged(["fairy", "charge_cost"], RANGED_CHARGE_COST * 2.0)) +FAIRY_SHOT_COOLDOWN = float(_ranged(["fairy", "cooldown"], 1.4)) FAIRY_SHOT_RANGE = float(_ranged(["fairy", "range"], 560.0)) FAIRY_VOICE_EVERY = max(1, int(_ranged(["fairy", "voice_every"], 3))) FAIRY_DAMAGE_MULT = float(_ranged(["fairy", "damage_mult"], RANGED_DAMAGE_MULT)) @@ -983,6 +986,25 @@ ENEMY_TYPES_DEFAULT = { "contact_damage": 1, "drop_profile": "normal" }, + "swooper": { + "rect": [40, 40], + "health": 3, + "speed": 2.0, + "air_speed": 2.0, + "detection_range": 340, + "contact_damage": 1, + "drop_profile": "normal", + "behavior": "swooper", + "swooper": { + "bob_amplitude": 6, + "bob_speed": 2.4, + "charge_speed_mult": 3.4, + "charge_duration": 0.8, + "charge_cooldown": 1.8, + "return_speed": 2.2, + "windup": 0.0 + } + }, } ENEMY_DROPS_DEFAULT = { @@ -1564,6 +1586,42 @@ BOSS_PROJECTILE_RAW = _load_first_existing([ ]) BOSS_PROJECTILE_RAW_LEFT = pygame.transform.flip(BOSS_PROJECTILE_RAW, True, False) if BOSS_PROJECTILE_RAW is not None else None +# --- Enemy sprites (authored at each enemy's collision-rect size) --- +ENEMIES_SPRITES_DIR = DATA_ROOT / "sprites" / "enemies" + +def _load_enemy_sprite(name: str): + return _load_image_or_none(ENEMIES_SPRITES_DIR / name) + +ENEMY_SPRITES = { + "ground_enemy": _load_enemy_sprite("ground_enemy_1.png"), + "ground_miniboss": _load_enemy_sprite("ground_miniboss_1.png"), + "ground_boss": _load_enemy_sprite("ground_boss_1.png"), + "flying_enemy": _load_enemy_sprite("flying_enemy_1.png"), + "flying_boss": _load_enemy_sprite("flying_boss_1.png"), + "flying_boss_minion": _load_enemy_sprite("boss_flyer_minion.png"), +} + +def enemy_sprite_info(type_id: str, behavior: str): + """Return ``(raw_surface | None, source_facing)`` for an enemy. + + ``source_facing`` is ``"right"`` / ``"left"`` for art that must be mirrored + to match the enemy's facing, or ``None`` for art with no direction (drawn + as authored). A ``None`` surface means "no sprite yet" -> the entity keeps + its colored-rectangle placeholder.""" + if behavior == "flyer": + return ENEMY_SPRITES["flying_boss"], None + if behavior == "swooper": + return ENEMY_SPRITES["flying_enemy"], None + if behavior == "charger": + return ENEMY_SPRITES["ground_boss"], "left" + if behavior == "shockwave": + return None, None # shockwave boss art not made yet + if type_id == "boss_minion": + return ENEMY_SPRITES["flying_boss_minion"], None + if type_id == "miniboss": + return ENEMY_SPRITES["ground_miniboss"], "right" + return ENEMY_SPRITES["ground_enemy"], "right" + def _scale_icon(img: pygame.Surface | None, size: int) -> pygame.Surface | None: if img is None: return None @@ -1763,6 +1821,9 @@ class FairyProjectile(Projectile): w = max(1, int(img_raw.get_width() * scale)) h = max(1, int(img_raw.get_height() * scale)) img = pygame.transform.scale(img_raw, (w, h)) + # Raw fairy sprite points LEFT; flip it so it points right, then rotate + # to match the actual flight direction (same fix as the ranged bolt). + img = pygame.transform.flip(img, True, False) angle = -math.degrees(math.atan2(self.dir_y, self.dir_x)) img = pygame.transform.rotate(img, angle) surface.blit(img, (self.rect.centerx - img.get_width() // 2 - cx, @@ -3748,6 +3809,9 @@ class PickupFeed: self.pad_x = int(_ui(["pickup_feed", "pad_x"], 10)) self.pad_y = int(_ui(["pickup_feed", "pad_y"], 6)) self.line_gap = int(_ui(["pickup_feed", "line_gap"], 4)) + # Lift center "splash" messages above the normal feed line so they do + # not cover the fairy's speech bubble. + self.center_lift = int(_ui(["pickup_feed", "center_lift"], 60)) def push(self, name: str, rarity: str = ""): @@ -3830,7 +3894,7 @@ class PickupFeed: box_w = min(WIDTH - 80, w + pad_x * 2) box_h = h + pad_y * 2 box_x = (WIDTH - box_w) // 2 - box_y = y + box_y = y - self.center_lift box = pygame.Surface((box_w, box_h), pygame.SRCALPHA) box.fill((0, 0, 0, int(alpha * 0.8))) @@ -4995,6 +5059,29 @@ class Enemy: self.flyer_cycle_casts_left = 0 self.flyer_hard = False + # --- Hovering flyer ("swooper") state: the enemy idles in the air and, + # the moment it spots the player, dives straight at the point the player + # occupied when the charge began (that point stays frozen mid-dive). --- + swoop_cfg = (cfg.get("swooper", {}) if isinstance(cfg, dict) else {}) + if not isinstance(swoop_cfg, dict): + swoop_cfg = {} + self.swooper_bob_amplitude = float(swoop_cfg.get("bob_amplitude", 6)) + self.swooper_bob_speed = float(swoop_cfg.get("bob_speed", 2.4)) + self.swooper_charge_speed_mult = float(swoop_cfg.get("charge_speed_mult", 3.4)) + self.swooper_charge_duration = float(swoop_cfg.get("charge_duration", 0.8)) + self.swooper_charge_cooldown = float(swoop_cfg.get("charge_cooldown", 1.8)) + self.swooper_return_speed = float(swoop_cfg.get("return_speed", 2.2)) + self.swooper_windup = float(swoop_cfg.get("windup", 0.0)) + self.swooper_state = "idle" # idle | windup | charging | recover + self.swooper_timer = 0.0 + self.swooper_bob_t = random.random() * 10.0 + self.swooper_home_x = self.rect.centerx + self.swooper_home_y = self.rect.centery + self.swooper_dir_x = 0.0 + self.swooper_dir_y = 0.0 + self.swooper_target_x = float(self.rect.centerx) + self.swooper_target_y = float(self.rect.centery) + # --- summoned boss minion: expires after a short life, then heals the # boss; killing it early damages the boss. Color fades toward white. --- self.is_minion = (self.type_id == "boss_minion") @@ -5068,6 +5155,16 @@ class Enemy: self.flyer_startup_timer = self.flyer_startup_delay self.flyer_cycle_casts_left = 0 + self.swooper_state = "idle" + self.swooper_timer = 0.0 + self.swooper_bob_t = random.random() * 10.0 + self.swooper_home_x = self.rect.centerx + self.swooper_home_y = self.rect.centery + self.swooper_dir_x = 0.0 + self.swooper_dir_y = 0.0 + self.swooper_target_x = float(self.rect.centerx) + self.swooper_target_y = float(self.rect.centery) + self.turn_cooldown = 0.0 self.last_x = self.rect.x self.stuck_timer = 0.0 @@ -5559,6 +5656,157 @@ class Enemy: )) self.flyer_aoe_cd = self.flyer_aoe_cooldown + # --- Hovering flyer ("swooper") behavior --- + + def _swooper_update(self, dt, platforms, player): + """Idle in the air bobbing, then -- the instant the player is spotted -- + lock onto where the player stood at that moment and dive at that frozen + point. After the dive it recovers, drifts back to its perch and can + charge again once the cooldown elapses.""" + if self.hit_timer > 0.0: + self.hit_timer -= dt + if self.bind_timer > 0.0: + self.bind_timer = max(0.0, self.bind_timer - dt) + if self.fear_timer > 0.0: + self.fear_timer = max(0.0, self.fear_timer - dt) + if self.slow_timer > 0.0: + self.slow_timer = max(0.0, self.slow_timer - dt) + if self.slow_timer == 0.0: + self.slow_mult = 1.0 + self._behavior_tick(dt) + + stunned = self.stun_timer > 0.0 + if stunned: + self.stun_timer = max(0.0, self.stun_timer - dt) + + dx = player.rect.centerx - self.rect.centerx + dy = player.rect.centery - self.rect.centery + distance = math.hypot(dx, dy) + sees_player = (not player.shade) and (distance <= self.detection_range) + feared = self.fear_timer > 0.0 + speed = self.air_speed + if self.slow_timer > 0.0: + speed *= self.slow_mult + + if feared: + # Flee straight away from the player and abort any dive. + if distance > 0.001: + self._swooper_try_move(-dx / distance * TETHER_FEAR_SPEED_MULT * speed, + -dy / distance * TETHER_FEAR_SPEED_MULT * speed, + platforms) + self.swooper_state = "idle" + self.swooper_timer = 0.0 + self.was_chasing = False + return + + if stunned: + self.swooper_state = "idle" + self.swooper_timer = 0.0 + self.was_chasing = False + return + + if self.swooper_state == "idle": + self._swooper_hover(dt, platforms) + if sees_player: + self._swooper_start_charge(player) + elif self.swooper_state == "windup": + self._swooper_hover(dt, platforms) + self.swooper_timer -= dt + if self.swooper_timer <= 0.0: + self.swooper_state = "charging" + self.swooper_timer = self.swooper_charge_duration + elif self.swooper_state == "charging": + charge_speed = speed * self.swooper_charge_speed_mult + blocked = self._swooper_try_move( + self.swooper_dir_x * charge_speed, + self.swooper_dir_y * charge_speed, + platforms, + ) + self.swooper_timer -= dt + # Remaining vector to the frozen target; once it points behind us + # (or we hit something / ran out of time) the dive is over. + rem_x = self.swooper_target_x - self.rect.centerx + rem_y = self.swooper_target_y - self.rect.centery + passed_target = (rem_x * self.swooper_dir_x + rem_y * self.swooper_dir_y) <= 0.0 + if blocked or self.swooper_timer <= 0.0 or passed_target: + self.swooper_state = "recover" + self.swooper_timer = self.swooper_charge_cooldown + else: # recover + self._swooper_hover(dt, platforms) + self.swooper_timer -= dt + if self.swooper_timer <= 0.0: + self.swooper_state = "idle" + + self.was_chasing = sees_player + + def _swooper_hover(self, dt, platforms): + """Ease back toward the perch while bobbing gently on the y axis.""" + self.swooper_bob_t += dt * self.swooper_bob_speed + bob = math.sin(self.swooper_bob_t) * self.swooper_bob_amplitude + to_x = self.swooper_home_x - self.rect.centerx + to_y = (self.swooper_home_y + bob) - self.rect.centery + dist = math.hypot(to_x, to_y) + if dist < 0.001: + return + hover_speed = self.swooper_return_speed + if self.slow_timer > 0.0: + hover_speed *= self.slow_mult + step = min(hover_speed, dist) + self._swooper_try_move(to_x / dist * step, to_y / dist * step, platforms) + + def _swooper_start_charge(self, player): + """Freeze the player's position and begin the dive toward it.""" + self.swooper_target_x = float(player.rect.centerx) + self.swooper_target_y = float(player.rect.centery) + dx = self.swooper_target_x - self.rect.centerx + dy = self.swooper_target_y - self.rect.centery + length = math.hypot(dx, dy) + if length < 1.0: + dx, dy, length = 1.0, 0.0, 1.0 + self.swooper_dir_x = dx / length + self.swooper_dir_y = dy / length + self.direction = 1 if self.swooper_dir_x >= 0 else -1 + if self.swooper_windup > 0.0: + self.swooper_state = "windup" + self.swooper_timer = self.swooper_windup + else: + self.swooper_state = "charging" + self.swooper_timer = self.swooper_charge_duration + + def _swooper_try_move(self, dx, dy, platforms) -> bool: + """Move by a fractional per-frame delta, stopping on platforms and + clamping to the room. Returns True if a platform blocked the move.""" + blocked = False + if dx: + self.rect.x += int(dx) + for p in platforms: + if self.rect.colliderect(p): + if dx > 0: + self.rect.right = p.left + else: + self.rect.left = p.right + blocked = True + break + if dy: + self.rect.y += int(dy) + for p in platforms: + if self.rect.colliderect(p): + if dy > 0: + self.rect.bottom = p.top + else: + self.rect.top = p.bottom + blocked = True + break + lo = getattr(ROOM, "min_x", None) + hi = getattr(ROOM, "max_x", None) + if lo is not None: + self.rect.left = max(self.rect.left, lo) + if hi is not None: + self.rect.right = min(self.rect.right, hi) + room_h = getattr(ROOM, "room_h", 900) + self.rect.y = max(20, min(self.rect.y, room_h - self.rect.height)) + return blocked + def update(self, dt, platforms, ground_platforms, player, shockwaves_list: list[Shockwave], projectiles_list=None, enemies_list=None, aoes_list=None): # Summoned minions expire after their short life: no drops, heals the boss. @@ -5580,6 +5828,11 @@ class Enemy: ) return + # Hovering flyer ("swooper"): also fully aerial; replaces the ground body. + if self.behavior == "swooper": + self._swooper_update(dt, platforms, player) + return + if self.jump_cd_timer > 0: self.jump_cd_timer -= dt @@ -6012,6 +6265,8 @@ class Enemy: def draw(self, surface, cx, cy): if self.behavior == "flyer": color = FLYER_BOSS_HIT_COLOR if self.hit_timer > 0 else FLYER_BOSS_COLOR + elif self.behavior == "swooper": + color = SWOOPER_HIT_COLOR if self.hit_timer > 0 else SWOOPER_COLOR elif self.drop_profile == "boss": color = BOSS_HIT_COLOR if self.hit_timer > 0 else BOSS_COLOR elif self.drop_profile == "miniboss": @@ -6020,16 +6275,38 @@ class Enemy: color = ENEMY_HIT_COLOR if self.hit_timer > 0 else ENEMY_COLOR # Summoned minions fade toward white as their life runs out. + fade = 0.0 if getattr(self, "is_minion", False) and self.minion_duration > 0: frac = max(0.0, min(1.0, self.minion_timer / self.minion_duration)) - light = 1.0 - frac - color = tuple(int(c + (255 - c) * light * 0.9) for c in color) + fade = (1.0 - frac) * 0.9 - pygame.draw.rect( - surface, - color, - pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height) - ) + raw, source_facing = enemy_sprite_info(self.type_id, self.behavior) + if raw is not None: + img = raw + if source_facing == "right" and self.direction < 0: + img = pygame.transform.flip(img, True, False) + elif source_facing == "left" and self.direction > 0: + img = pygame.transform.flip(img, True, False) + draw_x = self.rect.centerx - img.get_width() // 2 - cx + draw_y = self.rect.bottom - img.get_height() - cy + surface.blit(img, (draw_x, draw_y)) + if self.hit_timer > 0: + flash = img.copy() + flash.fill((140, 140, 140, 0), special_flags=pygame.BLEND_RGBA_ADD) + surface.blit(flash, (draw_x, draw_y)) + if fade > 0.0: + add = int(255 * fade) + glow = img.copy() + glow.fill((add, add, add, 0), special_flags=pygame.BLEND_RGBA_ADD) + surface.blit(glow, (draw_x, draw_y)) + else: + if fade > 0.0: + color = tuple(int(c + (255 - c) * fade) for c in color) + pygame.draw.rect( + surface, + color, + pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height) + ) if self.reality_break_glow: pygame.draw.rect( @@ -8466,8 +8743,12 @@ diary_ui = DiaryUI(inventory, player, font) # ---------------------------- TOWN_NPC_TYPES = ("vendor", "upgrader", "necklace_vendor", "combat_master") +# The current first town (where the Boss Diary lives). +TOWN_ROOM_ID = "room_09.json" + TOWN_DISPLAY_NAMES = { "room_town_gloomreach.json": "Gloomreach", + "room_09.json": "Gloomreach", } @@ -9749,11 +10030,12 @@ while running: # Respawn on death/void if player.rect.top > KILL_Y or player.health <= 0: - apply_death_penalty() if ACTIVE_REFIGHT is not None: - # Died in a diary refight -> return to the diary in town. + # Died in a diary refight. Refights are consequence-free practice: + # skip the death penalty entirely (no coin/jar loss, no corpse) and + # return the player to the diary in town. ACTIVE_REFIGHT = None - room = room_manager.load_room("room_town_gloomreach.json") + room = room_manager.load_room(TOWN_ROOM_ID) diary_spawn = room.entry_spawns.get("entry_diary") or room.player_spawn room.player_spawn = [int(diary_spawn[0]), int(diary_spawn[1])] player.respawn(int(diary_spawn[0]), int(diary_spawn[1])) @@ -9761,6 +10043,7 @@ while running: player.shatterstep_landing_pending = False player.shatterstep_landing_damage = 0.0 else: + apply_death_penalty() # Normal death respawn: latest lantern save point, else the room spawn. if LANTERN_RESPAWN_ROOM and LANTERN_RESPAWN_POS: lx, ly = LANTERN_RESPAWN_POS @@ -10155,12 +10438,15 @@ while running: # Fairy companion bubble: active line, or a talk prompt when at a fairy spot. fairy_rect = player.fairy_screen_rect if fairy_rect is not None: + # Anchor the bubble above the player rather than the fairy: the fairy + # swaps sides when the player flips, which made the text jump. + bubble_x = player.rect.centerx - camera_x if fairy_speech_text: - draw_speech_bubble(screen, fairy_speech_text, fairy_rect.centerx, fairy_rect.top) + draw_speech_bubble(screen, fairy_speech_text, bubble_x, fairy_rect.top) elif get_near_fairy_dust(player, fairy_dusts): - draw_speech_bubble(screen, "(Press F to collect)", fairy_rect.centerx, fairy_rect.top) + draw_speech_bubble(screen, "(Press F to collect)", bubble_x, fairy_rect.top) elif get_near_fairy_spot(player, fairy_spots): - draw_speech_bubble(screen, "(Press F to talk)", fairy_rect.centerx, fairy_rect.top) + draw_speech_bubble(screen, "(Press F to talk)", bubble_x, fairy_rect.top) # NPC greeting bubble (shown near the player) greet_npc = get_near_npc(player, npcs) diff --git a/light_and_dark/saves/new sprites.json b/light_and_dark/saves/new sprites.json new file mode 100644 index 0000000..7ef485f --- /dev/null +++ b/light_and_dark/saves/new sprites.json @@ -0,0 +1,307 @@ +{ + "version": 1, + "saved_at": "2026-10-06T09:06:07", + "player": { + "room_id": "room_09.json", + "x": 1460, + "y": 660, + "health": 20, + "charge": 115.0, + "shade": true, + "coins_light": 92, + "coins_dark": 22, + "butterfly_jars": 1, + "butterfly_jar_seen": true, + "has_ranged": true, + "has_fairy_combat": true, + "opened_chests": [ + "room_03.json@90,194", + "room_07.json@440,494" + ], + "equipment": { + "helmet": { + "id": "gravemark_helm", + "name": "Gravemark Helmet", + "set": "gravemark", + "slot": "helmet", + "base_stats": {}, + "rarity_overrides": { + "tempered": { + "extra_stats": { + "charge": 10 + } + }, + "attuned": { + "extra_stats": { + "charge": 15, + "charge_regen_pct": 0.1 + } + }, + "awakened": { + "extra_stats": { + "charge": 15, + "charge_regen_pct": 0.15, + "health": 5 + } + } + }, + "rarity": "awakened", + "tier": 3, + "stats": { + "charge": 15.0, + "charge_regen_pct": 0.15, + "health": 5.0 + }, + "abilities": [] + }, + "chest": { + "id": "gravemark_chest", + "name": "Gravemark Chestplate", + "set": "gravemark", + "slot": "chest", + "base_stats": {}, + "rarity_overrides": { + "tempered": { + "extra_stats": { + "charge_regen_pct": 0.1 + } + }, + "attuned": { + "extra_stats": { + "charge_regen_pct": 0.1, + "health": 5 + } + }, + "awakened": { + "extra_stats": { + "charge_regen_pct": 0.2, + "health": 10 + } + } + }, + "rarity": "tempered", + "tier": 1, + "stats": { + "charge_regen_pct": 0.1 + }, + "abilities": [] + }, + "pants": { + "id": "vitalweave_greaves", + "name": "Vitalweave Greaves", + "set": "vitalweave", + "slot": "pants", + "base_stats": {}, + "rarity_overrides": { + "tempered": { + "extra_stats": { + "charge_regen_pct": 0.1 + } + }, + "attuned": { + "extra_stats": { + "charge_regen_pct": 0.15 + } + }, + "awakened": { + "extra_stats": { + "charge_regen_pct": 0.35 + } + } + }, + "rarity": "attuned", + "tier": 2, + "stats": { + "charge_regen_pct": 0.15 + }, + "abilities": [] + }, + "necklace": { + "id": "necklace_vigor", + "name": "Charm of Vigor", + "slot": "necklace", + "item_type": "necklace", + "base_stats": { + "health": 5 + }, + "rarity": "", + "tier": 0, + "abilities": [], + "stats": { + "health": 5 + } + } + } + }, + "inventory": [ + { + "id": "vitalweave_helm", + "name": "Vitalweave Helmet", + "set": "vitalweave", + "slot": "helmet", + "base_stats": {}, + "rarity_overrides": { + "tempered": { + "extra_stats": { + "health": 5 + } + }, + "attuned": { + "extra_stats": { + "health": 10 + } + }, + "awakened": { + "extra_stats": { + "health": 15 + } + } + }, + "rarity": "attuned", + "tier": 2, + "stats": { + "health": 10.0 + }, + "abilities": [] + }, + { + "id": "necklace_haste", + "name": "Charm of Haste", + "slot": "necklace", + "item_type": "necklace", + "base_stats": { + "move_speed_pct": 0.1 + }, + "rarity": "", + "tier": 0, + "abilities": [], + "stats": { + "move_speed_pct": 0.1 + } + }, + { + "id": "necklace_haste", + "name": "Charm of Haste", + "slot": "necklace", + "item_type": "necklace", + "base_stats": { + "move_speed_pct": 0.1 + }, + "rarity": "", + "tier": 0, + "abilities": [], + "stats": { + "move_speed_pct": 0.1 + } + }, + { + "id": "adaptive_traveller_helm", + "name": "Adaptive Traveller's Helmet", + "set": "adaptive_traveller", + "slot": "helmet", + "base_stats": {}, + "rarity_overrides": { + "tempered": { + "extra_stats": { + "charge": 10 + } + }, + "attuned": { + "extra_stats": { + "charge": 15 + } + }, + "awakened": { + "extra_stats": { + "charge": 15, + "charge_regen_pct": 0.15 + } + } + }, + "rarity": "tempered", + "tier": 1, + "stats": { + "charge": 10.0 + }, + "abilities": [] + }, + { + "id": "luminal_growth_helm", + "name": "Luminal Growth Helmet", + "set": "luminal_growth", + "slot": "helmet", + "base_stats": {}, + "rarity_overrides": { + "tempered": { + "extra_stats": { + "charge_regen_pct": 0.1 + } + }, + "attuned": { + "extra_stats": { + "charge_regen_pct": 0.1, + "health": 5 + } + }, + "awakened": { + "extra_stats": { + "charge_regen_pct": 0.15, + "health": 10, + "negate_damage_chance": 0.05 + } + } + }, + "rarity": "tempered", + "tier": 1, + "stats": { + "charge_regen_pct": 0.1 + }, + "abilities": [] + } + ], + "quests": { + "active": { + "first_kills_shade": { + "progress": 0 + }, + "quest_combat_master_tokens": { + "progress": 1 + } + }, + "completed_one_time": [ + "quest_fairy_dust" + ], + "ever_completed": [ + "quest_fairy_dust", + "quest_first_kills" + ], + "tokens": 0 + }, + "boss_diary": { + "slotted": { + "boss_02": 1 + } + }, + "defeated_bosses": [ + "room_05.json", + "room_08.json" + ], + "broken_doors": [], + "pending_token_drops": {}, + "opened_miniboss_walls": [ + "room_04.json:1340,540,20,160", + "room_04.json:460,540,20,160" + ], + "fairy_dust_collected": [ + "room_01.json:600,140", + "room_03.json:440,400", + "room_05.json:740,180", + "room_07.json:1100,520", + "room_09.json:840,560" + ], + "lantern_respawn": { + "room_id": "room_09.json", + "x": 1460, + "y": 660 + } +} \ No newline at end of file