import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
import { gsap } from 'gsap';
import './styles.css';

const MEASURED = Object.freeze({
  packCount: 15,
  packWidth: 3,
  packGap: 1.25,
  topHeightRatio: 3 / 50,
  cameraDistance: 12,
  cameraFov: 60,
  reflectionGap: 2.3,
  reflectionDarkness: 0.9,
  dragRadiansPerViewport: Math.PI * 0.4,
  dragSmoothing: 32,
  maxSpinRadPerSec: 2.5,
  tapDistancePx: 6,
  snapDuration: 1.2,
  focusAlignDuration: 0.45,
  focusDuration: 0.8,
  focusCameraAdvance: 3.5,
  focusCameraLift: 2.72,
  floatAmplitude: 0.07,
  floatSpeed: 1.1,
  floatStagger: 0.35,
  tearHapticIntervalMs: 50,
  packOpenHapticCount: 30,
  packOpenHapticDuration: 1,
  cardDissolveDuration: 4,
  tapSpeedMultiplier: 4,
});

const THEMES = {
  blue: { image: '/assets/packs/blue.png', particle: '#3788ff', tear: '#69afff', rarity: '#3d41ff' },
  orange: { image: '/assets/packs/orange.png', particle: '#ff6d24', tear: '#ff9b55', rarity: '#ff4e14' },
  gold: { image: '/assets/packs/gold.png', particle: '#ffd462', tear: '#caa74f', rarity: '#ffcf48' },
  red: { image: '/assets/packs/red.png', particle: '#ff334e', tear: '#ff6b78', rarity: '#fa1e62' },
};

const SAMPLE_COLORS = ['#9ca3af', '#22c55e', '#3b82f6', '#ef4444', '#ffd700'];
const PACK_RADIUS = (MEASURED.packCount * (MEASURED.packWidth + MEASURED.packGap)) / (Math.PI * 2);

const mount = document.querySelector('#scene');
const prompt = document.querySelector('#prompt');
const subPrompt = document.querySelector('#subPrompt');
const status = document.querySelector('.status');
const progress = document.querySelector('#tearProgress');
const progressBar = progress.querySelector('i');
const loading = document.querySelector('#loading');
const rareToggle = document.querySelector('#rareToggle');
const soundToggle = document.querySelector('#soundToggle');
const resetButton = document.querySelector('#resetButton');
const themeButtons = [...document.querySelectorAll('[data-theme]')];

const scene = new THREE.Scene();
scene.background = new THREE.Color('#000000');
scene.fog = new THREE.Fog('#000000', MEASURED.cameraDistance, PACK_RADIUS * 2 + MEASURED.cameraDistance + 2);

const camera = new THREE.PerspectiveCamera(MEASURED.cameraFov, 1, 0.1, 100);
camera.position.set(0, 0, PACK_RADIUS + MEASURED.cameraDistance);
camera.lookAt(0, 0, 0);

const initialDpr = Math.min(window.devicePixelRatio || 1, 2);
const renderer = new THREE.WebGLRenderer({ antialias: initialDpr < 2, alpha: false });
renderer.setPixelRatio(initialDpr);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.9;
mount.appendChild(renderer.domElement);

const bloomLayer = new THREE.Layers();
bloomLayer.set(1);
const darkMaterial = new THREE.MeshBasicMaterial({ color: 0x000000 });
const hiddenBloomMaterials = new Map();
const bloomComposer = new EffectComposer(renderer);
bloomComposer.renderToScreen = false;
bloomComposer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 2, 0.5, 0.1);
bloomComposer.addPass(bloom);

const finalComposer = new EffectComposer(renderer);
finalComposer.addPass(new RenderPass(scene, camera));
finalComposer.addPass(new ShaderPass(new THREE.ShaderMaterial({
  uniforms: {
    baseTexture: { value: null },
    bloomTexture: { value: bloomComposer.renderTarget2.texture },
  },
  vertexShader: 'varying vec2 vUv; void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}',
  fragmentShader: 'uniform sampler2D baseTexture; uniform sampler2D bloomTexture; varying vec2 vUv; void main(){gl_FragColor=texture2D(baseTexture,vUv)+texture2D(bloomTexture,vUv);}',
}), 'baseTexture'));
finalComposer.addPass(new OutputPass());

function enableBloom(object) {
  object.traverse((child) => {
    if (child.material) child.layers.enable(1);
  });
}

function darkenNonBloomed(object) {
  if (!object.material || bloomLayer.test(object.layers)) return;
  hiddenBloomMaterials.set(object.uuid, object.material);
  object.material = darkMaterial;
}

function restoreBloomMaterial(object) {
  const material = hiddenBloomMaterials.get(object.uuid);
  if (!material) return;
  object.material = material;
  hiddenBloomMaterials.delete(object.uuid);
}

function renderFrame() {
  scene.traverse(darkenNonBloomed);
  bloomComposer.render();
  scene.traverse(restoreBloomMaterial);
  finalComposer.render();
}

scene.add(new THREE.AmbientLight(0x7fa99a, 0.55));
const keyLight = new THREE.DirectionalLight(0xffffff, 7);
keyLight.position.set(3, 7, PACK_RADIUS + 9);
scene.add(keyLight);
const rimLight = new THREE.PointLight(0xff684f, 14, 22, 2);
rimLight.position.set(-6, 2, PACK_RADIUS + 5);
scene.add(rimLight);

const carousel = new THREE.Group();
const effects = new THREE.Group();
scene.add(carousel, effects);

const textureLoader = new THREE.TextureLoader();
const clock = new THREE.Clock();
const raycaster = new THREE.Raycaster();
const pointerNdc = new THREE.Vector2();
const packs = [];
const packMeshes = [];
const particleSystems = [];
const packStep = (Math.PI * 2) / MEASURED.packCount;

let activeThemeName = 'gold';
let activeTheme = THEMES[activeThemeName];
let packTextures = {};
let packBaseMaterial;
let packFoilMaterial;
let reflectionMaterial;
let packHeight = 5.1;
let state = 'booting';
let selectedPack = null;
let pointerDown = false;
let pointerId = null;
let pointerDownState = null;
let pointerStartX = 0;
let pointerStartY = 0;
let pointerLastX = 0;
let pointerLastTime = 0;
let dragDistance = 0;
let spinVelocity = 0;
let pendingSnap = false;
let snapTween = null;
let tearDirection = null;
let tearProgress = 0;
let tearLine = null;
let tearHapticTimer = null;
let revealTimeline = null;
let dissolveTween = null;
let activeCard = null;
let audioContext = null;
let audioManifest = {};
const audioBuffers = new Map();
const webAudioInstances = new Set();
const audioInstances = new Set();
let ambientDust = null;
let focusLookY = 0;
let focusSealSpark = null;
let tearSoundCarryPx = 0;
let tearSoundLastX = 0;
let tearSoundLastY = 0;
let lastPaperSoundAt = -Infinity;
let carouselAudioPreviousRotation = null;
let carouselAudioCarry = 0;

function setPrompt(title, subtitle = '', mode = '') {
  prompt.textContent = title;
  subPrompt.textContent = subtitle;
  status.dataset.mode = mode;
  status.classList.remove('hidden');
}

function hidePrompt() {
  status.classList.add('hidden');
}

function setThemeUi(name) {
  themeButtons.forEach((button) => button.classList.toggle('active', button.dataset.theme === name));
}

function configureTexture(texture) {
  texture.colorSpace = THREE.SRGBColorSpace;
  texture.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy());
  texture.minFilter = THREE.LinearMipmapLinearFilter;
  texture.magFilter = THREE.LinearFilter;
  texture.needsUpdate = true;
  return texture;
}

function imageAspectHeight(texture, width, fallback) {
  const image = texture.image;
  if (!image?.width || !image?.height) return fallback;
  return width * (image.height / image.width);
}

function makeSegmentGeometry(width, height, vMin, vMax, widthSegments = 40, heightSegments = 10) {
  const geometry = new THREE.PlaneGeometry(width, height, widthSegments, heightSegments);
  const uv = geometry.attributes.uv;
  const position = geometry.attributes.position;
  for (let index = 0; index < uv.count; index += 1) {
    uv.setY(index, THREE.MathUtils.lerp(vMin, vMax, uv.getY(index)));
    const x = position.getX(index) / (width * 0.5);
    const y = position.getY(index) / Math.max(height * 0.5, 0.001);
    const edge = Math.pow(Math.min(1, Math.abs(x)), 7) * 0.045;
    const crinkle = Math.sin(x * 17 + y * 7) * Math.cos(y * 11) * 0.006;
    position.setZ(index, edge + crinkle);
  }
  uv.needsUpdate = true;
  position.needsUpdate = true;
  geometry.computeVertexNormals();
  return geometry;
}

function createFoilMaterial(texture, color) {
  return new THREE.ShaderMaterial({
    uniforms: {
      uMap: { value: texture },
      uColor: { value: new THREE.Color(color) },
      uTime: { value: 0 },
    },
    vertexShader: `
      varying vec2 vUv;
      void main() {
        vUv = uv;
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
      }
    `,
    fragmentShader: `
      uniform sampler2D uMap;
      uniform vec3 uColor;
      uniform float uTime;
      varying vec2 vUv;
      float hash(vec2 p) {
        return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
      }
      void main() {
        vec4 tex = texture2D(uMap, vUv);
        if (tex.a < 0.05) discard;
        float sweep = abs(fract(vUv.x * 0.72 + vUv.y * 0.48 - uTime * 0.055) - 0.5);
        float band = pow(max(0.0, 1.0 - sweep * 8.0), 4.0);
        float facets = pow(hash(floor(vUv * vec2(28.0, 46.0)) + floor(uTime * 1.2)), 15.0);
        float alpha = tex.a * (band * 0.025 + facets * 0.01 + 0.001);
        gl_FragColor = vec4(uColor * (0.9 + band * 1.0 + facets), alpha);
      }
    `,
    transparent: true,
    depthWrite: false,
    blending: THREE.AdditiveBlending,
    side: THREE.DoubleSide,
    toneMapped: false,
  });
}

