HarmonyOS APP开发---"光剑谱"光剑招式App,需要用到这个API

2 阅读11分钟

如果你想做一个光剑招式App,需要用到这个API

想要亲自体验"光剑谱"这款App的功能?打开你的鸿蒙手机,前往华为应用市场,搜索"光剑谱"即可下载安装,跟着教程练起来吧。


写在前面

光剑,不管是星战里的那种,还是武术里的剑术套路,核心都是"轨迹"。一个招式好不好看,关键在于你挥动的路径。所以"光剑谱"这个App的核心功能就是:在Canvas上画出你比划的轨迹,然后分析你的攻击角度、招式序列,帮你把武术练好。

这篇文章我会带你做三件事:用Canvas绑定拖拽手势来绘制路径、计算攻击角度、管理招式序列。先看Web端的React版本,再转到鸿蒙的ArkTS。

这篇文章聊什么

  1. Canvas绑定手势事件 -- 怎么让手指在Canvas上滑动时画出光剑的轨迹
  2. 路径绘制算法 -- 怎么让轨迹看起来像光剑,而不是一条普通的线
  3. 角度计算 -- 怎么从轨迹数据推算出攻击角度和方向
flowchart TD
    A[用户打开App] --> B[显示招式列表]
    B --> C{选择招式}
    C --> D[进入绘制模式]
    D --> E[手指按下 onActionStart]
    E --> F[记录起点坐标]
    F --> G[手指移动 onActionUpdate]
    G --> H[绘制光剑轨迹]
    H --> I[手指抬起 onActionEnd]
    I --> J[计算攻击角度]
    J --> K[保存招式数据]
    K --> L[显示评分]

第一步:React版 -- 用Canvas API画光剑轨迹

Web端用HTML5 Canvas来绘制。React里拿到Canvas的context,然后在鼠标/触摸事件里画线。

import React, { useState, useRef, useEffect, useCallback } from 'react';

