import pyxel
import math
# ==========================================
# AUDIO (inlined — single-file build)
# ==========================================
class AudioLibrary:
    """Refined sound palette for The Magista."""

    SOUNDS = [
        {
            "name": "Menu move",
            "notes": "c2 r c3",
            "tone": "p",
            "volume": "432",
            "effect": "n",
            "speed": 3,
        },
        {
            "name": "Menu confirm",
            "notes": "c2 g2 c3 r g1",
            "tone": "t",
            "volume": "55432",
            "effect": "s",
            "speed": 5,
        },
        {
            "name": "Cutscene text tick",
            "notes": "f3 r e3",
            "tone": "p",
            "volume": "210",
            "effect": "n",
            "speed": 2,
        },
        {
            "name": "Cutscene advance",
            "notes": "c2 r",
            "tone": "p",
            "volume": "60",
            "effect": "f",
            "speed": 3,
        },
        {
            "name": "Jump burst",
            "notes": "c2 c1 r",
            "tone": "pn",
            "volume": "650",
            "effect": "ff",
            "speed": 3,
        },
        {
            "name": "Stone landing",
            "notes": "c1 r",
            "tone": "n",
            "volume": "620",
            "effect": "n",
            "speed": 2,
        },
        {
            "name": "Cleaver swing",
            "notes": "g2 c2 r",
            "tone": "tn",
            "volume": "640",
            "effect": "ff",
            "speed": 2,
        },
        {
            "name": "Exhausted swing",
            "notes": "c2 c1 r",
            "tone": "pn",
            "volume": "320",
            "effect": "ff",
            "speed": 4,
        },
        {
            "name": "Wood hit",
            "notes": "g1 c1 r",
            "tone": "pn",
            "volume": "750",
            "effect": "ff",
            "speed": 2,
        },
        {
            "name": "Pelt hit",
            "notes": "c2 f1 r",
            "tone": "sn",
            "volume": "640",
            "effect": "sf",
            "speed": 3,
        },
        {
            "name": "Dummy break",
            "notes": "g2 c2 c1 r c1 g0 c0",
            "tone": "pnnnnnn",
            "volume": "7640530",
            "effect": "fffffff",
            "speed": 3,
        },
        {
            "name": "Sword pickup",
            "notes": "c1 c2 r",
            "tone": "s",
            "volume": "540",
            "effect": "s",
            "speed": 8,
        },
        {
            "name": "Apple restore",
            "notes": "c3 e3 r",
            "tone": "t",
            "volume": "460",
            "effect": "ff",
            "speed": 5,
        },
        {
            "name": "Magista sprout save",
            "notes": "c3 e3 g3 c4 r e4 c4 g3 e3 c3",
            "tone": "p",
            "volume": "3456065432",
            "effect": "v",
            "speed": 7,
        },
        {
            "name": "Door to rot",
            "notes": "c1 g0 c0 g0 c0 r",
            "tone": "snnnnn",
            "volume": "654320",
            "effect": "f",
            "speed": 12,
        },
        {
            "name": "Rat warning screech",
            "notes": "d4 f4 f#4 g4 r f#4 e4 c4",
            "tone": "p",
            "volume": "45670642",
            "effect": "v",
            "speed": 2,
        },
        {
            "name": "Rat lunge",
            "notes": "c4 g3 c2 r",
            "tone": "ptn",
            "volume": "5640",
            "effect": "vff",
            "speed": 2,
        },
        {
            "name": "Rat defeated",
            "notes": "g4 e4 c4 r c2 g1 c1",
            "tone": "ppnnnnn",
            "volume": "7650431",
            "effect": "vffffff",
            "speed": 3,
        },
        {
            "name": "Shambler windup",
            "notes": "c1 r c1 r d1 r f1",
            "tone": "t",
            "volume": "3020405",
            "effect": "s",
            "speed": 8,
        },
        {
            "name": "Shambler swipe",
            "notes": "c2 g1 c1 r",
            "tone": "n",
            "volume": "7640",
            "effect": "f",
            "speed": 4,
        },
        {
            "name": "Shambler defeated",
            "notes": "c2 g1 f1 d1 c1 r",
            "tone": "pnnnnn",
            "volume": "765430",
            "effect": "ffffff",
            "speed": 5,
        },
        {
            "name": "Player hurt",
            "notes": "c2 c1 g0 r",
            "tone": "snn",
            "volume": "7760",
            "effect": "vff",
            "speed": 3,
        },
        {
            "name": "Enter zone",
            "notes": "c2 d#2 f#2 r",
            "tone": "s",
            "volume": "5640",
            "effect": "f",
            "speed": 15,
        },
        {
            "name": "Exit zone",
            "notes": "f#2 d#2 c2 r",
            "tone": "s",
            "volume": "4530",
            "effect": "f",
            "speed": 15,
        },
        {
            "name": "Mechanic unlocked",
            "notes": "c3 f#3 r e3 r",
            "tone": "t",
            "volume": "46050",
            "effect": "s",
            "speed": 6,
        },
    ]

    MUSIC = [
        {
            "name": "Menu: dead town pulse",
            "channels": [24, 25],
        },
        {
            "name": "Cutscene: father motif",
            "channels": [26, 27],
        },
        {
            "name": "Sprout: fragile life",
            "channels": [28, 29],
        },
        {
            "name": "Zone 2: dead horizon",
            "channels": [30, 31, 32],
        },
        {
            "name": "Menu v2: watchful ruins",
            "channels": [33, 34, 35],
        },
        {
            "name": "Ending cutscene: the dead horizon",
            "channels": [36, 37, 38],
        },
        {
            "name": "Tutorial v1: cold training yard",
            "channels": [39, 40, 41],
        },
    ]

    @staticmethod
    def _fit(pattern, length):
        if len(pattern) == length:
            return pattern
        if len(pattern) == 1:
            return pattern * length
        repeats = (length // len(pattern)) + 1
        return (pattern * repeats)[:length]

    @classmethod
    def load(cls):
        for index, sound in enumerate(cls.SOUNDS):
            note_count = len(sound["notes"].split())
            pyxel.sound(index).set(
                sound["notes"],
                cls._fit(sound["tone"], note_count),
                cls._fit(sound["volume"], note_count),
                cls._fit(sound["effect"], note_count),
                sound["speed"],
            )

        # Original music tracks kept as is
        pyxel.sound(24).set(
            "c1 r r r c1 r g0 r c1 r r r d1 r g0 r",
            "t" * 16,
            "2" * 16,
            "n" * 16,
            18,
        )
        pyxel.sound(25).set(
            "r r g2 r r r f2 r r r d2 r r r f2 r",
            "s" * 16,
            "1" * 16,
            "v" * 16,
            18,
        )
        pyxel.sound(26).set(
            "c2 r e2 r g2 r e2 r d2 r f2 r e2 r c2 r",
            "s" * 16,
            "2" * 16,
            "v" * 16,
            14,
        )
        pyxel.sound(27).set(
            "c1 r r r g0 r r r a0 r r r f0 r r r",
            "t" * 16,
            "2" * 16,
            "n" * 16,
            14,
        )
        pyxel.sound(28).set(
            "c3 e3 g3 c4 r g3 e3 c3",
            "t" * 8,          # triangle wave: soft and round, not jarring square
            "12233210",       # gentle swell that fades out, much quieter
            "v" * 8,
            20,               # slower, more languid tempo
        )
        pyxel.sound(29).set(
            "c2 r g2 r c3 r g2 r",
            "t" * 8,
            "11111111",       # quieter bass
            "n" * 8,
            20,               # slower
        )
        pyxel.sound(30).set(
            "f0 r r r f0 r e0 r d0 r r r c0 r d0 r",
            "t" * 16,
            "2" * 16,
            "n" * 16,
            22,
        )
        pyxel.sound(31).set(
            "r r c2 r r r b1 r r r g1 r r r b1 r",
            "s" * 16,
            "1" * 16,
            "v" * 16,
            22,
        )
        pyxel.sound(32).set(
            "r r r r c1 r r r r r c1 r r g0 r r",
            "n" * 16,
            "1" * 16,
            "f" * 16,
            22,
        )
        pyxel.sound(33).set(
            "c1 r r r g0 r r r c1 r r r b0 r g0 r",
            "t" * 16,
            "2222222222222222",
            "n" * 16,
            20,
        )
        pyxel.sound(34).set(
            "r r e2 r r r d#2 r r r c2 r r r g1 r",
            "s" * 16,
            "1010101010101010",
            "v" * 16,
            20,
        )
        pyxel.sound(35).set(
            "r r r r c0 r r r r r r r g0 r r r",
            "n" * 16,
            "1000000010000000",
            "f" * 16,
            20,
        )
        pyxel.sound(36).set(
            "c2 r r r r g1 r r r a1 r r r f1 r r",
            "s" * 16,
            "2000001001001000",
            "v" * 16,
            20,
        )
        pyxel.sound(37).set(
            "c1 r r r r r g0 r r r r r f0 r r r",
            "t" * 16,
            "2000001000001000",
            "n" * 16,
            20,
        )
        pyxel.sound(38).set(
            "r r r r r r e2 r r r r r d#2 r r r",
            "p" * 16,
            "0000001000001000",
            "v" * 16,
            20,
        )
        pyxel.sound(39).set(
            "c1 r c1 r g0 r c1 r c1 r d1 r g0 r c1 r",
            "t" * 16,
            "1111111111111111",
            "n" * 16,
            18,
        )
        pyxel.sound(40).set(
            "r r g1 r r c2 r r r r g1 r r d2 r r",
            "s" * 16,
            "0000100000001000",
            "v" * 16,
            18,
        )
        pyxel.sound(41).set(
            "r r r r c0 r r r r g0 r r r r c0 r",
            "n" * 16,
            "0001000000010000",
            "f" * 16,
            18,
        )


# Zone 1 world constants
ZONE_W = 6500          # total world width in pixels
ZGROUND = 400          # ground floor y (player/enemy feet rest here)
ZONE2_W = 6200         # Zone 2 world width

# Weapons. Knife: fast/cheap/short. Cleaver: slow/costly/long, staggers enemies
# and is the ONLY thing that hurts a Feral Dog's armored hide.
WEAPON_STATS = {
    "knife":   {"swing": 8,  "cost": 12, "reach": 34, "hh": 44, "dmg": 1, "stagger": False},
    "cleaver": {"swing": 20, "cost": 28, "reach": 70, "hh": 56, "dmg": 2, "stagger": True},
}

# ==========================================
# SHARED WANDERER SPRITE
# Drawn by both the tutorial (target = pyxel, 1x) and the cutscene
# (target = an image bank, later upscaled) so they look identical.
# `t` is any object exposing line/rect/tri (the pyxel module or a pyxel.Image).
# ==========================================
def draw_wanderer(t, cx, cy, d, anim_frame, moving, grounded,
                  has_sword, attacking, attack_timer, backpack=False,
                  swing_len=12, atk_reach=24):
    swing = int(math.sin(anim_frame * 0.5) * 12) if (moving and grounded) else 0
    jump_bend = 0 if grounded else -6
    wind_flap = (anim_frame % 3) if moving else 0

    # Legs — color 5 (dark gray) so they read against the color-1 wall background
    t.line(cx - (2*d), cy + 36, cx - (swing * d), cy + 46 + jump_bend, 5)
    t.rect(cx - (swing * d) - (2*d), cy + 46 + jump_bend, 4, 2, 0)
    t.line(cx + (2*d), cy + 36, cx + (swing * d), cy + 46 + jump_bend, 5)
    t.rect(cx + (swing * d) - (1*d), cy + 46 + jump_bend, 4, 2, 0)

    # Sweeping Cloak — 13 (slate) body, 5 (dark gray) shadow fold
    if d == 1:
        t.tri(cx, cy + 12, cx - 12, cy + 40, cx + 10, cy + 38, 13)
        t.tri(cx, cy + 12, cx - 12, cy + 40, cx - 4, cy + 40, 5)
        t.line(cx - 12, cy + 40, cx - 14 - wind_flap, cy + 42, 13)
    else:
        t.tri(cx, cy + 12, cx + 12, cy + 40, cx - 10, cy + 38, 13)
        t.tri(cx, cy + 12, cx + 12, cy + 40, cx + 4, cy + 40, 5)
        t.line(cx + 12, cy + 40, cx + 14 + wind_flap, cy + 42, 13)

    # Face & Hat
    t.rect(cx - 4, cy + 8, 8, 5, 15)
    t.rect(cx - 7, cy + 2, 14, 6, 0)
    t.line(cx - 12, cy + 8, cx + 12, cy + 8, 0)

    # Scarf
    t.rect(cx - 6, cy + 11, 12, 6, 8)
    t.line(cx - (6*d), cy + 13, cx - (14*d) - wind_flap, cy + 15, 8)
    t.line(cx - (5*d), cy + 15, cx - (12*d) + wind_flap, cy + 20, 8)

    # Pack worn low on the back (cutscene departure pose)
    if backpack:
        back = -d
        bxp = cx + back * 8
        t.rect(bxp - 5, cy + 16, 11, 20, 4)             # pack body, low on the back
        t.rect(bxp - 5, cy + 13, 11, 5, 9)              # bedroll strapped on top
        t.line(bxp, cy + 17, bxp, cy + 35, 0)           # vertical seam
        t.line(bxp - 5, cy + 26, bxp + 6, cy + 26, 0)   # horizontal strap
        t.line(cx - d * 5, cy + 13, cx + d * 4, cy + 30, 4)  # shoulder strap across chest

    # Front Arm & Sword
    hand_x = cx + (swing * d) + (2*d)
    hand_y = cy + 32
    t.line(cx + (2*d), cy + 18, hand_x, hand_y, 1)
    if has_sword:
        rch = atk_reach
        if attacking:
            arc_offset = (swing_len - attack_timer) * 3
            t.line(hand_x, hand_y, hand_x + (rch * d), hand_y + arc_offset - 12, 7)
            t.line(hand_x, hand_y, hand_x + (rch * d), hand_y + arc_offset - 12, 13)
        else:
            t.line(hand_x, hand_y, hand_x + (rch // 2 * d), hand_y - 14, 7)
            t.line(hand_x, hand_y, hand_x + (rch // 2 * d), hand_y - 14, 13)
    t.rect(hand_x - 1, hand_y, 3, 3, 15)


# ==========================================
# 1. CORE ENGINE SETUP
# ==========================================
class Resolution:
    WIDTH = 512
    HEIGHT = 512
    SCALE = 1

    @classmethod
    def setup_screen(cls):
        pyxel.init(cls.WIDTH, cls.HEIGHT, title="The Magista", display_scale=cls.SCALE)
        pyxel.mouse(True)

# ==========================================
# 2. PLAYER MECHANICS
# ==========================================
class Player:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.w = 24  
        self.h = 48  
       
        self.hp = 50      
        self.max_hp = 100
       
        self.stamina = 100
        self.max_stamina = 100
        # Recharges completely in 5 seconds (150 frames at 30fps)
        self.stamina_regen = 100 / 150.0
        self.is_exhausted = False
       
        self.dy = 0.0          
        self.gravity = 1.6    
        self.jump_power = -22  
        self.speed = 5.2      
        self.is_grounded = False
       
        self.direction = 1    
        self.is_moving = False
        self.anim_frame = 0    
       
        self.has_sword = False
        self.is_attacking = False
        self.attack_timer = 0
        self.weapon = "cleaver"   # default; Zone 2 lets the player switch to knife
        self.swing_len = 12       # frames of the current swing (set per weapon)
        self.stamina_mult = 1.0   # >1 when diseased: swings cost more stamina

    def update(self, floor_y, platforms, attack_pressed=None,
               world_w=None, ground_spans=None):
        self.is_moving = False
        self.anim_frame += 1
        if attack_pressed is None:
            attack_pressed = pyxel.btnp(pyxel.KEY_SPACE)
       
        # Stamina Regeneration
        if not self.is_attacking and self.stamina < self.max_stamina:
            self.stamina = min(self.max_stamina, self.stamina + self.stamina_regen)
        # Clear exhaustion whenever stamina is full (robust against respawn leaving
        # the flag stuck True with a full bar — which softlocked attacking).
        if self.stamina >= self.max_stamina:
            self.is_exhausted = False
       
        # Combat Logic (Requires 25 Stamina, locks out if exhausted)
        if self.is_attacking:
            self.attack_timer -= 1
            if self.attack_timer <= 0:
                self.is_attacking = False
        elif attack_pressed and self.has_sword:
            w = WEAPON_STATS[self.weapon]
            cost = w["cost"] * self.stamina_mult     # disease makes swings more tiring
            if self.stamina >= cost and not self.is_exhausted:
                self.is_attacking = True
                self.attack_timer = w["swing"]
                self.swing_len = w["swing"]
                self.stamina -= cost
                # Account for minor regen between fast consecutive swings
                if self.stamina <= 15:
                    self.stamina = 0
                    self.is_exhausted = True
           
        # Movement
        dx = 0
        if pyxel.btn(pyxel.KEY_LEFT):
            dx -= self.speed
            self.direction = -1
            self.is_moving = True
        if pyxel.btn(pyxel.KEY_RIGHT):
            dx += self.speed
            self.direction = 1
            self.is_moving = True
           
        self.x += dx
       
        # Boundaries (world width in Zone 1, screen width otherwise)
        bound = world_w if world_w is not None else Resolution.WIDTH
        if self.x < 0: self.x = 0
        if self.x + self.w > bound: self.x = bound - self.w
       
        # Platform Horizontal Collision
        for px, py, pw, ph in platforms:
            if self.x < px + pw and self.x + self.w > px and self.y < py + ph and self.y + self.h > py:
                if dx > 0: self.x = px - self.w
                elif dx < 0: self.x = px + pw  

        # Gravity
        self.dy += self.gravity
        self.y += self.dy
        self.is_grounded = False
       
        # Platform Vertical Collision
        for px, py, pw, ph in platforms:
            if self.x < px + pw and self.x + self.w > px and self.y < py + ph and self.y + self.h > py:
                if self.dy > 0:
                    self.y = py - self.h
                    self.dy = 0
                    self.is_grounded = True
                elif self.dy < 0:
                    self.y = py + ph
                    self.dy = 0

        # Floor Collision (skip where the ground has a gap, so the player can fall)
        on_solid = True
        if ground_spans is not None:
            cxw = self.x + self.w / 2
            on_solid = any(x0 <= cxw <= x1 for (x0, x1) in ground_spans)
        if on_solid and self.y + self.h >= floor_y:
            self.y = floor_y - self.h
            self.dy = 0
            self.is_grounded = True

        # Jump
        if pyxel.btnp(pyxel.KEY_UP) and self.is_grounded:
            self.dy = self.jump_power

    # ------------------------------------------
    # PLAYER VISUALS
    # ------------------------------------------
    def draw(self):
        cx = self.x + (self.w // 2)
        cy = self.y
        reach = WEAPON_STATS[self.weapon]["reach"]
        draw_wanderer(pyxel, cx, cy, self.direction, self.anim_frame,
                      self.is_moving, self.is_grounded,
                      self.has_sword, self.is_attacking, self.attack_timer,
                      swing_len=self.swing_len, atk_reach=reach)


# ==========================================
# 2b. ENEMIES (Zone 1)
# ==========================================
class Enemy:
    W, H = 16, 16
    def __init__(self, x, y):
        self.x = float(x)
        self.y = float(y)
        self.dy = 0.0
        self.hp = self.max_hp = 1
        self.state = "idle"
        self.timer = 0
        self.cooldown = 0
        self.flash = 0          # white-flash frames after being hit
        self.facing = -1
        self.dead = False
        self.hit_this_swing = False
        self.anim = 0
        self.armored = False    # if True, only the cleaver hurts it (knife "tinks")
        self.stagger = 0        # frames of stagger after a cleaver hit

    def overlaps(self, px, py, pw, ph):
        return (self.x < px + pw and self.x + self.W > px and
                self.y < py + ph and self.y + self.H > py)

    def on_death(self, g):
        pass

    def draw_hp(self, cam):
        if self.hp >= self.max_hp or self.dead:
            return
        bx = int(self.x - cam) - 1
        by = int(self.y) - 10
        pyxel.rect(bx, by, self.W + 2, 5, 0)
        pyxel.rectb(bx, by, self.W + 2, 5, 7)
        pyxel.rect(bx + 1, by + 1, int((self.hp / self.max_hp) * self.W), 3, 8)

    def tick_common(self):
        self.anim += 1
        if self.flash > 0: self.flash -= 1
        if self.cooldown > 0: self.cooldown -= 1


class Rat(Enemy):
    W, H = 16, 11
    GRAV = 1.4

    def __init__(self, x):
        super().__init__(x, ZGROUND - self.H)
        self.hp = self.max_hp = 1

    def update(self, g):
        if self.dead:
            return
        self.tick_common()
        p = g.player
        pcx = p.x + p.w / 2
        cx = self.x + self.W / 2
        dist = pcx - cx

        # Gravity / floor / platforms
        prev_feet = self.y + self.H
        self.dy += self.GRAV
        self.y += self.dy
        grounded = False
        if self.y + self.H >= ZGROUND:
            self.y = ZGROUND - self.H
            self.dy = 0
            grounded = True
        # Land on platform tops (one-way) so rats can climb the level
        if self.dy >= 0:
            for (px, py, pw, ph) in g.zone_platforms:
                if (self.x + self.W > px and self.x < px + pw and
                        prev_feet <= py + 6 and self.y + self.H >= py):
                    self.y = py - self.H
                    self.dy = 0
                    grounded = True
                    break

        if self.state == "idle":
            self.x += math.sin(self.anim * 0.2 + self.x) * 0.5
            if abs(dist) < 150:
                self.state = "chase"
        elif self.state == "chase":
            self.facing = 1 if dist > 0 else -1
            self.x += self.facing * 6
            # Leap up toward a player perched on a platform above
            if grounded and (p.y + p.h) < self.y - 6 and abs(dist) < 140:
                self.dy = -14
            if abs(dist) < 46 and grounded and self.cooldown == 0:
                self.state = "crouch"
                self.timer = 12
                g.play_sfx("Rat warning screech")
        elif self.state == "crouch":
            self.timer -= 1
            if self.timer <= 0:
                self.state = "lunge"
                self.dy = -10
                g.play_sfx("Rat lunge")
        elif self.state == "lunge":
            self.x += self.facing * 7
            if grounded and self.dy == 0:
                self.state = "chase"
                self.cooldown = 30

        if self.overlaps(p.x, p.y, p.w, p.h):
            g.hurt_player(12, cx)

    def draw(self, cam):
        x = int(self.x - cam)
        y = int(self.y)
        c = 7 if self.flash > 0 else 13           # mangy gray so it reads on dark ground
        crouch = 2 if self.state == "crouch" else 0
        d = self.facing
        # body
        pyxel.elli(x, y + 3 + crouch, self.W, self.H - 3 - crouch, c)
        pyxel.line(x + 2, y + 3 + crouch, x + self.W - 2, y + 3 + crouch, 5)  # back shadow
        # head (toward facing)
        hx = x + (self.W - 4 if d > 0 else 0)
        pyxel.elli(hx, y + 4 + crouch, 5, 5, c)
        pyxel.pset(hx + (4 if d > 0 else 0), y + 3 + crouch, 5)               # ear
        # tail
        pyxel.line(x + (0 if d > 0 else self.W), y + 7,
                   x + (-5 if d > 0 else self.W + 5), y + 4, c)
        # legs (dark, for definition)
        sk = int(math.sin(self.anim * 0.6) * 2)
        pyxel.line(x + 4, y + self.H, x + 4 - sk, y + self.H + 2, 0)
        pyxel.line(x + 11, y + self.H, x + 11 + sk, y + self.H + 2, 0)
        # red eye when alive
        if self.flash == 0:
            pyxel.pset(hx + (3 if d > 0 else 1), y + 6 + crouch, 8)


class Shambler(Enemy):
    W, H = 28, 46
    SPEED = 1.2

    def __init__(self, x):
        super().__init__(x, ZGROUND - self.H)
        self.hp = self.max_hp = 4
        self.state = "walk"
        self.swipe_timer = 0

    def update(self, g):
        if self.dead:
            return
        self.tick_common()
        p = g.player
        pcx = p.x + p.w / 2
        cx = self.x + self.W / 2
        dist = pcx - cx

        if self.state == "walk":
            self.facing = 1 if dist > 0 else -1
            if abs(dist) < 380:                 # activated
                if abs(dist) <= 70 and self.cooldown == 0:   # larger reach-in range
                    self.state = "windup"
                    self.timer = 10             # very fast wind-up — hard to dodge
                    g.play_sfx("Shambler windup")
                else:
                    self.x += self.facing * self.SPEED
        elif self.state == "windup":
            self.timer -= 1
            if self.timer <= 0:
                self.state = "swipe"
                self.swipe_timer = 9
                g.play_sfx("Shambler swipe")
        elif self.state == "swipe":
            self.swipe_timer -= 1
            if self.swipe_timer > 3:            # active strike window
                reach = 70                      # larger swipe range
                hx0 = cx if self.facing > 0 else cx - reach
                hx1 = cx + reach if self.facing > 0 else cx
                if (p.x < hx1 and p.x + p.w > hx0 and
                        abs((p.y + p.h / 2) - (self.y + self.H / 2)) < 46):
                    g.hurt_player(32, cx)       # heavier hit
            if self.swipe_timer <= 0:
                self.state = "walk"
                self.cooldown = 30

    def draw(self, cam):
        x = int(self.x - cam)
        y = int(self.y)
        c = 7 if self.flash > 0 else 5
        outline = 7 if self.flash > 0 else 0
        d = self.facing
        sway = int(math.sin(self.anim * 0.12) * 2)
        # hunched body
        pyxel.rect(x + 4, y + 14, 20, 32, c)
        pyxel.tri(x + 4, y + 14, x + 24, y + 14, x + 14 + sway, y + 4, c)  # hunched shoulders
        pyxel.elli(x + 6 + sway, y, 16, 14, c)                            # bowed head
        pyxel.pset(x + 10 + sway + (4 if d > 0 else 0), y + 6, 8)         # eye
        # legs
        pyxel.rect(x + 7, y + 44, 6, 4, outline)
        pyxel.rect(x + 16, y + 44, 6, 4, outline)
        # arm: raised during wind-up (the tell), swinging during the strike
        if self.state == "windup":
            ax = x + (24 if d > 0 else 4)
            pyxel.line(x + 14, y + 18, ax + d * 8, y - 2, c)             # arm reared back
            pyxel.line(ax + d * 8, y - 2, ax + d * 16, y - 10, c)
            pyxel.circ(ax + d * 16, y - 10, 2, 8)                        # claw glint
        elif self.state == "swipe" and self.swipe_timer > 3:
            ax = x + (22 if d > 0 else 6)
            ex = ax + d * 52                                             # full swing reach
            pyxel.tri(x + 14, y + 16, ex, y + 14, ex, y + 30, 7)         # swept claw
            pyxel.line(x + 14, y + 18, ex, y + 22, 6)
            pyxel.line(ex, y + 14, ex + d * 6, y + 20, 6)               # claw tips
        else:
            pyxel.line(x + 14, y + 20, x + (22 if d > 0 else 6), y + 34, c)

    def draw_hp(self, cam):
        if self.hp >= self.max_hp or self.dead:
            return
        bx = int(self.x - cam) - 1
        by = int(self.y) - 10
        pyxel.rect(bx, by, self.W + 2, 5, 0)
        pyxel.rectb(bx, by, self.W + 2, 5, 7)
        pyxel.rect(bx + 1, by + 1, int((self.hp / self.max_hp) * self.W), 3, 8)


# ==========================================
# 2c. ENEMIES (Zone 2)
# ==========================================
class FeralDog(Enemy):
    W, H = 28, 16
    GRAV = 1.4

    def __init__(self, x):
        super().__init__(x, ZGROUND - self.H)
        self.hp = self.max_hp = 2
        self.armored = True               # knife "tinks" off; cleaver required
        self.state = "skulk"

    def update(self, g):
        if self.dead:
            return
        self.tick_common()
        p = g.player
        pcx = p.x + p.w / 2
        cx = self.x + self.W / 2
        dist = pcx - cx
        self.dy += self.GRAV
        self.y += self.dy
        if self.y + self.H >= ZGROUND:
            self.y = ZGROUND - self.H
            self.dy = 0
        if self.stagger > 0:
            self.stagger -= 1
            return
        self.facing = 1 if dist > 0 else -1
        if self.state == "skulk":
            if abs(dist) > 130:
                self.x += self.facing * 2.0      # close to medium range
            elif abs(dist) < 90:
                self.x -= self.facing * 2.2      # skitter back out of reach
            # Hold back during the early grace period so the player can learn
            # weapon switching before the first real attack.
            if (abs(dist) < 220 and self.cooldown == 0 and
                    getattr(g, "zone2_grace", 0) <= 0):
                self.state = "tense"
                self.timer = 14
                g.play_sfx("Rat warning screech")
        elif self.state == "tense":
            self.timer -= 1
            if self.timer <= 0:
                self.state = "burst"
                self.timer = 16
                g.play_sfx("Rat lunge")
        elif self.state == "burst":
            self.x += self.facing * 9            # explosive dash
            self.timer -= 1
            if self.timer <= 0:
                self.state = "skulk"
                self.cooldown = 40
        if self.overlaps(p.x, p.y, p.w, p.h):
            g.hurt_player(14, cx)

    def draw(self, cam):
        x = int(self.x - cam)
        y = int(self.y)
        crouch = 2 if self.state == "tense" else 0
        c = 7 if self.flash > 0 else 4
        d = self.facing
        pyxel.rect(x + 3, y + 5 + crouch, 22, 8 - crouch, c)               # body
        pyxel.elli(x + (19 if d > 0 else 0), y + 3 + crouch, 9, 9, c)      # head
        pyxel.rect(x + (26 if d > 0 else -2), y + 7 + crouch, 3, 3, c)     # snout
        for bx in range(x + 4, x + 22, 4):                                 # armored ridge
            pyxel.line(bx, y + 5 + crouch, bx + 1, y + 1 + crouch, 5)
        sk = int(math.sin(self.anim * 0.8) * 2)
        for lx in (x + 5, x + 12, x + 19):
            pyxel.line(lx, y + 13, lx - sk, y + 16, 0)
        if self.flash == 0:
            pyxel.pset(x + (22 if d > 0 else 4), y + 5 + crouch, 8)        # red eye


class BloatedTank(Enemy):
    W, H = 42, 44
    SPEED = 2.0

    def __init__(self, x):
        super().__init__(x, ZGROUND - self.H)
        self.hp = self.max_hp = 8

    def update(self, g):
        if self.dead:
            return
        self.tick_common()
        p = g.player
        pcx = p.x + p.w / 2
        cx = self.x + self.W / 2
        dist = pcx - cx
        if self.stagger > 0:
            self.stagger -= 1
        else:
            self.facing = 1 if dist > 0 else -1
            if abs(dist) < 480:
                # Rushes when it gets close — but you can leap over it and strike from range
                spd = 3.6 if abs(dist) < 150 else self.SPEED
                self.x += self.facing * spd
        if self.overlaps(p.x, p.y, p.w, p.h):
            g.hurt_player(26, cx)

    def on_death(self, g):
        # Bursts into a permanent diseased patch (width capped so it can't seal a route)
        cx = self.x + self.W / 2
        g.zone_hazards.append((cx - 26, cx + 26))
        g.play_sfx("Shambler defeated")

    def draw(self, cam):
        x = int(self.x - cam)
        y = int(self.y)
        c = 7 if self.flash > 0 else 2
        wob = int(math.sin(self.anim * 0.1) * 2)
        pyxel.elli(x, y + 6, self.W, self.H - 6, c)             # swollen gut
        pyxel.elli(x + 8, y, 26, 22, c)                         # upper bulk
        for (ox, oy) in [(10, 16), (26, 12), (18, 28), (32, 24), (8, 32)]:
            pyxel.circ(x + ox, y + oy + wob, 3, 3)              # pustules
            pyxel.pset(x + ox, y + oy + wob, 11)
        pyxel.rect(x + 8, y + self.H - 3, 6, 4, 0)
        pyxel.rect(x + self.W - 14, y + self.H - 3, 6, 4, 0)
        if self.flash == 0:
            pyxel.pset(x + 16, y + 9, 8)
            pyxel.pset(x + 24, y + 9, 8)


class CorruptedBear(Enemy):
    W, H = 92, 86

    def __init__(self, x):
        super().__init__(x, ZGROUND - self.H)
        self.hp = self.max_hp = 30
        self.state = "intro"
        self.timer = 40
        self.phase = 1
        self.shockwaves = []          # [x, dir, life]

    def update(self, g):
        if self.dead:
            return
        self.tick_common()
        p = g.player
        pcx = p.x + p.w / 2
        cx = self.x + self.W / 2
        dist = pcx - cx
        self.dy += 1.6
        self.y += self.dy
        grounded = False
        if self.y + self.H >= ZGROUND:
            self.y = ZGROUND - self.H
            self.dy = 0
            grounded = True

        # Phase 2 trigger
        if self.phase == 1 and self.hp <= self.max_hp * 0.5:
            self.phase = 2
            self.state = "roar"
            self.timer = 36
            g.play_sfx("Shambler windup")

        # Shockwaves travel along the ground
        for s in self.shockwaves[:]:
            s[0] += s[1] * 6
            s[2] -= 1
            if (p.is_grounded and abs((p.x + p.w / 2) - s[0]) < 22):
                g.hurt_player(24, s[0])
            if s[2] <= 0:
                self.shockwaves.remove(s)

        spd = 1.4 if self.phase == 1 else 2.4
        if self.state in ("intro", "roar"):
            self.timer -= 1
            if self.timer <= 0:
                self.state = "approach"
        elif self.state == "approach":
            self.facing = 1 if dist > 0 else -1
            if grounded:
                self.x += self.facing * spd
            if abs(dist) < 160 and self.cooldown == 0:
                self.state = "tele_bite" if pyxel.rndi(0, 1) else "tele_slam"
                self.timer = 22 if self.phase == 1 else 14
                g.play_sfx("Shambler windup")
        elif self.state == "tele_bite":
            self.timer -= 1
            if self.timer <= 0:
                self.state = "bite"
                self.timer = 16
                g.play_sfx("Shambler swipe")
        elif self.state == "bite":
            self.x += self.facing * 8
            self.timer -= 1
            mx0 = cx if self.facing > 0 else cx - 64
            mx1 = cx + 64 if self.facing > 0 else cx
            if (p.x < mx1 and p.x + p.w > mx0 and
                    abs((p.y + p.h / 2) - (self.y + self.H / 2)) < 52):
                g.hurt_player(28, cx)
            if self.timer <= 0:
                self.state = "approach"
                self.cooldown = 40
        elif self.state == "tele_slam":
            self.timer -= 1
            if self.timer <= 0:
                self.state = "slam"
                self.dy = -16
        elif self.state == "slam":
            if grounded and self.dy == 0:
                self.state = "approach"
                self.cooldown = 38
                g.play_sfx("Stone landing")
                self.shockwaves.append([cx - 24, -1, 30])
                self.shockwaves.append([cx + 24, 1, 30])
                if self.phase == 2:
                    g.zone_hazards.append((cx - 44, cx - 12))
                    g.zone_hazards.append((cx + 12, cx + 44))

        # Body contact hurts only at its level — if you leap above its shoulders
        # you can vault clean over it to reposition.
        if (self.overlaps(p.x, p.y, p.w, p.h) and
                self.state not in ("intro", "roar") and
                (p.y + p.h) > self.y + 30):
            g.hurt_player(16, cx)

    def draw(self, cam):
        x = int(self.x - cam)
        y = int(self.y)
        d = self.facing
        c = 7 if self.flash > 0 else 1
        pyxel.elli(x, y + 22, self.W, self.H - 22, c)                       # body
        pyxel.elli(x + (self.W - 50 if d > 0 else 0), y, 52, 52, c)        # head/hump
        for (ox, oy, r) in [(20, 34, 8), (60, 44, 10), (40, 64, 7), (74, 26, 6)]:
            pyxel.circ(x + ox, y + oy, r, 5)                                # mutated growths
        pyxel.rect(x + 10, y + self.H - 9, 16, 11, 0)
        pyxel.rect(x + self.W - 28, y + self.H - 9, 16, 11, 0)
        ex = x + (self.W - 26 if d > 0 else 16)
        eye = 9 if self.state.startswith("tele") else (10 if self.phase == 2 else 8)
        pyxel.circ(ex, y + 20, 3, eye)
        pyxel.circ(ex + (12 if d > 0 else -12), y + 20, 3, eye)
        if self.state in ("tele_bite", "bite"):
            pyxel.tri(ex, y + 30, ex + d * 22, y + 24, ex + d * 22, y + 40, 8)  # maw
        for s in self.shockwaves:
            sx = int(s[0] - cam)
            pyxel.tri(sx - 6, ZGROUND, sx + 6, ZGROUND, sx, ZGROUND - 13, 13)

    def draw_hp(self, cam):
        pass                          # boss uses the big arena bar instead


# ==========================================
# 3. MAIN GAME CONTROLLER
# ==========================================
class MagistaGame:
    def __init__(self):
        Resolution.setup_screen()
        AudioLibrary.load()
        self.sound_indexes = {
            sound["name"]: index for index, sound in enumerate(AudioLibrary.SOUNDS)
        }
        self.music_indexes = {
            music["name"]: music for music in AudioLibrary.MUSIC
        }
        self.current_music = None
       
        self.state = "MENU"
        self.difficulty = "NORMAL"
        self.menu_options = ["Start Journey", "Tutorial"]
        self.current_selection = 0
        self.frame_count = 0
       
        # State timers
        self.fade_timer = 0
        self.cutscene_timer = 0
        self.cutscene_slide = 0
       
        # Menu Data
        self.dust_particles = [[pyxel.rndi(0, Resolution.WIDTH), pyxel.rndi(0, Resolution.HEIGHT), pyxel.rndi(1, 5)] for _ in range(120)]
        self.buildings = [(0, 280, 80), (70, 240, 60), (120, 300, 90), (200, 220, 80), (270, 260, 70), (330, 320, 80), (390, 250, 90), (460, 290, 60)]
        self.windows = []
        for b in self.buildings:
            self.windows.append(("door", b[0] + (b[2] // 2) - 8, 370, 16, 30))
            for wx in range(b[0] + 8, b[0] + b[2] - 12, 16):
                for wy in range(b[1] + 16, 360, 24):
                    if pyxel.rndi(0, 10) > 8: self.windows.append(("lit", wx, wy, 8, 10))
                    elif pyxel.rndi(0, 10) > 3: self.windows.append(("dark", wx, wy, 8, 10))

        self.ground_details = [(pyxel.rndi(0, 1), pyxel.rndi(0, Resolution.WIDTH), pyxel.rndi(405, 500), pyxel.rndi(6, 12), pyxel.rndi(0, 1)) for _ in range(120)]
        self.tree_lines = []
        self.generate_dead_forest()
       
        # Tutorial Data
        self.tutorial_platforms = [(216, 280, 80, 120)]
        self.tutorial_ground = [(pyxel.rndi(0, Resolution.WIDTH), pyxel.rndi(405, 500), pyxel.rndi(0, 1)) for _ in range(60)]
        self.torches = [(96, 240), (416, 240)]
        self.cracks = [(pyxel.rndi(0, 500), pyxel.rndi(50, 350)) for _ in range(15)]
       
        self.dummies = [
            {"x": 376, "y": 330, "hp": 3, "max_hp": 3, "type": "wood", "hit_this_swing": False, "hit_flash": 0},
            {"x": 434, "y": 326, "hp": 3, "max_hp": 3, "type": "pelt", "hit_this_swing": False, "hit_flash": 0}
        ]
        self.hit_particles = []
       
        self.has_learned_stamina = False
       
        self.interactables = [
            {"id": "sword", "x": 140, "y": 380, "w": 20, "h": 20, "text": "A heavy silver cleaver.\n\n[SWORD ACQUIRED]"},
            {"id": "apple", "x": 250, "y": 260, "w": 20, "h": 20, "text": "A pristine apple. Uncorrupted by the dust.\n\nRestores health and stamina."},
            {"id": "note", "x": 100, "y": 380, "w": 30, "h": 20, "text": "Discarded Note:\n\nUse ARROW KEYS to move and UP to leap.\nThe dust is changing our muscles. Use it."},
            {"id": "sprout", "x": 450, "y": 350, "w": 40, "h": 50, "text": "A glowing Magista Tree.\nLife persists even in the rot.\n\n[GAME SAVED]"},
            {"id": "exit", "x": 0, "y": 300, "w": 20, "h": 100, "text": "The heavy iron doors leading into the Ruined Town.\nThere is no turning back.\n\n[PRESS 'I' AGAIN TO ENTER THE ROT]"}
        ]
       
        self.nearby_obj = None
        self.reading_text = ""
        self.player = Player(60, 200)

        # Zone 1 runtime state
        self.camera_x = 0
        self.enemies = []
        self.zone_particles = []
        self.player_iframes = 0
        self.hitstop = 0
        self.save_cooldown = 0
        self.checkpoint_x = 60
        self.zone_done_timer = 0
        self.cheat_cd = 0
        self.ending_phase = "walk"
        self.ending_timer = 0
        self.build_zone1()
        self.build_zone2()

        self.play_music("Menu: dead town pulse")
        pyxel.run(self.update, self.draw)

    # ------------------------------------------
    # BACKGROUND & ENGINE HELPERS
    # ------------------------------------------
    def draw_big_text(self, x, y, text, color, scale):
        lines = text.split('\n')
        for line_idx, line in enumerate(lines):
            pyxel.image(2).rect(0, 0, len(line)*4 + 1, 6, 0)
            pyxel.image(2).text(0, 0, line, color)
            for px in range(len(line) * 4):
                for py_px in range(6):
                    if pyxel.image(2).pget(px, py_px) == color:
                        pyxel.rect(x + (px * scale), y + (line_idx * 8 * scale) + (py_px * scale), scale, scale, color)

    def draw_spiderweb(self, cx, cy, radius, flip_x=False):
        dir = -1 if flip_x else 1
        lines = 4
        for i in range(lines + 1):
            angle = (math.pi / 2) * (i / lines)
            x_end = cx + (math.cos(angle) * radius * dir)
            y_end = cy + (math.sin(angle) * radius)
            pyxel.line(cx, cy, x_end, y_end, 13)
        rings = 3
        for r in range(1, rings + 1):
            dist = (radius / rings) * r
            for i in range(lines):
                a1 = (math.pi / 2) * (i / lines)
                a2 = (math.pi / 2) * ((i+1) / lines)
                pyxel.line(cx + (math.cos(a1) * dist * dir), cy + (math.sin(a1) * dist),
                           cx + (math.cos(a2) * dist * dir), cy + (math.sin(a2) * dist), 13)

    def build_branch(self, x, y, angle, depth, length):
        if depth == 0: return
        x2 = x + math.cos(angle) * length
        y2 = y + math.sin(angle) * length
        self.tree_lines.append((x, y, x2, y2, depth))
        self.build_branch(x2, y2, angle - pyxel.rndf(0.2, 0.7), depth - 1, length * pyxel.rndf(0.6, 0.8))
        self.build_branch(x2, y2, angle + pyxel.rndf(0.2, 0.7), depth - 1, length * pyxel.rndf(0.6, 0.8))
        if depth > 3 and pyxel.rndi(0, 1) == 1:
            self.build_branch(x2, y2, angle + pyxel.rndf(-0.3, 0.3), depth - 1, length * 0.5)

    def generate_dead_forest(self):
        self.build_branch(60, 420, -math.pi/2, 7, 70)  
        self.build_branch(440, 430, -math.pi/2, 8, 90)  
        self.build_branch(250, 410, -math.pi/2, 6, 40)  

    def play_sfx(self, name):
        sound_index = self.sound_indexes.get(name)
        if sound_index is not None:
            pyxel.play(3, sound_index)

    def stop_music(self):
        for channel in range(3):
            pyxel.stop(channel)
        self.current_music = None

    def play_music(self, name):
        if self.current_music == name:
            return
        music = self.music_indexes.get(name)
        if music is None:
            return
        self.stop_music()
        for channel, sound_index in enumerate(music["channels"]):
            pyxel.play(channel, sound_index, loop=True)
        self.current_music = name

    # ------------------------------------------
    # GAME LOGIC (UPDATE LOOPS)
    # ------------------------------------------
    def update(self):
        self.frame_count += 1
        for particle in self.dust_particles:
            particle[0] += particle[2]          # drift the other way (with the wind streaks)
            if particle[0] > Resolution.WIDTH:
                particle[0] = 0
                particle[1] = pyxel.rndi(0, Resolution.HEIGHT)

        # Dev shortcut: hold Y + E + S together to skip straight to Zone 2
        if (self.state not in ("ZONE2", "ZONE2_DONE") and
                pyxel.btn(pyxel.KEY_Y) and pyxel.btn(pyxel.KEY_E) and pyxel.btn(pyxel.KEY_S)):
            self.start_zone2()
            return
        # Dev shortcut: hold E + N + D to preview the ending sequence
        if (self.state != "ENDING" and
                pyxel.btn(pyxel.KEY_E) and pyxel.btn(pyxel.KEY_N) and pyxel.btn(pyxel.KEY_D)):
            self.start_ending()
            return
        # Cheat: hold C + H + E to smite every enemy (the boss takes 50% damage)
        if self.cheat_cd > 0:
            self.cheat_cd -= 1
        if (self.state in ("ZONE1", "ZONE2") and self.cheat_cd == 0 and
                pyxel.btn(pyxel.KEY_C) and pyxel.btn(pyxel.KEY_H) and pyxel.btn(pyxel.KEY_E)):
            self._cheat_smite()
            self.cheat_cd = 20

        # Press M to return to the main menu from anywhere
        if pyxel.btnp(pyxel.KEY_M) and self.state != "MENU":
            self.state = "MENU"
            self.reading_text = ""
            self.current_selection = 0
            self.play_music("Menu v2: watchful ruins")
            return

        if self.state == "MENU": self.update_menu()
        elif self.state == "TUTORIAL": self.update_tutorial()
        elif self.state == "FADE_OUT": self.update_fade_out()
        elif self.state == "CUTSCENE": self.update_cutscene()
        elif self.state == "ZONE1": self.update_zone1()
        elif self.state == "ZONE1_DONE": self.update_zone1_done()
        elif self.state == "ZONE2": self.update_zone2()
        elif self.state == "ZONE2_DONE": self.update_zone2_done()
        elif self.state == "ENDING": self.update_ending()

    def update_menu(self):
        if pyxel.btnp(pyxel.KEY_UP):
            self.current_selection = (self.current_selection - 1) % len(self.menu_options)
            self.play_sfx("Menu move")
        elif pyxel.btnp(pyxel.KEY_DOWN):
            self.current_selection = (self.current_selection + 1) % len(self.menu_options)
            self.play_sfx("Menu move")
           
        if pyxel.btnp(pyxel.KEY_RETURN) or pyxel.btnp(pyxel.KEY_Z):
            self.play_sfx("Menu confirm")
            if self.current_selection == 0:
                self.state = "CUTSCENE"
                self.cutscene_timer = 0
                self.cutscene_slide = 0
                self.play_music("Cutscene: father motif")
            elif self.current_selection == 1:
                self.player.x = 60
                self.player.y = 400 - self.player.h    # start standing on the floor (no fall-in)
                self.player.dy = 0
                self.reading_text = ("Welcome to the Training Yard.\n\n"
                                     "Press [I] to interact with objects.\n"
                                     "Press M for the menu at any time.")
                self.state = "TUTORIAL"
                self.play_music("Tutorial v1: cold training yard")
                self.play_sfx("Enter zone")

    def update_tutorial(self):
        if self.reading_text != "":
            if pyxel.btnp(pyxel.KEY_I):
                if "[PRESS 'I' AGAIN TO ENTER THE ROT]" in self.reading_text:
                    self.reading_text = ""
                    self.state = "FADE_OUT"
                    self.fade_timer = 0
                    self.play_sfx("Door to rot")
                    return
                self.reading_text = ""
            return

        attack_pressed = pyxel.btnp(pyxel.KEY_SPACE)
        was_grounded = self.player.is_grounded
        was_attacking = self.player.is_attacking
        self.player.update(
            floor_y=400,
            platforms=self.tutorial_platforms,
            attack_pressed=attack_pressed,
        )
        if was_grounded and not self.player.is_grounded and self.player.dy < 0:
            self.play_sfx("Jump burst")
        if not was_grounded and self.player.is_grounded:
            self.play_sfx("Stone landing")
        if not was_attacking and self.player.is_attacking:
            self.play_sfx("Cleaver swing")
        elif (attack_pressed and self.player.has_sword
              and (self.player.stamina < 25 or self.player.is_exhausted)):
            self.play_sfx("Exhausted swing")
       
        # Reset hit flags when the attack ends
        if not self.player.is_attacking:
            for d in self.dummies:
                d["hit_this_swing"] = False
       
        # Show stamina tutorial on the very first swing (frame when timer is freshly set to 12)
        if self.player.is_attacking and self.player.attack_timer == 12 and not self.has_learned_stamina:
            self.has_learned_stamina = True
            self.play_sfx("Mechanic unlocked")
            self.reading_text = "Your arms feel incredibly heavy.\n\nSwinging this weapon drains STAMINA.\nWatch the cyan bar beneath your health.\nIt will recharge automatically if you rest."

        # Combat Check (Full-Body Hitbox)
        # Checking across 3 frames (10, 11, 12) makes the hit window much more forgiving
        if self.player.is_attacking and self.player.attack_timer in [10, 11, 12]:
            sword_w, sword_h = 45, 50
            if self.player.direction == 1:
                sword_x = self.player.x + 10
            else:                          
                sword_x = self.player.x - sword_w + 14      
            sword_y = self.player.y
           
            for d in self.dummies:
                if d["hp"] > 0 and not d["hit_this_swing"]:
                    # Generous Dummy bounding box (covers entire body and pole)
                    dx, dy, dw, dh = d["x"] - 15, d["y"] - 30, 50, 80
                   
                    if (sword_x < dx + dw and sword_x + sword_w > dx and
                        sword_y < dy + dh and sword_y + sword_h > dy):
                        d["hp"] -= 1
                        d["hit_this_swing"] = True
                        if d["hp"] <= 0:
                            self.play_sfx("Dummy break")
                        elif d["type"] == "wood":
                            self.play_sfx("Wood hit")
                        else:
                            self.play_sfx("Pelt hit")
                       
                        # Hit Visual Feedback
                        for _ in range(15):
                            color = 4 if d["type"] == "wood" else 13
                            self.hit_particles.append([d["x"] + 10, d["y"] + 10, pyxel.rndf(-2, 2), pyxel.rndf(-3, 1), color, 15])
                       
                        # Disintegrate if HP reaches 0
                        if d["hp"] <= 0:
                            for _ in range(40):
                                color = 4 if d["type"] == "wood" else 13
                                self.hit_particles.append([d["x"] + pyxel.rndi(0, 20), d["y"] + pyxel.rndi(-10, 30), pyxel.rndf(-4, 4), pyxel.rndf(-5, 2), color, 45])
                       
                        d["hit_flash"] = 8

        # Update hit particles
        for p in self.hit_particles[:]:
            p[0] += p[2]
            p[1] += p[3]
            p[3] += 0.2  # Gravity
            p[5] -= 1    # Lifetime
            if p[5] <= 0:
                self.hit_particles.remove(p)

        # Tick down hit flash timers
        for d in self.dummies:
            if d["hit_flash"] > 0:
                d["hit_flash"] -= 1

        # Interaction Check
        self.nearby_obj = None
        for obj in self.interactables:
            if (self.player.x < obj["x"] + obj["w"] + 10 and self.player.x + self.player.w > obj["x"] - 10 and
                self.player.y < obj["y"] + obj["h"] + 10 and self.player.y + self.player.h > obj["y"] - 10):
                self.nearby_obj = obj
                break
               
        if self.nearby_obj and pyxel.btnp(pyxel.KEY_I):
            if self.nearby_obj["id"] == "apple":
                self.player.hp = min(self.player.max_hp, self.player.hp + self.player.max_hp // 2)
                self.play_sfx("Apple restore")
                self.reading_text = "A pristine apple. Uncorrupted\nby the dust.\n\nEating food restores some HP.\nThe world has few such gifts left."
                self.interactables.remove(self.nearby_obj)
            elif self.nearby_obj["id"] == "sword":
                self.player.has_sword = True
                self.play_sfx("Sword pickup")
                self.reading_text = self.nearby_obj["text"]
                self.interactables.remove(self.nearby_obj)
            elif self.nearby_obj["id"] == "sprout":
                self.play_sfx("Magista sprout save")
                self.reading_text = self.nearby_obj["text"]
            elif self.nearby_obj["id"] == "exit":
                self.play_sfx("Exit zone")
                self.reading_text = self.nearby_obj["text"]
            else:
                # Includes the "exit" door, which opens the text prompt first.
                self.reading_text = self.nearby_obj["text"]

    def update_fade_out(self):
        self.fade_timer += 1
        if self.fade_timer >= 60:
            self.state = "MENU"  # return to main screen after tutorial exit
            self.play_music("Menu: dead town pulse")

    def update_cutscene(self):
        self.cutscene_timer += 1
        if 18 < self.cutscene_timer < 130 and self.cutscene_timer % 7 == 0:
            self.play_sfx("Cutscene text tick")
        advance = (pyxel.btnp(pyxel.KEY_I) or pyxel.btnp(pyxel.KEY_SPACE)
                   or pyxel.btnp(pyxel.KEY_RETURN))
        if advance and self.cutscene_timer > 20:
            self.play_sfx("Cutscene advance")
            self.cutscene_slide += 1
            self.cutscene_timer = 0
            if self.cutscene_slide >= 5:
                self.cutscene_slide = 0
                self.start_zone1()


    # ==========================================
    # ZONE 1 — THE RUINED TOWN
    # ==========================================
    def build_zone1(self):
        """Static level layout (built once), keyed by world-x."""
        self.zone_w = ZONE_W
        # Pits in the ground (player falls through these gaps)
        self.zone_pits = [(1280, 1360), (1980, 2080), (4700, 4790), (5550, 5630)]
        spans, prev = [], 0
        for a, b in self.zone_pits:
            spans.append((prev, a)); prev = b
        spans.append((prev, ZONE_W))
        self.zone_ground = spans
        # Diseased ground patches — toxic, drain HP if you stand on them.
        # They force the player up onto the platforms instead of walking straight.
        self.zone_hazards = [
            (1080, 1170), (1580, 1660), (2180, 2280),       # first blood / warren
            (2540, 2640),                                   # before the shambler
            (4000, 4090), (4950, 5060),                     # plaza
            (5300, 5440),                                   # far gate approach
        ]
        # Platforms (rubble, rooftops, ledges) — world coords. Heavy verticality:
        # the level is meant to be climbed and hopped, not walked in a line.
        self.zone_platforms = [
            # Fringe — a first taste of platforming
            (560, 360, 60, 14), (660, 330, 64, 14),
            # First Blood — hop the toxic patch toward pit 1
            (1040, 352, 70, 14), (1140, 308, 70, 14), (1240, 352, 70, 14),
            (1288, 360, 64, 14),                            # ledge over pit 1
            # The Warren — a vertical climb across collapsed rooftops
            (1440, 360, 60, 14), (1520, 330, 90, 16), (1560, 286, 70, 14),
            (1660, 250, 70, 14), (1760, 296, 70, 14), (1820, 332, 80, 16),
            (1900, 292, 70, 14), (1986, 356, 78, 14),       # ledge over pit 2
            (2120, 326, 70, 14), (2240, 282, 70, 14), (2340, 344, 70, 14),
            # First Shambler — a ledge to juke onto
            (2520, 352, 70, 14), (2960, 330, 70, 14),
            # Plaza — fountain base + tiered ledges around the climax
            (4060, 352, 70, 14), (4180, 306, 70, 14),
            (4470, 360, 130, 14),                           # fountain base
            (4686, 346, 100, 14),                           # ledge over pit 3
            (4820, 300, 72, 14), (4980, 344, 80, 14), (5080, 298, 72, 14),
            # Far Gate — a final climb across a pit to the gate
            (5260, 352, 70, 14), (5380, 310, 70, 14), (5500, 352, 70, 14),
            (5650, 316, 70, 14), (6000, 350, 80, 14), (6150, 310, 80, 14),
        ]
        # Foreground broken streetlamps (decorative, world coords)
        self.zone_lamps = [120, 560, 980, 1500, 2600, 3050, 4100, 5000, 5500, 6050]
        # Interactables (master copy; per-run copies live in self.zone_inter)
        self.zone_inter_master = [
            {"id": "sign", "x": 230, "y": 348, "w": 26, "h": 52,
             "text": "A rotted signboard.\n\n WELCOME TO ... the rest is gone.\nThe only town left in the world."},
            {"id": "note", "x": 430, "y": 374, "w": 20, "h": 14,
             "text": "A child's shoe, half-buried in dust.\n\nThis place was alive, once.\nA long time ago."},
            {"id": "sprout", "x": 3450, "y": 350, "w": 40, "h": 50,
             "text": "A Magista Sprout -- a speck of\ngreen in all the grey.\n\n[GAME SAVED]"},
            {"id": "apple", "x": 3560, "y": 362, "w": 18, "h": 18,
             "text": "A pristine apple, uncorrupted.\n\nRestores half of your health."},
            {"id": "note2", "x": 3660, "y": 374, "w": 20, "h": 14,
             "text": "Father's mark, carved in the wall:\na crescent and a road.\n\nHe came through here. Fifteen years gone."},
            {"id": "note3", "x": 5820, "y": 374, "w": 20, "h": 14,
             "text": "Scratched into the gate:\n\n'I go toward the wasteland --\ntoward the dead horizon.'  - Father"},
            {"id": "exit", "x": 6360, "y": 300, "w": 26, "h": 100,
             "text": "The far gate out of town.\nThe road to the wasteland beyond.\n\n[PRESS I AGAIN TO LEAVE ZONE 1]"},
        ]
        # Enemy spawns (type, world-x): ~15 rats, 3 shamblers
        self.zone_enemy_master = [
            ("rat", 1000), ("rat", 1120),
            ("rat", 1620), ("rat", 1780), ("rat", 1900), ("rat", 2120), ("rat", 2300),
            ("shambler", 2820), ("rat", 2700),
            ("rat", 4120), ("rat", 4280), ("rat", 4420), ("rat", 4620), ("rat", 4860),
            ("shambler", 4520), ("shambler", 4960),
            ("rat", 5400), ("rat", 5720), ("rat", 6050),
        ]

    def start_zone1(self):
        self.player.x = 60.0
        self.player.y = float(ZGROUND - self.player.h)
        self.player.dy = 0.0
        self.player.hp = self.player.max_hp
        self.player.stamina = self.player.max_stamina
        self.player.has_sword = True          # he carries his knife from the cutscene
        self.player.direction = 1
        self.camera_x = 0
        self.checkpoint_x = 60
        self.player_iframes = 60
        self.hitstop = 0
        self.save_cooldown = 0
        self.hazard_tick = 0
        self.zone_particles = []
        self.zone_inter = [dict(o) for o in self.zone_inter_master]
        self.spawn_zone1_enemies()
        self.nearby_obj = None
        self.reading_text = ""
        self.state = "ZONE1"
        self.play_music("Menu: dead town pulse")
        self.play_sfx("Enter zone")

    def spawn_zone1_enemies(self):
        self.enemies = [Rat(x) if k == "rat" else Shambler(x)
                        for k, x in self.zone_enemy_master]

    def hurt_player(self, dmg, src_cx):
        if self.player_iframes > 0:
            return
        self.player.hp -= dmg
        self.player_iframes = 30
        self.play_sfx("Player hurt")
        pcx = self.player.x + self.player.w / 2
        self.player.x += -14 if src_cx > pcx else 14
        self.player.dy = -5
        if self.player.hp <= 0:
            if self.state == "ZONE2":
                self.respawn_zone2()
            else:
                self.respawn_zone1()

    def respawn_zone1(self):
        self.player.hp = self.player.max_hp
        self.player.stamina = self.player.max_stamina
        self.player.is_exhausted = False
        self.player.stamina_mult = 1.0
        self.player.x = float(self.checkpoint_x)
        self.player.y = float(ZGROUND - self.player.h)
        self.player.dy = 0.0
        self.player_iframes = 60
        self.spawn_zone1_enemies()

    def _burst(self, e, n):
        cx = e.x + e.W / 2
        cy = e.y + e.H / 2
        for _ in range(n):
            col = 8 if pyxel.rndi(0, 2) else 13
            self.zone_particles.append([cx, cy, pyxel.rndf(-3, 3),
                                        pyxel.rndf(-4, 1), pyxel.rndi(12, 30), col])

    def finish_zone1(self):
        # Out the Far Gate, into the Open Wasteland
        self.start_zone2()

    def update_zone1(self):
        # Reading a note / sign pauses the world
        if self.reading_text != "":
            if pyxel.btnp(pyxel.KEY_I):
                if "[PRESS I AGAIN TO LEAVE ZONE 1]" in self.reading_text:
                    self.reading_text = ""
                    self.play_sfx("Exit zone")
                    self.finish_zone1()
                    return
                self.reading_text = ""
            return

        # Hit-stop freeze ("the Crunch")
        if self.hitstop > 0:
            self.hitstop -= 1
            return

        if self.player_iframes > 0:
            self.player_iframes -= 1
        if self.save_cooldown > 0:
            self.save_cooldown -= 1

        attack_pressed = pyxel.btnp(pyxel.KEY_SPACE)
        was_grounded = self.player.is_grounded
        was_attacking = self.player.is_attacking
        self.player.update(ZGROUND, self.zone_platforms, attack_pressed,
                           world_w=self.zone_w, ground_spans=self.zone_ground)
        if was_grounded and not self.player.is_grounded and self.player.dy < 0:
            self.play_sfx("Jump burst")
        if not was_grounded and self.player.is_grounded:
            self.play_sfx("Stone landing")
        if not was_attacking and self.player.is_attacking:
            self.play_sfx("Cleaver swing")

        # Fell into a pit
        if self.player.y > 520:
            self.player.hp -= 20
            if self.player.hp <= 0:
                self.respawn_zone1()
            else:
                self.player.x = float(self.checkpoint_x)
                self.player.y = float(ZGROUND - self.player.h)
                self.player.dy = 0.0
                self.player_iframes = 40

        # Diseased ground drains HP — only when standing on the main floor (not a platform)
        on_main = self.player.is_grounded and (self.player.y + self.player.h >= ZGROUND - 1)
        pcx = self.player.x + self.player.w / 2
        if on_main and any(a <= pcx <= b for a, b in self.zone_hazards):
            self.hazard_tick += 1
            if self.hazard_tick % 5 == 0:
                self.player.hp -= 3
                if self.player.hp <= 0:
                    self.respawn_zone1()
            if self.hazard_tick % 20 == 1:
                self.play_sfx("Player hurt")
        else:
            self.hazard_tick = 0

        # Camera follow (clamped to world)
        self.camera_x = max(0, min(self.zone_w - Resolution.WIDTH,
                                   self.player.x + self.player.w / 2 - Resolution.WIDTH / 2))

        # Enemy AI (only those near the player)
        for e in self.enemies:
            if abs(e.x - self.player.x) < 700:
                e.update(self)

        if not self.player.is_attacking:
            for e in self.enemies:
                e.hit_this_swing = False

        # Sword vs enemies
        if self.player.is_attacking and self.player.attack_timer in [10, 11, 12]:
            sw, sh = 45, 50
            sx = self.player.x + 10 if self.player.direction == 1 else self.player.x - sw + 14
            sy = self.player.y
            for e in self.enemies:
                if e.dead or e.hit_this_swing:
                    continue
                if (sx < e.x + e.W and sx + sw > e.x and sy < e.y + e.H and sy + sh > e.y):
                    e.hp -= 1
                    e.hit_this_swing = True
                    e.flash = 8
                    self.hitstop = 2
                    self._burst(e, 12)
                    if e.hp <= 0:
                        e.dead = True
                        self._burst(e, 30)
                        self.play_sfx("Rat defeated" if isinstance(e, Rat) else "Shambler defeated")
                    else:
                        self.play_sfx("Wood hit")

        # Stomp kills (falling onto a rat)
        if self.player.dy > 0:
            feet = self.player.y + self.player.h
            for e in self.enemies:
                if e.dead or not isinstance(e, Rat):
                    continue
                if (self.player.x < e.x + e.W and self.player.x + self.player.w > e.x and
                        feet >= e.y and feet <= e.y + e.H + self.player.dy):
                    e.dead = True
                    self.player.dy = -12
                    self._burst(e, 24)
                    self.play_sfx("Rat defeated")

        self.enemies = [e for e in self.enemies if not e.dead]

        # Particles
        for q in self.zone_particles[:]:
            q[0] += q[2]; q[1] += q[3]; q[3] += 0.2; q[4] -= 1
            if q[4] <= 0:
                self.zone_particles.remove(q)

        # Interaction
        self.nearby_obj = None
        for obj in self.zone_inter:
            if (self.player.x < obj["x"] + obj["w"] + 12 and
                    self.player.x + self.player.w > obj["x"] - 12 and
                    self.player.y < obj["y"] + obj["h"] + 20 and
                    self.player.y + self.player.h > obj["y"] - 12):
                self.nearby_obj = obj
                break
        if self.nearby_obj and pyxel.btnp(pyxel.KEY_I):
            o = self.nearby_obj
            if o["id"] == "apple":
                self.player.hp = min(self.player.max_hp, self.player.hp + self.player.max_hp // 2)
                self.play_sfx("Apple restore")
                self.zone_inter.remove(o)
            elif o["id"] == "sprout":
                # The sprout always restores you; only the SAVE is on cooldown.
                self.player.hp = self.player.max_hp
                self.player.stamina = self.player.max_stamina
                self.player.is_exhausted = False
                if self.save_cooldown == 0:
                    self.checkpoint_x = o["x"]
                    self.save_cooldown = 60 * 60       # ~1 minute
                    self.play_sfx("Magista sprout save")
                    self.reading_text = o["text"]
                else:
                    self.play_sfx("Apple restore")
                    self.reading_text = "You rest at the sprout.\nYour wounds close over.\n\n(The save is still recovering.)"
            else:
                self.reading_text = o["text"]

        # Music shifts in the sheltered 'Breath' alcove
        if 3300 < self.player.x < 3900:
            self.play_music("Sprout: fragile life")
        else:
            self.play_music("Menu: dead town pulse")

    def update_zone1_done(self):
        self.zone_done_timer += 1
        if self.zone_done_timer > 30 and (pyxel.btnp(pyxel.KEY_I) or
                                          pyxel.btnp(pyxel.KEY_RETURN) or
                                          pyxel.btnp(pyxel.KEY_SPACE)):
            self.state = "MENU"
            self.play_music("Menu: dead town pulse")

    # ------------------------------------------
    # ZONE 1 GRAPHICS
    # ------------------------------------------
    def draw_reading_box(self):
        scale = 2
        lines = self.reading_text.split('\n')
        max_len = max(len(l) for l in lines)
        box_w = (max_len * 4 * scale) + 20
        box_h = (len(lines) * 8 * scale) + 20
        box_x = (Resolution.WIDTH - box_w) // 2
        box_y = 90
        pyxel.rect(box_x, box_y, box_w, box_h + 15, 0)
        pyxel.rectb(box_x, box_y, box_w, box_h + 15, 7)
        self.draw_big_text(box_x + 10, box_y + 10, self.reading_text, 7, scale)
        pyxel.text(box_x + 10, box_y + box_h, "Press I to continue", 13)

    def draw_zone1_bg(self, cam):
        self.cs_gradient(0, 512, 0, 300, 1, 0)
        # Cratered moon (slow parallax)
        mx = int(380 - cam * 0.06)
        pyxel.circ(mx, 92, 28, 5)
        pyxel.circ(mx - 10, 82, 7, 1)
        pyxel.circ(mx + 12, 100, 6, 1)
        # Distant skyline (parallax 0.4), tiled
        pxs = cam * 0.4
        spacing = 150
        start = int(pxs // spacing) - 1
        for i in range(start, start + 6):
            sx = int(i * spacing - pxs)
            h = 110 + (abs(i) * 47 % 90)
            top = 300 - h
            pyxel.rect(sx, top, 96, h, 1)
            # Windows are keyed to the building's world index + grid cell, NOT to
            # screen position, so they stay fixed (no twinkling) as the camera scrolls.
            col = 0
            for lwx in range(10, 86, 18):
                col += 1
                row = 0
                for lwy in range(14, h - 4, 26):
                    row += 1
                    key = i * 131 + col * 17 + row * 7
                    if key % 5 == 0:
                        pyxel.rect(sx + lwx, top + lwy, 5, 7, 9 if key % 3 == 0 else 0)
        # Foreground dust
        for p in self.dust_particles:
            pyxel.pset(int(p[0]) % 512, int(p[1]) % 400, 13)

    def draw_zone1(self):
        cam = self.camera_x
        pyxel.cls(0)
        self.draw_zone1_bg(cam)

        # Ground band, with pits carved out
        pyxel.rect(0, ZGROUND, 512, 512 - ZGROUND, 1)
        for a, b in self.zone_pits:
            sx0 = a - cam
            if b - cam > 0 and sx0 < 512:
                pyxel.rect(int(sx0), ZGROUND, int(b - a), 512 - ZGROUND, 0)
        for (x0, x1) in self.zone_ground:
            sx0 = max(0, x0 - cam)
            sx1 = min(512, x1 - cam)
            if sx1 > sx0:
                pyxel.rect(int(sx0), ZGROUND, int(sx1 - sx0), 7, 5)
                pyxel.line(int(sx0), ZGROUND, int(sx1), ZGROUND, 6)

        # Diseased ground (toxic, no-go) — sickly purple sludge with bubbling spores
        for a, b in self.zone_hazards:
            sx0 = max(0, a - cam)
            sx1 = min(512, b - cam)
            if sx1 > sx0:
                pyxel.rect(int(sx0), ZGROUND, int(sx1 - sx0), 22, 2)
                pyxel.line(int(sx0), ZGROUND, int(sx1), ZGROUND, 14)
                for bx in range(int(a - cam) // 12 * 12, int(b - cam), 12):
                    if bx < 0 or bx > 512:
                        continue
                    yb = ZGROUND + 5 + int(math.sin(self.frame_count * 0.12 + bx) * 3)
                    pyxel.pset(bx + 3, yb, 3)
                    pyxel.pset(bx + 7, yb + 4, 11)

        # Platforms
        for (px, py, pw, ph) in self.zone_platforms:
            sx = px - cam
            if sx + pw > 0 and sx < 512:
                pyxel.rect(int(sx), py, pw, ph, 5)
                pyxel.rect(int(sx), py, pw, 3, 13)

        # Broken streetlamps (foreground silhouettes)
        for lx in self.zone_lamps:
            sx = lx - cam
            if -20 < sx < 532:
                pyxel.rect(int(sx), 332, 3, 68, 0)
                pyxel.line(int(sx) + 1, 332, int(sx) + 11, 328, 0)
                pyxel.circ(int(sx) + 12, 328, 2, 4)

        # Interactables
        for o in self.zone_inter:
            sx = int(o["x"] - cam)
            if sx < -40 or sx > 540:
                continue
            self._draw_zone_object(o, sx)

        # Particles
        for q in self.zone_particles:
            pyxel.pset(int(q[0] - cam), int(q[1]), int(q[5]))

        # Enemies
        for e in self.enemies:
            if -40 < e.x - cam < 540:
                e.draw(cam)
                if isinstance(e, Shambler):
                    e.draw_hp(cam)

        # Player (world->screen), flashing during i-frames
        if self.player_iframes == 0 or (self.player_iframes // 3) % 2 == 0:
            cx = int(self.player.x - cam + self.player.w // 2)
            draw_wanderer(pyxel, cx, int(self.player.y), self.player.direction,
                          self.player.anim_frame, self.player.is_moving,
                          self.player.is_grounded, self.player.has_sword,
                          self.player.is_attacking, self.player.attack_timer)

        # HUD
        pyxel.rect(10, 10, 104, 12, 0)
        pyxel.rect(12, 12, max(0, self.player.hp), 8, 8)
        pyxel.text(15, 14, "HP", 7)
        pyxel.rect(10, 24, 104, 8, 0)
        sp_color = 5 if self.player.is_exhausted else 12
        pyxel.rect(12, 25, int(self.player.stamina), 6, sp_color)
        pyxel.text(15, 26, "SP", 7)

        # Diegetic interact prompt
        if self.reading_text == "" and self.nearby_obj:
            bounce = int(math.sin(self.frame_count * 0.2) * 3)
            hx = int(self.player.x - cam + 6)
            pyxel.text(hx, int(self.player.y) - 12 + bounce, "[I]", 0)
            pyxel.text(hx - 1, int(self.player.y) - 13 + bounce, "[I]", 10)

        if self.reading_text != "":
            self.draw_reading_box()

    def _draw_zone_object(self, o, sx):
        oid = o["id"]
        if oid == "sprout":
            glow = int(math.sin(self.frame_count * 0.1) * 2)
            pyxel.circ(sx + 20, 372, 16 + glow, 1)
            pyxel.circ(sx + 20, 372, 10 + glow, 5)
            pyxel.line(sx + 20, 398, sx + 20, 360, 7)
            pyxel.circ(sx + 12, 366, 4, 11)
            pyxel.circ(sx + 28, 362, 5, 11)
            pyxel.circ(sx + 20, 356, 6, 3)
            pyxel.circ(sx + 20, 356, 3, 11)
        elif oid == "apple":
            pyxel.circ(sx + 9, 372, 5, 8)
            pyxel.line(sx + 9, 367, sx + 11, 364, 3)
        elif oid == "sign":
            pyxel.rect(sx + 11, 360, 3, 40, 0)
            pyxel.rect(sx, 350, 26, 14, 4)
            pyxel.rectb(sx, 350, 26, 14, 0)
            pyxel.line(sx + 3, 354, sx + 20, 354, 0)
            pyxel.line(sx + 3, 358, sx + 16, 358, 0)
        elif oid == "exit":
            pyxel.rect(sx, 300, 26, 100, 0)
            pyxel.rectb(sx, 300, 26, 100, 5)
            pyxel.line(sx + 13, 300, sx + 13, 400, 5)
            pyxel.rect(sx + 18, 348, 3, 5, 7)
        else:  # notes / shoe / carved marks
            pyxel.rect(sx, 380, 12, 7, 7)
            pyxel.line(sx + 1, 382, sx + 9, 382, 0)
            pyxel.line(sx + 1, 384, sx + 8, 384, 0)

    def draw_zone1_done(self):
        pyxel.cls(0)
        self.cs_gradient(0, 512, 0, 320, 1, 0)
        if self.zone_done_timer > 12:
            self.draw_big_text(256 - len("THE RUINED TOWN") * 4, 150, "THE RUINED TOWN", 7, 2)
            self.draw_big_text(256 - len("- CLEARED -") * 4, 186, "- CLEARED -", 13, 2)
        if self.zone_done_timer > 50:
            sub = "The wasteland waits beyond the gate."
            pyxel.text(256 - len(sub) * 2, 250, sub, 6)
        if self.zone_done_timer > 80:
            pyxel.text(256 - 40, 470, "[ I ]  Continue", 5)

    # ==========================================
    # ZONE 2 — THE OPEN WASTELAND
    # ==========================================
    def build_zone2(self):
        self.zone2_w = ZONE2_W
        self.arena_x0 = 4520
        self.arena_x1 = 5680
        self.zone2_pits = [(1500, 1580), (2400, 2480), (2900, 2980)]
        spans, prev = [], 0
        for a, b in self.zone2_pits:
            spans.append((prev, a)); prev = b
        spans.append((prev, ZONE2_W))
        self.zone2_ground = spans
        # Static diseased patches (tanks/boss add more at runtime)
        self.zone2_hazards_static = [(700, 760), (1700, 1780), (2620, 2700)]
        # Platforms: rusted caravans, spikes, rocks, arena ledges
        self.zone2_platforms = [
            (1000, 352, 80, 14), (1120, 316, 70, 14), (1300, 352, 90, 14), (1460, 330, 70, 14),
            (2000, 350, 70, 14), (2120, 310, 70, 14), (2300, 346, 80, 14),
            (2520, 320, 70, 14), (2700, 350, 80, 14), (2860, 316, 70, 14),
            (4700, 344, 80, 14), (5080, 328, 90, 14), (5420, 344, 80, 14),   # arena
        ]
        self.zone2_ribs = [180, 900, 1850, 3850, 4300]      # decorative bleached ribcages
        self.zone2_inter_master = [
            {"id": "sign", "x": 150, "y": 348, "w": 26, "h": 52,
             "text": "Press 1 for the Rusted Knife,\n2 for the Heavy Cleaver.\n\nSome hides only the Cleaver can bite."},
            {"id": "sprout", "x": 3250, "y": 350, "w": 40, "h": 50,
             "text": "A Magista Sprout in a rocky hollow.\nThe wind dies here.\n\n[GAME SAVED]"},
            {"id": "apple", "x": 3370, "y": 362, "w": 18, "h": 18,
             "text": "A pristine apple.\n\nRestores half of your health."},
            {"id": "note", "x": 3480, "y": 374, "w": 20, "h": 14,
             "text": "Father's note, pinned with a bone:\n'The creatures here are armored in rot.\nHeavy strikes only. I head for the tree line.'"},
        ]
        # Enemy spawns (type, world-x). Boss is spawned separately at the arena.
        self.zone2_enemy_master = [
            ("dog", 600),
            ("tank", 1180), ("tank", 1560),
            ("dog", 2100), ("tank", 2350), ("dog", 2620), ("tank", 2820),
        ]

    def start_zone2(self):
        self.player.x = 40.0
        self.player.y = float(ZGROUND - self.player.h)
        self.player.dy = 0.0
        self.player.hp = self.player.max_hp
        self.player.stamina = self.player.max_stamina
        self.player.has_sword = True
        self.player.weapon = "knife"          # starts on the knife; must learn to swap
        self.player.direction = 1
        self.camera_x = 0
        self.checkpoint_x = 40
        self.player_iframes = 60
        self.hitstop = 0
        self.save_cooldown = 0
        self.hazard_tick = 0
        self.wind = -0.5
        self.player.stamina_mult = 1.0
        self.zone2_disease = 0.0          # infection: fills as you progress
        self.zone2_progress = 40.0        # furthest x reached
        self.zone2_grace = 240            # dogs hold back this long (learn the cleaver)
        self.zone_particles = []
        self.zone_hazards = list(self.zone2_hazards_static)
        self.zone2_inter = [dict(o) for o in self.zone2_inter_master]
        self.boss = None
        self.boss_started = False
        self.boss_active = False
        self.spawn_zone2_enemies()
        self.nearby_obj = None
        # He catches the rot the moment he steps into the open
        self.reading_text = ("You feel it the instant you pass the gate --\n"
                             "the rot, taking root in your blood.\n\n"
                             "[INFECTED. It will only worsen as you go on.]")
        self.state = "ZONE2"
        self.play_music("Zone 2: dead horizon")
        self.play_sfx("Enter zone")

    def spawn_zone2_enemies(self):
        self.enemies = [FeralDog(x) if t == "dog" else BloatedTank(x)
                        for t, x in self.zone2_enemy_master]

    def respawn_zone2(self):
        self.player.hp = self.player.max_hp
        self.player.stamina = self.player.max_stamina
        self.player.is_exhausted = False
        self.player.stamina_mult = 1.0
        self.zone2_disease = 0.0                      # the rot recedes on death/respawn
        self.zone2_progress = float(self.checkpoint_x)
        self.player.x = float(self.checkpoint_x)
        self.player.y = float(ZGROUND - self.player.h)
        self.player.dy = 0.0
        self.player_iframes = 60
        self.zone_hazards = list(self.zone2_hazards_static)
        self.spawn_zone2_enemies()
        if self.boss_started:
            self.boss = CorruptedBear(self.arena_x1 - 220)
            self.enemies.append(self.boss)
            self.boss_active = True

    def boss_defeated(self):
        self.boss_active = False
        self.boss = None
        self.zone2_inter.append(
            {"id": "chasm", "x": 5980, "y": 300, "w": 40, "h": 100,
             "text": "A vast chasm splits the earth, descending\ninto a sickly green gloom below.\n\n[PRESS I AGAIN TO DESCEND INTO ZONE 3]"})

    def finish_zone2(self):
        self.state = "ZONE2_DONE"
        self.zone_done_timer = 0

    def _spark(self, e):
        cx = e.x + e.W / 2
        cy = e.y + e.H / 2
        for _ in range(8):
            self.zone_particles.append([cx, cy, pyxel.rndf(-2, 2), pyxel.rndf(-2, 2),
                                        pyxel.rndi(6, 12), 7])

    def _cheat_smite(self):
        # C+H+E cheat: wipe every enemy; the boss only takes 50% of its max HP.
        for e in self.enemies:
            if isinstance(e, CorruptedBear):
                e.hp -= e.max_hp * 0.5
                e.flash = 8
                self._burst(e, 20)
                if e.hp <= 0:
                    e.dead = True
                    self._burst(e, 30)
                    self.boss_defeated()
            else:
                e.dead = True
                self._burst(e, 18)
        self.play_sfx("Shambler defeated")
        self.enemies = [e for e in self.enemies if not e.dead]

    def update_zone2(self):
        if self.reading_text != "":
            if pyxel.btnp(pyxel.KEY_I):
                if "DESCEND INTO ZONE 3" in self.reading_text:
                    self.reading_text = ""
                    self.play_sfx("Exit zone")
                    self.finish_zone2()
                    return
                self.reading_text = ""
            return

        if self.hitstop > 0:
            self.hitstop -= 1
            return
        if self.player_iframes > 0:
            self.player_iframes -= 1
        if self.save_cooldown > 0:
            self.save_cooldown -= 1
        if self.zone2_grace > 0:
            self.zone2_grace -= 1

        # Disease creeps up with progress through the wasteland; the further the
        # Wanderer gets, the more each swing tires him out.
        self.zone2_progress = max(self.zone2_progress, self.player.x)
        target = min(100.0, (self.zone2_progress - 40) / (self.arena_x0 - 40) * 100.0)
        self.zone2_disease = min(100.0, max(self.zone2_disease,
                                            self.zone2_disease + (target - self.zone2_disease) * 0.02))
        self.player.stamina_mult = 1.0 + (self.zone2_disease / 100.0) * 1.3

        # Weapon switching
        if pyxel.btnp(pyxel.KEY_1) and self.player.weapon != "knife":
            self.player.weapon = "knife"
            self.play_sfx("Menu move")
        if pyxel.btnp(pyxel.KEY_2) and self.player.weapon != "cleaver":
            self.player.weapon = "cleaver"
            self.play_sfx("Menu move")

        attack_pressed = pyxel.btnp(pyxel.KEY_SPACE)
        was_grounded = self.player.is_grounded
        was_attacking = self.player.is_attacking
        self.player.update(ZGROUND, self.zone2_platforms, attack_pressed,
                           world_w=self.zone2_w, ground_spans=self.zone2_ground)
        if was_grounded and not self.player.is_grounded and self.player.dy < 0:
            self.play_sfx("Jump burst")
        if not was_grounded and self.player.is_grounded:
            self.play_sfx("Stone landing")
        if not was_attacking and self.player.is_attacking:
            self.play_sfx("Cleaver swing")

        # Wind pushes against you in the open (not in the sheltered camp or arena)
        sheltered = (3000 < self.player.x < 3720) or self.boss_active
        if not sheltered:
            self.player.x += self.wind
            if self.player.x < 0:
                self.player.x = 0

        # Boss arena: trap the player once they step in
        if not self.boss_started and self.player.x > self.arena_x0 + 40:
            self.boss_started = True
            self.boss_active = True
            self.boss = CorruptedBear(self.arena_x1 - 220)
            self.enemies.append(self.boss)
            self.checkpoint_x = self.arena_x0 + 30
            self.play_sfx("Enter zone")
        if self.boss_active:
            self.player.x = max(self.arena_x0,
                                min(self.arena_x1 - self.player.w, self.player.x))

        # Fell into a pit
        if self.player.y > 520:
            self.player.hp -= 20
            if self.player.hp <= 0:
                self.respawn_zone2()
            else:
                self.player.x = float(self.checkpoint_x)
                self.player.y = float(ZGROUND - self.player.h)
                self.player.dy = 0.0
                self.player_iframes = 40

        # Diseased ground drains HP
        on_main = self.player.is_grounded and (self.player.y + self.player.h >= ZGROUND - 1)
        pcx = self.player.x + self.player.w / 2
        if on_main and any(a <= pcx <= b for a, b in self.zone_hazards):
            self.hazard_tick += 1
            if self.hazard_tick % 5 == 0:
                self.player.hp -= 3
                if self.player.hp <= 0:
                    self.respawn_zone2()
            if self.hazard_tick % 20 == 1:
                self.play_sfx("Player hurt")
        else:
            self.hazard_tick = 0

        # Camera (locked to the arena during the boss)
        if self.boss_active:
            self.camera_x = max(self.arena_x0, min(self.arena_x1 - Resolution.WIDTH,
                                                   self.player.x + self.player.w / 2 - Resolution.WIDTH / 2))
        else:
            self.camera_x = max(0, min(self.zone2_w - Resolution.WIDTH,
                                       self.player.x + self.player.w / 2 - Resolution.WIDTH / 2))

        # Enemy AI
        for e in self.enemies:
            if e is self.boss or abs(e.x - self.player.x) < 700:
                e.update(self)

        if not self.player.is_attacking:
            for e in self.enemies:
                e.hit_this_swing = False

        # Sword vs enemies (weapon-aware; armored dogs need the cleaver)
        sl = self.player.swing_len
        if self.player.is_attacking and self.player.attack_timer in (sl, sl - 1, sl - 2):
            w = WEAPON_STATS[self.player.weapon]
            sw, sh = w["reach"], w["hh"]
            sx = self.player.x + 10 if self.player.direction == 1 else self.player.x - sw + 14
            sy = self.player.y
            for e in self.enemies:
                if e.dead or e.hit_this_swing:
                    continue
                if (sx < e.x + e.W and sx + sw > e.x and sy < e.y + e.H and sy + sh > e.y):
                    e.hit_this_swing = True
                    if e.armored and self.player.weapon != "cleaver":
                        self.play_sfx("Exhausted swing")     # "tink" — no damage
                        self._spark(e)
                        continue
                    e.hp -= w["dmg"]
                    e.flash = 8
                    self.hitstop = 2
                    self._burst(e, 10)
                    if w["stagger"]:
                        e.stagger = 12
                        e.x += 6 if self.player.direction == 1 else -6
                    if e.hp <= 0:
                        e.dead = True
                        e.on_death(self)
                        self._burst(e, 30)
                        if isinstance(e, CorruptedBear):
                            self.boss_defeated()
                        elif not isinstance(e, BloatedTank):
                            self.play_sfx("Rat defeated")
                    else:
                        self.play_sfx("Wood hit")

        self.enemies = [e for e in self.enemies if not e.dead]

        for q in self.zone_particles[:]:
            q[0] += q[2]; q[1] += q[3]; q[3] += 0.2; q[4] -= 1
            if q[4] <= 0:
                self.zone_particles.remove(q)

        # Interaction
        self.nearby_obj = None
        for obj in self.zone2_inter:
            if (self.player.x < obj["x"] + obj["w"] + 12 and
                    self.player.x + self.player.w > obj["x"] - 12 and
                    self.player.y < obj["y"] + obj["h"] + 20 and
                    self.player.y + self.player.h > obj["y"] - 12):
                self.nearby_obj = obj
                break
        if self.nearby_obj and pyxel.btnp(pyxel.KEY_I):
            o = self.nearby_obj
            if o["id"] == "apple":
                self.player.hp = min(self.player.max_hp, self.player.hp + self.player.max_hp // 2)
                self.play_sfx("Apple restore")
                self.zone2_inter.remove(o)
            elif o["id"] == "sprout":
                # The sprout always restores you; only the SAVE is on cooldown.
                self.player.hp = self.player.max_hp
                self.player.stamina = self.player.max_stamina
                self.player.is_exhausted = False
                if self.save_cooldown == 0:
                    self.checkpoint_x = o["x"]
                    self.save_cooldown = 60 * 60
                    self.play_sfx("Magista sprout save")
                    self.reading_text = o["text"]
                else:
                    self.play_sfx("Apple restore")
                    self.reading_text = "You rest at the sprout.\nYour wounds close over.\n\n(The save is still recovering.)"
            else:
                self.reading_text = o["text"]

        if 3000 < self.player.x < 3720:
            self.play_music("Sprout: fragile life")
        else:
            self.play_music("Zone 2: dead horizon")

    def update_zone2_done(self):
        self.zone_done_timer += 1
        if self.zone_done_timer > 30 and (pyxel.btnp(pyxel.KEY_I) or
                                          pyxel.btnp(pyxel.KEY_RETURN) or
                                          pyxel.btnp(pyxel.KEY_SPACE)):
            # (Until Zones 3-4 exist, the wasteland leads straight to the finale.)
            self.start_ending()

    # ==========================================
    # ENDING — The Final Rest (playable finale)
    # ==========================================
    ENDING_NOTE = ["I found him. He didn't make it,",
                   "and... neither will I.",
                   "",
                   "But he was right -- the cure is real,",
                   "glowing just ahead in the dark.",
                   "",
                   "My father and I walked as far as our",
                   "broken bodies could carry us.",
                   "Whoever finds this: take the final",
                   "steps for us.  Please."]

    def start_ending(self):
        self.state = "ENDING"
        self.ending_phase = "walk"            # walk -> rest -> fade -> note -> title
        self.ending_timer = 0
        self.ending_note_total = sum(len(l) + 4 for l in self.ENDING_NOTE)
        self.ending_tree_x = 452              # glowing Magista Tree on the right
        self.player.x = 40.0
        self.player.y = float(ZGROUND - self.player.h)
        self.player.dy = 0.0
        self.player.direction = 1
        self.player.is_moving = False
        self.player.is_attacking = False
        self.play_music("Ending cutscene: the dead horizon")

    def update_ending(self):
        self.ending_timer += 1
        p = self.player
        if self.ending_phase == "walk":
            # The player limps right toward the tree (holding Right).
            p.is_moving = False
            if pyxel.btn(pyxel.KEY_RIGHT):
                if self.ending_timer % 3 != 0:        # a halting, exhausted limp
                    p.x += 1.4
                p.is_moving = True
                p.anim_frame += 1
            if p.x >= self.ending_tree_x - 56:
                p.x = self.ending_tree_x - 56
                self.ending_phase = "rest"
                self.ending_timer = 0
                p.is_moving = False
        elif self.ending_phase == "rest":
            # Controls disabled; he lies down beside his father. Hold on the stillness.
            if self.ending_timer > 150:
                self.ending_phase = "fade"
                self.ending_timer = 0
        elif self.ending_phase == "fade":
            if self.ending_timer > 70:
                self.ending_phase = "note"
                self.ending_timer = 0
        elif self.ending_phase == "note":
            done = self.ending_timer > 30 + self.ending_note_total * 2
            if done and (pyxel.btnp(pyxel.KEY_I) or pyxel.btnp(pyxel.KEY_SPACE) or
                         pyxel.btnp(pyxel.KEY_RETURN)):
                self.ending_phase = "title"
                self.ending_timer = 0
        elif self.ending_phase == "title":
            if self.ending_timer > 60 and (pyxel.btnp(pyxel.KEY_I) or
                                           pyxel.btnp(pyxel.KEY_SPACE) or
                                           pyxel.btnp(pyxel.KEY_RETURN)):
                self.state = "MENU"
                self.play_music("Menu v2: watchful ruins")

    def _draw_ending_scene(self):
        # The dark swamp clearing, the one pristine Magista Tree glowing in the gloom
        self.cs_gradient(0, 512, 0, 300, 1, 0)
        pyxel.rect(0, ZGROUND, 512, 512 - ZGROUND, 1)
        pyxel.line(0, ZGROUND, 512, ZGROUND, 5)
        tx = self.ending_tree_x
        glow = int(math.sin(self.frame_count * 0.08) * 3)
        # halo
        pyxel.dither(0.3); pyxel.circ(tx, 300, 80 + glow, 11); pyxel.dither(1.0)
        # trunk + living canopy (the only green left)
        pyxel.rect(tx - 4, 300, 9, 100, 5)
        pyxel.circ(tx, 286, 30 + glow, 3)
        pyxel.circ(tx - 16, 300, 18, 3)
        pyxel.circ(tx + 16, 296, 20, 3)
        pyxel.circ(tx, 286, 16, 11)
        pyxel.circ(tx + 10, 300, 9, 11)
        # Father's corpse at the base, lying down
        fx = tx - 44
        pyxel.rect(fx - 14, 392, 30, 6, 0)
        pyxel.circ(fx - 16, 392, 5, 0)              # head
        pyxel.line(fx - 20, 390, fx - 24, 392, 8)  # faded red scarf
        # drifting spores
        for i in range(20):
            ax = (i * 53 + self.frame_count) % 512
            ay = 120 + (i * 37 + self.frame_count // 2) % 260
            pyxel.pset(ax, ay, 3 if i % 2 else 11)

    def _draw_wanderer_prone(self, cx):
        # The Wanderer lying flat beside his father
        pyxel.rect(cx - 16, 392, 32, 6, 13)         # body/cloak
        pyxel.rect(cx - 20, 393, 6, 4, 5)           # legs
        pyxel.circ(cx + 16, 392, 5, 15)             # head
        pyxel.rect(cx + 12, 387, 12, 4, 0)          # hat, fallen flat
        pyxel.line(cx + 10, 394, cx + 2, 396, 8)    # red scarf trailing

    def draw_ending(self):
        pyxel.cls(0)
        ph = self.ending_phase
        if ph in ("walk", "rest", "fade"):
            self._draw_ending_scene()
            cam = 0
            cx = int(self.player.x + self.player.w // 2)
            if ph == "walk":
                draw_wanderer(pyxel, cx, int(self.player.y), 1, self.player.anim_frame,
                              self.player.is_moving, True, True, False, 0,
                              swing_len=12, atk_reach=24, backpack=True)
            else:
                self._draw_wanderer_prone(cx)
            if ph == "walk" and (self.frame_count // 24) % 2 == 0:
                hint = "Hold  >  to go on..."
                self.draw_big_text(256 - len(hint) * 2 * 2, 108, hint, 5, 2)
            if ph == "fade":
                prog = min(1.0, self.ending_timer / 70.0)
                bs = int(prog * 16)
                if bs > 0:
                    for y in range(0, 512, 16):
                        for x in range(0, 512, 16):
                            pyxel.rect(x + 8 - bs, y + 8 - bs, bs * 2, bs * 2, 0)
        elif ph == "note":
            scale = 2                                           # match the cutscene text size
            y = 64
            budget = max(0, (self.ending_timer - 30)) // 2     # slow typewriter
            for line in self.ENDING_NOTE:
                if budget <= 0 and line:
                    break
                seg = line[:budget] if budget < len(line) else line
                if seg:
                    lx = 256 - len(line) * 2 * scale            # centre on full width
                    self.draw_big_text(lx, y, seg, 7, scale)
                budget -= len(line) + 4
                y += 18
            if (self.ending_timer > 30 + self.ending_note_total * 2 and
                    (self.frame_count // 20) % 2 == 0):
                prompt = "[ I ]  CONTINUE"
                self.draw_big_text(256 - len(prompt) * 2 * scale, 462, prompt, 5, scale)
        elif ph == "title":
            if self.ending_timer > 20:
                title = "THE MAGISTA"
                self.draw_big_text(256 - len(title) * 2 * 4, 210, title, 7, 4)
            if self.ending_timer > 70 and (self.frame_count // 20) % 2 == 0:
                pyxel.text(256 - 40, 470, "[ I ]  Main Menu", 5)

    # ------------------------------------------
    # ZONE 2 GRAPHICS
    # ------------------------------------------
    def draw_zone2_bg(self, cam):
        # Bleached, washed-out sky
        self.cs_gradient(0, 512, 0, 150, 6, 7)
        self.cs_gradient(0, 512, 150, 300, 7, 13)
        # Jagged tooth-like mountains (parallax)
        pxs = cam * 0.3
        spacing = 120
        start = int(pxs // spacing) - 1
        for i in range(start, start + 7):
            sx = int(i * spacing - pxs)
            h = 70 + (abs(i) * 37 % 70)
            pyxel.tri(sx, 300, sx + 60, 300, sx + 30, 300 - h, 13)
            pyxel.tri(sx + 40, 300, sx + 100, 300, sx + 70, 300 - h + 18, 5)
        # Aggressive horizontal wind streaks
        for k in range(26):
            base = (self.frame_count * 9 + k * 79) % 560 - 40
            wy = (k * 53) % 300 + 10
            col = 7 if k % 3 else 13
            pyxel.line(int(base), wy, int(base) + 16, wy, col)

    def draw_zone2(self):
        cam = self.camera_x
        pyxel.cls(0)
        self.draw_zone2_bg(cam)

        # Bleached ribcages (background landmarks)
        for rx in self.zone2_ribs:
            sx = rx - cam
            if -60 < sx < 560:
                pyxel.line(int(sx), 400, int(sx) + 4, 320, 13)
                pyxel.line(int(sx) + 60, 400, int(sx) + 56, 320, 13)
                for ry in range(326, 396, 12):
                    pyxel.line(int(sx) + 4, ry, int(sx) + 56, ry - 4, 13)

        # Cracked white earth, with pits
        pyxel.rect(0, ZGROUND, 512, 512 - ZGROUND, 5)
        for a, b in self.zone2_pits:
            sx0 = a - cam
            if b - cam > 0 and sx0 < 512:
                pyxel.rect(int(sx0), ZGROUND, int(b - a), 512 - ZGROUND, 0)
        for (x0, x1) in self.zone2_ground:
            sx0 = max(0, x0 - cam)
            sx1 = min(512, x1 - cam)
            if sx1 > sx0:
                pyxel.rect(int(sx0), ZGROUND, int(sx1 - sx0), 8, 13)
                pyxel.line(int(sx0), ZGROUND, int(sx1), ZGROUND, 7)

        # Diseased patches (static + tank/boss-created)
        for a, b in self.zone_hazards:
            sx0 = max(0, a - cam)
            sx1 = min(512, b - cam)
            if sx1 > sx0:
                pyxel.rect(int(sx0), ZGROUND, int(sx1 - sx0), 22, 2)
                pyxel.line(int(sx0), ZGROUND, int(sx1), ZGROUND, 14)
                for bx in range(int(a - cam) // 12 * 12, int(b - cam), 12):
                    if 0 <= bx <= 512:
                        yb = ZGROUND + 5 + int(math.sin(self.frame_count * 0.12 + bx) * 3)
                        pyxel.pset(bx + 3, yb, 3)
                        pyxel.pset(bx + 7, yb + 4, 11)

        # Platforms (rusted caravans)
        for (px, py, pw, ph) in self.zone2_platforms:
            sx = px - cam
            if sx + pw > 0 and sx < 512:
                pyxel.rect(int(sx), py, pw, ph, 4)
                pyxel.rect(int(sx), py, pw, 3, 9)
                pyxel.line(int(sx) + 6, py + ph, int(sx) + 6, py + ph + 6, 0)
                pyxel.line(int(sx) + pw - 6, py + ph, int(sx) + pw - 6, py + ph + 6, 0)

        # Interactables
        for o in self.zone2_inter:
            sx = int(o["x"] - cam)
            if -40 <= sx <= 540:
                self._draw_zone_object(o, sx)

        # Arena gate (a wall of rusted spikes behind the player when locked in)
        if self.boss_active:
            sx = int(self.arena_x0 - cam)
            if -20 < sx < 532:
                pyxel.rect(sx - 6, 300, 6, 100, 0)
                for sy in range(308, 400, 12):
                    pyxel.tri(sx, sy, sx, sy + 8, sx + 10, sy + 4, 5)

        for q in self.zone_particles:
            pyxel.pset(int(q[0] - cam), int(q[1]), int(q[5]))

        for e in self.enemies:
            if -60 < e.x - cam < 560:
                e.draw(cam)
                e.draw_hp(cam)

        # Player
        w = WEAPON_STATS[self.player.weapon]
        if self.player_iframes == 0 or (self.player_iframes // 3) % 2 == 0:
            cx = int(self.player.x - cam + self.player.w // 2)
            draw_wanderer(pyxel, cx, int(self.player.y), self.player.direction,
                          self.player.anim_frame, self.player.is_moving,
                          self.player.is_grounded, self.player.has_sword,
                          self.player.is_attacking, self.player.attack_timer,
                          swing_len=self.player.swing_len, atk_reach=w["reach"])

        # HUD
        pyxel.rect(10, 10, 104, 12, 0)
        pyxel.rect(12, 12, max(0, self.player.hp), 8, 8)
        pyxel.text(15, 14, "HP", 7)
        pyxel.rect(10, 24, 104, 8, 0)
        sp_color = 5 if self.player.is_exhausted else 12
        pyxel.rect(12, 25, int(self.player.stamina), 6, sp_color)
        pyxel.text(15, 26, "SP", 7)
        # Disease bar (the infection) — sickly green/purple, grows as you progress
        pyxel.rect(10, 34, 104, 8, 0)
        pyxel.rect(12, 35, int(self.zone2_disease), 6, 3)
        for dx in range(12, 12 + int(self.zone2_disease), 6):
            pyxel.pset(dx, 36, 11)
        pyxel.text(15, 36, "DIS", 0)
        # Weapon indicator
        kn = 10 if self.player.weapon == "knife" else 5
        cl = 10 if self.player.weapon == "cleaver" else 5
        pyxel.text(10, 46, "1:KNIFE", kn)
        pyxel.text(58, 46, "2:CLEAVER", cl)

        # Boss health bar
        if self.boss_active and self.boss:
            pyxel.rect(96, 462, 320, 14, 0)
            pyxel.rectb(96, 462, 320, 14, 7)
            bw = int((max(0, self.boss.hp) / self.boss.max_hp) * 316)
            pyxel.rect(98, 464, bw, 10, 8)
            title = "THE CORRUPTED BEAR"
            pyxel.text(256 - len(title) * 2, 452, title, 7)

        if self.reading_text == "" and self.nearby_obj:
            bounce = int(math.sin(self.frame_count * 0.2) * 3)
            hx = int(self.player.x - cam + 6)
            pyxel.text(hx, int(self.player.y) - 12 + bounce, "[I]", 0)
            pyxel.text(hx - 1, int(self.player.y) - 13 + bounce, "[I]", 10)

        if self.reading_text != "":
            self.draw_reading_box()

    def draw_zone2_done(self):
        pyxel.cls(0)
        self.cs_gradient(0, 512, 0, 320, 1, 0)
        if self.zone_done_timer > 12:
            self.draw_big_text(256 - len("THE OPEN WASTELAND") * 4, 150, "THE OPEN WASTELAND", 7, 2)
            self.draw_big_text(256 - len("- CLEARED -") * 4, 186, "- CLEARED -", 13, 2)
        if self.zone_done_timer > 50:
            sub = "You descend into the Forest of Suffering."
            self.draw_big_text(256 - len(sub) * 2 * 2, 250, sub, 6, 2)
        if self.zone_done_timer > 80:
            pyxel.text(256 - 40, 470, "[ I ]  Continue", 5)

    # ------------------------------------------
    # GRAPHICS (DRAW LOOPS)
    # ------------------------------------------
    def draw(self):
        pyxel.cls(0)
        if self.state == "MENU": self.draw_menu()
        elif self.state == "TUTORIAL": self.draw_tutorial()
        elif self.state == "FADE_OUT": self.draw_fade_out()
        elif self.state == "CUTSCENE": self.draw_cutscene()
        elif self.state == "ZONE1": self.draw_zone1()
        elif self.state == "ZONE1_DONE": self.draw_zone1_done()
        elif self.state == "ZONE2": self.draw_zone2()
        elif self.state == "ZONE2_DONE": self.draw_zone2_done()
        elif self.state == "ENDING": self.draw_ending()

    def draw_tutorial(self):
        pyxel.rect(0, 0, Resolution.WIDTH, 400, 1)
        for by in range(0, 400, 32):
            pyxel.line(0, by, Resolution.WIDTH, by, 0)
            offset = 32 if (by // 32) % 2 == 0 else 0
            for bx in range(offset, Resolution.WIDTH, 64):
                pyxel.line(bx, by, bx, by + 32, 0)
               
        for cx, cy in self.cracks:
            pyxel.line(cx, cy, cx + 8, cy + 12, 0)
            pyxel.line(cx + 8, cy + 12, cx + 4, cy + 24, 0)
               
        self.draw_spiderweb(0, 0, 20, False)
        self.draw_spiderweb(0, 35, 12, False)
        self.draw_spiderweb(Resolution.WIDTH, 0, 25, True)
        self.draw_spiderweb(Resolution.WIDTH, 45, 15, True)
        self.draw_spiderweb(216, 280, 15, False)
        self.draw_spiderweb(296, 280, 15, True)
        self.draw_spiderweb(Resolution.WIDTH // 2, 0, 18, False)

        for tx, ty in self.torches:
            pyxel.rect(tx, ty, 4, 12, 4)
            pyxel.rect(tx - 2, ty - 2, 8, 4, 0)
            f_h = 8 + pyxel.rndi(-2, 2)
            pyxel.tri(tx - 2, ty - 2, tx + 6, ty - 2, tx + 2, ty - 2 - f_h, 9)
            pyxel.tri(tx, ty - 2, tx + 4, ty - 2, tx + 2, ty - 2 - (f_h//2), 10)

        # Ground — layered stone floor with dark underground fill
        pyxel.rect(0, 400, Resolution.WIDTH, 112, 1)   # dark underground
        pyxel.rect(0, 400, Resolution.WIDTH, 8, 5)     # stone surface slab
        pyxel.line(0, 400, Resolution.WIDTH, 400, 6)   # bright top edge highlight
        pyxel.line(0, 401, Resolution.WIDTH, 401, 13)  # secondary highlight
        pyxel.line(0, 407, Resolution.WIDTH, 407, 0)   # shadow beneath slab
        for fx in range(0, Resolution.WIDTH, 48):
            pyxel.line(fx, 402, fx, 406, 0)            # floor tile seams
        for tx, ty, t_type in self.tutorial_ground:
            if t_type == 0: pyxel.line(tx, ty, tx + 4, ty, 5)   # gray rock chips
            else: pyxel.pset(tx, ty, 13)                          # slate pebbles

        # Platform — stone pillar with staggered brick texture
        for px, py, pw, ph in self.tutorial_platforms:
            pyxel.rect(px, py, pw, ph, 5)
            # Horizontal mortar lines
            for by in range(py + 16, py + ph, 16):
                pyxel.line(px + 1, by, px + pw - 2, by, 1)
            # Vertical joints, staggered per row
            for ri, by in enumerate(range(py + 8, py + ph, 16)):
                x_off = 20 if ri % 2 == 0 else 0
                for bx in range(px + x_off + 20, px + pw - 1, 40):
                    pyxel.line(bx, by + 1, bx, min(by + 15, py + ph - 1), 1)
            # Top cap and edge highlights for depth
            pyxel.rect(px, py, pw, 6, 13)
            pyxel.line(px, py, px + pw - 1, py, 6)
            pyxel.line(px + 1, py + 1, px + pw - 2, py + 1, 7)
            pyxel.line(px, py + 6, px, py + ph - 1, 1)
            pyxel.line(px + pw - 1, py + 6, px + pw - 1, py + ph - 1, 13)

        if any(o["id"] == "apple" for o in self.interactables):
            pyxel.circ(256, 276, 4, 8)      
            pyxel.line(256, 272, 258, 270, 3)
           
        if any(o["id"] == "sword" for o in self.interactables):
            pyxel.line(150, 396, 150, 386, 7)
            pyxel.line(148, 386, 152, 386, 0)
            pyxel.line(150, 386, 150, 382, 4)
       
        pyxel.rect(0, 320, 16, 80, 0)
        pyxel.rectb(0, 320, 16, 80, 5)
        pyxel.rect(12, 360, 2, 4, 7)
       
        glow = int(math.sin(self.frame_count * 0.1) * 2)
        pyxel.circ(470, 375, 18 + glow, 1)
        pyxel.circ(470, 375, 12 + glow, 5)
        pyxel.line(470, 400, 470, 370, 7)
        pyxel.line(471, 400, 468, 385, 7)
        pyxel.line(470, 385, 460, 375, 7)
        pyxel.line(470, 380, 480, 370, 7)
        pyxel.circ(460, 375, 4, 11)
        pyxel.circ(480, 370, 5, 11)
        pyxel.circ(470, 365, 6, 3)
        pyxel.circ(470, 365, 3, 11)
       
        pyxel.rect(110, 396, 12, 6, 7)
        pyxel.line(111, 397, 119, 397, 0)
        pyxel.line(111, 399, 118, 399, 0)

        # Draw Dummies with HP Bars!
        for d in self.dummies:
            if d["hp"] > 0:
                shake = pyxel.rndi(-1, 1) if d["hp"] < d["max_hp"] else 0
                dx, dy = d["x"] + shake, d["y"]
                flash = d["hit_flash"] > 0

                # The Dummy Body
                pyxel.rect(dx + 10, 360, 4, 40, 4)
                if d["type"] == "wood":
                    body_col = 7 if flash else 4
                    pyxel.rect(dx, dy, 24, 30, body_col)
                    pyxel.rect(dx + 6, dy - 14, 12, 14, body_col)
                else:
                    body_col = 7 if flash else 13
                    pyxel.rect(dx - 2, dy - 4, 28, 36, body_col)
                    pyxel.rect(dx + 4, dy - 18, 16, 14, body_col)
                    pyxel.line(dx, dy, dx + 6, dy + 10, 5)

                # The Floating Dummy HP Bar (6px tall, white border, red fill)
                bar_x = d["x"] - 2
                bar_y = d["y"] - 36
                pyxel.rect(bar_x, bar_y, 28, 6, 0)
                pyxel.rectb(bar_x, bar_y, 28, 6, 7)
                hp_w = int((d["hp"] / d["max_hp"]) * 26)
                pyxel.rect(bar_x + 1, bar_y + 1, hp_w, 4, 8)
               
        # Draw Hit Particles
        for p in self.hit_particles:
            pyxel.pset(p[0], p[1], p[4])

        # UI Health Bar (Top Left)
        pyxel.rect(10, 10, 104, 12, 0)        
        pyxel.rect(12, 12, self.player.hp, 8, 8)
        pyxel.text(15, 14, "HP", 7)

        # UI Stamina Bar (Below Health)
        pyxel.rect(10, 24, 104, 8, 0)
        sp_color = 5 if self.player.is_exhausted else 12  # gray when exhausted, bright cyan otherwise
        pyxel.rect(12, 25, int(self.player.stamina), 6, sp_color)
        pyxel.text(15, 26, "SP", 7)

        self.player.draw()

        # Dynamic Overlays
        if self.reading_text == "":
            if self.nearby_obj:
                bounce = int(math.sin(self.frame_count * 0.2) * 3)
                pyxel.text(self.player.x + 6, self.player.y - 12 + bounce, "[I]", 0)
                pyxel.text(self.player.x + 5, self.player.y - 13 + bounce, "[I]", 10)
               
            if self.player.x > 330 and self.player.has_sword and any(d["hp"] > 0 for d in self.dummies):
                pyxel.text(371, 269, "[Press SPACE to Attack]", 0)
                pyxel.text(370, 268, "[Press SPACE to Attack]", 8)
        else:
            scale = 2  
            lines = self.reading_text.split('\n')
            max_line_len = max(len(l) for l in lines)
           
            box_width = (max_line_len * 4 * scale) + 20
            box_height = (len(lines) * 8 * scale) + 20
           
            box_x = (Resolution.WIDTH - box_width) // 2
            box_y = 100
           
            pyxel.rect(box_x, box_y, box_width, box_height + 15, 0)  
            pyxel.rectb(box_x, box_y, box_width, box_height + 15, 7)
           
            self.draw_big_text(box_x + 10, box_y + 10, self.reading_text, 7, scale)
            pyxel.text(box_x + 10, box_y + box_height, "Press I to continue", 13)

    def draw_fade_out(self):
        # Draw the tutorial base beneath the fade
        self.draw_tutorial()
       
        # Calculate fade progression (0.0 to 1.0)
        progress = min(1.0, self.fade_timer / 60.0)
        # Block size goes from 0 to 16
        block_size = int(progress * 16)
       
        if block_size > 0:
            for y in range(0, Resolution.HEIGHT, 16):
                for x in range(0, Resolution.WIDTH, 16):
                    # Grow a black square from the center of every 16x16 chunk
                    pyxel.rect(x + 8 - block_size, y + 8 - block_size, block_size*2, block_size*2, 0)

    # ==========================================
    # CUTSCENE — cinematic intro
    # ==========================================
    # Visible art window sits between the two letterbox bars.
    CS_TOP = 58          # bottom of top letterbox bar
    CS_BOT = 366         # top of bottom letterbox / text bar

    def cs_gradient(self, x, w, y0, y1, c_top, c_bot, steps=12):
        """Dithered vertical gradient from c_top to c_bot over a region."""
        h = y1 - y0
        if h <= 0: return
        pyxel.rect(x, y0, w, h, c_top)
        band = h / steps
        for i in range(steps):
            pyxel.dither((i + 0.5) / steps)
            pyxel.rect(x, int(y0 + i * band), w, int(band) + 1, c_bot)
        pyxel.dither(1.0)

    def cs_glow(self, cx, cy, r, col, core=None):
        """Soft dithered halo, optional solid core."""
        pyxel.dither(0.30); pyxel.circ(cx, cy, r, col)
        pyxel.dither(0.55); pyxel.circ(cx, cy, int(r * 0.68), col)
        pyxel.dither(1.0)
        if core is not None:
            pyxel.circ(cx, cy, max(1, int(r * 0.40)), core)

    def cs_tree(self, bx, gy, h, col, rim=None):
        """Gnarled dead-tree silhouette with optional rim light on the right."""
        pyxel.rect(bx - 2, gy - h, 4, h, col)
        if rim is not None:
            pyxel.rect(bx + 1, gy - h, 1, h, rim)
        # (height fraction, angle, length)
        nodes = [(0.42, -2.35, 0.42), (0.50, -0.85, 0.38), (0.60, -2.65, 0.30),
                 (0.68, -0.55, 0.30), (0.78, -2.25, 0.24), (0.85, -0.80, 0.22)]
        for frac, ang, lf in nodes:
            ln = h * lf
            sx, sy = bx, gy - int(h * frac)
            ex = sx + int(math.cos(ang) * ln)
            ey = sy + int(math.sin(ang) * ln)
            pyxel.line(sx, sy, ex, ey, col)
            tx = ex + int(math.cos(ang - 0.5) * ln * 0.55)
            ty = ey + int(math.sin(ang - 0.5) * ln * 0.55)
            pyxel.line(ex, ey, tx, ty, col)
            if rim is not None:
                pyxel.line(sx + 1, sy, ex + 1, ey, rim)

    # ------------------------------------------
    # SLIDE 1 — THE MYTH
    # ------------------------------------------
    def _cs_myth(self):
        # Layered, dithered sky: dark void -> slate -> pale sick horizon
        self.cs_gradient(0, 512, self.CS_TOP, 150, 1, 5)
        self.cs_gradient(0, 512, 150, 232, 5, 13)
        self.cs_gradient(0, 512, 232, 270, 13, 6)
        # Cold, heatless sun low on the horizon
        self.cs_glow(372, 150, 50, 7, core=6)
        # Distant ridge lines for depth
        pyxel.dither(1.0)
        for rx in range(0, 512, 4):
            pyxel.pset(rx, 262 + int(math.sin(rx * 0.05) * 5), 5)
            pyxel.pset(rx, 266 + int(math.sin(rx * 0.03 + 2) * 4), 1)
        # Ground falling into shadow
        self.cs_gradient(0, 512, 268, self.CS_BOT, 1, 0)
        # Far, faint trees (parallax)
        self.cs_tree(120, 268, 46, 1)
        self.cs_tree(415, 268, 52, 1)
        # Foreground gnarled trees, rim-lit by the pale sun
        self.cs_tree(70, 300, 150, 0, rim=5)
        self.cs_tree(250, 296, 120, 0, rim=5)
        self.cs_tree(452, 304, 140, 0, rim=5)

    # ------------------------------------------
    # SLIDE 2 — THE CAUSE
    # ------------------------------------------
    def _cs_cause(self):
        # Ruined dark interior
        self.cs_gradient(0, 512, self.CS_TOP, 300, 1, 0)
        # Sickly disease-haze behind the plant — irregular, not a clean disk
        for hx, hy, hr, hd in [(248, 210, 70, 0.22), (266, 178, 52, 0.28),
                               (250, 250, 60, 0.18), (238, 150, 42, 0.30)]:
            pyxel.dither(hd)
            pyxel.elli(hx - hr, hy - int(hr * 0.8), hr * 2, int(hr * 1.6), 3)
        pyxel.dither(1.0)
        # Cracked back wall (faint)
        for cx0, cy0 in [(70, 95), (330, 120), (150, 75), (440, 150)]:
            x, y = cx0, cy0
            for _ in range(5):
                nx, ny = x + pyxel.rndi(-7, 7), y + pyxel.rndi(7, 15)
                pyxel.line(x, y, nx, ny, 0)
                x, y = nx, ny
        # Murky diseased water pooling across the floor
        self.cs_gradient(0, 512, 300, self.CS_BOT, 3, 1)
        pyxel.line(0, 300, 512, 300, 11)
        for wx in range(0, 512, 22):
            ry = 306 + int(math.sin(self.frame_count * 0.05 + wx) * 1.5)
            pyxel.line(wx, ry, wx + 11, ry, 11)
        # The blackened, crumbling plant
        self._cs_plant(252, 300)
        # Faint reflection in the water
        pyxel.dither(0.35)
        pyxel.rect(250, 300, 5, 44, 0)
        pyxel.dither(1.0)
        # Crumbling ash drifting down, animated
        for i in range(16):
            ax = 252 + int(math.sin(i * 2.1) * 70)
            ay = 130 + ((self.frame_count * 2 + i * 31) % 175)
            pyxel.pset(ax, ay, 5 if i % 3 else 3)

    def _cs_plant(self, bx, base_y):
        """A gnarled, asymmetric, drooping dead plant with a swollen rotten base."""
        top = base_y - 165
        # Leaning tapered trunk + bulbous rot at the base
        pyxel.tri(bx - 7, base_y, bx + 8, base_y, bx - 1, top, 0)
        pyxel.rect(bx - 9, base_y - 26, 18, 26, 0)
        pyxel.elli(bx - 13, base_y - 16, 26, 18, 0)
        # Asymmetric withered limbs (more, heavier on the left), each drooping
        limbs = [(top + 18, -1, 46, 22, 3), (top + 30, 1, 34, 16, 2),
                 (top + 52, -1, 56, 30, 3), (top + 62, 1, 30, 20, 2),
                 (top + 86, -1, 42, 26, 2), (top + 98, 1, 46, 32, 3),
                 (top + 122, -1, 32, 24, 2)]
        for sy, d, ln, droop, twigs in limbs:
            mx = bx + d * (ln // 2)
            ex = bx + d * ln
            ey = sy + droop // 2
            pyxel.line(bx, sy, mx, sy - 4, 0)        # branch rises then
            pyxel.line(mx, sy - 4, ex, ey, 0)        # reaches out
            pyxel.line(ex, ey, ex + d * 8, ey + droop, 0)   # then droops, dead
            for k in range(twigs):
                txx = bx + d * int(ln * (0.4 + 0.2 * k))
                tyy = sy - 2 + k * 3
                pyxel.line(txx, tyy, txx + d * 10, tyy - 8 + k * 5, 0)
        # Broken, splintered crown
        pyxel.line(bx - 1, top, bx - 14, top - 26, 0)
        pyxel.line(bx, top, bx + 16, top - 22, 0)
        pyxel.line(bx, top, bx + 3, top - 30, 0)
        pyxel.pset(bx - 16, top - 28, 0)
        pyxel.pset(bx + 18, top - 24, 0)
        # Sickly rim light catching the left edge
        pyxel.line(bx - 5, base_y - 26, bx - 2, top, 11)

    # ------------------------------------------
    # SLIDE 3 — THE SUFFERING
    # ------------------------------------------
    def _cs_person(self, fx, base_y, h, f):
        """A seated figure hugging its knees, in profile, facing the fire (f=+1 right).
        Backlit silhouette with a cold rim down the back and warm firelight in front."""
        # Body silhouette: hip -> hunched back -> shoulder -> over the knees -> shin
        hip = (fx - f * 8, base_y)
        bak = (fx - f * 7, base_y - int(h * 0.46))
        sho = (fx - f * 2, base_y - int(h * 0.66))
        kne = (fx + f * 13, base_y - int(h * 0.34))
        shn = (fx + f * 9, base_y)
        pyxel.tri(*hip, *bak, *sho, 0)
        pyxel.tri(*hip, *sho, *kne, 0)
        pyxel.tri(*hip, *kne, *shn, 0)
        # Bowed head on a short neck, tipped toward the fire
        hr = max(3, h // 7)
        hx, hy = fx + f * 1, base_y - int(h * 0.75)
        pyxel.circ(hx, hy, hr, 0)
        # Arm resting over the knees, reaching to the warmth
        pyxel.line(sho[0], sho[1] + 2, fx + f * 16, base_y - int(h * 0.30), 0)
        # Cold moonlight rim down the hunched back
        pyxel.line(bak[0] - f, bak[1], sho[0] - f, sho[1], 5)
        pyxel.line(bak[0] - f, bak[1], hip[0] - f, hip[1] - 3, 5)
        pyxel.pset(hx - f * hr, hy, 5)
        # Warm firelight catching the face and knees
        pyxel.pset(hx + f * (hr - 1), hy + 1, 9)
        pyxel.pset(kne[0], kne[1], 4)

    def _cs_suffering(self):
        ol, oR = 228, 286                       # far opening (the street outside)
        ot, ob = 172, 298                       # opening top / bottom
        midx = (ol + oR) // 2
        pyxel.rect(0, 0, 512, 512, 0)           # ceiling / void = black
        # The ruined street at the end of the alley: pale sky, skyline, a moon
        self.cs_gradient(ol, oR - ol, ot, ob, 6, 1)
        pyxel.circ(midx + 9, ot + 20, 6, 7)                      # distant moon
        pyxel.rect(ol + 3, ob - 30, 9, 30, 1)                   # far buildings
        pyxel.rect(ol + 15, ob - 44, 11, 44, 1)
        pyxel.rect(oR - 14, ob - 34, 10, 34, 1)
        # Side walls — solid receding planes
        pyxel.tri(0, self.CS_TOP, ol, ot, ol, ob, 1)
        pyxel.tri(0, self.CS_TOP, ol, ob, 0, self.CS_BOT, 1)
        pyxel.tri(512, self.CS_TOP, oR, ot, oR, ob, 1)
        pyxel.tri(512, self.CS_TOP, oR, ob, 512, self.CS_BOT, 1)
        # Floor — navy trapezoid (lighter than the black ceiling, so ground reads)
        pyxel.tri(0, self.CS_BOT, ol, ob, oR, ob, 1)
        pyxel.tri(0, self.CS_BOT, oR, ob, 512, self.CS_BOT, 1)
        # Moonlight spilling from the opening across the floor
        pyxel.dither(0.5)
        pyxel.tri(ol, ob, oR, ob, midx + 46, self.CS_BOT, 6)
        pyxel.tri(ol, ob, midx + 46, self.CS_BOT, midx - 46, self.CS_BOT, 6)
        pyxel.dither(1.0)
        # Brick grid on the walls: horizontal courses + vertical joints (recede to opening)
        for fr in (0.26, 0.5, 0.74, 0.92):
            yn = self.CS_TOP + (self.CS_BOT - self.CS_TOP) * fr
            yf = ot + (ob - ot) * fr
            pyxel.line(0, yn, ol, yf, 0)
            pyxel.line(512, yn, oR, yf, 0)
        for g in (0.34, 0.6, 0.82):
            lx = ol * g
            rx = 512 - (512 - oR) * g
            tp = self.CS_TOP + (ot - self.CS_TOP) * g
            bt = self.CS_BOT + (ob - self.CS_BOT) * g
            pyxel.line(lx, tp, lx, bt, 0)
            pyxel.line(rx, tp, rx, bt, 0)
        # Wall-to-floor skirting, faintly lit
        pyxel.line(0, self.CS_BOT, ol, ob, 5)
        pyxel.line(512, self.CS_BOT, oR, ob, 5)
        # The brazier — the only warmth left
        bx, by = 256, 348
        self.cs_glow(bx, by - 6, 40, 9, core=10)
        pyxel.rect(bx - 11, by, 22, 13, 5)
        pyxel.rect(bx - 13, by + 11, 26, 4, 1)
        for i in range(10):                     # rising embers
            ex = bx + int(math.sin(self.frame_count * 0.1 + i) * 12)
            ey = by - 8 - ((self.frame_count * 2 + i * 17) % 70)
            pyxel.pset(ex, ey, 10 if i % 2 else 9)
        # Huddled people around the fire (closer = larger), each facing the warmth
        for fx, base_y, fh, face in [(196, 360, 60, 1), (232, 366, 46, 1),
                                     (312, 360, 62, -1), (340, 366, 44, -1)]:
            self._cs_person(fx, base_y, fh, face)

    # ------------------------------------------
    # SLIDE 4 — THE DEPARTURE
    # ------------------------------------------
    def _cs_departure(self):
        # Washed-out, fog-choked world
        self.cs_gradient(0, 512, self.CS_TOP, 230, 7, 6)
        self.cs_gradient(0, 512, 230, 300, 6, 13)
        self.cs_gradient(0, 512, 300, self.CS_BOT, 13, 5)
        # Drifting fog banks — many soft puffs so they read as mist, not discs
        for i, (yb, col) in enumerate([(150, 7), (205, 6), (255, 7), (300, 13)]):
            base = (self.frame_count * (0.4 + i * 0.15)) % 600 - 80
            pyxel.dither(0.32 - i * 0.05)
            for k in range(6):
                px0 = int(base + k * 95)
                pyxel.elli(px0, yb + (k % 2) * 10 - 4, 120, 22, col)
        pyxel.dither(1.0)
        # Father, mid-distance, dissolving into the fog (dithered = translucent)
        fx, fy, fh = 300, 286, 44
        pyxel.dither(0.65)
        pyxel.rect(fx - 3, fy - fh, 7, fh, 0)
        pyxel.circ(fx, fy - fh - 6, 6, 0)
        pyxel.line(fx - 10, fy - fh, fx + 10, fy - fh, 0)            # hat brim
        pyxel.tri(fx - 3, fy - fh + 5, fx - 16, fy, fx + 8, fy, 0)   # cloak sweep
        pyxel.line(fx, fy, fx - 6, fy + 16, 0)
        pyxel.line(fx + 1, fy, fx + 8, fy + 14, 0)
        pyxel.dither(1.0)
        # Footprints receding toward him
        for step in range(7):
            sx = 250 + step * 16
            sy = 320 - step * 4 + (step % 2) * 4
            pyxel.dither(0.7 - step * 0.07)
            pyxel.elli(sx, sy, 7, 4, 5)
        pyxel.dither(1.0)
        # The child — large, dark, foreground; the eyes we watch through
        cx, cy, ch = 96, self.CS_BOT, 110
        pyxel.tri(cx - 26, cy, cx + 26, cy, cx, cy - ch + 30, 0)     # cloaked body
        pyxel.rect(cx - 22, cy - ch + 50, 44, ch - 50, 0)
        pyxel.circ(cx, cy - ch + 26, 16, 0)                          # head
        # Reaching arm — from the RIGHT SHOULDER (below the head), bent elbow, hand
        shx, shy = cx + 14, cy - ch + 52                             # right shoulder
        elx, ely = cx + 32, cy - ch + 64                             # elbow (drops out)
        hnx, hny = cx + 54, cy - ch + 50                             # hand (lifts toward father)
        pyxel.tri(shx, shy - 4, shx, shy + 4, elx, ely + 1, 0)       # upper arm
        pyxel.tri(shx, shy + 4, elx, ely + 1, elx, ely - 3, 0)
        pyxel.tri(elx, ely - 3, elx, ely + 2, hnx, hny + 1, 0)       # forearm (tapering)
        pyxel.tri(elx, ely - 3, hnx, hny + 1, hnx, hny - 2, 0)
        pyxel.circ(hnx, hny, 3, 0)                                   # hand
        pyxel.line(hnx, hny - 3, hnx + 4, hny - 6, 0)                # reaching fingers
        pyxel.line(hnx + 1, hny - 1, hnx + 6, hny - 3, 0)
        # Cold rim light tracing the child's silhouette
        pyxel.line(cx + 20, cy - ch + 55, cx + 24, cy, 5)
        pyxel.line(cx + 12, cy - ch + 30, cx + 16, cy - ch + 44, 5)

    # ------------------------------------------
    # SLIDE 5 — THE DECISION
    # ------------------------------------------
    def cs_draw_wanderer(self, dest_cx, dest_bottom, scale, backpack=False,
                         moving=False, face=1):
        """Render the shared Wanderer sprite into an image bank, then upscale it
        pixel-for-pixel to the screen so it matches the tutorial exactly."""
        SX, SY = 36, 6                          # sprite origin within the bank
        BG = 11                                 # sentinel "transparent" colour
        bank = pyxel.images[2]
        bank.rect(0, 0, 80, 64, BG)
        draw_wanderer(bank, SX, SY, face, self.frame_count, moving, True,
                      False, False, 0, backpack=backpack)
        foot = SY + 48
        for px in range(80):
            ox = dest_cx + (px - SX) * scale
            for py in range(60):
                c = bank.pget(px, py)
                if c != BG:
                    pyxel.rect(ox, dest_bottom + (py - foot) * scale, scale, scale, c)

    def _cs_decision(self):
        # Side-scrolling treadmill: the Wanderer walks in place while the world
        # slides left past him, so his stride matches the road's direction.
        ground_y = 304
        # Pale overcast sky
        self.cs_gradient(0, 512, self.CS_TOP, 180, 12, 6)
        self.cs_gradient(0, 512, 180, ground_y, 6, 13)
        # Clouds drifting slowly left (far parallax), looping seamlessly
        sc = self.frame_count * 0.4
        Wc = 620
        for wx, cy0, cw in [(60, 92, 150), (250, 128, 200), (450, 104, 120)]:
            base = (wx - sc) % Wc
            for x in (base, base - Wc):
                pyxel.dither(0.45)
                pyxel.elli(int(x), cy0, cw, 22, 7)
                pyxel.elli(int(x) + cw // 2, cy0 + 5, cw - 50, 16, 7)
        pyxel.dither(1.0)
        # Flat dusty ground (side view)
        self.cs_gradient(0, 512, ground_y, self.CS_BOT, 13, 4)
        pyxel.line(0, ground_y, 512, ground_y, 5)
        # Ground details scrolling left fast — the main motion cue, loops cleanly
        sg = self.frame_count * 2.4
        W = 768
        props = [(20, 's'), (78, 't'), (140, 'c'), (210, 'p'), (270, 's'),
                 (330, 't'), (400, 'c'), (470, 's'), (540, 'p'), (610, 't'),
                 (680, 'c'), (730, 's')]
        for wx, kind in props:
            base = (wx - sg) % W
            for x in (int(base), int(base - W)):
                if x < -20 or x > 520:
                    continue
                gy = ground_y + 8 + (wx % 5) * 3
                if kind == 's':                       # stone
                    pyxel.elli(x, gy, 7, 4, 5)
                    pyxel.pset(x + 1, gy, 13)
                elif kind == 't':                     # dead grass tuft
                    pyxel.line(x, gy + 3, x - 1, gy - 4, 5)
                    pyxel.line(x + 2, gy + 3, x + 2, gy - 5, 5)
                    pyxel.line(x + 4, gy + 3, x + 5, gy - 3, 5)
                elif kind == 'c':                     # crack
                    pyxel.line(x, gy, x + 6, gy + 3, 0)
                    pyxel.line(x + 6, gy + 3, x + 4, gy + 7, 0)
                else:                                 # pebbles
                    pyxel.pset(x, gy, 5); pyxel.pset(x + 3, gy + 1, 5)
                    pyxel.pset(x + 6, gy, 13)
        # Soft shadow grounding the Wanderer
        pyxel.dither(0.4)
        pyxel.elli(236, ground_y - 2, 40, 8, 0)
        pyxel.dither(1.0)
        # The Wanderer walking in place — the world scrolls past him
        self.cs_draw_wanderer(256, ground_y, 3, backpack=True, moving=True, face=1)

    # ------------------------------------------
    # CUTSCENE FRAME & TEXT
    # ------------------------------------------
    def cs_typewriter(self, lines, t, start, cps):
        """Reveal centred narrative lines progressively, upscaled. Returns True when done."""
        budget = max(0, (t - start)) * cps
        scale = 2
        y = self.CS_BOT + 16
        done = True
        for line in lines:
            shown = int(min(len(line), budget)) if budget > 0 else 0
            if shown < len(line): done = False
            if shown > 0:
                lx = 256 - len(line) * 2 * scale   # centre on full line width (no jitter)
                self.draw_big_text(lx, y, line[:shown], 7, scale)
            budget -= len(line) + 6
            y += 22
        return done and (t - start) * cps > 0

    def draw_cutscene(self):
        pyxel.cls(0)
        t = self.cutscene_timer
        s = self.cutscene_slide

        slides = [
            ("THE MYTH", [
                "It has been a lifetime since anyone",
                "saw the colour green. Forests, sunlight,",
                "clean water... only legends now.",
            ]),
            ("THE CAUSE", [
                "It was not a natural decay. A relentless,",
                "incurable disease poisoned the soil",
                "and choked the life from our world.",
            ]),
            ("THE SUFFERING", [
                "With the land rotting beneath us, we",
                "slowly crumbled, awaiting the end.",
                "Someone had to stop it.",
            ]),
            ("THE DEPARTURE", [
                "That someone was my father. Fifteen years",
                "ago he left to find the magista -- a",
                "mythical cure. He never returned.",
            ]),
            ("THE DECISION", [
                "Today is the day I follow his path.",
                "I will find him, and I will finish",
                "what he started.",
            ]),
        ]

        # Draw the slide artwork
        [self._cs_myth, self._cs_cause, self._cs_suffering,
         self._cs_departure, self._cs_decision][s]()

        # Drifting dust over the scene for atmosphere
        for p in self.dust_particles:
            if self.CS_TOP < p[1] < self.CS_BOT:
                shade = 13 if p[2] > 3 else 5
                pyxel.pset(p[0], p[1], shade)

        # Soft side vignette to focus the eye
        pyxel.dither(0.5)
        pyxel.rect(0, self.CS_TOP, 40, self.CS_BOT - self.CS_TOP, 0)
        pyxel.rect(472, self.CS_TOP, 40, self.CS_BOT - self.CS_TOP, 0)
        pyxel.dither(1.0)

        # Letterbox bars
        pyxel.rect(0, 0, 512, self.CS_TOP, 0)
        pyxel.rect(0, self.CS_BOT, 512, 512 - self.CS_BOT, 0)
        pyxel.line(0, self.CS_TOP, 512, self.CS_TOP, 1)
        pyxel.line(0, self.CS_BOT, 512, self.CS_BOT, 1)

        title, lines = slides[s]

        # Slide number + title in the top bar (title upscaled for legibility)
        pyxel.text(24, 24, "%d / 5" % (s + 1), 5)
        if t > 6:
            self.draw_big_text(256 - len(title) * 2 * 2, 16, title, 7, 2)

        # Narrative typewriter in the bottom bar
        done = self.cs_typewriter(lines, t, 18, 0.9)

        # Blinking continue prompt once the text has fully revealed (upscaled)
        if done and (t // 20) % 2 == 0:
            prompt = "[ I ]  CONTINUE"
            self.draw_big_text(256 - len(prompt) * 2 * 2, 474, prompt, 6, 2)

        # Fade in from black at the start of each slide
        if t < 16:
            pyxel.dither(1.0 - t / 16.0)
            pyxel.rect(0, 0, 512, 512, 0)
            pyxel.dither(1.0)

    def draw_background(self):
        pyxel.circ(400, 120, 40, 5)
        pyxel.circ(390, 110, 8, 1)
        pyxel.circ(416, 136, 6, 1)
        pyxel.circ(384, 130, 12, 1)
       
        for b in self.buildings:
            pyxel.rect(b[0], b[1], b[2], 400 - b[1], 1)
        for w in self.windows:
            if w[0] == "door" or w[0] == "dark": pyxel.rect(w[1], w[2], w[3], w[4], 0)
            elif w[0] == "lit": pyxel.rect(w[1], w[2], w[3], w[4], 9)
           
        pyxel.rect(0, 400, Resolution.WIDTH, 112, 1)
        for g_type, gx, gy, w, hlt in self.ground_details:
            if g_type == 0:
                pyxel.line(gx, gy, gx + w, gy, 0)
                pyxel.line(gx + 1, gy - 1, gx + w - 1, gy - 1, 5)
                pyxel.line(gx + 2, gy - 2, gx + w - 2, gy - 2, 5)
                if w > 8: pyxel.line(gx + 3, gy - 3, gx + w - 3, gy - 3, 5)
                if hlt == 1: pyxel.pset(gx + (w // 2), gy - 2, 4)
            else:
                pyxel.line(gx, gy, gx - 2, gy - 4, 4)
                pyxel.line(gx, gy, gx + 1, gy - 5, 5)
                pyxel.line(gx, gy, gx + 3, gy - 3, 4)
                pyxel.pset(gx + 1, gy - 6, 9)
               
        for line in self.tree_lines:
            thickness = int(line[4] / 1.5)
            for ox in range(-thickness, thickness + 1):
                for oy in range(-thickness, thickness + 1):
                    pyxel.line(line[0] + ox, line[1] + oy, line[2] + ox, line[3] + oy, 0)
            pyxel.line(line[0], line[1], line[2], line[3], 5)
        for p in self.dust_particles:
            pyxel.pset(p[0], p[1], 13)

    def draw_menu(self):
        self.draw_background()
        pyxel.text(230, 100, "THE MAGISTA", 7)
        for i, option in enumerate(self.menu_options):
            color = 10 if i == self.current_selection else 7
            if i == self.current_selection: pyxel.text(185, 200 + (i * 25), ">", 10)
            pyxel.text(205, 200 + (i * 25), option, color)

MagistaGame()