function rebuildSharedMaterials() {
  const texture = packTextures[activeThemeName];
  packBaseMaterial?.dispose();
  packFoilMaterial?.dispose();
  reflectionMaterial?.dispose();
  packBaseMaterial = new THREE.MeshBasicMaterial({
    map: texture,
    transparent: true,
    alphaTest: 0.05,
    side: THREE.DoubleSide,
    color: 0xd8dedb,
  });
  packFoilMaterial = createFoilMaterial(texture, activeTheme.particle);
  reflectionMaterial = new THREE.MeshBasicMaterial({
    map: texture,
    transparent: true,
    opacity: 1 - MEASURED.reflectionDarkness,
    alphaTest: 0.05,
    depthWrite: false,
    side: THREE.DoubleSide,
    color: new THREE.Color(0x71827b),
  });
}

function segmentMesh(geometry, material, z = 0) {
  const mesh = new THREE.Mesh(geometry, material);
  mesh.position.z = z;
  mesh.frustumCulled = false;
  return mesh;
}

function createPack(index, geometries) {
  const angle = -index * packStep;
  const group = new THREE.Group();
  const floatGroup = new THREE.Group();
  const bodyGroup = new THREE.Group();
  const topGroup = new THREE.Group();
  const bottomGroup = new THREE.Group();
  const cardsGroup = new THREE.Group();
  const reflectionGroup = new THREE.Group();

  group.position.set(Math.sin(angle) * PACK_RADIUS, 0, Math.cos(angle) * PACK_RADIUS);
  group.rotation.y = angle;
  group.scale.setScalar(0);
  group.add(floatGroup);
  floatGroup.add(bodyGroup, topGroup, bottomGroup, cardsGroup, reflectionGroup);

  bodyGroup.position.y = 0;
  topGroup.position.y = packHeight * 0.5 - geometries.topHeight * 0.5;
  bottomGroup.position.y = -packHeight * 0.5 + geometries.bottomHeight * 0.5;

  const body = segmentMesh(geometries.body, packBaseMaterial);
  const bodyFoil = segmentMesh(geometries.body, packFoilMaterial, 0.012);
  const darkMaterial = new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0, depthWrite: false });
  const darkOverlay = segmentMesh(geometries.body, darkMaterial, 0.018);
  bodyGroup.add(body, bodyFoil, darkOverlay);

  const top = segmentMesh(geometries.top, packBaseMaterial);
  const topFoil = segmentMesh(geometries.top, packFoilMaterial, 0.012);
  topGroup.add(top, topFoil);

  const bottom = segmentMesh(geometries.bottom, packBaseMaterial);
  const bottomFoil = segmentMesh(geometries.bottom, packFoilMaterial, 0.012);
  bottomGroup.add(bottom, bottomFoil);

  const reflection = segmentMesh(geometries.full, reflectionMaterial);
  reflection.scale.y = -1;
  reflection.position.y = -(packHeight + MEASURED.reflectionGap);
  reflectionGroup.add(reflection);

  const pack = {
    index,
    angle,
    group,
    floatGroup,
    bodyGroup,
    topGroup,
    bottomGroup,
    cardsGroup,
    reflectionGroup,
    body,
    top,
    bottom,
    darkMaterial,
    bodyBaseY: bodyGroup.position.y,
    topBaseY: topGroup.position.y,
    bottomBaseY: bottomGroup.position.y,
  };
  group.userData.pack = pack;
  body.userData.pack = pack;
  top.userData.pack = pack;
  packMeshes.push(body, top);
  carousel.add(group);
  return pack;
}

function buildPacks() {
  while (carousel.children.length) carousel.remove(carousel.children[0]);
  packs.length = 0;
  packMeshes.length = 0;
  rebuildSharedMaterials();
  const topHeight = packHeight * MEASURED.topHeightRatio;
  const bottomHeight = topHeight;
  const bodyHeight = packHeight - topHeight - bottomHeight;
  const geometries = {
    topHeight,
    bottomHeight,
    body: makeSegmentGeometry(MEASURED.packWidth, bodyHeight, MEASURED.topHeightRatio, 1 - MEASURED.topHeightRatio, 40, 22),
    top: makeSegmentGeometry(MEASURED.packWidth, topHeight, 1 - MEASURED.topHeightRatio, 1, 40, 6),
    bottom: makeSegmentGeometry(MEASURED.packWidth, topHeight, 0, MEASURED.topHeightRatio, 40, 6),
    full: makeSegmentGeometry(MEASURED.packWidth, packHeight, 0, 1, 20, 24),
  };
  for (let index = 0; index < MEASURED.packCount; index += 1) packs.push(createPack(index, geometries));
  gsap.to(packs.map((pack) => pack.group.scale), {
    x: 1,
    y: 1,
    z: 1,
    duration: 1,
    ease: 'power3.out',
  });
}

function createCircleTexture() {
  const canvas = document.createElement('canvas');
  canvas.width = 128;
  canvas.height = 128;
  const context = canvas.getContext('2d');
  const gradient = context.createRadialGradient(64, 64, 0, 64, 64, 64);
  gradient.addColorStop(0, 'rgba(255,255,255,1)');
  gradient.addColorStop(0.08, 'rgba(255,255,255,1)');
  gradient.addColorStop(0.12, 'rgba(255,255,255,.96)');
  gradient.addColorStop(0.3, 'rgba(255,255,255,.3)');
  gradient.addColorStop(0.75, 'rgba(255,255,255,.075)');
  gradient.addColorStop(1, 'rgba(255,255,255,0)');
  context.fillStyle = gradient;
  context.fillRect(0, 0, 128, 128);
  const texture = new THREE.CanvasTexture(canvas);
  texture.colorSpace = THREE.SRGBColorSpace;
  return texture;
}

const particleTexture = createCircleTexture();

function createSealGlowTexture() {
  const canvas = document.createElement('canvas');
  canvas.width = 512;
  canvas.height = 256;
  const context = canvas.getContext('2d');
  const image = context.createImageData(canvas.width, canvas.height);
  for (let y = 0; y < canvas.height; y += 1) {
    const v = y / (canvas.height - 1);
    for (let x = 0; x < canvas.width; x += 1) {
      const u = x / (canvas.width - 1);
      const dx = Math.abs(u - 0.5) / 0.5;
      const dy = v < 0.62 ? (0.62 - v) / 0.62 : (v - 0.62) / 0.38;
      const distance = Math.sqrt(dx * dx + dy * dy);
      const falloff = Math.max(0, 1 - distance);
      const alpha = Math.pow(falloff, 2.35) * 0.24;
      const offset = (y * canvas.width + x) * 4;
      image.data[offset] = 255;
      image.data[offset + 1] = 255;
      image.data[offset + 2] = 255;
      image.data[offset + 3] = Math.round(alpha * 255);
    }
  }
  context.putImageData(image, 0, 0);
  const texture = new THREE.CanvasTexture(canvas);
  texture.colorSpace = THREE.SRGBColorSpace;
  return texture;
}

const sealGlowTexture = createSealGlowTexture();

function disposeFocusSealSpark() {
  if (!focusSealSpark) return;
  focusSealSpark.group.removeFromParent();
  focusSealSpark.sealAura.removeFromParent();
  focusSealSpark.material.dispose();
  focusSealSpark.haloMaterial.dispose();
  focusSealSpark.sealAuraMaterial.dispose();
  focusSealSpark = null;
}

function createFocusSealSpark() {
  disposeFocusSealSpark();
  if (!selectedPack) return;
  const material = new THREE.SpriteMaterial({
    map: particleTexture,
    color: 0x2673ff,
    transparent: true,
    opacity: 0,
    depthTest: false,
    depthWrite: false,
    blending: THREE.AdditiveBlending,
  });
  const haloMaterial = material.clone();
  haloMaterial.color.set(0xffffff);
  const sealAuraMaterial = new THREE.SpriteMaterial({
    map: sealGlowTexture,
    color: 0xffffff,
    transparent: true,
    opacity: 0,
    depthTest: false,
    depthWrite: false,
    blending: THREE.AdditiveBlending,
  });
  const sprite = new THREE.Sprite(material);
  const halo = new THREE.Sprite(haloMaterial);
  halo.scale.set(1.9, 2.4, 1);
  const sealAura = new THREE.Sprite(sealAuraMaterial);
  sealAura.position.set(0, packHeight * 0.5 - packHeight * MEASURED.topHeightRatio + 0.12, 0.11);
  sealAura.scale.set(MEASURED.packWidth + 0.75, 0.92, 1);
  sealAura.visible = false;
  const group = new THREE.Group();
  group.position.set(-MEASURED.packWidth / 2 + 0.08, packHeight * 0.5 - packHeight * MEASURED.topHeightRatio + 0.045, 0.14);
  group.add(halo, sprite);
  group.visible = false;
  selectedPack.floatGroup.add(sealAura, group);
  enableBloom(group);
  enableBloom(sealAura);
  focusSealSpark = {
    group,
    sprite,
    halo,
    sealAura,
    material,
    haloMaterial,
    sealAuraMaterial,
    phase: 0,
    pauseRemaining: 3,
  };
}

function updateFocusSealSpark(delta) {
  const spark = focusSealSpark;
  if (!spark || state !== 'tear' || tearProgress > 0.001) {
    if (spark) {
      spark.group.visible = false;
      spark.sealAura.visible = false;
    }
    return;
  }
  spark.sealAura.visible = true;
  spark.sealAuraMaterial.opacity = 0.38 + Math.sin(clock.elapsedTime * 2.2) * 0.025;
  if (spark.pauseRemaining > 0) {
    spark.pauseRemaining = Math.max(0, spark.pauseRemaining - delta);
    spark.group.visible = false;
    return;
  }

  spark.phase += (delta / 1.6) * 0.95;
  if (spark.phase >= 1) {
    spark.phase = 0;
    spark.pauseRemaining = 3;
    spark.group.visible = false;
    return;
  }

  const progress = spark.phase;
  const fade = 1 - THREE.MathUtils.smoothstep(progress, 0.32, 0.9);
  const width = THREE.MathUtils.lerp(0.18, 0.015, progress);
  spark.group.visible = true;
  spark.group.position.x = THREE.MathUtils.lerp(-MEASURED.packWidth / 2 + 0.08, MEASURED.packWidth / 2 - 0.08, progress);
  spark.sprite.scale.set(width, 0.042, 1);
  spark.halo.scale.set(Math.max(width * 2.15, 0.09), 0.11, 1);
  spark.material.opacity = fade * 0.78;
  spark.haloMaterial.opacity = fade * 0.22;
}