function LightSaberPage() {
  const canvasRef = useRef(null);
  const [isDrawing, setIsDrawing] = useState(false);
  // 当前正在绘制的路径点
  const pointsRef = useRef([]);
  // 所有已完成的招式
  const [moves, setMoves] = useState([
    { id: 1, name: '横斩', angle: 90, description: '从左到右水平斩击', points: [] },
    { id: 2, name: '竖劈', angle: 180, description: '从上到下垂直劈砍', points: [] },
    { id: 3, name: '斜斩', angle: 45, description: '右上到左下斜向斩击', points: [] },
  ]);
  const [currentMove, setCurrentMove] = useState(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext('2d');

    // 设置Canvas尺寸
    // 为什么在这里设置?因为Canvas的宽高属性决定了绘图区域的分辨率
    // 和CSS的宽高不一样,CSS的宽高只影响显示尺寸
    canvas.width = canvas.offsetWidth * 2;  // 2倍分辨率
    canvas.height = canvas.offsetHeight * 2;
    ctx.scale(2, 2);  // 缩放回来,保证坐标计算正确

    // 绘制参考网格
    drawGrid(ctx, canvas.offsetWidth, canvas.offsetHeight);

    return () => {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
    };
  }, []);

  // 绘制背景网格,帮助用户判断角度
  const drawGrid = (ctx, width, height) => {
    ctx.strokeStyle = 'rgba(100, 100, 255, 0.1)';
    ctx.lineWidth = 1;
    // 十字线
    ctx.beginPath();
    ctx.moveTo(width / 2, 0);
    ctx.lineTo(width / 2, height);
    ctx.moveTo(0, height / 2);
    ctx.lineTo(width, height / 2);
    ctx.stroke();
    // 对角线
    ctx.strokeStyle = 'rgba(100, 100, 255, 0.05)';
    ctx.beginPath();
    ctx.moveTo(0, 0);
    ctx.lineTo(width, height);
    ctx.moveTo(width, 0);
    ctx.lineTo(0, height);
    ctx.stroke();
  };

  // 计算两个点之间的角度
  // 返回值是角度数(0-360)
  const calculateAngle = (startX, startY, endX, endY) => {
    const deltaX = endX - startX;
    const deltaY = endY - startY;
    // Math.atan2返回弧度,乘以180/PI转成角度
    let angle = Math.atan2(deltaY, deltaX) * (180 / Math.PI);
    // 把负角度转为正角度
    if (angle < 0) angle += 360;
    return angle;
  };

  // 判断招式类型
  const identifyMove = (angle) => {
    // 根据角度判断是什么招式
    // 45度附近 = 右上到左下斜斩
    // 90度附近 = 从左到右横斩
    // 135度附近 = 右下到左上斜斩
    // 180度附近 = 从上到下竖劈
    if (angle >= 60 && angle <= 120) return { name: '横斩', targetAngle: 90 };
    if (angle >= 150 && angle <= 210) return { name: '竖劈', targetAngle: 180 };
    if (angle >= 15 && angle <= 60) return { name: '右上斜斩', targetAngle: 30 };
    if (angle >= 120 && angle <= 165) return { name: '右下斜斩', targetAngle: 135 };
    return { name: '未知招式', targetAngle: angle };
  };

  const getCanvasPos = (e) => {
    const canvas = canvasRef.current;
    const rect = canvas.getBoundingClientRect();
    const clientX = e.touches ? e.touches[0].clientX : e.clientX;
    const clientY = e.touches ? e.touches[0].clientY : e.clientY;
    return {
      x: clientX - rect.left,
      y: clientY - rect.top,
    };
  };

  const handleStart = (e) => {
    e.preventDefault();
    setIsDrawing(true);
    pointsRef.current = [getCanvasPos(e)];
  };

  const handleMove = useCallback((e) => {
    if (!isDrawing) return;
    e.preventDefault();
    const pos = getCanvasPos(e);
    pointsRef.current.push(pos);

    // 绘制光剑轨迹
    const canvas = canvasRef.current;
    const ctx = canvas.getContext('2d');
    const points = pointsRef.current;

    if (points.length >= 2) {
      const last = points[points.length - 2];
      const current = points[points.length - 1];

      // 光剑效果:外层发光 + 内层亮线
      // 先画外层光晕
      ctx.beginPath();
      ctx.moveTo(last.x, last.y);
      ctx.lineTo(current.x, current.y);
      ctx.strokeStyle = 'rgba(0, 150, 255, 0.3)';
      ctx.lineWidth = 12;
      ctx.lineCap = 'round';
      ctx.stroke();

      // 再画内层亮线
      ctx.beginPath();
      ctx.moveTo(last.x, last.y);
      ctx.lineTo(current.x, current.y);
      ctx.strokeStyle = 'rgba(100, 200, 255, 0.9)';
      ctx.lineWidth = 3;
      ctx.lineCap = 'round';
      ctx.stroke();
    }
  }, [isDrawing]);

  const handleEnd = (e) => {
    if (!isDrawing) return;
    setIsDrawing(false);

    const points = pointsRef.current;
    if (points.length < 2) return;

    // 用起点和终点计算攻击角度
    const start = points[0];
    const end = points[points.length - 1];
    const angle = calculateAngle(start.x, start.y, end.x, end.y);
    const moveType = identifyMove(angle);

    setCurrentMove({
      ...moveType,
      actualAngle: Math.round(angle),
      points: points,
      accuracy: 100 - Math.abs(angle - moveType.targetAngle),
    });
  };

  // 清除画布
  const clearCanvas = () => {
    const canvas = canvasRef.current;
    const ctx = canvas.getContext('2d');
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    drawGrid(ctx, canvas.offsetWidth, canvas.offsetHeight);
    pointsRef.current = [];
    setCurrentMove(null);
  };

  return (
    <div style={{ padding: 20, maxWidth: 500, margin: '0 auto' }}>
      <h1>光剑谱</h1>
      <p style={{ color: '#666' }}>在画布上用手指画出招式轨迹</p>

      <div style={{
        position: 'relative',
        width: '100%',
        height: 400,
        backgroundColor: '#0a0a2e',
        borderRadius: 12,
        overflow: 'hidden',
        touchAction: 'none',
      }}>
        <canvas
          ref={canvasRef}
          style={{ width: '100%', height: '100%', display: 'block' }}
          onMouseDown={handleStart}
          onMouseMove={handleMove}
          onMouseUp={handleEnd}
          onTouchStart={handleStart}
          onTouchMove={handleMove}
          onTouchEnd={handleEnd}
        />
      </div>

      <div style={{ display: 'flex', gap: 10, marginTop: 10 }}>
        <button onClick={clearCanvas}>清除画布</button>
        <button onClick={() => {
          if (currentMove) {
            setMoves(prev => [...prev, {
              id: Date.now(),
              ...currentMove,
            }]);
          }
          clearCanvas();
        }}>保存招式</button>
      </div>

      {currentMove && (
        <div style={{ marginTop: 10, padding: 10, backgroundColor: '#1a1a3e', borderRadius: 8, color: '#fff' }}>
          <p>招式: {currentMove.name}</p>
          <p>角度: {currentMove.actualAngle}度</p>
          <p>准确度: {Math.round(currentMove.accuracy)}%</p>
        </div>
      )}

      <h2 style={{ marginTop: 20 }}>招式列表</h2>
      {moves.map(move => (
        <div key={move.id} style={{ padding: 10, borderBottom: '1px solid #eee' }}>
          <strong>{move.name}</strong>
          <p style={{ fontSize: 14, color: '#666' }}>{move.description}</p>
        </div>
      ))}
    </div>
  );
}

