use wasm_bindgen::prelude::*;

/// Детерминированный быстрый генератор псевдослучайных чисел (xorshift32).
/// Используется вместо `rand`, чтобы не тащить в WASM лишние зависимости и
/// не платить за криптографическую случайность — здесь важна только скорость,
/// так как `next_u32()` вызывается тысячи раз за кадр. Состояние не может
/// быть равно 0 (иначе xorshift зациклится на нуле), поэтому `new()` подменяет
/// нулевой seed на 1.
struct FastRng {
    state: u32,
}

impl FastRng {
    fn new(seed: u32) -> Self {
        Self {
            state: if seed == 0 { 1 } else { seed },
        }
    }

    #[inline(always)]
    fn next_u32(&mut self) -> u32 {
        let mut x = self.state;
        x ^= x << 13;
        x ^= x >> 17;
        x ^= x << 5;
        self.state = x;
        x
    }

    #[inline(always)]
    fn gen_f32(&mut self) -> f32 {
        (self.next_u32() >> 8) as f32 / 16777216.0
    }

    #[inline(always)]
    fn gen_range(&mut self, min: f32, max: f32) -> f32 {
        min + self.gen_f32() * (max - min)
    }

    #[inline(always)]
    fn gen_bool(&mut self, chance: f32) -> bool {
        self.gen_f32() < chance
    }

    #[inline(always)]
    fn gen_usize(&mut self, max: usize) -> usize {
        (self.next_u32() as usize) % max
    }
}

/// Одна "капля" символов матричного дождя. Живёт в своей колонке (`base_x`)
/// и падает вниз по `y` в логических единицах (не в пикселях — реальная
/// экранная позиция считается как `y * font_size * parallax_factor`, см. `tick()`).
/// `offset_x`/`vel_x` — горизонтальное смещение и скорость смещения от точки
/// покоя, которые дают капле "оттолкнуться" от мыши/свайпа/шоквейва и потом
/// демпфированно вернуться на место (пружинная модель, см. блок с `-0.12 * offset_x`).
/// `layer` задаёт параллакс-слой (0 = far, 1 = mid, 2 = near — но за пределами
/// mobile-режима, где слой far отключён совсем).
struct Drop {
    base_x: f32,
    y: f32,
    offset_x: f32,
    vel_x: f32,
    speed: f32,
    layer: u8,
    glitch_timer: f32,
}

/// Непрозрачный цветной прямоугольник-осколок для эффекта глитча (VHS-подобный
/// сбой изображения). Живёт `life` кадров и рендерится в отдельном шейдере
/// (`vsGlitch`/`fsGlitch`), поэтому не связан со шрифтовым атласом капель.
struct GlitchBlock {
    x: f32,
    y: f32,
    w: f32,
    h: f32,
    life: f32,
    color_type: f32,
}

impl GlitchBlock {
    fn new(x: f32, y: f32, rng: &mut FastRng, is_mobile: bool) -> Self {
        // Сильно уменьшили масштаб глитч-блоков
        let scale = if is_mobile { 0.2 } else { 0.4 };

        let w = rng.gen_range(10.0 * scale, 120.0 * scale);
        let h = rng.gen_range(2.0 * scale, 25.0 * scale);
        let choice = rng.gen_usize(100);

        let color_type = match choice {
            0..=15 => 0.0,
            16..=50 => 1.0,
            51..=75 => 2.0,
            76..=95 => 3.0,
            _ => 4.0,
        };

        // Радиус разброса осколков сильно урезан
        let scatter = 40.0 * scale;

        Self {
            x: x - w / 2.0 + rng.gen_range(-scatter, scatter),
            y: y - h / 2.0 + rng.gen_range(-scatter, scatter),
            w,
            h,
            life: rng.gen_range(5.0, 15.0),
            color_type,
        }
    }
}

/// Кольцевая ударная волна от клика/тапа/EMP-импульса. Пул из 5 штук
/// переиспользуется (см. `shockwaves` в `MatrixEngine::new`) вместо
/// динамического создания/удаления — `trigger_shockwave` просто ищет
/// первый неактивный слот (`!s.active`). `radius` растёт каждый тик,
/// `life` тикает вниз до 0, после чего волна деактивируется.
struct Shockwave {
    x: f32,
    y: f32,
    radius: f32,
    life: f32,
    max_life: f32,
    active: bool,
}

