diff --git a/light_and_dark/data/sprites/npcs/fairy1.png b/light_and_dark/data/sprites/npcs/fairy1.png new file mode 100644 index 0000000..5fc8856 Binary files /dev/null and b/light_and_dark/data/sprites/npcs/fairy1.png differ diff --git a/light_and_dark/main.py b/light_and_dark/main.py index baabbe1..c7b5fd8 100644 --- a/light_and_dark/main.py +++ b/light_and_dark/main.py @@ -1208,6 +1208,18 @@ BUTTERFLY_RAW = _load_image_or_none(BUTTERFLY_PATH) BUTTERFLY_JAR_PATH = OBJECTS_DIR / "flow_butterfly_jar1.png" BUTTERFLY_JAR_RAW = _load_image_or_none(BUTTERFLY_JAR_PATH) +# --- Fairy companion (hovers behind the player's head) --- +FAIRY_PATH = DATA_ROOT / "sprites" / "npcs" / "fairy1.png" +FAIRY_RAW = _load_image_or_none(FAIRY_PATH) +FAIRY_SPRITE = None +if FAIRY_RAW is not None: + try: + fs = max(1, int(FAIRY_RAW.get_width() * PLAYER_SPRITE_SCALE * 0.55)) + FAIRY_SPRITE = pygame.transform.smoothscale(FAIRY_RAW, (fs, fs)) + except Exception: + FAIRY_SPRITE = FAIRY_RAW +FAIRY_SPRITE_FLIPPED = pygame.transform.flip(FAIRY_SPRITE, True, False) if FAIRY_SPRITE is not None else None + # ---------------------------- @@ -2357,6 +2369,8 @@ class InventoryUI: self.equip_idx = (self.equip_idx - 1) % len(self.equip_slots) elif key == key_for("ui_down"): self.equip_idx = (self.equip_idx + 1) % len(self.equip_slots) + elif key in (key_for("ui_confirm"), key_for("ui_confirm_alt")): + self._unequip_selected() def _equip_selected_swap(self): if len(self.inv.items) == 0: @@ -2382,6 +2396,18 @@ class InventoryUI: self._clamp_cursor() + def _unequip_selected(self): + """Move the item in the selected equipment slot back to the inventory.""" + slot = self.equip_slots[self.equip_idx] + item = self.player.equipment.get(slot) + if item is None: + return + self.player.equipment[slot] = None + self.inv.items.append(item) + self.player.recalc_bonuses() + self.player._clamp_resources() + self._clamp_cursor() + def _blit(self, surface, text, x, y, color=(230, 230, 230)): surface.blit(self.font.render(text, True, color), (x, y)) @@ -2778,7 +2804,7 @@ class InventoryUI: # Hint text (NOW hint_y exists) self._blit( surface, - "UP/DOWN move ENTER equip LEFT/RIGHT switch panel", + "UP/DOWN move ENTER equip / unequip LEFT/RIGHT switch panel", self.x + self.pad + 6, hint_y + 4, (160, 160, 160), @@ -3213,6 +3239,7 @@ class Player: self.idle_frame = 0 self.idle_anim_time = 0.0 self.idle_bob_t = 0.0 + self.fairy_bob_t = random.random() * 10.0 self.idle_hold_time = 0.0 self.ground_contact_timer = 0.0 self.is_idle = False @@ -3661,6 +3688,7 @@ class Player: self.just_jumped = False self.just_landed = False self.just_air_jumped = False + self.fairy_bob_t += dt if self.invincibility_timer > 0: self.invincibility_timer -= dt @@ -3839,6 +3867,20 @@ class Player: self._clamp_resources() + def _draw_fairy(self, surface, cx, cy, px, py, pw, ph): + """Draw the fairy companion hovering behind/beside the player's head.""" + img = FAIRY_SPRITE if self.facing_right else FAIRY_SPRITE_FLIPPED + if img is None or pw <= 0 or ph <= 0: + return + fw, fh = img.get_size() + # Trail behind the player (opposite the facing direction) at head height. + trail = max(1, (pw + fw) // 3) + off = -trail if self.facing_right else trail + fx = px + (pw - fw) // 2 + off + fairy_cy = py + int(ph * 0.18) + bob = int(math.sin(self.fairy_bob_t * 2.2) * 3) + surface.blit(img, (fx, fairy_cy - fh // 2 + bob)) + def draw(self, surface, cx, cy): if self.healing: dur = self.effective_heal_duration() @@ -3855,6 +3897,7 @@ class Player: if self.sprite_right is None: player_color = PLAYER_HIT_COLOR if self.invincibility_timer > 0 else (PLAYER_DARK if self.shade else PLAYER_LIGHT) pr = pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height) + self._draw_fairy(surface, cx, cy, self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height) pygame.draw.rect(surface, player_color, pr) else: use_idle = self.is_idle and self.idle_right_frames and (self.idle_hold_time >= PLAYER_IDLE_START_DELAY) @@ -3879,6 +3922,7 @@ class Player: draw_y = self.rect.y - cy - (img.get_height() - self.rect.height) + PLAYER_SPRITE_OFFSET_Y if self.is_idle and (not self.shade or not self.has_shade_sprite): draw_y -= int(math.sin(self.idle_bob_t) * PLAYER_IDLE_BOB_AMP) + self._draw_fairy(surface, cx, cy, draw_x, draw_y, img.get_width(), img.get_height()) surface.blit(img, (draw_x, draw_y)) # Debug: show player hitbox @@ -5268,9 +5312,10 @@ def reset_room_combat_state(): def apply_death_penalty(): """On death: lose butterfly jars for good and drop coins into a corpse. - A corpse holds 100% of carried coins on the first death, or 50% on a repeat - death (the other 50% is lost forever and the old corpse vanishes). Void deaths - (falling out of the world) leave no recoverable corpse. + A corpse holds 100% of carried coins on the first death. On a repeat death + (while a corpse is still unclaimed) only 50% of the remaining coins stay + recoverable — the other 50% is lost forever and the old corpse vanishes. + Void deaths (falling out of the world) leave no recoverable corpse. """ global corpse, JAR_DEATH_ROOM player.butterfly_jars = 0 @@ -5280,8 +5325,12 @@ def apply_death_penalty(): carried_l = player.coins_light carried_d = player.coins_dark if corpse is not None: - lost_l = int(carried_l * 0.5) - lost_d = int(carried_d * 0.5) + # "Remaining coins" = coins still held by the old corpse + anything carried + # since then. The new corpse only keeps half of that pool. + pool_l = corpse.coins_light + carried_l + pool_d = corpse.coins_dark + carried_d + lost_l = int(pool_l * 0.5) + lost_d = int(pool_d * 0.5) else: lost_l = carried_l lost_d = carried_d diff --git a/light_and_dark/saves/save 1.json b/light_and_dark/saves/save 1.json index 5daa3ba..095e293 100644 --- a/light_and_dark/saves/save 1.json +++ b/light_and_dark/saves/save 1.json @@ -1,93 +1,211 @@ { "version": 1, - "saved_at": "2026-09-28T10:53:55", + "saved_at": "2026-09-29T09:39:49", "player": { "room_id": "room_town_gloomreach.json", - "x": 120, + "x": 230, "y": 360, - "health": 9, - "charge": 64.71, + "health": 10, + "charge": 125.0, "shade": false, - "coins_light": 84, - "coins_dark": 41, - "butterfly_jars": 2, + "coins_light": 0, + "coins_dark": 0, + "butterfly_jars": 0, "has_ranged": true, "reward_chest_opened": true, "equipment": { - "helmet": null, - "chest": null, - "pants": null, - "necklace": null + "helmet": { + "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": [] + }, + "chest": { + "id": "dawnrunner_chest", + "name": "Dawnrunner Chestplate", + "set": "dawnrunner", + "slot": "chest", + "base_stats": {}, + "rarity_overrides": { + "tempered": { + "extra_stats": { + "charge": 10 + } + }, + "attuned": { + "extra_stats": { + "charge": 15, + "move_speed_light_pct": 0.05 + } + }, + "awakened": { + "extra_stats": { + "charge": 15, + "move_speed_light_pct": 0.05, + "heal_plus": 1 + } + } + }, + "rarity": "attuned", + "tier": 2, + "stats": { + "charge": 15.0, + "move_speed_light_pct": 0.05 + }, + "abilities": [] + }, + "pants": { + "id": "dawnrunner_greaves", + "name": "Dawnrunner Greaves", + "set": "dawnrunner", + "slot": "pants", + "base_stats": {}, + "rarity_overrides": { + "tempered": { + "extra_stats": { + "heal_speed_pct": 0.1 + } + }, + "attuned": { + "extra_stats": { + "heal_speed_pct": 0.1, + "move_speed_light_pct": 0.1 + } + }, + "awakened": { + "extra_stats": { + "heal_speed_pct": 0.15, + "move_speed_light_pct": 0.15 + } + } + }, + "rarity": "tempered", + "tier": 1, + "stats": { + "heal_speed_pct": 0.1 + }, + "abilities": [] + }, + "necklace": { + "id": "necklace_placeholder", + "name": "Plain Necklace", + "slot": "necklace", + "item_type": "necklace", + "base_stats": { + "health": 2 + }, + "rarity": "", + "tier": 0, + "abilities": [], + "stats": { + "health": 2 + } + } } }, "inventory": [ { - "id": "necklace_placeholder", - "name": "Plain Necklace", - "slot": "necklace", - "item_type": "necklace", - "base_stats": { - "health": 2 - }, - "rarity": "", - "tier": 0, - "abilities": [], - "stats": { - "health": 2 - } - }, - { - "id": "dawnrunner_chest", - "name": "Dawnrunner Chestplate", - "set": "dawnrunner", - "slot": "chest", + "id": "twister_helm", + "name": "Twister Helmet", + "set": "twister", + "slot": "helmet", "base_stats": {}, "rarity_overrides": { "tempered": { "extra_stats": { - "charge": 10 + "attack_speed_pct": 0.1 } }, "attuned": { "extra_stats": { - "charge": 15, - "move_speed_light_pct": 0.05 + "attack_speed_pct": 0.15 } }, "awakened": { "extra_stats": { - "charge": 15, - "move_speed_light_pct": 0.05, - "heal_plus": 1 + "attack_speed_pct": 0.15, + "charge_regen_pct": 0.15 } } }, - "rarity": "attuned", - "tier": 2, + "rarity": "tempered", + "tier": 1, "stats": { - "charge": 15.0, - "move_speed_light_pct": 0.05 + "attack_speed_pct": 0.1 }, "abilities": [] }, { - "item_type": "boss_token", - "boss_id": "boss_01", - "name": "Gloom Warden's Token", - "tier": 0 + "id": "dawnrunner_helm", + "name": "Dawnrunner Helmet", + "set": "dawnrunner", + "slot": "helmet", + "base_stats": {}, + "rarity_overrides": { + "tempered": { + "extra_stats": { + "move_speed_light_pct": 0.1 + } + }, + "attuned": { + "extra_stats": { + "move_speed_light_pct": 0.1, + "heal_speed_pct": 0.1 + } + }, + "awakened": { + "extra_stats": { + "move_speed_light_pct": 0.15, + "heal_speed_pct": 0.2 + } + } + }, + "rarity": "tempered", + "tier": 1, + "stats": { + "move_speed_light_pct": 0.1 + }, + "abilities": [] } ], "quests": { "active": { "quest_first_kills": { - "progress": 12 + "progress": 21 } }, "completed_one_time": [], "tokens": 0 }, "boss_diary": { - "slotted": {} + "slotted": { + "boss_01": 1 + } }, "defeated_bosses": [ "room_boss_01.json"