卢春龙
2026-08-06 8b6572c1da2b3864c44aa884bab35f844dbebe4d
feat: 订单结束礼花动效
5个文件已添加
2个文件已修改
712 ■■■■■ 已修改文件
src/robotaxi/components/fireworks/error-boundary.tsx 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/components/fireworks/index.less 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/components/fireworks/index.tsx 436 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/components/fireworks/礼花效果.md 139 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/css-folder/calling-car.less 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/trip-finished-fireworks/index.tsx 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/index.tsx 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/components/fireworks/error-boundary.tsx
New file
@@ -0,0 +1,35 @@
import { Component, ReactNode } from 'react';
// 通用错误边界:捕获子树在「渲染 / 生命周期」阶段抛出的错误,出错后静默渲染为空,
// 避免动效等非核心 UI 的异常向上冒泡拖垮整个页面。
// 注意:错误边界无法捕获异步回调(requestAnimationFrame、Promise、事件处理器)中的错误,
// 那部分需在对应异步逻辑内自行 try/catch 兜底。
type Props = {
  children: ReactNode;
  /** 出错时的兜底 UI,默认不显示任何内容 */
  fallback?: ReactNode;
};
type State = {
  hasError: boolean;
};
export default class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false };
  static getDerivedStateFromError(): State {
    return { hasError: true };
  }
  componentDidCatch(error: any) {
    // eslint-disable-next-line no-console
    console.warn('[Fireworks] 已忽略渲染错误:', error);
  }
  render() {
    if (this.state.hasError) {
      return this.props.fallback ?? null;
    }
    return this.props.children;
  }
}
src/robotaxi/components/fireworks/index.less
New file
@@ -0,0 +1,9 @@
.xp-fireworks-canvas {
  position: fixed;
  left: 0;
  top: 0;
  width: 100vw;
  height: 100vh;
  z-index: 999;
  pointer-events: none;
}
src/robotaxi/components/fireworks/index.tsx
New file
@@ -0,0 +1,436 @@
import Taro from '@tarojs/taro';
import { Canvas } from '@tarojs/components';
import {
  forwardRef,
  useEffect,
  useImperativeHandle,
  useRef,
} from 'react';
import ErrorBoundary from './error-boundary';
import './index.less';
// 粒子礼花动效:小程序原生 Canvas 2D 自绘,复刻 react-canvas-confetti 的 fireworks 预设。
// 参数命名与 canvas-confetti 对齐,方便与 H5 侧对照调参。
export type FireworkShape = 'square' | 'circle';
export type FireworksOptions = {
  /** 单簇粒子数量(连发时随剩余时间递减) */
  particleCount?: number;
  /** 散布角度(度),360 即向四周炸开 */
  spread?: number;
  /** 发射方向角度(度),90 表示向上(spread=360 时无影响) */
  angle?: number;
  /** 初始速度 */
  startVelocity?: number;
  /** 重力(每帧对 vy 的增量) */
  gravity?: number;
  /** 横向漂移 */
  drift?: number;
  /** 每帧速度衰减(空气阻力) */
  decay?: number;
  /** 粒子大小缩放(方片边长 = 6 * scalar) */
  scalar?: number;
  /** 粒子存活帧数 */
  ticks?: number;
  /** 颜色数组 */
  colors?: string[];
  /** 形状(单个),与 shapes 二选一 */
  shape?: FireworkShape;
  /** 形状数组,随机取一,优先级高于 shape */
  shapes?: FireworkShape[];
  /** 发射中心点,取值 0~1 的相对坐标(x 向右、y 向下) */
  origin?: { x?: number; y?: number };
  /** 连发总时长(ms),仅 play 连发时生效 */
  duration?: number;
  /** 两次发射间隔(ms),仅 play 连发时生效 */
  interval?: number;
};
export type FireworksRef = {
  /** 连发礼花(在 duration 内每隔 interval 在随机位置发射一簇) */
  play: (options?: FireworksOptions) => void;
  /** 单次发射一簇礼花(等价 canvas-confetti 的 confetti(options)) */
  shoot: (options?: FireworksOptions) => void;
  /** 立即停止并清空 */
  stop: () => void;
};
type Props = {
  /** canvas 层级 */
  zIndex?: number;
};
const DEFAULTS = {
  particleCount: 80,
  spread: 360,
  angle: 90,
  startVelocity: 28,
  gravity: 0.85,
  drift: 0,
  decay: 0.9,
  scalar: 1,
  ticks: 150,
  colors: ['#7FE6F2', '#F9A76A', '#95CF29'],
  shapes: ['square'] as FireworkShape[],
  origin: { x: 0.5, y: 0.5 },
  duration: 2500,
  interval: 250,
};
type NormalizedOptions = {
  particleCount: number;
  spread: number;
  angle: number;
  startVelocity: number;
  gravity: number;
  drift: number;
  decay: number;
  scalar: number;
  ticks: number;
  colors: string[];
  shapes: FireworkShape[];
  origin: { x: number; y: number };
  duration: number;
  interval: number;
};
// 合并默认值 + 兼容 shape 单值写法,产出归一化配置
function normalize(options: FireworksOptions = {}): NormalizedOptions {
  const shapes = options.shapes && options.shapes.length
    ? options.shapes
    : options.shape
      ? [options.shape]
      : DEFAULTS.shapes;
  return {
    particleCount: options.particleCount ?? DEFAULTS.particleCount,
    spread: options.spread ?? DEFAULTS.spread,
    angle: options.angle ?? DEFAULTS.angle,
    startVelocity: options.startVelocity ?? DEFAULTS.startVelocity,
    gravity: options.gravity ?? DEFAULTS.gravity,
    drift: options.drift ?? DEFAULTS.drift,
    decay: options.decay ?? DEFAULTS.decay,
    scalar: options.scalar ?? DEFAULTS.scalar,
    ticks: options.ticks ?? DEFAULTS.ticks,
    colors: options.colors ?? DEFAULTS.colors,
    shapes,
    origin: {
      x: options.origin?.x ?? DEFAULTS.origin.x,
      y: options.origin?.y ?? DEFAULTS.origin.y,
    },
    duration: options.duration ?? DEFAULTS.duration,
    interval: options.interval ?? DEFAULTS.interval,
  };
}
function rand(min: number, max: number) {
  return Math.random() * (max - min) + min;
}
// 粒子模型对齐 canvas-confetti:每片纸屑带 wobble(飘动)+ tiltAngle(翻转),
// 因此绘制成会抖动、翻面的四边形,而非静止方块,视觉上更接近真实纸屑。
type Particle = {
  x: number;
  y: number;
  velocity: number; // 爆发初速(逐帧按 decay 衰减)
  angle2D: number; // 发射方向(弧度)
  wobble: number; // 飘动相位
  wobbleSpeed: number;
  wobbleX: number;
  wobbleY: number;
  tiltAngle: number; // 翻转角
  gravity: number; // 每帧恒定下落量(= opt.gravity * 3)
  decay: number;
  drift: number;
  scalar: number;
  color: string;
  shape: FireworkShape;
  tick: number;
  totalTicks: number;
};
// 生成一簇粒子。angle 为发射主方向(度,90 向上),spread 为散布角度。
// angle2D = -angle ± spread/2,屏幕 y 向下,故 angle=90 时 sin(angle2D)=-1 表现为向上。
// 初速取 sqrt(random)×startVelocity:用平方根让粒子按「面积」均匀分布,
// 避免大量粒子挤在中心(若用线性 random,低速粒子过多会在中心结团);
// 同时最小值仍接近 0,保留少量粒子填充中心,整体从中心向四周均匀铺满屏幕。
function createBurst(cx: number, cy: number, particleCount: number, opt: NormalizedOptions) {
  const list: Particle[] = [];
  const radAngle = (opt.angle * Math.PI) / 180;
  const radSpread = (opt.spread * Math.PI) / 180;
  for (let i = 0; i < particleCount; i++) {
    list.push({
      x: cx,
      y: cy,
      velocity: Math.sqrt(Math.random()) * opt.startVelocity,
      angle2D: -radAngle + (0.5 * radSpread - Math.random() * radSpread),
      wobble: Math.random() * 10,
      wobbleSpeed: Math.min(0.11, Math.random() * 0.1 + 0.05),
      wobbleX: 0,
      wobbleY: 0,
      tiltAngle: (Math.random() * 0.5 + 0.25) * Math.PI,
      gravity: opt.gravity * 3,
      decay: opt.decay,
      drift: opt.drift,
      // 每片纸屑大小在 scalar 基础上随机(0.5×~1.8×),大的一端更大,呈现有大有小的层次感
      scalar: opt.scalar * rand(0.5, 1.8),
      color: opt.colors[(Math.random() * opt.colors.length) | 0],
      shape: opt.shapes[(Math.random() * opt.shapes.length) | 0],
      tick: 0,
      totalTicks: opt.ticks,
    });
  }
  return list;
}
// canvas 节点 id 前缀。真正的 id 会在每个实例内拼接自增序号 + 随机串,保证同页多个
// <Fireworks> 实例不会因固定 id 冲突而 createSelectorQuery 选错节点(class 仍固定用于样式)。
const CANVAS_ID_PREFIX = 'xp-fireworks-canvas';
// 实例自增序号:模块级,配合随机串生成唯一 canvas id
let fireworksInstanceSeq = 0;
const FireworksInner = forwardRef<FireworksRef, Props>((props, ref) => {
  const canvasRef = useRef<any>(null);
  const ctxRef = useRef<any>(null);
  const sizeRef = useRef<{ w: number; h: number }>({ w: 0, h: 0 });
  const particlesRef = useRef<Particle[]>([]);
  const runningRef = useRef(false);
  // 世代号:stop() 递增,loop 每帧比对——若世代变化则自终止,解决 stop() 停不掉已排队的 rAF
  const generationRef = useRef(0);
  // 当前发射器:play 设 fireRef,shoot 设 null;运行中更换 fireRef 即可热替换发射逻辑,解决吞新 play()
  const fireRef = useRef<((now: number) => boolean) | null>(null);
  // 本实例唯一的 canvas id(惰性生成一次,跨渲染稳定)
  const canvasIdRef = useRef<string>('');
  if (!canvasIdRef.current) {
    fireworksInstanceSeq += 1;
    canvasIdRef.current = `${CANVAS_ID_PREFIX}-${fireworksInstanceSeq}-${Math.random().toString(36).slice(2, 8)}`;
  }
  // 统一的错误吞噬:礼花只是锦上添花的动效,任何异常都不应外溢影响页面,出错即静默不显示
  const swallow = (err: unknown) => {
    runningRef.current = false;
    // eslint-disable-next-line no-console
    console.warn('[Fireworks] 已忽略动效内部错误:', err);
  };
  // 包裹异步入口方法:错误边界捕获不到 rAF/Promise 等异步栈里的错误,这里统一兜底吞掉
  const safe = <A extends any[]>(fn: (...args: A) => void | Promise<void>) => (...args: A) => {
    try {
      const ret = fn(...args);
      if (ret && typeof (ret as Promise<void>).catch === 'function') {
        (ret as Promise<void>).catch(swallow);
      }
    } catch (err) {
      swallow(err);
    }
  };
  // 初始化 canvas 节点(Canvas 2D)。同步异常会 reject 由 safe 兜底;
  // exec 回调是异步栈,safe 捕获不到,故回调内单独 try/catch。
  const initCanvas = () =>
    new Promise<void>((resolve) => {
      if (canvasRef.current) {
        resolve();
        return;
      }
      const query = Taro.createSelectorQuery();
      query
        .select(`#${canvasIdRef.current}`)
        .fields({ node: true, size: true })
        .exec((res) => {
          try {
            if (!res || !res[0] || !res[0].node) {
              resolve();
              return;
            }
            const canvas = res[0].node;
            const ctx = canvas.getContext('2d');
            const dpr = (Taro.getSystemInfoSync() || {}).pixelRatio || 1;
            canvas.width = res[0].width * dpr;
            canvas.height = res[0].height * dpr;
            ctx.scale(dpr, dpr);
            canvasRef.current = canvas;
            ctxRef.current = ctx;
            sizeRef.current = { w: res[0].width, h: res[0].height };
            resolve();
          } catch (err) {
            swallow(err);
            resolve();
          }
        });
    });
  // 逐帧更新并绘制单个粒子,返回 false 表示已消亡。逻辑对齐 canvas-confetti 的 updateFizz。
  const updateAndDraw = (ctx: any, p: Particle) => {
    // 位置:沿 angle2D 前进(速度按 decay 衰减)+ 恒定重力下落 + 横向漂移
    p.x += Math.cos(p.angle2D) * p.velocity + p.drift;
    p.y += Math.sin(p.angle2D) * p.velocity + p.gravity;
    p.velocity *= p.decay;
    // wobble:让粒子飘动,绘制成随时间抖动的四边形
    p.wobble += p.wobbleSpeed;
    p.wobbleX = p.x + 10 * p.scalar * Math.cos(p.wobble);
    p.wobbleY = p.y + 10 * p.scalar * Math.sin(p.wobble);
    // tiltAngle:让纸屑翻面(宽度周期性收缩,模拟 3D 翻转)
    p.tiltAngle += 0.1;
    const tiltSin = Math.sin(p.tiltAngle);
    const tiltCos = Math.cos(p.tiltAngle);
    const random = Math.random() + 2; // 对齐 canvas-confetti,决定纸片另一维的尺寸
    const x1 = p.x + random * tiltCos;
    const y1 = p.y + random * tiltSin;
    const x2 = p.wobbleX + random * tiltCos;
    const y2 = p.wobbleY + random * tiltSin;
    const progress = p.tick / p.totalTicks;
    p.tick++;
    if (p.tick >= p.totalTicks) return false;
    ctx.globalAlpha = 1 - progress;
    ctx.fillStyle = p.color;
    ctx.beginPath();
    if (p.shape === 'circle') {
      // 椭圆:随 wobble/tilt 改变长短轴,呈现翻转的圆片(ovalScalar=0.6)
      ctx.ellipse(
        p.x,
        p.y,
        Math.abs(x2 - x1) * 0.6,
        Math.abs(y2 - y1) * 0.6,
        (Math.PI / 10) * p.wobble,
        0,
        2 * Math.PI,
      );
    } else {
      // 方片:由 4 个随 wobble/tilt 抖动的顶点构成的四边形,会飘动翻面
      ctx.moveTo(Math.floor(p.x), Math.floor(p.y));
      ctx.lineTo(Math.floor(p.wobbleX), Math.floor(y1));
      ctx.lineTo(Math.floor(x2), Math.floor(y2));
      ctx.lineTo(Math.floor(x1), Math.floor(p.wobbleY));
    }
    ctx.closePath();
    ctx.fill();
    return true;
  };
  // 逐帧渲染循环:读取 fireRef 驱动发射、比对 generationRef 自终止,无粒子且无发射时自动停止 rAF
  const startLoop = () => {
    const canvas = canvasRef.current;
    const ctx = ctxRef.current;
    if (!canvas || !ctx) return;
    if (runningRef.current) return; // 循环唯一,发射器靠 fireRef 热替换,新 play 无需重启循环
    runningRef.current = true;
    const { w, h } = sizeRef.current;
    const myGen = generationRef.current;
    const loop = () => {
      // 单帧出错时吞掉并终止循环,清空粒子,避免错误反复外溢
      try {
        // 世代号比对:stop() 递增后,已排队的下一帧检测到变化即自终止
        if (myGen !== generationRef.current) {
          runningRef.current = false;
          return;
        }
        const now = Date.now();
        const keepFiring = fireRef.current ? fireRef.current(now) : false;
        ctx.clearRect(0, 0, w, h);
        particlesRef.current = particlesRef.current.filter((p) => updateAndDraw(ctx, p));
        ctx.globalAlpha = 1;
        if (particlesRef.current.length || keepFiring) {
          canvas.requestAnimationFrame(loop);
        } else {
          runningRef.current = false;
          fireRef.current = null;
        }
      } catch (err) {
        particlesRef.current = [];
        try {
          ctx.clearRect(0, 0, w, h);
        } catch (_) {
          // 清屏也失败则忽略
        }
        swallow(err);
      }
    };
    canvas.requestAnimationFrame(loop);
  };
  // 单次发射一簇礼花(safe 包裹,异步异常统一吞掉)
  const shoot = safe(async (options: FireworksOptions = {}) => {
    await initCanvas();
    if (!canvasRef.current || !ctxRef.current) return;
    const opt = normalize(options);
    const { w, h } = sizeRef.current;
    const cx = opt.origin.x * w;
    const cy = opt.origin.y * h;
    fireRef.current = null;
    particlesRef.current.push(...createBurst(cx, cy, opt.particleCount, opt));
    startLoop();
  });
  // 连发礼花:在 duration 内每隔 interval 在随机位置发射一簇。
  // fireRef 先设后调 startLoop——即使循环已在运行也能热替换发射逻辑,不再吞新 play()。
  const play = safe(async (options: FireworksOptions = {}) => {
    await initCanvas();
    if (!canvasRef.current || !ctxRef.current) return;
    const opt = normalize(options);
    const { w, h } = sizeRef.current;
    const end = Date.now() + opt.duration;
    let lastFire = 0;
    fireRef.current = (now) => {
      if (now <= end && now - lastFire >= opt.interval) {
        lastFire = now;
        const remain = (end - now) / opt.duration;
        const count = Math.max(1, Math.ceil(opt.particleCount * remain));
        const cx = rand(0.2, 0.8) * w;
        const cy = rand(0.35, 0.5) * h;
        particlesRef.current.push(...createBurst(cx, cy, count, opt));
      }
      return now <= end;
    };
    startLoop();
  });
  const stop = safe(() => {
    generationRef.current += 1;
    runningRef.current = false;
    fireRef.current = null;
    particlesRef.current = [];
    const ctx = ctxRef.current;
    const { w, h } = sizeRef.current;
    if (ctx) ctx.clearRect(0, 0, w, h);
  });
  useImperativeHandle(ref, () => ({ play, shoot, stop }));
  useEffect(() => {
    return () => {
      generationRef.current += 1;
      runningRef.current = false;
      fireRef.current = null;
      particlesRef.current = [];
    };
  }, []);
  return (
    <Canvas
      type='2d'
      id={canvasIdRef.current}
      className='xp-fireworks-canvas'
      style={props.zIndex != null ? { zIndex: props.zIndex } : undefined}
    />
  );
});
// 用错误边界包裹:捕获渲染/生命周期阶段的错误(异步错误由内部 safe 兜底),出错静默渲染为空。
// ref 透传给内部组件,外部照常拿到 play / shoot / stop。
const Fireworks = forwardRef<FireworksRef, Props>((props, ref) => (
  <ErrorBoundary>
    <FireworksInner {...props} ref={ref} />
  </ErrorBoundary>
));
export default Fireworks;
src/robotaxi/components/fireworks/礼花效果.md
New file
@@ -0,0 +1,139 @@
# 礼花(Fireworks)动效组件
> 小程序原生 **Canvas 2D** 自绘的粒子礼花组件,复刻 [`canvas-confetti`](https://github.com/catdad/canvas-confetti) / [`react-canvas-confetti`](https://github.com/ulitcos/react-canvas-confetti) 的 `confetti`/`fireworks` 观感,**不依赖任何第三方库**。
> 参数命名与 `canvas-confetti` 对齐,方便与 H5 侧对照调参。
组件位置:`src/robotaxi/components/fireworks/`
```
fireworks/
├── index.tsx          # 组件主体(forwardRef,暴露 play/shoot/stop)
├── error-boundary.tsx # 通用错误边界(吞掉渲染/生命周期错误)
├── index.less         # 全屏、置顶、pointer-events:none 的 canvas 样式
└── 礼花效果.md         # 本文档
```
---
## 1. 背景
现有 H5 工程的撒花动效基于 `react-canvas-confetti`(底层 `canvas-confetti`),强依赖浏览器 DOM/BOM(`document`、`window`、DOM canvas)。**小程序没有 DOM,canvas 是专有组件**,无法直接复用,因此在小程序侧用**原生 Canvas 2D 自绘粒子系统**复刻同样的效果。
## 2. 对外 API
组件通过 `forwardRef` + `useImperativeHandle` 暴露 3 个命令式方法(`FireworksRef`):
| 方法 | 说明 |
|------|------|
| `shoot(options?)` | **单次发射一簇**礼花,等价 `canvas-confetti` 的 `confetti(options)`。到达结束态即用此方法。 |
| `play(options?)` | **连发**:在 `duration` 内每隔 `interval` 在随机位置发射一簇,`particleCount` 随剩余时间递减。 |
| `stop()` | 立即停止并清空画布。 |
Props:
| Prop | 类型 | 说明 |
|------|------|------|
| `zIndex` | `number?` | canvas 层级;不传则用样式默认 `999`。 |
### 用法
```tsx
import Fireworks, { FireworksRef } from '@/robotaxi/components/fireworks';
const ref = useRef<FireworksRef | null>(null);
// 挂载
<Fireworks ref={(node) => { ref.current = node; }} zIndex={99999} />
// 单簇(到达庆祝)
ref.current?.shoot({
  origin: { x: 0.5, y: 0.4 },
  angle: 90,
  particleCount: 260,
  spread: 360,
  startVelocity: 42,
  gravity: 0.9,
  scalar: 0.9,
  ticks: 180,
  colors: ['#FFD666', '#F9A76A', '#FFF1B8', '#7FE6F2', '#95CF29'],
});
// 连发
ref.current?.play({ duration: 2500, interval: 250 });
```
### 当前落地场景
- **到达结束态庆祝**:`src/robotaxi/pages/begin-route/index.tsx` 中,检测 `tripStatus` 由非结束态切到 `TripFinished(8)` 的上升沿,延迟约 180ms 后调用 `shoot` 发射一簇(与背景卡片升起同步呈现,只发一次避免重复观感)。
- **调参 Demo**:`src/robotaxi/pages/fireworks-demo/index.tsx`(仅调试用),点击按钮调用 `shoot` 绽放一簇。
## 3. 参数(`FireworksOptions`)与默认值
| 参数 | 含义 | 默认值 |
|------|------|--------|
| `particleCount` | 单簇粒子数量(连发时随剩余时间递减) | `80` |
| `spread` | 散布角度(度),`360` 即向四周炸开 | `360` |
| `angle` | 发射主方向(度),`90` 向上(`spread=360` 时无影响) | `90` |
| `startVelocity` | 初始速度(实际按 `sqrt(random)` 分布,见下) | `28` |
| `gravity` | 重力(每帧恒定下落量 = `gravity * 3`) | `0.85` |
| `drift` | 横向漂移 | `0` |
| `decay` | 每帧速度衰减(空气阻力) | `0.9` |
| `scalar` | 粒子大小缩放(每片再随机 `0.5×~1.8×`) | `1` |
| `ticks` | 粒子存活帧数 | `150` |
| `colors` | 颜色数组 | `['#7FE6F2', '#F9A76A', '#95CF29']` |
| `shape` / `shapes` | 形状:`'square'`(方片)/ `'circle'`(椭圆片),`shapes` 优先 | `['square']` |
| `origin` | 发射中心,相对坐标 `0~1`(x 向右、y 向下) | `{ x: 0.5, y: 0.5 }` |
| `duration` | 连发总时长(ms),仅 `play` 生效 | `2500` |
| `interval` | 两次发射间隔(ms),仅 `play` 生效 | `250` |
## 4. 粒子模型与算法
粒子模型对齐 `canvas-confetti`:每片纸屑带 `wobble`(飘动)与 `tiltAngle`(翻转),绘制成**会抖动、翻面的四边形/椭圆**,而非静止方块,视觉上更接近真实纸屑。
**发射(`createBurst`)**
- 主方向 `angle2D = -radAngle + (0.5*radSpread - random*radSpread)`;屏幕 y 向下,故 `angle=90` 时向上。
- 初速 `velocity = sqrt(random) * startVelocity`:用平方根让粒子按「面积」均匀分布,避免大量粒子挤在中心结团;同时保留少量低速粒子填充中心,整体从中心向四周均匀铺满。
- 每片大小 `scalar * rand(0.5, 1.8)`:有大有小的层次感。
**每帧更新(`updateAndDraw`)**
```
x += cos(angle2D) * velocity + drift
y += sin(angle2D) * velocity + gravity   // gravity 为每帧恒定下落量
velocity *= decay                         // 速度衰减
wobble   += wobbleSpeed                    // 飘动相位,决定四边形顶点抖动
tiltAngle += 0.1                           // 翻面(宽度周期性收缩,模拟 3D 翻转)
alpha = 1 - tick / totalTicks              // 线性淡出
tick++ ; if (tick >= totalTicks) → 移除
```
- 方片:由 4 个随 wobble/tilt 抖动的顶点构成的四边形。
- 椭圆:随 wobble/tilt 改变长短轴(ovalScalar=0.6),呈现翻转的圆片。
**渲染循环(`startLoop`)**:单实例只跑一个 `requestAnimationFrame` 循环,新发射的粒子追加进同一循环;无存活粒子且无待发射时自动停止 rAF,避免空转耗电。
## 5. 健壮性设计(三层容错)
礼花只是锦上添花的动效,任何异常都不应拖垮页面,出错即静默不显示:
1. **`ErrorBoundary`**:捕获子树在渲染/生命周期阶段抛出的错误,出错渲染为空。
2. **`safe()` 包裹**:包裹 `shoot`/`play`/`stop` 等异步入口,兜底 rAF/Promise 等异步栈里错误边界捕获不到的错误。
3. **逐帧 `try/catch`**:单帧出错即清空粒子并终止循环,避免错误反复外溢。
## 6. 关键实现细节
- **Canvas 2D(`type="2d"`)**:新版 canvas,性能优于旧版 `createCanvasContext`。
- **DPR 适配**:`canvas.width = 逻辑宽 * pixelRatio` 并 `ctx.scale(dpr, dpr)`,否则高清屏发虚。
- **唯一 canvas id**:每个组件实例惰性生成 `xp-fireworks-canvas-{seq}-{random}` 作为 canvas id(`className` 仍固定 `xp-fireworks-canvas` 用于样式),避免同页多个 `<Fireworks>` 实例因固定 id 导致 `createSelectorQuery` 选错节点。
- **点击穿透**:canvas `position:fixed` 全屏置顶 + `pointer-events:none`,不阻挡按钮/支付等交互。
- **卸载清理**:`useEffect` 卸载时停循环、清粒子;到达页对 `<Fireworks>` 做条件渲染,离开结束态即卸载。
## 7. 验收标准
- [ ] 到达结束态(6→8)时,随背景卡片升起绽放一簇礼花,粒子四散、受重力下落并淡出,仅播一次。
- [ ] 配色/形状/密度可通过 `FireworksOptions` 配置。
- [ ] 动画期间不阻塞页面交互(点击穿透正常)。
- [ ] 动画结束后 rAF 停止、canvas 无残留、CPU 回落。
- [ ] 组件内部任何异常都被静默吞掉,不影响页面主流程。
- [ ] 真机(iOS + Android)低端机型帧率可接受,无明显卡顿。
src/robotaxi/pages/begin-route/components/css-folder/calling-car.less
@@ -10,6 +10,32 @@
  }
}
// 结束态背景图(calling-car-bg)「缓缓升起」进场:
// height 恒为 844px(不改变高度、不拉伸图片),仅用 translateY 从「相当于 282px 露出」的位置升到最终位置(translateY 0)。
// 起始位移 = 844 - 282 = 562px,随组件挂载触发,结束停在 translateY(0) 的正确位置。
@keyframes finished-bg-rise {
  from {
    transform: translateY(562px);
  }
  to {
    transform: translateY(0);
  }
}
// 结束态公里数「渐显」:轻微上移并淡入,配合卡片升起分层呈现
@keyframes finished-distance-fade {
  from {
    transform: translateY(16px);
    opacity: 0;
  }
  to {
    transform: translateY(0);
    opacity: 1;
  }
}
.calling-car {
  position: relative;
  padding: 148px 0px 0px 0px;
@@ -39,6 +65,8 @@
    }
    &.finished-bg {
      height: 844px;
      // 背景卡(背景图)缓缓升起:进入结束态时随组件挂载触发,时长 1s
      animation: finished-bg-rise 1s cubic-bezier(0.22, 0.61, 0.36, 1) both;
    }
  }
  .calling-car-vehicle-pic {
@@ -109,6 +137,8 @@
// 到达终点/待支付态(图一、图二):背景透明化 + 到达目的地头部 + 白色费用卡
.calling-car--finished {
  padding-top: 0;
  // 结束态整卡背景透明:避免背景图升起时顶部露出 .calling-car 的 @gary-theme 底色
  background: transparent;
  .calling-car-content-box {
    background: transparent;
    padding: 0;
@@ -133,6 +163,8 @@
    font-family: @font-xpsans-bold;
    letter-spacing: normal;
    color: #FFFFFF;
    // 公里数在卡片升起后渐显出现
    animation: finished-distance-fade 700ms ease-out 450ms both;
  }
  // 白色费用卡(横向内边距由卡片自身承担,因 content-box 已透明化)
  // 提升特异性:与 .calling-car .fare-box-card { padding-bottom: 0 } 同特异性且在其之前,
src/robotaxi/pages/begin-route/components/trip-finished-fireworks/index.tsx
New file
@@ -0,0 +1,58 @@
import { useEffect, useRef } from 'react';
import Fireworks, { FireworksRef } from '../../../../components/fireworks';
import { TripStatus } from '../../../../constants/trip-status';
type Props = {
  /** 当前行程状态,由父页面传入 */
  tripStatus: TripStatus;
};
/**
 * 到达结束态全屏礼花组件(begin-route 页面专有)。
 *
 * ## 职责
 * - 监听 tripStatus 变化,检测「送驾中(6) → 行程结束(8)」上升沿
 * - 延迟约 180ms 后发射一簇礼花(与背景卡片升起动画同步呈现)
 * - 仅发射一次,prevStatus 去重防重复播放
 * - tripStatus 非 TripFinished 时卸载 canvas(条件渲染)
 */
export default function TripFinishedFireworks({ tripStatus }: Props) {
  const fireworksRef = useRef<FireworksRef | null>(null);
  const prevStatusRef = useRef<TripStatus>(tripStatus);
  // 送驾(6) → 结束(8) 转场:进入到达结束态时播放一次全屏礼花(顶层、pointer-events:none 不阻挡操作)
  useEffect(() => {
    const prevStatus = prevStatusRef.current;
    prevStatusRef.current = tripStatus;
    if (tripStatus !== TripStatus.TripFinished || prevStatus === TripStatus.TripFinished) {
      return;
    }
    // 略微延迟,等待 canvas 挂载与背景卡片升起动画启动,礼花随卡片一同呈现
    const timer = setTimeout(() => {
      // 仅发射一簇(single burst),避免连发造成「播放多次」的观感
      fireworksRef.current?.shoot({
        origin: { x: 0.5, y: 0.4 },
        angle: 90,
        particleCount: 260,
        spread: 360,
        startVelocity: 42,
        gravity: 0.9,
        scalar: 0.9,
        ticks: 180,
        colors: ['#FFD666', '#F9A76A', '#FFF1B8', '#7FE6F2', '#95CF29'],
      });
    }, 180);
    return () => clearTimeout(timer);
  }, [tripStatus]);
  if (tripStatus !== TripStatus.TripFinished) {
    return null;
  }
  return (
    <Fireworks
      ref={(node) => { fireworksRef.current = node; }}
      zIndex={99999}
    />
  );
}
src/robotaxi/pages/begin-route/index.tsx
@@ -11,6 +11,8 @@
import CouponSelectPopup from '../../components/coupon-select-popup';
// 调试面板:模拟完整行程流转(仅 dev 构建可见)
// import DebugOrderSimulatorPanel from './components/debug-order-simulator-panel';
// 到达结束态全屏礼花动效
import TripFinishedFireworks from './components/trip-finished-fireworks';
// 弹窗状态管理
import usePopupManager from './hooks/use-popup-manager';
// WebSocket 事件编排
@@ -2645,6 +2647,7 @@
        onClose={handleCloseCreditPayPopup}
        onConfirm={handleConfirmCreditPayPopup}
      />
      <TripFinishedFireworks tripStatus={tripStatus} />
      {/* <DebugOrderSimulatorPanel
        orderId={orderId}
        setOrderId={setOrderId}