/// Декоративный "осыпающийся" символ, который на короткое время появляется
/// рядом с курсором при взаимодействии (эффект битого стекла/осыпающихся
/// пикселей). Не связан с основными каплями дождя и рендерится отдельным
/// буфером (`glass_buffer`).
struct GlassSymbol {
    x: f32,
    y: f32,
    char_idx: usize,
    life: f32,
    max_life: f32,
    velocity_y: f32,
}

// Количество f32-полей на одну каплю в `instance_buffer`: x, y, char_idx,
// atlas_row, scale. Должно совпадать со stride, который читает WebGL-сторона
// в `matrix_worker.js` (`strideBytes = 5 * 4`) — при изменении набора полей
// нужно синхронно поправить оба места.
const STRIDE: usize = 5;

/// Главный движок симуляции матричного дождя и "глаз".
///
/// # Протокол памяти WASM <-> JS
/// Все производимые за кадр данные (позиции капель, глитч-блоки, "стеклянные"
/// символы, состояние глаз) пишутся в плоские `Vec<f32>` буферы
/// (`instance_buffer`, `glitch_buffer`, `glass_buffer`, `eye_buffer`) с
/// фиксированным шагом (stride) полей на один элемент. wasm-bindgen НЕ
/// возвращает эти `Vec<f32>` напрямую в JS (это потребовало бы копирования
/// всего буфера в JS-кучу на каждый кадр), вместо этого экспортируются пары
/// методов `*_ptr()`/`*_len()`, возвращающие сырой указатель в линейную
/// память WASM и длину в элементах. JS-сторона (`matrix_worker.js`) оборачивает
/// эту память в `Float32Array` напрямую поверх `wasm.memory.buffer` без
/// копирования и сразу заливает во WebGL-буфер через `bufferSubData`.
/// Это горячий путь (60 раз в секунду на тысячах капель), поэтому
/// избежание лишних аллокаций/копий здесь критично для производительности.
///
/// Важно: указатель, возвращённый `render_ptr()` и т.п., валиден только до
/// следующего вызова любого `&mut self` метода движка (например, `tick()`),
/// так как рост `Vec` может переместить буфер в памяти — JS обязан прочитать
/// данные сразу после `tick()`, не сохраняя указатель между кадрами.
#[wasm_bindgen]
pub struct MatrixEngine {
    width: u32,
    height: u32,
    font_size: u32,
    charset_len: usize,
    rng: FastRng,

    drops: Vec<Drop>,
    shockwaves: Vec<Shockwave>,
    glass_symbols: Vec<GlassSymbol>,
    glitch_blocks: Vec<GlitchBlock>,

    // Буфер рендера капель: [x, y, char_idx, atlas_row, scale] на каждую
    // видимую каплю (Stride = STRIDE = 5). Переиспользуется между кадрами —
    // не пересоздаётся, а перезаписывается по индексу, чтобы не аллоцировать.
    instance_buffer: Vec<f32>,
    visible_drops: usize, // Сколько капель реально записано в instance_buffer в этом кадре (после frustum culling)

    // Буфер глитч-блоков: [x, y, w, h, color_type] на блок (Stride = 5).
    glitch_buffer: Vec<f32>,
    // Буфер "стеклянных" символов: [x, y, char_idx, alpha] на символ (Stride = 4).
    glass_buffer: Vec<f32>,
    // Буфер состояния глаз: [cx, cy, gaze_x, gaze_y, 0.0, eye_closedness].
    // Всегда ровно 6 элементов (один взгляд на пару глаз), 5-й слот зарезервирован
    // (сейчас всегда 0.0) — JS читает его по фиксированным индексам, см. eyeData[] в matrix_worker.js.
    eye_buffer: Vec<f32>,

    mouse_x: f32,
    mouse_y: f32,
    is_drawing: bool,
    is_mobile: bool,
    is_vpn_on: bool,
    tick_count: u32,
    // EMP-импульс (триггерится вместе со шоквейвом в trigger_glitch): временно
    // "стирает" капли рядом с точкой удара (см. is_void/is_emp_ring/is_scrambled в tick()).
    emp_timer: f32,
    emp_x: f32,
    emp_y: f32,
    // Горизонтальная "трещина" экрана — редкий декоративный сбой не на мобильных.
    tear_y: f32,
    tear_timer: f32,