function registerParticleSystem(points, update, duration, dispose = null) {
  const system = { points, update, age: 0, duration, dispose };
  particleSystems.push(system);
  effects.add(points);
  enableBloom(points);
  return system;
}

function disposeParticleSystem(system) {
  system.points.removeFromParent();
  if (system.dispose) {
    system.dispose();
    return;
  }
  system.points.geometry.dispose();
  system.points.material.dispose();
}

function spawnBurst({ count, origin, color, speed = 3, life = 2, size = 0.08, gravity = 0.6, spread = 1, radial = false }) {
  const positions = new Float32Array(count * 3);
  const velocities = [];
  for (let index = 0; index < count; index += 1) {
    const offset = new THREE.Vector3(
      (Math.random() - 0.5) * spread,
      (Math.random() - 0.5) * spread,
      (Math.random() - 0.5) * spread,
    );
    positions.set([origin.x + offset.x, origin.y + offset.y, origin.z + offset.z], index * 3);
    const direction = radial ? offset.normalize() : new THREE.Vector3(Math.random() - 0.5, Math.random() * 0.9 + 0.15, Math.random() - 0.5).normalize();
    velocities.push(direction.multiplyScalar(speed * (0.45 + Math.random() * 0.85)));
  }
  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  const material = new THREE.PointsMaterial({
    color,
    size,
    map: particleTexture,
    transparent: true,
    opacity: 1,
    depthWrite: false,
    blending: THREE.AdditiveBlending,
    sizeAttenuation: true,
  });
  const points = new THREE.Points(geometry, material);
  return registerParticleSystem(points, (delta, age) => {
    for (let index = 0; index < count; index += 1) {
      const velocity = velocities[index];
      velocity.y -= gravity * delta;
      positions[index * 3] += velocity.x * delta;
      positions[index * 3 + 1] += velocity.y * delta;
      positions[index * 3 + 2] += velocity.z * delta;
    }
    geometry.attributes.position.needsUpdate = true;
    material.opacity = Math.max(0, 1 - age / life);
  }, life);
}

function spawnTearStream({ origin, count = 10, life = 1.5 }) {
  const positions = new Float32Array(count * 3);
  const linePositions = new Float32Array(count * 6);
  const velocities = [];
  for (let index = 0; index < count; index += 1) {
    const direction = new THREE.Vector3(
      (Math.random() - 0.5) * 0.9,
      Math.random() < 0.78 ? 0.5 + Math.random() * 1.2 : -(0.15 + Math.random() * 0.35),
      (Math.random() - 0.5) * 0.42,
    ).normalize();
    const velocity = direction.multiplyScalar(1.3 + Math.random() * 1.55);
    const jitter = new THREE.Vector3((Math.random() - 0.5) * 0.08, Math.random() * 0.04, (Math.random() - 0.5) * 0.04);
    positions.set([origin.x + jitter.x, origin.y + jitter.y, origin.z + jitter.z], index * 3);
    velocities.push(velocity);
  }
  const pointGeometry = new THREE.BufferGeometry();
  pointGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  const pointMaterial = new THREE.PointsMaterial({
    color: activeTheme.particle,
    size: 0.16,
    map: particleTexture,
    transparent: true,
    opacity: 0.95,
    depthWrite: false,
    blending: THREE.AdditiveBlending,
  });
  const points = new THREE.Points(pointGeometry, pointMaterial);
  const lineGeometry = new THREE.BufferGeometry();
  lineGeometry.setAttribute('position', new THREE.BufferAttribute(linePositions, 3));
  const lineMaterial = new THREE.LineBasicMaterial({ color: activeTheme.particle, transparent: true, opacity: 0.8, depthWrite: false, blending: THREE.AdditiveBlending });
  const streaks = new THREE.LineSegments(lineGeometry, lineMaterial);
  const group = new THREE.Group();
  group.add(streaks, points);
  return registerParticleSystem(group, (delta, age) => {
    const fade = Math.max(0, 1 - age / life);
    for (let index = 0; index < count; index += 1) {
      const offset = index * 3;
      const velocity = velocities[index];
      velocity.y -= 0.55 * delta;
      positions[offset] += velocity.x * delta;
      positions[offset + 1] += velocity.y * delta;
      positions[offset + 2] += velocity.z * delta;
      const lineOffset = index * 6;
      linePositions.set([positions[offset], positions[offset + 1], positions[offset + 2]], lineOffset);
      linePositions.set([
        positions[offset] - velocity.x * 0.24,
        positions[offset + 1] - velocity.y * 0.24,
        positions[offset + 2] - velocity.z * 0.24,
      ], lineOffset + 3);
    }
    pointGeometry.attributes.position.needsUpdate = true;
    lineGeometry.attributes.position.needsUpdate = true;
    pointMaterial.opacity = fade * 0.95;
    lineMaterial.opacity = fade * 0.8;
  }, life, () => {
    pointGeometry.dispose();
    pointMaterial.dispose();
    lineGeometry.dispose();
    lineMaterial.dispose();
  });
}

function addSparseParticleColors(geometry, count, visibleRatio) {
  const colors = new Float32Array(count * 3);
  const color = new THREE.Color(activeTheme.particle);
  for (let index = 0; index < count; index += 1) {
    if (Math.random() > visibleRatio) continue;
    colors[index * 3] = color.r;
    colors[index * 3 + 1] = color.g;
    colors[index * 3 + 2] = color.b;
  }
  geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
}

function spawnVortex() {
  const count = 1000;
  const positions = new Float32Array(count * 3);
  const seeds = Array.from({ length: count }, () => ({
    angle: Math.random() * Math.PI * 2,
    radius: Math.sqrt(Math.random()) * PACK_RADIUS * 0.8,
    y: Math.random() * 11,
    angularSpeed: THREE.MathUtils.lerp(2.1, 4.8, Math.random()) * 0.15,
  }));
  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  addSparseParticleColors(geometry, count, 0.34);
  const material = new THREE.PointsMaterial({ color: 0xffffff, vertexColors: true, size: 0.1, map: particleTexture, transparent: true, opacity: 0.68, depthWrite: false, blending: THREE.AdditiveBlending });
  const points = new THREE.Points(geometry, material);
  const nebulaMaterial = new THREE.SpriteMaterial({ map: sealGlowTexture, color: activeTheme.particle, transparent: true, opacity: 0, depthTest: false, depthWrite: false, blending: THREE.AdditiveBlending });
  const nebula = new THREE.Sprite(nebulaMaterial);
  nebula.position.set(0, 3.1, PACK_RADIUS - 0.4);
  nebula.scale.set(8.5, 11.5, 1);
  const group = new THREE.Group();
  group.add(points, nebula);
  return registerParticleSystem(group, (delta, age) => {
    seeds.forEach((seed, index) => {
      const orbitTime = Math.min(age, 0.3);
      const transition = THREE.MathUtils.clamp((age - 0.3) / 0.35, 0, 1);
      const integratedTime = orbitTime + transition - transition * transition * 0.5;
      const angle = seed.angle + integratedTime * seed.angularSpeed;
      const intro = THREE.MathUtils.smoothstep(THREE.MathUtils.clamp(age / 1.2, 0, 1), 0, 1);
      const radiusScale = THREE.MathUtils.lerp(0.68, 1, intro);
      const riseTime = Math.max(0, age - 0.65);
      positions[index * 3] = Math.cos(angle) * seed.radius * radiusScale;
      positions[index * 3 + 1] = seed.y + riseTime * 0.8;
      positions[index * 3 + 2] = PACK_RADIUS + Math.sin(angle) * seed.radius * radiusScale * 0.24;
    });
    geometry.attributes.position.needsUpdate = true;
    material.opacity = Math.min(0.68, age / 0.7) * Math.max(0, 1 - age / 15.3);
    nebulaMaterial.opacity = Math.min(0.1, age * 0.09) * Math.max(0, 1 - age / 8.5);
  }, 15.3, () => {
    geometry.dispose();
    material.dispose();
    nebulaMaterial.dispose();
  });
}

function spawnSnow() {
  const count = 300;
  const positions = new Float32Array(count * 3);
  const seeds = Array.from({ length: count }, () => ({
    angle: Math.random() * Math.PI * 2,
    radius: Math.sqrt(Math.random()) * PACK_RADIUS * 0.8,
    y: THREE.MathUtils.lerp(-2, 10, Math.random()),
    angularSpeed: THREE.MathUtils.lerp(2.1, 4.8, Math.random()) * 0.15,
    phase: Math.random() * Math.PI * 2,
    wobble: THREE.MathUtils.lerp(0.12, 0.35, Math.random()),
  }));
  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  addSparseParticleColors(geometry, count, 0.48);
  const material = new THREE.PointsMaterial({ color: 0xffffff, vertexColors: true, size: 0.2, map: particleTexture, transparent: true, opacity: 0.72, depthWrite: false, blending: THREE.AdditiveBlending });
  const points = new THREE.Points(geometry, material);
  return registerParticleSystem(points, (delta, age) => {
    seeds.forEach((seed, index) => {
      const orbitTime = Math.min(age, 1.2);
      const fallTime = Math.max(0, age - 1.2);
      const angle = seed.angle + orbitTime * seed.angularSpeed;
      const intro = THREE.MathUtils.smoothstep(THREE.MathUtils.clamp(age / 0.6, 0, 1), 0, 1);
      const radius = seed.radius * THREE.MathUtils.lerp(0.8, 1, intro);
      const wobbleIn = THREE.MathUtils.clamp(fallTime / 0.9, 0, 1);
      const wobbleX = Math.sin(fallTime * Math.PI * 2 * 0.55 + seed.phase) * seed.wobble * wobbleIn;
      const wobbleZ = Math.cos(fallTime * Math.PI * 2 * 0.55 + seed.phase) * seed.wobble * wobbleIn;
      positions[index * 3] = Math.cos(angle) * radius + wobbleX;
      positions[index * 3 + 1] = seed.y - fallTime * fallTime * 4.25 * 0.5;
      positions[index * 3 + 2] = PACK_RADIUS + Math.sin(angle) * radius * 0.24 + wobbleZ;
    });
    geometry.attributes.position.needsUpdate = true;
    material.opacity = Math.min(0.85, age / 0.6) * Math.max(0, 1 - age / 5.4);
  }, 5.4);
}

