如果你想做一个光剑招式App,需要用到这个API
想要亲自体验"光剑谱"这款App的功能?打开你的鸿蒙手机,前往华为应用市场,搜索"光剑谱"即可下载安装,跟着教程练起来吧。
写在前面
光剑,不管是星战里的那种,还是武术里的剑术套路,核心都是"轨迹"。一个招式好不好看,关键在于你挥动的路径。所以"光剑谱"这个App的核心功能就是:在Canvas上画出你比划的轨迹,然后分析你的攻击角度、招式序列,帮你把武术练好。
这篇文章我会带你做三件事:用Canvas绑定拖拽手势来绘制路径、计算攻击角度、管理招式序列。先看Web端的React版本,再转到鸿蒙的ArkTS。
这篇文章聊什么
- Canvas绑定手势事件 -- 怎么让手指在Canvas上滑动时画出光剑的轨迹
- 路径绘制算法 -- 怎么让轨迹看起来像光剑,而不是一条普通的线
- 角度计算 -- 怎么从轨迹数据推算出攻击角度和方向
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方法画了三条线:
- 外层:14px宽,半透明蓝色(rgba(0, 150, 255, 0.3))-- 这是最外层的光晕
- 中间层:8px宽,中等透明度的蓝色 -- 光剑的主体发光部分
- 核心层: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/End | PanGesture 三阶段回调 |
| 线条绘制 | ctx.moveTo + ctx.lineTo + ctx.stroke | 相同API |
| 光剑效果 | 两层线条(光晕+核心) | 三层线条(光晕+主体+核心) |
| 角度计算 | Math.atan2 | Math.atan2(相同) |
| 列表渲染 | array.map() | ForEach组件 |
| 样式方案 | CSS / inline styles | 链式属性调用 |
Canvas的绑定方式是最大的区别。Web端直接在canvas上绑定事件,鸿蒙需要手势覆盖层来间接实现。但绘制API本身几乎一模一样,都是从Web Canvas API继承过来的。
权限声明说明
光剑谱这个App不需要任何特殊权限:
{
"module": {
"reqPermissions": []
}
}
纯本地运算,没有网络请求,没有文件读写,没有传感器调用。所有数据都在内存中处理,是权限需求最轻的一类App。
总结
光剑谱这篇文章我们搞明白了:
- Canvas绑定手势 -- 用Stack + 透明覆盖层的架构,让手势事件和Canvas绘制解耦
- 三层光剑绘制 -- 外层光晕、中间层主体、核心层亮线,三层叠加模拟发光效果
- 角度计算和招式识别 -- atan2算角度,按角度范围匹配招式类型,计算准确度
最核心的技巧就是Canvas + 手势覆盖层的架构。记住这个模式,后面做任何Canvas绑手势的场景都能用。
好,去华为应用市场搜索"光剑谱"下载体验,亲手比划一下你的第一个光剑招式吧。