export default LightSaberPage;

这段React代码的核心在于Canvas的事件处理。每次手指移动时,我们拿到坐标,然后画一条线段。为了做出光剑的效果,每条线段画两遍:外层一条粗的半透明线当光晕,内层一条细的亮线当核心。


第二步:ArkTS版 -- PanGesture + Canvas绑定

鸿蒙的Canvas不能直接绑定鼠标/触摸事件,要通过手势组件来中转。我们的做法是:用一个Stack包裹Canvas和一个透明的手势区域,手势区域负责接收拖拽事件,Canvas负责绘制轨迹。

import { promptAction } from '@kit.ArkUI';

// 招式数据结构
interface MoveData {
  id: number;
  name: string;
  angle: number;          // 攻击角度(度)
  targetAngle: number;   // 目标角度
  description: string;
  accuracy: number;       // 准确度百分比
  timestamp: number;
}

// 轨迹点
interface PointData {
  x: number;
  y: number;
}

@Entry
@Component
struct LightSaberPage {
  // Canvas绑定的绘图上下文
  private settings: CanvasRenderingContext2D = new CanvasRenderingContext2D(new CanvasRenderingContext2DSettings());
  // 当前绘制的轨迹点
  private currentPoints: PointData[] = [];
  // 是否正在绘制
  @State isDrawing: boolean = false;
  // 当前识别出的招式
  @State currentMove: MoveData | null = null;
  // 已保存的招式列表
  @State savedMoves: MoveData[] = [
    { id: 1, name: '横斩', angle: 90, targetAngle: 90, description: '从左到右水平斩击', accuracy: 95, timestamp: Date.now() },
    { id: 2, name: '竖劈', angle: 180, targetAngle: 180, description: '从上到下垂直劈砍', accuracy: 88, timestamp: Date.now() },
    { id: 3, name: '斜斩', angle: 45, targetAngle: 45, description: '右上到左下斜向斩击', accuracy: 76, timestamp: Date.now() },
  ];
  // Canvas的尺寸
  private canvasWidth: number = 350;
  private canvasHeight: number = 400;

  aboutToAppear() {
    // 延迟绘制网格,等Canvas渲染完成
    setTimeout(() => {
      this.drawGrid();
    }, 100);
  }