    // --- Состояние глаз: моргание и взгляд ---
    blink_timer: f32,       // >0, пока веки закрыты в рамках обычного моргания
    last_interaction: f32,  // Кадров с последнего движения мыши/тача — используется и для отсчёта моргания, и для "блуждающего" взгляда на мобильных
    gaze_target_x: f32,     // Куда должен смотреть взгляд (обычно — позиция курсора/тача)
    gaze_target_y: f32,
    gaze_x: f32,            // Текущее сглаженное смещение зрачка (интерполируется к цели с инерцией)
    gaze_y: f32,
    eye_closedness: f32,     // 0 = открыты, 1 = закрыты; демпфированная пружина к target_closedness
    eye_closedness_vel: f32, // "скорость" пружины eye_closedness
    eye_anchor_y: f32,       // Y-координата центра глаз, приходит из DOM (см. set_eye_anchor / #eye-anchor в React)
    mobile_wander_timer: f32, // Таймер для автономного блуждания взгляда, когда на мобильном давно не было тача

    // --- Наклон телефона и кинетический свайп ---
    tilt_x: f32, // deviceorientation.gamma, нормализованный к [-1, 1] — используется для лёгкого 3D-параллакса капель
    tilt_y: f32,
    swipe_x: f32,
    swipe_y: f32,
    swipe_timer: f32, // >0 короткое время после свайпа — капли в этой точке отталкиваются, как от мыши
}

#[wasm_bindgen]
impl MatrixEngine {
    #[wasm_bindgen(constructor)]
    pub fn new(width: u32, height: u32, font_size: u32) -> MatrixEngine {
        console_error_panic_hook::set_once();
        let max_drops = 10;
        let shockwaves = (0..5)
            .map(|_| Shockwave {
                x: 0.0,
                y: 0.0,
                radius: 0.0,
                life: 0.0,
                max_life: 0.0,
                active: false,
            })
            .collect();

        let mut engine = MatrixEngine {
            width,
            height,
            font_size,
            charset_len: 84,
            rng: FastRng::new(1337),
            drops: Vec::new(),
            shockwaves,
            glass_symbols: Vec::new(),
            glitch_blocks: Vec::new(),
            instance_buffer: vec![0.0; max_drops * STRIDE],
            visible_drops: 0,
            glitch_buffer: Vec::new(),
            glass_buffer: Vec::new(),
            eye_buffer: Vec::new(),
            mouse_x: width as f32 / 2.0,
            mouse_y: height as f32 / 2.0,
            is_drawing: false,
            is_mobile: false,
            is_vpn_on: false,
            tick_count: 0,
            emp_timer: 0.0,
            emp_x: 0.0,
            emp_y: 0.0,
            tear_y: 0.0,
            tear_timer: 0.0,
            blink_timer: 0.0,
            last_interaction: 0.0,
            gaze_target_x: width as f32 / 2.0,
            gaze_target_y: height as f32 / 2.0,
            gaze_x: 0.0,
            gaze_y: 0.0,
            eye_closedness: 0.0,
            eye_closedness_vel: 0.0,
            eye_anchor_y: 0.0,
            mobile_wander_timer: 0.0,
            tilt_x: 0.0,
            tilt_y: 0.0,
            swipe_x: 0.0,
            swipe_y: 0.0,
            swipe_timer: 0.0,
        };
        engine.init_drops();
        engine
    }

    /// (Пере)генерирует всю популяцию капель под текущие width/font_size.
    /// Вызывается из `new()` и из `resize()` — сетка колонок целиком зависит
    /// от ширины экрана, поэтому при ресайзе капли просто создаются заново,
    /// а не переиспользуются. Кортеж на слой — `(layer_id, scale, min_speed, max_speed)`.
    fn init_drops(&mut self) {
        self.drops.clear();

        let mut layers = vec![
            (1, 0.7, 0.02, 0.06), // Mid
            (2, 1.0, 0.05, 0.15), // Near
        ];

        if !self.is_mobile {
            layers.insert(0, (0, 0.4, 0.005, 0.02)); // Far
        }

        let base_spacing = self.font_size as f32;

        // --- ВОЗВРАЩАЕМ СПОКОЙНУЮ ПЛОТНОСТЬ ФОНА ---
        let density_multiplier = if self.is_mobile { 0.5 } else { 1.0 };
        let drops_per_column = if self.is_mobile { 1 } else { 2 };

        for (layer, scale, min_spd, max_spd) in layers.iter() {
            let spacing = (base_spacing * scale) / density_multiplier;
            let columns = (self.width as f32 / spacing).ceil() as u32 + 20;

            for i in 0..columns {
                let start_x = (i as f32 * spacing) - (10.0 * spacing);

                for _ in 0..drops_per_column {
                    self.drops.push(Drop {
                        base_x: start_x + self.rng.gen_range(-2.0, 2.0),
                        y: self
                            .rng
                            .gen_range(-100.0, self.height as f32 / (base_spacing * scale)),
                        offset_x: 0.0,
                        vel_x: 0.0,
                        speed: self.rng.gen_range(*min_spd, *max_spd),
                        layer: *layer as u8,
                        glitch_timer: 0.0,
                    });
                }
            }
        }
        self.instance_buffer.resize(self.drops.len() * STRIDE, 0.0);
    }