function spawnFinalDust() {
  const count = 1000;
  const positions = new Float32Array(count * 3);
  const seeds = Array.from({ length: count }, () => ({
    x: (Math.random() * 2 - 1),
    z: (Math.random() - 0.5) * PACK_RADIUS * 0.5,
    phase: Math.random() * 21.4,
    drift: (Math.random() - 0.5) * 0.35,
  }));
  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  addSparseParticleColors(geometry, count, 0.3);
  const material = new THREE.PointsMaterial({ color: 0xffffff, vertexColors: true, size: 0.16, map: particleTexture, transparent: true, opacity: 0.72, depthWrite: false, blending: THREE.AdditiveBlending });
  const points = new THREE.Points(geometry, material);
  return registerParticleSystem(points, (delta, age) => {
    seeds.forEach((seed, index) => {
      const localAge = (age + seed.phase) % 21.4;
      const progress = localAge / 21.4;
      positions[index * 3] = seed.x + Math.sin(localAge * 0.45 + seed.phase) * seed.drift;
      positions[index * 3 + 1] = -5 + localAge * 1.2;
      positions[index * 3 + 2] = PACK_RADIUS + seed.z + progress * 2.5;
    });
    geometry.attributes.position.needsUpdate = true;
    material.opacity = Math.min(0.82, age);
  }, 30);
}

function spawnFireworks() {
  const bursts = [
    { at: 0.2, position: [3, 8, PACK_RADIUS + 1], size: 0.08, audio: 'fireworks1' },
    { at: 0.7, position: [-3, 4, PACK_RADIUS - 3], size: 0.13, audio: 'fireworks2' },
    { at: 1.5, position: [2.7, 1, PACK_RADIUS + 2], size: 0.13, audio: 'fireworks2' },
    { at: 2.2, position: [-3.2, 3, PACK_RADIUS - 3], size: 0.13, audio: 'fireworks3' },
  ];
  bursts.forEach(({ at, position, size, audio }) => {
    gsap.delayedCall(at, () => {
      spawnBurst({ count: 250, origin: new THREE.Vector3(...position), color: activeTheme.particle, speed: 4.2, life: 2.6, size, gravity: 1.3, spread: 0.2, radial: true });
      haptic('heavy');
      playAudioCue(audio);
    });
  });
}

function createAmbientDust() {
  const count = 8;
  const positions = new Float32Array(count * 3);
  const phases = [];
  for (let index = 0; index < count; index += 1) {
    const angle = Math.random() * Math.PI * 2;
    const radius = Math.sqrt(Math.random()) * PACK_RADIUS * 0.75;
    positions.set([Math.cos(angle) * radius, -3 + Math.random() * 10, Math.sin(angle) * radius], index * 3);
    phases.push(Math.random() * Math.PI * 2);
  }
  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  const material = new THREE.PointsMaterial({ color: activeTheme.particle, size: 0.3, map: particleTexture, transparent: true, opacity: 0.86, depthWrite: false, blending: THREE.AdditiveBlending });
  ambientDust = { points: new THREE.Points(geometry, material), positions, phases, material };
  enableBloom(ambientDust.points);
  scene.add(ambientDust.points);
}

function updateAmbientDust(elapsed) {
  if (!ambientDust) return;
  for (let index = 0; index < ambientDust.phases.length; index += 1) {
    ambientDust.positions[index * 3 + 1] += 0.003;
    if (ambientDust.positions[index * 3 + 1] > 7) ambientDust.positions[index * 3 + 1] = -3;
    ambientDust.positions[index * 3] += Math.sin(elapsed * 0.4 + ambientDust.phases[index]) * 0.0008;
  }
  ambientDust.points.geometry.attributes.position.needsUpdate = true;
}

function cleanupParticles() {
  particleSystems.splice(0).forEach(disposeParticleSystem);
}

function createCardTexture() {
  const canvas = document.createElement('canvas');
  canvas.width = 919;
  canvas.height = 1591;
  const context = canvas.getContext('2d');
  context.fillStyle = '#d7dcda';
  context.fillRect(0, 0, canvas.width, canvas.height);
  context.fillStyle = '#222825';
  context.fillRect(34, 34, canvas.width - 68, canvas.height - 68);
  context.fillStyle = '#eef1ef';
  context.fillRect(54, 54, canvas.width - 108, canvas.height - 108);
  context.fillStyle = '#f7faf8';
  context.fillRect(70, 70, canvas.width - 140, 154);
  context.fillStyle = '#151a18';
  context.font = '700 46px Segoe UI, sans-serif';
  context.fillText('ARCHIVE GRADE', 104, 134);
  context.font = '700 74px Segoe UI, sans-serif';
  context.fillText('10', 744, 158);
  const art = context.createRadialGradient(460, 700, 30, 460, 700, 520);
  art.addColorStop(0, '#fff5a8');
  art.addColorStop(0.35, '#ff775f');
  art.addColorStop(0.72, '#4f2f93');
  art.addColorStop(1, '#07110e');
  context.fillStyle = art;
  context.fillRect(94, 260, canvas.width - 188, 840);
  context.strokeStyle = '#55e7b5';
  context.lineWidth = 10;
  for (let index = 0; index < 24; index += 1) {
    const angle = (index / 24) * Math.PI * 2;
    context.beginPath();
    context.moveTo(460, 680);
    context.lineTo(460 + Math.cos(angle) * (120 + (index % 5) * 45), 680 + Math.sin(angle) * (120 + (index % 7) * 34));
    context.stroke();
  }
  context.fillStyle = '#101513';
  context.font = '800 68px Segoe UI, sans-serif';
  context.fillText('PRISM 07', 100, 1200);
  context.font = '500 34px Segoe UI, sans-serif';
  context.fillText('ARCHIVE EDITION / GEM 10', 100, 1270);
  context.fillText('SERIAL 000184', 100, 1330);
  const texture = new THREE.CanvasTexture(canvas);
  texture.colorSpace = THREE.SRGBColorSpace;
  texture.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy());
  return texture;
}

const finalCardTexture = createCardTexture();

function createCardGlowTexture() {
  const canvas = document.createElement('canvas');
  canvas.width = 256;
  canvas.height = 448;
  const context = canvas.getContext('2d');
  context.clearRect(0, 0, canvas.width, canvas.height);
  for (let index = 34; index >= 1; index -= 1) {
    const t = index / 34;
    context.strokeStyle = `rgba(255,255,255,${(1 - t) * (1 - t) * 0.12})`;
    context.lineWidth = 9;
    context.strokeRect(index, index, canvas.width - index * 2, canvas.height - index * 2);
  }
  const texture = new THREE.CanvasTexture(canvas);
  texture.colorSpace = THREE.SRGBColorSpace;
  return texture;
}

const cardGlowTexture = createCardGlowTexture();

function createCardBackPatternTexture() {
  const canvas = document.createElement('canvas');
  canvas.width = 640;
  canvas.height = 640;
  const context = canvas.getContext('2d');
  context.fillStyle = '#050505';
  context.fillRect(0, 0, canvas.width, canvas.height);
  context.strokeStyle = '#ffffff';
  context.lineWidth = 42;
  context.lineJoin = 'miter';
  context.lineCap = 'square';

  const drawPath = (points) => {
    context.beginPath();
    points.forEach(([x, y], index) => {
      const method = index === 0 ? 'moveTo' : 'lineTo';
      context[method](x * canvas.width, y * canvas.height);
    });
    context.stroke();
  };

  drawPath([[-0.08, 0.04], [0.3, 0.31], [0.5, 0.13], [0.7, 0.31], [1.08, 0.04]]);
  drawPath([[-0.08, 0.22], [0.18, 0.42], [0.31, 0.3]]);
  drawPath([[1.08, 0.22], [0.82, 0.42], [0.69, 0.3]]);
  drawPath([[0.42, 0.31], [0.5, 0.24], [0.58, 0.31], [0.5, 0.39], [0.42, 0.31]]);
  drawPath([[-0.08, 0.48], [0.2, 0.69], [0.5, 0.43], [0.8, 0.69], [1.08, 0.48]]);
  drawPath([[0.06, 0.72], [0.22, 0.84], [0.5, 0.59], [0.78, 0.84], [0.94, 0.72]]);
  drawPath([[0.18, 1.08], [0.5, 0.79], [0.82, 1.08]]);
  drawPath([[0.35, 1.08], [0.5, 0.94], [0.65, 1.08]]);
  drawPath([[-0.08, 0.81], [0.1, 0.95], [0.21, 0.85]]);
  drawPath([[1.08, 0.81], [0.9, 0.95], [0.79, 0.85]]);

  const texture = new THREE.CanvasTexture(canvas);
  texture.wrapS = THREE.RepeatWrapping;
  texture.wrapT = THREE.RepeatWrapping;
  texture.colorSpace = THREE.SRGBColorSpace;
  texture.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy());
  return texture;
}

const cardBackPatternTexture = createCardBackPatternTexture();