  // ==================== 绘制背景网格 ====================
  drawGrid() {
    const ctx = this.settings;
    const w = this.canvasWidth;
    const h = this.canvasHeight;

    // 清空画布
    ctx.clearRect(0, 0, w, h);

    // 背景
    ctx.fillStyle = '#0a0a2e';
    ctx.fillRect(0, 0, w, h);

    // 十字线
    ctx.strokeStyle = 'rgba(100, 150, 255, 0.15)';
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(w / 2, 0);
    ctx.lineTo(w / 2, h);
    ctx.moveTo(0, h / 2);
    ctx.lineTo(w, h / 2);
    ctx.stroke();

    // 对角线 -- 帮助用户判断45度角
    ctx.strokeStyle = 'rgba(100, 150, 255, 0.08)';
    ctx.setLineDash([5, 5]); // 虚线
    ctx.beginPath();
    ctx.moveTo(0, 0);
    ctx.lineTo(w, h);
    ctx.moveTo(w, 0);
    ctx.lineTo(0, h);
    ctx.stroke();
    ctx.setLineDash([]); // 恢复实线

    // 角度标注
    ctx.fillStyle = 'rgba(255, 255, 255, 0.2)';
    ctx.font = '10px sans-serif';
    ctx.fillText('0', w - 20, h / 2 - 5);
    ctx.fillText('90', w / 2 + 5, h - 10);
    ctx.fillText('180', 5, h / 2 - 5);
    ctx.fillText('270', w / 2 + 5, 15);
  }

  // ==================== 光剑轨迹绘制 ====================
  // 核心方法:在Canvas上绘制一段光剑轨迹
  // 为什么分两层画?外层光晕 + 内层亮线 = 光剑发光效果
  drawSaberSegment(from: PointData, to: PointData) {
    const ctx = this.settings;

    // 外层光晕 -- 宽一点的半透明蓝色
    ctx.beginPath();
    ctx.moveTo(from.x, from.y);
    ctx.lineTo(to.x, to.y);
    ctx.strokeStyle = 'rgba(0, 150, 255, 0.3)';
    ctx.lineWidth = 14;
    ctx.lineCap = 'round';
    ctx.stroke();

    // 中间层 -- 比外层细,更亮
    ctx.beginPath();
    ctx.moveTo(from.x, from.y);
    ctx.lineTo(to.x, to.y);
    ctx.strokeStyle = 'rgba(80, 180, 255, 0.6)';
    ctx.lineWidth = 8;
    ctx.lineCap = 'round';
    ctx.stroke();

    // 核心层 -- 最细最亮,白色
    ctx.beginPath();
    ctx.moveTo(from.x, from.y);
    ctx.lineTo(to.x, to.y);
    ctx.strokeStyle = 'rgba(200, 230, 255, 0.95)';
    ctx.lineWidth = 2;
    ctx.lineCap = 'round';
    ctx.stroke();
  }

  // ==================== 角度计算 ====================
  // 根据起点和终点计算攻击角度
  // 返回0-360度的值
  calculateAngle(start: PointData, end: PointData): number {
    const deltaX = end.x - start.x;
    const deltaY = end.y - start.y;
    // atan2返回的是弧度值,需要转换成角度
    let angle = Math.atan2(deltaY, deltaX) * (180 / Math.PI);
    // 把负角度转成正角度(比如-45度变成315度)
    if (angle < 0) {
      angle += 360;
    }
    return angle;
  }