    pub fn resize(&mut self, width: u32, height: u32) {
        self.width = width;
        self.height = height;
        self.init_drops();
    }

    pub fn update_mouse(&mut self, x: f32, y: f32) {
        self.mouse_x = x;
        self.mouse_y = y;
        self.gaze_target_x = x;
        self.gaze_target_y = y;
        self.last_interaction = 0.0;

        self.swipe_x = x;
        self.swipe_y = y;
        self.swipe_timer = 15.0;
    }

    pub fn set_drawing(&mut self, is_drawing: bool) {
        self.is_drawing = is_drawing;
    }
    pub fn set_mobile(&mut self, is_mobile: bool) {
        self.is_mobile = is_mobile;
    }
    pub fn set_eye_anchor(&mut self, cy: f32) {
        self.eye_anchor_y = cy;
    }
    pub fn set_vpn_status(&mut self, is_vpn_on: bool) {
        self.is_vpn_on = is_vpn_on;
    }
    pub fn set_tilt(&mut self, tilt_x: f32, tilt_y: f32) {
        self.tilt_x = tilt_x;
        self.tilt_y = tilt_y;
    }

    pub fn trigger_shockwave(&mut self, x: f32, y: f32) {
        self.update_mouse(x, y);

        // Волна стала больше и живет дольше
        let shockwave_life = if self.is_mobile { 15.0 } else { 25.0 };

        if let Some(sw) = self.shockwaves.iter_mut().find(|s| !s.active) {
            sw.x = x;
            sw.y = y;
            sw.radius = 0.0;
            sw.life = shockwave_life;
            sw.max_life = shockwave_life;
            sw.active = true;
        }

        // Больше глитч-блоков при клике
        let blocks_count = if self.is_mobile { 8 } else { 18 };
        for _ in 0..blocks_count {
            let block = GlitchBlock::new(x, y, &mut self.rng, self.is_mobile);
            self.glitch_blocks.push(block);
        }
    }

    pub fn kinetic_swipe(&mut self, x: f32, y: f32) {
        self.swipe_x = x;
        self.swipe_y = y;
        self.swipe_timer = 15.0;
        self.update_mouse(x, y);
    }