function createPlaceholderMaterial(color) {
  return new THREE.ShaderMaterial({
    uniforms: {
      uColor: { value: new THREE.Color(color) },
      uPattern: { value: cardBackPatternTexture },
      uTime: { value: 0 },
    },
    vertexShader: 'varying vec2 vUv; void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}',
    fragmentShader: `
      uniform vec3 uColor;
      uniform sampler2D uPattern;
      uniform float uTime;
      varying vec2 vUv;
      void main(){
        float pattern = smoothstep(0.35, 0.65, texture2D(uPattern, vUv * vec2(1.0, 1.3)).r);
        vec3 base = mix(uColor, vec3(1.0), 0.1);
        vec2 centered = vUv - 0.5;
        vec2 direction = vec2(cos(-0.9), -sin(-0.9));
        float diagonal = dot(centered, direction);
        float phase = abs(fract(diagonal + uTime * 0.12 + 0.5) - 0.5);
        float sweep = pow(1.0 - smoothstep(0.0, 0.28, phase), 2.0);
        vec3 patterned = mix(base * 0.76, mix(base, vec3(1.0), 0.48), pattern);
        vec3 color = patterned + pattern * sweep * 0.28;
        gl_FragColor = vec4(color, 1.0);
      }
    `,
    side: THREE.DoubleSide,
  });
}

function createCardChaseMaterial() {
  return new THREE.ShaderMaterial({
    uniforms: {
      uTime: { value: 0 },
      uStartTime: { value: 0 },
      uDuration: { value: 5 },
      uSize: { value: new THREE.Vector2(3, (3 * 1591) / 919) },
      uBorderThickness: { value: 0.03 },
      uSpinnerSpeed: { value: 0.4 },
      uSpinnerArc: { value: 0.01 },
      uSpinnerFeather: { value: 0.3 },
      uIntensity: { value: 3 },
      uFinaleIntensity: { value: 1.25 },
    },
    vertexShader: 'varying vec2 vUv; void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}',
    fragmentShader: `
      uniform float uTime;
      uniform float uStartTime;
      uniform float uDuration;
      uniform vec2 uSize;
      uniform float uBorderThickness;
      uniform float uSpinnerSpeed;
      uniform float uSpinnerArc;
      uniform float uSpinnerFeather;
      uniform float uIntensity;
      uniform float uFinaleIntensity;
      varying vec2 vUv;

      float circularDistance(float a, float b) {
        float d = abs(a - b);
        return min(d, 1.0 - d);
      }

      void main(){
        vec2 edgeWorld = min(vUv, 1.0 - vUv) * uSize;
        float edgeDistance = min(edgeWorld.x, edgeWorld.y);
        float border = 1.0 - smoothstep(uBorderThickness * 0.72, uBorderThickness, edgeDistance);

        float width = uSize.x;
        float height = uSize.y;
        float perimeter = 2.0 * (width + height);
        float bottom = vUv.y;
        float top = 1.0 - vUv.y;
        float left = vUv.x;
        float right = 1.0 - vUv.x;
        float nearest = min(min(bottom, top), min(left, right));
        float perimeterPosition;
        if (nearest == bottom) perimeterPosition = vUv.x * width;
        else if (nearest == right) perimeterPosition = width + vUv.y * height;
        else if (nearest == top) perimeterPosition = width + height + (1.0 - vUv.x) * width;
        else perimeterPosition = width * 2.0 + height + (1.0 - vUv.y) * height;
        perimeterPosition /= perimeter;

        float elapsed = clamp(uTime - uStartTime, 0.0, uDuration);
        float progress = elapsed / uDuration;
        float eased = 1.0 - (1.0 - progress) * (1.0 - progress);
        float head = fract(10.0 - eased * uSpinnerSpeed * uDuration);
        float distanceToHead = circularDistance(perimeterPosition, head);
        float trail = 1.0 - smoothstep(uSpinnerArc * 0.5, uSpinnerArc * 0.5 + uSpinnerFeather, distanceToHead);
        float fade = 1.0 - smoothstep(uDuration - 1.25, uDuration, elapsed);

        float finaleT = clamp((elapsed - (uDuration - 1.0)) / 1.0, 0.0, 1.0);
        float finale = smoothstep(0.0, 0.5, finaleT) * (1.0 - smoothstep(0.5, 1.0, finaleT));
        float gradientT = clamp(dot(vUv, normalize(vec2(0.62, 0.78))), 0.0, 1.0);
        vec3 chaseColor = mix(vec3(0.596, 0.984, 0.565), vec3(0.98, 0.788, 0.478), gradientT) * uIntensity;
        vec3 finaleColor = vec3(1.0) * uFinaleIntensity;
        float alpha = border * clamp(trail * fade + finale, 0.0, 1.0);
        gl_FragColor = vec4(chaseColor * trail * fade + finaleColor * finale, alpha);
      }
    `,
    transparent: true,
    depthWrite: false,
    depthTest: false,
    blending: THREE.AdditiveBlending,
    toneMapped: false,
    side: THREE.DoubleSide,
  });
}

function createDissolveMaterial(texture) {
  return new THREE.ShaderMaterial({
    uniforms: {
      uMap: { value: texture },
      uProgress: { value: 0 },
      uEdgeColor: { value: new THREE.Color(activeTheme.particle) },
    },
    vertexShader: 'varying vec2 vUv; void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}',
    fragmentShader: `
      uniform sampler2D uMap;
      uniform float uProgress;
      uniform vec3 uEdgeColor;
      varying vec2 vUv;
      float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
      void main(){
        vec4 tex = texture2D(uMap,vUv);
        float noise = hash(floor(vUv * vec2(68.0, 112.0)));
        float wave = uProgress * 1.35 - 0.15 + (vUv.y - 0.5) * 0.18;
        float mask = smoothstep(noise - 0.05, noise + 0.05, wave);
        if(mask < 0.01 || tex.a < 0.01) discard;
        float edge = 1.0 - smoothstep(0.0, 0.09, abs(wave-noise));
        gl_FragColor = vec4(mix(tex.rgb, uEdgeColor * 2.0, edge * 0.75), tex.a * mask);
      }
    `,
    transparent: true,
    depthWrite: true,
    side: THREE.DoubleSide,
  });
}

function createRevealCard() {
  const group = new THREE.Group();
  group.position.set(0, -0.5, -2);
  const width = 3;
  const height = (width * 1591) / 919;
  const geometry = new THREE.PlaneGeometry(width, height, 1, 1);
  const placeholderMaterial = createPlaceholderMaterial(SAMPLE_COLORS[0]);
  const placeholder = new THREE.Mesh(geometry, placeholderMaterial);
  const finalMaterial = createDissolveMaterial(finalCardTexture);
  const finalCard = new THREE.Mesh(geometry, finalMaterial);
  finalCard.position.z = 0.025;
  const chaseMaterial = createCardChaseMaterial();
  const chaseGlow = new THREE.Mesh(geometry, chaseMaterial);
  chaseGlow.position.z = 0.055;
  chaseGlow.renderOrder = 8;
  chaseGlow.visible = false;
  const glowMaterial = new THREE.MeshBasicMaterial({ map: cardGlowTexture, color: activeTheme.particle, transparent: true, opacity: 0, depthWrite: false, blending: THREE.AdditiveBlending });
  const glow = new THREE.Mesh(new THREE.PlaneGeometry(width + 0.72, height + 0.72), glowMaterial);
  glow.position.z = -0.08;
  enableBloom(chaseGlow);
  enableBloom(glow);
  group.add(glow, placeholder, finalCard, chaseGlow);
  group.userData = {
    placeholder,
    placeholderMaterial,
    finalCard,
    finalMaterial,
    chaseGlow,
    chaseMaterial,
    glow,
    glowMaterial,
    colorIndex: 0,
    spinCarry: Math.PI / 2,
    previousRotation: 0,
    holdHalfTurns: 2,
  };
  return group;
}

function advanceCardColorFromSpin(card) {
  const data = card.userData;
  const delta = Math.abs(card.rotation.y - data.previousRotation);
  data.previousRotation = card.rotation.y;
  data.spinCarry += delta;
  while (data.spinCarry >= Math.PI) {
    data.spinCarry -= Math.PI;
    if (data.holdHalfTurns > 0) data.holdHalfTurns -= 1;
    else {
      data.colorIndex = (data.colorIndex + 1) % SAMPLE_COLORS.length;
      data.placeholderMaterial.uniforms.uColor.value.set(SAMPLE_COLORS[data.colorIndex]);
      haptic('medium');
    }
  }
}

function startCardReveal() {
  if (!selectedPack) return;
  state = 'revealing';
  setPrompt('Tap to speed up', '', 'reveal');
  activeCard = createRevealCard();
  selectedPack.cardsGroup.add(activeCard);
  const data = activeCard.userData;

  revealTimeline = gsap.timeline({
    delay: 0.3,
    defaults: { ease: 'power2.inOut' },
  });
  revealTimeline
    .to(activeCard.position, { y: 3, duration: 1 })
    .to(activeCard.position, { z: 0.85, duration: 0.75 }, '-=0.5')
    .to(activeCard.rotation, {
      y: `-=${Math.PI * 8 + Math.PI * 0.75}`,
      duration: 3,
      ease: 'sine.inOut',
      onUpdate: () => advanceCardColorFromSpin(activeCard),
    }, '-=0.75')
    .to(activeCard.rotation, {
      y: `+=${Math.PI * 0.75}`,
      duration: 1,
      ease: 'back.inOut(0.2)',
      onUpdate: () => advanceCardColorFromSpin(activeCard),
      onComplete: () => {
        data.placeholderMaterial.uniforms.uColor.value.set(activeTheme.rarity);
        gsap.to(data.glowMaterial, { opacity: 0.65, duration: 0.5, yoyo: true, repeat: 1 });
        data.chaseMaterial.uniforms.uStartTime.value = clock.elapsedTime;
        data.chaseGlow.visible = true;
        dissolveTween = gsap.to(data.finalMaterial.uniforms.uProgress, {
          value: 1,
          duration: MEASURED.cardDissolveDuration,
          ease: 'expo.out',
          onComplete: () => {
            data.placeholder.visible = false;
            revealTimeline = null;
            dissolveTween = null;
            state = 'complete';
            setPrompt('Tap to continue', '', 'complete');
          },
        });
      },
    });

  const revealAudioCues = rareToggle.checked
    ? [['cardPackSlice', 0], ['carouselBounce3', 0.66], ['cardRevealLightChimes', 1.3]]
    : [['cardRevealBass', 0.3], ['cardRevealBassRare', 0.4], ['cardRevealLightChimes', 0.85]];
  revealAudioCues.forEach(([name, at]) => revealTimeline.call(() => playAudioCue(name), null, at));
}