  // ==================== 招式识别 ====================
  // 根据角度判断是什么招式
  identifyMove(angle: number): { name: string; targetAngle: number; description: string } {
    // 每个招式有一个角度范围
    // 比如"横斩"目标角度是90度,允许正负30度的误差
    if (angle >= 60 && angle <= 120) {
      return { name: '横斩', targetAngle: 90, description: '从左到右水平斩击' };
    }
    if (angle >= 150 && angle <= 210) {
      return { name: '竖劈', targetAngle: 180, description: '从上到下垂直劈砍' };
    }
    if (angle >= 15 && angle <= 60) {
      return { name: '右上斜斩', targetAngle: 37, description: '从左下到右上斜向斩击' };
    }
    if (angle >= 120 && angle <= 165) {
      return { name: '左上斜斩', targetAngle: 142, description: '从右下到左上斜向斩击' };
    }
    if (angle >= 210 && angle <= 255) {
      return { name: '左下斜斩', targetAngle: 232, description: '从右上到左下斜向斩击' };
    }
    if (angle >= 255 && angle <= 300) {
      return { name: '斜挑', targetAngle: 270, description: '从下往上挑击' };
    }
    // 角度不在任何招式范围内
    return { name: '自由斩', targetAngle: angle, description: '未识别的招式' };
  }

  // ==================== 清除画布 ====================
  clearCanvas() {
    this.currentPoints = [];
    this.currentMove = null;
    this.drawGrid();
  }

  // ==================== 保存招式 ====================
  saveMove() {
    if (!this.currentMove) {
      promptAction.showToast({ message: '请先比划一个招式', duration: 1500 });
      return;
    }
    const newMove: MoveData = {
      ...this.currentMove,
      id: Date.now(),
      timestamp: Date.now(),
    };
    this.savedMoves = [...this.savedMoves, newMove];
    promptAction.showToast({
      message: `招式"${this.currentMove.name}"已保存`,
      duration: 1500,
    });
    this.clearCanvas();
  }

  // ==================== 删除招式 ====================
  deleteMove(id: number) {
    this.savedMoves = this.savedMoves.filter(m => m.id !== id);
    promptAction.showToast({ message: '已删除', duration: 1000 });
  }

  // ==================== UI构建 ====================
  build() {
    Column() {
      // 标题
      Text('光剑谱')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 16, bottom: 4 })
      Text('在画布上比划招式,系统自动识别角度')
        .fontSize(13)
        .fontColor('#999999')
        .width('100%')
        .textAlign(TextAlign.Center)

      // Canvas绘制区域
      // 关键:用Stack包裹Canvas和手势区域
      // Canvas只负责绘制,手势区域负责接收触摸事件
      Stack() {
        Canvas(this.settings)
          .width(this.canvasWidth)
          .height(this.canvasHeight)
          .borderRadius(12)
          .border({ width: 2, color: '#2a2a5e' })

        // 透明手势覆盖层
        // 为什么需要一个透明的覆盖层?
        // 因为Canvas组件本身不支持直接绑定手势
        // 所以我们在Canvas上面叠一个Column,把手势绑在这个Column上
        Column()
          .width(this.canvasWidth)
          .height(this.canvasHeight)
          .backgroundColor(Color.Transparent)
          // 绑定PanGesture
          .gesture(
            PanGesture({ direction: PanDirection.All, distance: 5 })
              .onActionStart((event: GestureEvent) => {
                // 手指按下,开始绘制
                this.isDrawing = true;
                this.currentPoints = [{
                  x: event.fingers[0].localX,
                  y: event.fingers[0].localY,
                }];
                this.currentMove = null;
              })
              .onActionUpdate((event: GestureEvent) => {
                // 手指移动,绘制轨迹
                if (!this.isDrawing) return;
                const newPoint: PointData = {
                  x: event.fingers[0].localX,
                  y: event.fingers[0].localY,
                };
                const lastPoint = this.currentPoints[this.currentPoints.length - 1];

                // 绘制新的一段轨迹
                this.drawSaberSegment(lastPoint, newPoint);
                this.currentPoints.push(newPoint);
              })
              .onActionEnd(() => {
                // 手指抬起,计算角度
                if (!this.isDrawing) return;
                this.isDrawing = false;

                if (this.currentPoints.length >= 2) {
                  // 用起点和终点来计算攻击角度
                  // 为什么不用所有点的平均值?因为光剑斩击是快速的动作
                  // 起点和终点最能反映攻击方向
                  const start = this.currentPoints[0];
                  const end = this.currentPoints[this.currentPoints.length - 1];
                  const angle = this.calculateAngle(start, end);
                  const moveInfo = this.identifyMove(angle);
                  const accuracy = Math.max(0, 100 - Math.abs(angle - moveInfo.targetAngle));

                  this.currentMove = {
                    id: 0,
                    name: moveInfo.name,
                    angle: Math.round(angle),
                    targetAngle: moveInfo.targetAngle,
                    description: moveInfo.description,
                    accuracy: accuracy,
                    timestamp: Date.now(),
                  };
                }
              })
          )
      }
      .width(this.canvasWidth)
      .height(this.canvasHeight)
      .alignContent(Alignment.TopStart)
      .margin({ top: 16 })