    /// EMP-импульс: сильный сбой (шоквейв + всплеск глитч-блоков + временная
    /// "дыра" в поле капель вокруг точки, см. is_void/is_emp_ring/is_scrambled
    /// в tick()). Вызывается воркером как один из двух случайных вариантов
    /// реакции на DRAW_START наравне с обычным trigger_shockwave.
    pub fn trigger_glitch(&mut self, x: f32, y: f32) {
        self.emp_timer = 40.0; // Чуть больше белое пятно
        self.emp_x = x;
        self.emp_y = y;

        if let Some(sw) = self.shockwaves.iter_mut().find(|s| !s.active) {
            sw.x = x;
            sw.y = y;
            sw.radius = 0.0;
            sw.life = 30.0;
            sw.max_life = 30.0;
            sw.active = true;
        }

        let blocks_count = if self.is_mobile { 12 } else { 35 };
        for _ in 0..blocks_count {
            let block = GlitchBlock::new(x, y, &mut self.rng, self.is_mobile);
            self.glitch_blocks.push(block);
        }
        self.update_mouse(x, y);
    }
    /// Главный шаг симуляции, вызывается воркером ровно раз на кадр рендера
    /// (~60 FPS). Пересчитывает всё состояние и заполняет буферы заново
    /// (instance/glitch/glass/eye) — движок не хранит "дельту" между кадрами,
    /// JS-сторона каждый раз читает свежий снимок через *_ptr()/*_len().
    /// Логически tick() состоит из блоков: обновление шоквейвов -> логика
    /// взгляда и моргания глаз -> редкие декоративные глитчи/трещины ->
    /// EMP-геометрия -> основной проход по каплям (движение, взаимодействия,
    /// запись в instance_buffer) -> обновление глитч-блоков и "стеклянных" символов.
    pub fn tick(&mut self) {
        self.tick_count = self.tick_count.wrapping_add(1);
        self.last_interaction += 1.0;
        self.visible_drops = 0;

        self.glitch_buffer.clear();
        self.glass_buffer.clear();
        self.eye_buffer.clear();

        // --- Обновление активных ударных волн ---
        // Скорость расхождения волны снижена
        let sw_speed = if self.is_mobile { 3.0 } else { 8.0 };

        for sw in self.shockwaves.iter_mut().filter(|s| s.active) {
            sw.radius += sw_speed;
            sw.life -= 1.0;
            if sw.life <= 0.0 {
                sw.active = false;
            }
        }

        if self.swipe_timer > 0.0 {
            self.swipe_timer -= 1.0;
        }

        // --- Логика Глаз (Остается прежней) ---
        // Взгляд плавно едет к gaze_target_{x,y} (позиция курсора/тача, либо
        // случайная точка "блуждания" на мобильных при простое), моргание —
        // это отдельная демпфированная пружина eye_closedness с VPN как
        // "постоянно закрытыми глазами" (это выключенный тоггл = закрытые глаза,
        // см. target_closedness ниже).
        let cx = self.width as f32 / 2.0;
        let cy = if self.eye_anchor_y > 0.0 {
            self.eye_anchor_y
        } else {
            if self.is_mobile {
                150.0
            } else {
                180.0
            }
        };

        if self.is_mobile && self.last_interaction > 150.0 {
            self.mobile_wander_timer += 1.0;
            if self.mobile_wander_timer > 108.0 {
                self.mobile_wander_timer = 0.0;
                if self.rng.gen_bool(0.8) {
                    self.gaze_target_x = cx + self.rng.gen_range(-100.0, 100.0);
                    self.gaze_target_y = cy + self.rng.gen_range(-100.0, 100.0);
                    if self.rng.gen_bool(0.4) {
                        self.blink_timer = 12.0;
                    }
                } else {
                    self.gaze_target_x = cx;
                    self.gaze_target_y = cy;
                }
            }
        }

        let dx = self.gaze_target_x - cx;
        let dy = self.gaze_target_y - cy;

        let distance = (dx * dx + dy * dy).sqrt();
        let target_dist = (distance * 0.1_f32).min(20.0_f32);
        let angle = dy.atan2(dx);

        self.gaze_x += (angle.cos() * target_dist - self.gaze_x) * 0.35_f32;
        self.gaze_y += (angle.sin() * target_dist - self.gaze_y) * 0.35_f32;

        if self.blink_timer > 0.0 {
            self.blink_timer -= 1.0;
        } else if !self.is_vpn_on && self.rng.gen_bool(0.015) {
            // Шанс моргнуть стал 0.6% каждый кадр
            self.blink_timer = 14.0; // Само закрытие длится чуть дольше
        }

        let target_closedness = if self.is_vpn_on || self.blink_timer > 0.0 {
            1.0
        } else {
            0.0
        };
        self.eye_closedness_vel += (target_closedness - self.eye_closedness) * 0.35_f32;
        self.eye_closedness_vel *= 0.55_f32;
        self.eye_closedness =
            (self.eye_closedness + self.eye_closedness_vel).clamp(-0.3_f32, 1.3_f32);

        self.eye_buffer.extend_from_slice(&[
            cx,
            cy,
            self.gaze_x,
            self.gaze_y,
            0.0,
            self.eye_closedness,
        ]);

        if !self.is_mobile {
            if self.rng.gen_bool(0.01) {
                let rx = self.rng.gen_range(0.0, self.width as f32);
                let ry = self.rng.gen_range(0.0, self.height as f32);
                // Было: self.glitch_blocks.push(GlitchBlock::new(rx, ry, &mut self.rng));
                // Стало:
                self.glitch_blocks
                    .push(GlitchBlock::new(rx, ry, &mut self.rng, self.is_mobile));
            }
            if self.rng.gen_bool(0.002) {
                self.tear_y = self.rng.gen_range(0.0, self.height as f32);
                self.tear_timer = self.rng.gen_range(5.0, 15.0);
            }
        }

        if self.emp_timer > 0.0 {
            self.emp_timer -= 1.0;
        }
        if self.tear_timer > 0.0 {
            self.tear_timer -= 1.0;
        }

        // EMP расширяется наружу по мере убывания emp_timer (25 кадров жизни),
        // формируя три концентрические зоны вокруг точки удара, проверяемые
        // ниже как quadrance (dist_sq) без sqrt ради скорости:
        //   [0, emp_r80_sq)      — is_void: капли полностью гасятся (эпицентр)
        //   [emp_r80_sq, emp_r20_sq) — is_scrambled: капли получают случайный символ
        //   [emp_r20_sq, emp_rad_sq) — is_emp_ring: яркое кольцо-фронт волны
        let emp_rad = (25.0 - self.emp_timer) * 10.0;
        let emp_rad_sq = emp_rad * emp_rad;
        let emp_r20_sq = if emp_rad > 20.0 {
            (emp_rad - 20.0).powi(2)
        } else {
            0.0
        };
        let emp_r80_sq = if emp_rad > 80.0 {
            (emp_rad - 80.0).powi(2)
        } else {
            0.0
        };

        // --- Итерация по каплям ---
        // Для каждой капли: (1) горизонтальный wrap колонки при выходе за
        // экран, (2) frustum culling по Y с ре-спауном сверху/снизу,
        // (3) реакция на активные шоквейвы/мышь-свайп/EMP/трещину,
        // (4) пружинное затухание offset_x к нулю, (5) выбор символа и его
        // "яркости" (atlas_row) через хэш от индекса+Y+времени — эмулирует
        // случайное мерцание символов без re-roll каждого символа отдельно,
        // (6) запись итоговых 5 полей в instance_buffer.
        for i in 0..self.drops.len() {
            let drop = &mut self.drops[i];

            let parallax_factor = match drop.layer {
                0 => 0.3,
                1 => 0.6,
                _ => 1.0,
            };

            let wrap_margin = 150.0;
            if drop.base_x < -wrap_margin {
                drop.base_x += self.width as f32 + wrap_margin * 2.0;
                drop.y = 0.0;
            } else if drop.base_x > self.width as f32 + wrap_margin {
                drop.base_x -= self.width as f32 + wrap_margin * 2.0;
                drop.y = 0.0;
            }

            let current_x = drop.base_x + drop.offset_x;
            let current_y = drop.y * (self.font_size as f32 * parallax_factor);

            // === ИСПРАВЛЕННЫЙ FRUSTUM CULLING + РЕСПАУН ===
            // Проверяем смерть капли ПЕРЕД отсечением
            if current_y > self.height as f32 + 50.0 {
                if self.rng.gen_bool(0.03) {
                    // 3% шанс возродиться каждый кадр (дает плотный поток)
                    drop.y = self.rng.gen_range(-20.0, -5.0);
                    drop.speed = match drop.layer {
                        0 => self.rng.gen_range(0.01, 0.04),
                        1 => self.rng.gen_range(0.04, 0.12),
                        _ => self.rng.gen_range(0.10, 0.30),
                    };
                } else {
                    drop.y += drop.speed; // Если не возродилась, продолжает падать в пустоте
                }
                continue; // Не рендерим, она за экраном
            } else if current_y < -50.0 {
                drop.y += drop.speed;
                continue; // Не рендерим, она еще летит сверху
            }

            let mut is_scrambled = false;
            let mut is_void = false;
            let mut is_emp_ring = false;
            let mut force_white = false;

            for sw in self.shockwaves.iter().filter(|s| s.active) {
                let dx = current_x - sw.x;
                let dy = current_y - sw.y;

                if dx.abs() > sw.radius + 40.0 || dy.abs() > sw.radius + 40.0 {
                    continue;
                }

                // Стало:
                let dist = (dx * dx + dy * dy).sqrt();

                // Делаем саму зону волны тоньше на телефоне (25.0 вместо 40.0)
                let sw_thickness = if self.is_mobile { 25.0 } else { 40.0 };

                if (dist - sw.radius).abs() < sw_thickness {
                    let base_sw_force = if self.is_mobile { 3.0 } else { 18.0 }; // На мобилке импульс в 3 раза слабее
                    let life_ratio = sw.life / sw.max_life;
                    let force = (life_ratio * life_ratio) * base_sw_force * parallax_factor;
                    drop.vel_x += (dx / dist.max(1.0_f32)) * force;
                    force_white = true;
                }
            }

            let check_swipe = self.swipe_timer > 0.0;
            let check_mouse = !self.is_mobile && self.last_interaction < 10.0;
            if check_swipe || check_mouse {
                let (ix, iy) = if check_swipe {
                    (self.swipe_x, self.swipe_y)
                } else {
                    (self.mouse_x, self.mouse_y)
                };
                let dx = current_x - ix;
                let dy = current_y - iy;
                let dist_sq = dx * dx + dy * dy;

                // РАДИУС ВЗАИМОДЕЙСТВИЯ МЫШИ УМЕНЬШЕН ВДВОЕ (было 70/140)
                let rad = if self.is_mobile { 35.0 } else { 60.0 };

                if dist_sq < rad * rad {
                    let dist = dist_sq.sqrt().max(1.0_f32);
                    let force = (rad - dist) / rad;
                    let interaction_force = if self.is_mobile { 2.5 } else { 10.0 };

                    drop.vel_x += (dx / dist) * force * interaction_force * parallax_factor;
                    drop.y -= drop.speed * force * 0.6;
                    is_scrambled = true;
                    if dist < rad * 0.4 {
                        force_white = true;
                    }
                }
            }

            if self.emp_timer > 0.0 && drop.layer > 0 {
                let dx = current_x - self.emp_x;
                let dy = current_y - self.emp_y;
                let dist_sq = dx * dx + dy * dy;
                if dist_sq < emp_rad_sq {
                    if dist_sq > emp_r20_sq {
                        is_emp_ring = true;
                    } else if dist_sq > emp_r80_sq {
                        is_scrambled = true;
                    } else {
                        is_void = true;
                    }
                }
            }

            if !self.is_mobile && self.tear_timer > 0.0 && (current_y - self.tear_y).abs() < 60.0 {
                drop.vel_x += self.rng.gen_range(-10.0, 10.0);
                is_scrambled = true;
            }

            if is_void {
                drop.y += drop.speed;
                continue; // Не пишем в буфер вообще
            }

            drop.vel_x += -0.12 * drop.offset_x;
            drop.vel_x *= 0.85;
            drop.offset_x += drop.vel_x;

            if self.rng.gen_bool(0.001) {
                drop.glitch_timer = 5.0;
            }

            // Пространственный хэш (индекс капли + ячейка Y + медленно
            // тикающий сдвиг времени) вместо rng.next_u32(): даёт детерминированный
            // "случайный" символ для конкретной позиции капли, который стабилен
            // между кадрами (не мерцает каждый символ каждый тик) и меняется
            // только когда сама капля сдвигается на новую Y-ячейку либо раз
            // в 10 тиков через time_shift. 73856093/19349663 — стандартные
            // простые числа для склейки координат в hash-grid (частая практика
            // в процедурной генерации, минимизирует коллизии решётки).
            let cell_y = drop.y as i32 as u32;
            let time_shift = self.tick_count / 10;
            let hash = (i as u32)
                .wrapping_mul(73856093)
                .wrapping_add(cell_y.wrapping_mul(19349663))
                .wrapping_add(time_shift);
            let mut char_idx = (hash as usize) % self.charset_len;

            let mut is_head = (hash % 100) < 15;
            let mut base_alpha = match drop.layer {
                0 => 0.15,
                1 => 0.45,
                _ => 0.8,
            };

            if is_emp_ring {
                is_head = true;
                base_alpha = 1.0;
                char_idx = (hash.wrapping_add(self.tick_count) as usize) % self.charset_len;
            } else if is_scrambled || drop.glitch_timer > 0.0 {
                char_idx = (hash.wrapping_add(self.tick_count) as usize) % self.charset_len;
                base_alpha = (base_alpha + 0.2_f32).clamp(0.0_f32, 1.0_f32);
                is_head = (hash % 10) < 2;
                if drop.glitch_timer > 0.0 {
                    drop.glitch_timer -= 1.0;
                }
            }

            if force_white {
                is_head = true;
            }

            // ОПТИМИЗАЦИЯ: Расчет строки атласа вместо прозрачности (0..10)
            let atlas_row = if is_head {
                10.0 // Строка 10 - полностью белая голова
            } else {
                (base_alpha * 9.0).clamp(0.0_f32, 9.0_f32).floor() // От 0 до 9 для цвета
            };

            let scale = match drop.layer {
                0 => 0.4,
                1 => 0.7,
                _ => 1.0,
            };
            // Добавляем красивый 3D гироскоп
            let final_x = drop.base_x + drop.offset_x + (self.tilt_x * 60.0 * parallax_factor);

            // Пишем в буфер (Stride = 5)
            let base_idx = self.visible_drops * STRIDE;

            // Используем вычисленные локальные переменные
            self.instance_buffer[base_idx] = final_x; // a_position.x
            self.instance_buffer[base_idx + 1] = current_y; // a_position.y
            self.instance_buffer[base_idx + 2] = char_idx as f32; // a_charIdx
            self.instance_buffer[base_idx + 3] = atlas_row; // a_atlasRow
            self.instance_buffer[base_idx + 4] = scale; // a_scale

            self.visible_drops += 1;
            drop.y += drop.speed;
        }

        // --- Глитчи и Осколки (Пылинки) ---
        for block in self.glitch_blocks.iter_mut() {
            self.glitch_buffer.extend_from_slice(&[
                block.x,
                block.y,
                block.w,
                block.h,
                block.color_type,
            ]);
            block.life -= 1.0;
        }
        self.glitch_blocks.retain(|b| b.life > 0.0);

        if !self.is_mobile && self.last_interaction < 2.0 && self.rng.gen_bool(0.2) {
            self.glass_symbols.push(GlassSymbol {
                x: self.mouse_x + self.rng.gen_range(-20.0, 20.0),
                y: self.mouse_y + self.rng.gen_range(-20.0, 20.0),
                char_idx: self.rng.gen_usize(self.charset_len),
                life: 1.0,
                max_life: self.rng.gen_range(20.0, 50.0),
                velocity_y: self.rng.gen_range(-1.5, 1.5),
            });
        }

        for sym in self.glass_symbols.iter_mut() {
            sym.y += sym.velocity_y;
            sym.x += self.rng.gen_range(-0.5, 0.5);
            sym.life -= 1.0;
            let alpha = (sym.life / sym.max_life).max(0.0);
            self.glass_buffer
                .extend_from_slice(&[sym.x, sym.y, sym.char_idx as f32, alpha]);
        }
        self.glass_symbols.retain(|s| s.life > 0.0);
    }