function accelerateReveal() {
  revealTimeline?.timeScale(MEASURED.tapSpeedMultiplier);
  dissolveTween?.timeScale(MEASURED.tapSpeedMultiplier);
  hidePrompt();
}

function ensureAudioContext() {
  const AudioContextClass = window.AudioContext || window.webkitAudioContext;
  if (!AudioContextClass) return null;
  audioContext ||= new AudioContextClass();
  return audioContext;
}

function synthSound(kind) {
  if (!soundToggle.checked) return;
  const context = ensureAudioContext();
  if (!context) return;
  context.resume().catch(() => {});
  const now = context.currentTime;
  const oscillator = context.createOscillator();
  const gain = context.createGain();
  const settings = {
    ambient: [96, 82, 0.8, 'sine'],
    select: [360, 170, 0.12, 'sine'],
    slice: [860, 180, 0.2, 'triangle'],
    paper: [430, 210, 0.09, 'triangle'],
    whoosh: [720, 72, 0.48, 'sine'],
    bass: [128, 42, 0.55, 'sine'],
    chime: [1320, 560, 0.72, 'sine'],
    open: [110, 38, 0.75, 'sawtooth'],
    firework: [145, 52, 0.5, 'square'],
  }[kind] || [260, 120, 0.16, 'sine'];
  oscillator.type = settings[3];
  oscillator.frequency.setValueAtTime(settings[0], now);
  oscillator.frequency.exponentialRampToValueAtTime(settings[1], now + settings[2]);
  gain.gain.setValueAtTime(kind === 'open' ? 0.07 : 0.035, now);
  gain.gain.exponentialRampToValueAtTime(0.0001, now + settings[2]);
  oscillator.connect(gain).connect(context.destination);
  oscillator.start(now);
  oscillator.stop(now + settings[2]);
}

const SYNTH_CUE_MAP = {
  carouselTheme: 'ambient',
  carouselBounce3: 'select',
  cardPackTear: 'open',
  cardPackSlice: 'slice',
  anticipationBright: 'slice',
  anticipationBright2_5x: 'whoosh',
  wrappingPaper1: 'paper',
  wrappingPaper2: 'paper',
  wrappingPaper3: 'paper',
  wrappingPaper4: 'paper',
  victoryParadeStart: 'open',
  victoryParadeLoop: 'ambient',
  cardRevealBass: 'bass',
  cardRevealBassRare: 'bass',
  cardRevealLightChimes: 'chime',
  fireworks1: 'firework',
  fireworks2: 'firework',
  fireworks3: 'firework',
};

async function loadAudioManifest() {
  try {
    const response = await fetch('/assets/audio/manifest.json');
    if (!response.ok) throw new Error(`Audio manifest HTTP ${response.status}`);
    const manifest = await response.json();
    audioManifest = manifest.cues && typeof manifest.cues === 'object' ? manifest.cues : {};
    const context = ensureAudioContext();
    if (!context) return;
    await Promise.all(Object.entries(audioManifest).map(async ([name, value]) => {
      try {
        const audioResponse = await fetch(resolveAudioSource(value));
        if (!audioResponse.ok) throw new Error(`HTTP ${audioResponse.status}`);
        const buffer = await context.decodeAudioData(await audioResponse.arrayBuffer());
        audioBuffers.set(name, buffer);
      } catch (error) {
        console.warn(`Unable to preload audio cue: ${name}`, error);
      }
    }));
  } catch (error) {
    console.warn('Audio manifest unavailable; using synthesized cues.', error);
    audioManifest = {};
  }
}

function resolveAudioSource(value) {
  if (!value) return null;
  if (/^(https?:)?\/\//i.test(value) || value.startsWith('/')) return value;
  return `/assets/audio/${value}`;
}

function stopAudioCue(name) {
  for (const entry of [...webAudioInstances]) {
    if (entry.name !== name) continue;
    entry.stopped = true;
    entry.source.stop();
    webAudioInstances.delete(entry);
  }
  for (const audio of [...audioInstances]) {
    if (audio._cueName !== name) continue;
    audio._stopped = true;
    audio.pause();
    audio.currentTime = 0;
    audioInstances.delete(audio);
  }
}

function stopAllAudio() {
  for (const entry of webAudioInstances) {
    entry.stopped = true;
    entry.source.stop();
  }
  webAudioInstances.clear();
  for (const audio of audioInstances) {
    audio._stopped = true;
    audio.pause();
    audio.currentTime = 0;
  }
  audioInstances.clear();
}

function playAudioCue(name, { loop = false, volume = 1, onEnded, oneShot = false } = {}) {
  if (!soundToggle.checked) return null;
  if (!oneShot) stopAudioCue(name);
  const context = ensureAudioContext();
  const buffer = audioBuffers.get(name);
  if (context && buffer) {
    context.resume().catch(() => {});
    const source = context.createBufferSource();
    const gain = context.createGain();
    const entry = { name, source, gain, stopped: false };
    source.buffer = buffer;
    source.loop = loop;
    gain.gain.value = THREE.MathUtils.clamp(volume, 0, 1);
    source.connect(gain).connect(context.destination);
    source.addEventListener('ended', () => {
      webAudioInstances.delete(entry);
      source.disconnect();
      gain.disconnect();
      if (!entry.stopped) onEnded?.();
    }, { once: true });
    webAudioInstances.add(entry);
    source.start(0);
    return source;
  }

  const source = resolveAudioSource(audioManifest[name]);
  if (!source) {
    synthSound(SYNTH_CUE_MAP[name] || 'select');
    return null;
  }
  const audio = new Audio(source);
  audio._cueName = name;
  audio._stopped = false;
  audio.loop = loop;
  audio.volume = THREE.MathUtils.clamp(volume, 0, 1);
  audio.preload = 'auto';
  audioInstances.add(audio);
  audio.addEventListener('ended', () => {
    audioInstances.delete(audio);
    if (!audio._stopped) onEnded?.();
  }, { once: true });
  audio.play().catch((error) => {
    audioInstances.delete(audio);
    console.warn(`Unable to play audio cue: ${name}`, error);
    synthSound(SYNTH_CUE_MAP[name] || 'select');
  });
  return audio;
}

function playPaperCue() {
  const now = performance.now();
  if (now - lastPaperSoundAt < 150) return;
  lastPaperSoundAt = now;
  const index = 1 + Math.floor(Math.random() * 4);
  playAudioCue(`wrappingPaper${index}`, { volume: 0.1, oneShot: true });
}

function resetTearSoundTracking(point = null) {
  tearSoundCarryPx = 0;
  tearSoundLastX = point?.x ?? 0;
  tearSoundLastY = point?.y ?? 0;
}

function emitTearMoveAudio(point) {
  const distance = Math.hypot(point.x - tearSoundLastX, point.y - tearSoundLastY);
  tearSoundLastX = point.x;
  tearSoundLastY = point.y;
  if (!Number.isFinite(distance) || distance <= 0) return;
  tearSoundCarryPx += distance;
  while (tearSoundCarryPx >= 10) {
    tearSoundCarryPx -= 10;
    playPaperCue();
  }
}

function resyncCarouselStepAudio() {
  const rotation = carousel.rotation.y;
  carouselAudioPreviousRotation = rotation;
  carouselAudioCarry = rotation - Math.round(rotation / packStep) * packStep;
}

function updateCarouselStepAudio(deltaTime) {
  const rotation = carousel.rotation.y;
  if (carouselAudioPreviousRotation === null) {
    resyncCarouselStepAudio();
    return;
  }
  const deltaRotation = rotation - carouselAudioPreviousRotation;
  carouselAudioPreviousRotation = rotation;
  if (deltaRotation === 0 || !Number.isFinite(deltaTime) || deltaTime <= 0) return;
  carouselAudioCarry += deltaRotation;
  const threshold = packStep * 0.7;
  let crossed = false;
  if (carouselAudioCarry >= threshold) {
    const count = Math.floor((carouselAudioCarry - threshold) / packStep) + 1;
    carouselAudioCarry -= count * packStep;
    crossed = true;
  } else if (carouselAudioCarry <= -threshold) {
    const count = Math.floor((-carouselAudioCarry - threshold) / packStep) + 1;
    carouselAudioCarry += count * packStep;
    crossed = true;
  }
  if (!crossed) return;
  const omega = Math.abs(deltaRotation / deltaTime);
  const volume = omega <= 2 ? 1 : omega >= 4 ? 0.65 : 1 - ((omega - 2) / 2) * 0.35;
  playAudioCue('carouselBounce3', { volume });
}

function haptic(style) {
  if (!('vibrate' in navigator)) return;
  navigator.vibrate(style === 'heavy' ? 35 : style === 'medium' ? 20 : 10);
}

function tearHapticStyle() {
  return tearProgress < 1 / 3 ? 'light' : tearProgress < 2 / 3 ? 'medium' : 'heavy';
}

function startTearHaptics() {
  stopTearHaptics();
  haptic(tearHapticStyle());
  tearHapticTimer = window.setInterval(() => haptic(tearHapticStyle()), MEASURED.tearHapticIntervalMs);
}

function stopTearHaptics() {
  if (tearHapticTimer !== null) window.clearInterval(tearHapticTimer);
  tearHapticTimer = null;
}

function startPackOpenHaptics() {
  const interval = (MEASURED.packOpenHapticDuration * 1000) / (MEASURED.packOpenHapticCount - 1);
  for (let index = 0; index < MEASURED.packOpenHapticCount; index += 1) {
    window.setTimeout(() => haptic('light'), index * interval);
  }
}

function shortestDelta(from, to) {
  return Math.atan2(Math.sin(to - from), Math.cos(to - from));
}

function snapCarousel(duration = MEASURED.snapDuration) {
  snapTween?.kill();
  pendingSnap = false;
  spinVelocity = 0;
  const current = carousel.rotation.y;
  const target = current + shortestDelta(current, Math.round(current / packStep) * packStep);
  snapTween = gsap.to(carousel.rotation, {
    y: target,
    duration,
    ease: 'power3.out',
    onComplete: () => { snapTween = null; },
  });
}

function updateCameraLook() {
  if (state === 'focus' || state === 'tear' || state === 'opening' || state === 'revealing' || state === 'complete') {
    camera.lookAt(0, focusLookY, PACK_RADIUS);
  } else {
    camera.lookAt(0, 0, 0);
  }
}

function selectPack(pack) {
  if (state !== 'carousel' || !pack) return;
  state = 'focus';
  selectedPack = pack;
  spinVelocity = 0;
  pendingSnap = false;
  snapTween?.kill();
  haptic('heavy');
  hidePrompt();

  const current = carousel.rotation.y;
  const targetBase = -pack.angle;
  const target = current + shortestDelta(current, targetBase);
  const needsAlign = Math.abs(target - current) > 0.0001;
  const focusProgress = { value: 0 };
  focusLookY = 0;

  const timeline = gsap.timeline({
    defaults: { ease: 'power3.out' },
    onComplete: () => {
      packs.forEach((item) => {
        if (item !== pack) item.group.visible = false;
      });
      pack.reflectionGroup.visible = false;
      state = 'tear';
      setPrompt('Slice the seal', '', 'tear');
      progress.classList.add('visible');
      ensureTearLine();
      createFocusSealSpark();
    },
  });
  if (needsAlign) timeline.to(carousel.rotation, { y: target, duration: MEASURED.focusAlignDuration }, 0);
  packs.forEach((item) => {
    if (item !== pack) timeline.to(item.group.scale, { x: 0, y: 0, z: 0, duration: MEASURED.focusDuration }, 0);
  });
  timeline.to(focusProgress, {
    value: 1,
    duration: MEASURED.focusDuration,
    onUpdate: () => {
      const value = focusProgress.value;
      camera.position.z = PACK_RADIUS + MEASURED.cameraDistance - MEASURED.focusCameraAdvance * value;
      camera.position.y = MEASURED.focusCameraLift * value;
      focusLookY = camera.position.y;
      updateCameraLook();
    },
  }, 0);
}

function ensureTearLine() {
  if (tearLine || !selectedPack) return;
  const count = 96;
  const positions = new Float32Array(count * 3);
  for (let index = 0; index < count; index += 1) {
    const t = index / (count - 1);
    positions[index * 3] = THREE.MathUtils.lerp(-MEASURED.packWidth / 2, MEASURED.packWidth / 2, t);
    positions[index * 3 + 1] = -Math.sin(t * Math.PI) * 0.45 + Math.sin(t * Math.PI * 4) * 0.025;
    positions[index * 3 + 2] = 0.06;
  }
  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  geometry.setDrawRange(0, 0);
  const line = new THREE.Line(geometry, new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.95, depthTest: false }));
  const glowLine = new THREE.Line(geometry, new THREE.LineBasicMaterial({ color: activeTheme.particle, transparent: true, opacity: 0.55, depthTest: false, blending: THREE.AdditiveBlending }));
  glowLine.scale.set(1, 1.8, 1);
  const group = new THREE.Group();
  group.position.set(0, packHeight * 0.5 - packHeight * MEASURED.topHeightRatio, 0.04);
  group.add(glowLine, line);
  enableBloom(glowLine);
  selectedPack.floatGroup.add(group);
  tearLine = { group, line, glowLine, geometry, count, direction: 'right' };
}