      // 操作按钮
      Row({ space: 12 }) {
        Button('清除画布')
          .fontSize(14)
          .height(36)
          .backgroundColor('#555555')
          .fontColor('#FFFFFF')
          .onClick(() => this.clearCanvas())

        Button('保存招式')
          .fontSize(14)
          .height(36)
          .backgroundColor('#4ECDC4')
          .fontColor('#FFFFFF')
          .onClick(() => this.saveMove())
      }
      .width(this.canvasWidth)
      .justifyContent(FlexAlign.SpaceEvenly)
      .margin({ top: 12 })

      // 当前识别结果
      if (this.currentMove) {
        Column() {
          Row() {
            Text(this.currentMove.name)
              .fontSize(22)
              .fontWeight(FontWeight.Bold)
              .fontColor('#4ECDC4')
            Text(`${Math.round(this.currentMove.accuracy)}%`)
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor(this.currentMove.accuracy >= 80 ? '#4ECDC4' : '#FF6B6B')
              .margin({ left: 12 })
          }
          Text(this.currentMove.description)
            .fontSize(13)
            .fontColor('#AAAAAA')
            .margin({ top: 4 })
          Text(`攻击角度: ${this.currentMove.angle}度 / 目标: ${this.currentMove.targetAngle}度`)
            .fontSize(12)
            .fontColor('#888888')
            .margin({ top: 2 })
        }
        .width(this.canvasWidth)
        .padding(12)
        .backgroundColor('#1a1a3e')
        .borderRadius(8)
        .margin({ top: 12 })
      }

      // 招式列表
      Column() {
        Text('已保存的招式')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .width('100%')
          .margin({ bottom: 8 })

        if (this.savedMoves.length === 0) {
          Text('还没有保存的招式')
            .fontSize(14)
            .fontColor('#999999')
            .width('100%')
            .textAlign(TextAlign.Center)
            .padding(20)
        }

        List({ space: 8 }) {
          ForEach(this.savedMoves, (move: MoveData) => {
            ListItem() {
              Row() {
                Column({ space: 2 }) {
                  Text(move.name)
                    .fontSize(16)
                    .fontWeight(FontWeight.Bold)
                  Text(move.description)
                    .fontSize(12)
                    .fontColor('#999999')
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)

                Column({ space: 2 }) {
                  Text(`${move.angle}度`)
                    .fontSize(14)
                    .fontColor('#666666')
                  Text(`准确度 ${Math.round(move.accuracy)}%`)
                    .fontSize(12)
                    .fontColor(move.accuracy >= 80 ? '#4ECDC4' : '#FF6B6B')
                }
                .alignItems(HorizontalAlign.End)

                Button('删除')
                  .fontSize(12)
                  .height(30)
                  .width(50)
                  .backgroundColor('#FF6B6B')
                  .margin({ left: 8 })
                  .onClick(() => this.deleteMove(move.id))
              }
              .width('100%')
              .padding(10)
              .backgroundColor('#F5F5F5')
              .borderRadius(8)
            }
          })
        }
        .width('100%')
        .layoutWeight(1)
      }
      .width(this.canvasWidth)
      .margin({ top: 16 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#12122e')
  }
}

Canvas + 手势覆盖层的技巧

这个架构是整篇文章最核心的部分。Canvas组件本身不能直接绑定gesture属性,所以我们必须在它上面叠一个透明的Column组件,把PanGesture绑在这个Column上。当手指在Column上滑动时,我们在onActionUpdate回调里调用Canvas的绘制方法来画线。

为什么用一个透明Column而不是其他组件?因为Column的背景设为Transparent后完全不影响下面的Canvas显示,而且Column可以精确覆盖Canvas的区域。你也可以用其他组件,比如一个空的Text或者一个空白区域,但Column最简单直接。

三层光剑效果

drawSaberSegment方法画了三条线:

