crude sprites

bad sprites for enemies, some fairy changes
This commit is contained in:
2026-10-06 12:04:05 +02:00
parent 002d1e83bf
commit a181f30b1e
15 changed files with 648 additions and 21 deletions
+3 -1
View File
@@ -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": {
+2 -2
View File
@@ -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": {
+2 -2
View File
@@ -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,
@@ -28,6 +28,7 @@
"pad_y": 6,
"line_gap": 4,
"x": 14,
"center_lift": 60,
"y_offset_from_center": -10
}
}
@@ -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
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 421 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 883 B

@@ -22,6 +22,10 @@
"room9_bottom_right": [
2660,
600
],
"entry_diary": [
1774,
540
]
},
"platforms": [
+7
View File
@@ -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(){
+297 -11
View File
@@ -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,11 +6275,33 @@ 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
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,
@@ -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)
+307
View File
@@ -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
}
}