function disposeTearLine() {
  if (!tearLine) return;
  tearLine.group.removeFromParent();
  tearLine.geometry.dispose();
  tearLine.line.material.dispose();
  tearLine.glowLine.material.dispose();
  tearLine = null;
}

function reorderTearLine(direction) {
  if (!tearLine || tearLine.direction === direction) return;
  tearLine.direction = direction;
  const positions = tearLine.geometry.attributes.position;
  for (let index = 0; index < tearLine.count; index += 1) {
    const t = index / (tearLine.count - 1);
    const directionalT = direction === 'right' ? t : 1 - t;
    positions.setX(index, THREE.MathUtils.lerp(-MEASURED.packWidth / 2, MEASURED.packWidth / 2, directionalT));
    positions.setY(index, -Math.sin(t * Math.PI) * 0.45 + Math.sin(t * Math.PI * 4) * 0.025);
  }
  positions.needsUpdate = true;
}

function tearWorldPoint(value) {
  const directionalValue = tearDirection === 'left' ? 1 - value : value;
  const local = new THREE.Vector3(
    THREE.MathUtils.lerp(-MEASURED.packWidth / 2, MEASURED.packWidth / 2, directionalValue),
    packHeight * 0.5 - packHeight * MEASURED.topHeightRatio,
    0.12,
  );
  return selectedPack.floatGroup.localToWorld(local);
}

function setTearProgress(value) {
  tearProgress = THREE.MathUtils.clamp(value, 0, 1);
  progressBar.style.transform = `scaleX(${tearProgress})`;
  selectedPack.darkMaterial.opacity = tearProgress * 0.7;
  if (tearLine) tearLine.geometry.setDrawRange(0, Math.max(0, Math.floor(tearLine.count * tearProgress)));
  selectedPack.topGroup.rotation.z = (tearDirection === 'left' ? 1 : -1) * tearProgress * 0.08;
  selectedPack.topGroup.position.x = (tearDirection === 'left' ? -1 : 1) * tearProgress * 0.04;
}

function projectedTearBounds() {
  const bodyHalfHeight = (packHeight - packHeight * MEASURED.topHeightRatio * 2) / 2;
  const left = selectedPack.body.localToWorld(new THREE.Vector3(-MEASURED.packWidth / 2, bodyHalfHeight, 0));
  const right = selectedPack.body.localToWorld(new THREE.Vector3(MEASURED.packWidth / 2, bodyHalfHeight, 0));
  left.project(camera);
  right.project(camera);
  const rect = renderer.domElement.getBoundingClientRect();
  const toPx = (x) => (x * 0.5 + 0.5) * rect.width + rect.left;
  const min = Math.min(toPx(left.x), toPx(right.x)) + 5;
  const max = Math.max(toPx(left.x), toPx(right.x)) - 5;
  return { min, max };
}

function computeTearProgress(clientX) {
  const bounds = projectedTearBounds();
  const width = Math.max(1, bounds.max - bounds.min);
  return tearDirection === 'right'
    ? THREE.MathUtils.clamp((clientX - bounds.min) / width, 0, 1)
    : THREE.MathUtils.clamp((bounds.max - clientX) / width, 0, 1);
}

function tearStartDirection(clientX) {
  const bounds = projectedTearBounds();
  const width = Math.max(1, bounds.max - bounds.min);
  const tolerance = Math.min(18, width * 0.08);
  if (clientX >= bounds.min - tolerance && clientX <= bounds.min + width * 0.25) return 'right';
  if (clientX <= bounds.max + tolerance && clientX >= bounds.max - width * 0.25) return 'left';
  return null;
}

function emitTearSparks() {
  const origin = tearWorldPoint(tearProgress);
  spawnTearStream({ origin, count: 10, life: 1.5 });
}

function openPack() {
  if (state !== 'tear' || !selectedPack) return;
  state = 'opening';
  disposeFocusSealSpark();
  stopTearHaptics();
  progress.classList.remove('visible');
  hidePrompt();
  stopAudioCue('anticipationBright');
  stopAudioCue('carouselTheme');
  playAudioCue('cardPackTear');
  playAudioCue('victoryParadeStart', {
    oneShot: true,
    onEnded: () => playAudioCue('victoryParadeLoop', { loop: true }),
  });
  startPackOpenHaptics();

  spawnVortex();
  gsap.delayedCall(0.8, spawnSnow);
  gsap.delayedCall(rareToggle.checked ? 1.5 : 0, spawnFinalDust);
  if (rareToggle.checked) spawnFireworks();
  const openOrigin = tearWorldPoint(0.5);
  disposeTearLine();
  spawnBurst({ count: 100, origin: openOrigin, color: activeTheme.particle, speed: 2.4, life: 1.5, size: 0.14, gravity: 0.8, spread: 0.5 });

  const directionSign = tearDirection === 'left' ? -1 : 1;
  gsap.timeline({ defaults: { ease: 'power2.out' } })
    .to(selectedPack.topGroup.position, {
      x: selectedPack.topGroup.position.x - 4 * directionSign,
      z: 0.5,
      duration: 0.3,
      onComplete: () => { selectedPack.topGroup.visible = false; },
    })
    .to([selectedPack.bodyGroup.position, selectedPack.bottomGroup.position], {
      y: `-=5`,
      duration: 0.6,
      onComplete: () => {
        selectedPack.bodyGroup.visible = false;
        selectedPack.bottomGroup.visible = false;
      },
    }, '+=0.2');
  startCardReveal();
}

function pointerPosition(event) {
  const rect = renderer.domElement.getBoundingClientRect();
  return { x: event.clientX - rect.left, y: event.clientY - rect.top, width: rect.width, height: rect.height };
}