  1. 外层:14px宽,半透明蓝色(rgba(0, 150, 255, 0.3))-- 这是最外层的光晕
  2. 中间层:8px宽,中等透明度的蓝色 -- 光剑的主体发光部分
  3. 核心层:2px宽,接近白色的亮蓝色 -- 光剑的中心能量线

三层叠加在一起,看起来就像一把发光的光剑轨迹。你调整这些参数可以改变光剑的粗细和亮度。

角度识别的原理

用atan2函数从起点到终点计算角度。atan2(dx, dy)返回弧度值,我们转成角度。然后根据角度范围来判断招式类型。比如横斩的目标角度是90度(从左到右),允许正负30度的误差,所以60到120度之间的轨迹都会被识别为横斩。


流程图

flowchart TD
    A[手指按下] --> B[记录起点坐标]
    B --> C[isDrawing = true]
    C --> D{手指移动?}
    D -->|是| E[获取新坐标]
    E --> F[画三层光剑轨迹]
    F --> D
    D -->|否 - 手指抬起| G[isDrawing = false]
    G --> H[取起点和终点]
    H --> I[calculateAngle计算角度]
    I --> J[identifyMove识别招式]
    J --> K[计算准确度]
    K --> L{用户选择}
    L -->|保存| M[加入savedMoves数组]
    L -->|清除| N[drawGrid重绘网格]
    L -->|重新绘制| A

React vs ArkTS 对比表

功能点React (Web)ArkTS (鸿蒙)
绘图区域<canvas> + getContext('2d')Canvas + CanvasRenderingContext2D
事件绑定直接绑在canvas元素上Stack + 透明覆盖层 + gesture
拖拽事件onMouseDown/Move/Up + onTouchStart/Move/EndPanGesture 三阶段回调
线条绘制ctx.moveTo + ctx.lineTo + ctx.stroke相同API
光剑效果两层线条(光晕+核心)三层线条(光晕+主体+核心)
角度计算Math.atan2Math.atan2(相同)
列表渲染array.map()ForEach组件
样式方案CSS / inline styles链式属性调用

Canvas的绑定方式是最大的区别。Web端直接在canvas上绑定事件,鸿蒙需要手势覆盖层来间接实现。但绘制API本身几乎一模一样,都是从Web Canvas API继承过来的。


权限声明说明

光剑谱这个App不需要任何特殊权限:

{
  "module": {
    "reqPermissions": []
  }
}

纯本地运算,没有网络请求,没有文件读写,没有传感器调用。所有数据都在内存中处理,是权限需求最轻的一类App。


总结

光剑谱这篇文章我们搞明白了:

  1. Canvas绑定手势 -- 用Stack + 透明覆盖层的架构,让手势事件和Canvas绘制解耦
  2. 三层光剑绘制 -- 外层光晕、中间层主体、核心层亮线,三层叠加模拟发光效果
  3. 角度计算和招式识别 -- atan2算角度,按角度范围匹配招式类型,计算准确度

最核心的技巧就是Canvas + 手势覆盖层的架构。记住这个模式,后面做任何Canvas绑手势的场景都能用。

好,去华为应用市场搜索"光剑谱"下载体验,亲手比划一下你的第一个光剑招式吧。