    // --- Доступ к буферам для JS (см. doc-комментарий структуры MatrixEngine
    // про протокол памяти WASM<->JS). Каждый буфер — пара ptr()/len(): ptr()
    // отдаёт сырой указатель в линейную память WASM, len() — актуальную длину
    // в элементах f32 (не в байтах и не в количестве "объектов"). instance_buffer
    // физически может быть больше, чем нужно рендерить в этом кадре (ёмкость
    // растёт, но не всегда используется целиком), поэтому render_len() возвращает
    // не capacity буфера, а именно visible_drops * STRIDE.
    pub fn render_ptr(&self) -> *const f32 {
        self.instance_buffer.as_ptr()
    }
    // Возвращаем ТОЛЬКО размер видимых капель * 5
    pub fn render_len(&self) -> usize {
        self.visible_drops * 5
    }

    pub fn glitch_ptr(&self) -> *const f32 {
        self.glitch_buffer.as_ptr()
    }
    pub fn glitch_len(&self) -> usize {
        self.glitch_buffer.len()
    }
    pub fn glass_ptr(&self) -> *const f32 {
        self.glass_buffer.as_ptr()
    }
    pub fn glass_len(&self) -> usize {
        self.glass_buffer.len()
    }
    pub fn eye_ptr(&self) -> *const f32 {
        self.eye_buffer.as_ptr()
    }
    pub fn eye_len(&self) -> usize {
        self.eye_buffer.len()
    }

    pub fn has_active_shockwave(&self) -> bool {
        self.shockwaves.iter().any(|s| s.active)
    }

    pub fn get_shockwave_x(&self) -> f32 {
        self.shockwaves
            .iter()
            .find(|s| s.active)
            .map(|s| s.x)
            .unwrap_or(-1.0)
    }

    pub fn get_shockwave_y(&self) -> f32 {
        self.shockwaves
            .iter()
            .find(|s| s.active)
            .map(|s| s.y)
            .unwrap_or(-1.0)
    }

    pub fn get_shockwave_radius(&self) -> f32 {
        self.shockwaves
            .iter()
            .find(|s| s.active)
            .map(|s| s.radius)
            .unwrap_or(0.0)
    }
}