renderer.domElement.addEventListener('pointerdown', (event) => {
  if (event.button !== 0) return;
  const point = pointerPosition(event);
  if (state === 'tear') {
    const direction = tearStartDirection(event.clientX);
    if (!direction) return;
    tearDirection = direction;
    reorderTearLine(direction);
    setTearProgress(0);
    if (focusSealSpark) focusSealSpark.group.visible = false;
    startTearHaptics();
    playAudioCue('anticipationBright');
    resetTearSoundTracking(point);
    playPaperCue();
    hidePrompt();
  } else if (!['carousel', 'revealing', 'complete'].includes(state)) {
    return;
  }

  pointerDown = true;
  pointerId = event.pointerId;
  pointerDownState = state;
  pointerStartX = point.x;
  pointerStartY = point.y;
  pointerLastX = point.x;
  pointerLastTime = performance.now();
  dragDistance = 0;
  renderer.domElement.setPointerCapture(event.pointerId);

  if (state === 'carousel') {
    snapTween?.kill();
    snapTween = null;
    spinVelocity = 0;
    resyncCarouselStepAudio();
    playAudioCue('carouselTheme', { loop: true, volume: 0.35 });
  }
});

renderer.domElement.addEventListener('pointermove', (event) => {
  if (!pointerDown) {
    renderer.domElement.style.cursor = state === 'tear' && tearStartDirection(event.clientX) ? 'ew-resize' : 'default';
    return;
  }
  if (event.pointerId !== pointerId) return;
  const point = pointerPosition(event);
  const now = performance.now();
  const deltaX = point.x - pointerLastX;
  const deltaTime = Math.max(1 / 200, Math.min(0.05, (now - pointerLastTime) / 1000));
  pointerLastX = point.x;
  pointerLastTime = now;
  dragDistance = Math.hypot(point.x - pointerStartX, point.y - pointerStartY);

  if (state === 'carousel') {
    const deltaRotation = (deltaX / point.width) * MEASURED.dragRadiansPerViewport;
    carousel.rotation.y += deltaRotation;
    const measuredVelocity = THREE.MathUtils.clamp(deltaRotation / deltaTime, -MEASURED.maxSpinRadPerSec, MEASURED.maxSpinRadPerSec);
    spinVelocity = THREE.MathUtils.lerp(spinVelocity, measuredVelocity, 0.32);
  } else if (state === 'tear') {
    emitTearMoveAudio(point);
    const next = computeTearProgress(event.clientX);
    if (next !== tearProgress) {
      setTearProgress(next);
      if (Math.random() > 0.55) emitTearSparks();
      if (tearProgress >= 1) openPack();
    }
  }
});

function releasePointer(event) {
  if (!pointerDown || event.pointerId !== pointerId) return;
  const interactionState = pointerDownState;
  pointerDown = false;
  pointerDownState = null;
  if (renderer.domElement.hasPointerCapture(event.pointerId)) renderer.domElement.releasePointerCapture(event.pointerId);

  if (interactionState === 'carousel') {
    if (dragDistance <= MEASURED.tapDistancePx) {
      const point = pointerPosition(event);
      pointerNdc.set((point.x / point.width) * 2 - 1, -(point.y / point.height) * 2 + 1);
      raycaster.setFromCamera(pointerNdc, camera);
      const hit = raycaster.intersectObjects(packMeshes, false)[0];
      selectPack(hit?.object.userData.pack);
    } else if (Math.abs(spinVelocity) < 0.12) {
      snapCarousel(0.45);
    } else {
      pendingSnap = true;
    }
  } else if (interactionState === 'tear') {
    stopTearHaptics();
    stopAudioCue('anticipationBright');
    playPaperCue();
    if (tearProgress < 1) {
      const proxy = { value: tearProgress };
      gsap.to(proxy, { value: 0, duration: 0.32, ease: 'power2.out', onUpdate: () => setTearProgress(proxy.value) });
      setPrompt('Slice the seal', '', 'tear');
    }
  } else if (interactionState === 'revealing') {
    accelerateReveal();
  } else if (interactionState === 'complete') {
    reset();
  }
  pointerId = null;
}

renderer.domElement.addEventListener('pointerup', releasePointer);
renderer.domElement.addEventListener('pointercancel', releasePointer);

function reset() {
  stopTearHaptics();
  stopAllAudio();
  gsap.globalTimeline.clear();
  revealTimeline = null;
  dissolveTween = null;
  cleanupParticles();
  disposeTearLine();
  disposeFocusSealSpark();
  activeCard?.removeFromParent();
  activeCard = null;

  packs.forEach((pack) => {
    pack.group.visible = true;
    pack.group.scale.setScalar(1);
    pack.floatGroup.position.y = 0;
    pack.bodyGroup.visible = true;
    pack.topGroup.visible = true;
    pack.bottomGroup.visible = true;
    pack.reflectionGroup.visible = true;
    pack.bodyGroup.position.set(0, pack.bodyBaseY, 0);
    pack.topGroup.position.set(0, pack.topBaseY, 0);
    pack.bottomGroup.position.set(0, pack.bottomBaseY, 0);
    pack.bodyGroup.rotation.set(0, 0, 0);
    pack.topGroup.rotation.set(0, 0, 0);
    pack.bottomGroup.rotation.set(0, 0, 0);
    pack.darkMaterial.opacity = 0;
    while (pack.cardsGroup.children.length) pack.cardsGroup.remove(pack.cardsGroup.children[0]);
  });
  carousel.rotation.set(0, 0, 0);
  camera.position.set(0, 0, PACK_RADIUS + MEASURED.cameraDistance);
  focusLookY = 0;
  camera.lookAt(0, 0, 0);
  selectedPack = null;
  tearDirection = null;
  tearProgress = 0;
  setTearProgressUi(0);
  progress.classList.remove('visible');
  state = 'carousel';
  spinVelocity = 0;
  pendingSnap = false;
  carouselAudioPreviousRotation = null;
  carouselAudioCarry = 0;
  setPrompt('Pick a Pack');
}

function setTearProgressUi(value) {
  progressBar.style.transform = `scaleX(${value})`;
}

resetButton.addEventListener('click', reset);
soundToggle.addEventListener('change', () => {
  if (soundToggle.checked) playAudioCue('carouselTheme', { loop: true, volume: 0.35 });
  else stopAllAudio();
});
themeButtons.forEach((button) => {
  button.addEventListener('click', () => {
    const name = button.dataset.theme;
    if (!THEMES[name] || name === activeThemeName) return;
    activeThemeName = name;
    activeTheme = THEMES[name];
    setThemeUi(name);
    packBaseMaterial.map = packTextures[name];
    packBaseMaterial.needsUpdate = true;
    reflectionMaterial.map = packTextures[name];
    reflectionMaterial.needsUpdate = true;
    packFoilMaterial.uniforms.uMap.value = packTextures[name];
    packFoilMaterial.uniforms.uColor.value.set(activeTheme.particle);
    ambientDust?.material.color.set(activeTheme.particle);
    rimLight.color.set(activeTheme.particle);
    reset();
  });
});

function resize() {
  const width = window.innerWidth;
  const height = window.innerHeight;
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
  camera.aspect = width / height;
  camera.updateProjectionMatrix();
  renderer.setPixelRatio(dpr);
  renderer.setSize(width, height);
  bloomComposer.setPixelRatio(dpr);
  bloomComposer.setSize(width, height);
  finalComposer.setPixelRatio(dpr);
  finalComposer.setSize(width, height);
}

window.addEventListener('resize', resize);
resize();

function updateParticles(delta) {
  for (let index = particleSystems.length - 1; index >= 0; index -= 1) {
    const system = particleSystems[index];
    system.age += delta;
    system.update(delta, system.age);
    if (system.age >= system.duration) {
      disposeParticleSystem(system);
      particleSystems.splice(index, 1);
    }
  }
}

function animate() {
  requestAnimationFrame(animate);
  const delta = Math.min(clock.getDelta(), 1 / 30);
  const elapsed = clock.elapsedTime;
  packFoilMaterial && (packFoilMaterial.uniforms.uTime.value = elapsed);
  activeCard?.userData.placeholderMaterial.uniforms.uTime && (activeCard.userData.placeholderMaterial.uniforms.uTime.value = elapsed);
  activeCard?.userData.chaseMaterial.uniforms.uTime && (activeCard.userData.chaseMaterial.uniforms.uTime.value = elapsed);

  if (state === 'carousel') {
    packs.forEach((pack) => {
      pack.floatGroup.position.y = Math.sin(elapsed * MEASURED.floatSpeed + pack.index * MEASURED.floatStagger) * MEASURED.floatAmplitude;
    });
    if (!pointerDown && Math.abs(spinVelocity) > 0.001) {
      carousel.rotation.y += spinVelocity * delta;
      spinVelocity *= Math.exp(-1.65 * delta);
      if (pendingSnap && Math.abs(spinVelocity) < 0.08) snapCarousel();
    }
    const omega = Math.abs(spinVelocity);
    const targetBlend = Math.min(1, omega / 5);
    const baseZ = PACK_RADIUS + MEASURED.cameraDistance;
    const currentBlend = (baseZ - camera.position.z) / 2;
    const blend = currentBlend + (targetBlend - currentBlend) * (1 - Math.exp(-12 * delta));
    camera.position.z = baseZ - 2 * blend;
    updateCarouselStepAudio(delta);
    updateCameraLook();
  } else if (selectedPack) {
    selectedPack.floatGroup.position.y = Math.sin(elapsed * MEASURED.floatSpeed + selectedPack.index * MEASURED.floatStagger) * MEASURED.floatAmplitude;
  }

  updateAmbientDust(elapsed);
  updateFocusSealSpark(delta);
  updateParticles(delta);
  renderFrame();
}

async function boot() {
  try {
    const [entries] = await Promise.all([
      Promise.all(Object.entries(THEMES).map(async ([name, theme]) => [name, configureTexture(await textureLoader.loadAsync(theme.image))])),
      loadAudioManifest(),
    ]);
    packTextures = Object.fromEntries(entries);
    packHeight = imageAspectHeight(packTextures[activeThemeName], MEASURED.packWidth, 4);
    buildPacks();
    createAmbientDust();
    setThemeUi(activeThemeName);
    state = 'carousel';
    setPrompt('Pick a Pack');
    window.setTimeout(() => loading.classList.add('hide'), 650);
  } catch (error) {
    console.error(error);
    loading.querySelector('b').textContent = 'Unable to load pack assets';
  }
}

boot();
animate();
