第9章:交互与事件系统
9.1 章节概述
PixiJS 提供了强大的交互系统,支持鼠标、触摸、指针等多种输入方式。理解事件系统对于构建交互式应用至关重要。
本章将深入讲解:
- 事件系统基础:eventMode、事件类型
- 指针事件:点击、移动、拖拽
- 事件传播:冒泡、捕获、阻止传播
- 命中测试:hitArea、containsPoint
- 拖拽实现:基本拖拽、边界限制
- 手势识别:缩放、旋转手势
9.2 事件系统基础
9.2.1 事件系统架构
PixiJS 事件系统架构
┌─────────────────────────────────────────────────────────────┐
│ EventSystem │
├─────────────────────────────────────────────────────────────┤
│ - 监听 DOM 事件(mouse, touch, pointer) │
│ - 转换为 PixiJS 事件 │
│ - 执行命中测试 │
│ - 分发事件到显示对象 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ EventBoundary │
├─────────────────────────────────────────────────────────────┤
│ - 管理事件边界 │
│ - 处理事件传播(冒泡、捕获) │
│ - 维护 hover 状态 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ DisplayObject │
├─────────────────────────────────────────────────────────────┤
│ - eventMode: 事件模式 │
│ - hitArea: 命中区域 │
│ - cursor: 鼠标样式 │
│ - on/off/emit: 事件监听 │
└─────────────────────────────────────────────────────────────┘9.2.2 eventMode 事件模式
typescript
/**
* eventMode 事件模式
*/
// 事件模式类型
type EventMode = 'none' | 'passive' | 'auto' | 'static' | 'dynamic';
/*
none:
- 不接收任何事件
- 不参与命中测试
- 性能最好
passive:
- 不接收事件
- 但子对象可以接收事件
- 用于容器
auto:
- 自动检测是否有事件监听器
- 有监听器时参与命中测试
- 默认值
static:
- 总是参与命中测试
- 适合静态交互元素
- 推荐用于按钮等
dynamic:
- 总是参与命中测试
- 每帧更新命中区域
- 适合移动的交互元素
- 性能开销较大
*/
// 使用示例
const button = new PIXI.Sprite(texture);
button.eventMode = 'static'; // 推荐
const container = new PIXI.Container();
container.eventMode = 'passive'; // 容器不接收事件,但子对象可以
const decoration = new PIXI.Sprite(texture);
decoration.eventMode = 'none'; // 纯装饰,不需要交互9.2.3 基本事件监听
typescript
/**
* 基本事件监听
*/
const sprite = new PIXI.Sprite(texture);
sprite.eventMode = 'static';
// 添加事件监听
sprite.on('pointerdown', (event) => {
console.log('按下', event.global);
});
sprite.on('pointerup', (event) => {
console.log('释放');
});
sprite.on('pointermove', (event) => {
console.log('移动', event.global);
});
// 一次性监听
sprite.once('pointerdown', (event) => {
console.log('只触发一次');
});
// 移除监听
const handler = (event) => console.log('click');
sprite.on('pointerdown', handler);
sprite.off('pointerdown', handler);
// 移除所有监听
sprite.removeAllListeners();
sprite.removeAllListeners('pointerdown'); // 只移除特定事件9.3 事件类型
9.3.1 指针事件
typescript
/**
* 指针事件(推荐使用)
* 统一处理鼠标和触摸
*/
sprite.eventMode = 'static';
// 按下
sprite.on('pointerdown', (event) => {
console.log('按下');
});
// 释放
sprite.on('pointerup', (event) => {
console.log('释放');
});
// 在对象外释放
sprite.on('pointerupoutside', (event) => {
console.log('在对象外释放');
});
// 移动
sprite.on('pointermove', (event) => {
console.log('移动');
});
// 进入
sprite.on('pointerover', (event) => {
console.log('进入');
});
// 离开
sprite.on('pointerout', (event) => {
console.log('离开');
});
// 进入(不冒泡)
sprite.on('pointerenter', (event) => {
console.log('进入(不冒泡)');
});
// 离开(不冒泡)
sprite.on('pointerleave', (event) => {
console.log('离开(不冒泡)');
});
// 取消
sprite.on('pointercancel', (event) => {
console.log('取消');
});
// 点击(按下并释放)
sprite.on('pointertap', (event) => {
console.log('点击');
});9.3.2 鼠标事件
typescript
/**
* 鼠标特定事件
*/
sprite.eventMode = 'static';
// 右键点击
sprite.on('rightdown', (event) => {
console.log('右键按下');
});
sprite.on('rightup', (event) => {
console.log('右键释放');
});
sprite.on('rightclick', (event) => {
console.log('右键点击');
});
// 滚轮
sprite.on('wheel', (event) => {
console.log('滚轮', event.deltaY);
});
// 全局鼠标事件
app.stage.eventMode = 'static';
app.stage.hitArea = app.screen;
app.stage.on('globalpointermove', (event) => {
console.log('全局移动', event.global);
});9.3.3 触摸事件
typescript
/**
* 触摸特定事件
*/
sprite.eventMode = 'static';
sprite.on('touchstart', (event) => {
console.log('触摸开始');
});
sprite.on('touchend', (event) => {
console.log('触摸结束');
});
sprite.on('touchmove', (event) => {
console.log('触摸移动');
});
sprite.on('touchcancel', (event) => {
console.log('触摸取消');
});
sprite.on('tap', (event) => {
console.log('点击');
});9.3.4 事件对象
typescript
/**
* FederatedPointerEvent 事件对象
*/
sprite.on('pointerdown', (event: PIXI.FederatedPointerEvent) => {
// 全局坐标(相对于画布)
console.log('全局坐标:', event.global.x, event.global.y);
// 本地坐标(相对于目标对象)
console.log('本地坐标:', event.getLocalPosition(sprite));
// 原始 DOM 事件
console.log('原始事件:', event.nativeEvent);
// 事件目标
console.log('目标:', event.target);
console.log('当前目标:', event.currentTarget);
// 指针信息
console.log('指针 ID:', event.pointerId);
console.log('指针类型:', event.pointerType); // mouse, touch, pen
console.log('按钮:', event.button); // 0=左, 1=中, 2=右
console.log('按钮状态:', event.buttons);
// 修饰键
console.log('Shift:', event.shiftKey);
console.log('Ctrl:', event.ctrlKey);
console.log('Alt:', event.altKey);
console.log('Meta:', event.metaKey);
// 压力(触摸/笔)
console.log('压力:', event.pressure);
// 移动量
console.log('移动:', event.movementX, event.movementY);
});9.4 事件传播
9.4.1 冒泡与捕获
事件传播机制
捕获阶段(从外到内):
Stage → Container → Sprite
目标阶段:
Sprite(触发事件)
冒泡阶段(从内到外):
Sprite → Container → Stage
示例:
Stage
│
▼ 捕获
Container
│
▼ 捕获
Sprite ← 目标
│
▼ 冒泡
Container
│
▼ 冒泡
Stage9.4.2 事件传播控制
typescript
/**
* 事件传播控制
*/
// 阻止冒泡
sprite.on('pointerdown', (event) => {
event.stopPropagation();
console.log('事件不会冒泡到父容器');
});
// 阻止默认行为
sprite.on('pointerdown', (event) => {
event.preventDefault();
});
// 立即停止传播(同一对象的其他监听器也不执行)
sprite.on('pointerdown', (event) => {
event.stopImmediatePropagation();
});
// 监听捕获阶段
container.addEventListener('pointerdown', (event) => {
console.log('捕获阶段');
}, { capture: true });
// 监听冒泡阶段(默认)
container.on('pointerdown', (event) => {
console.log('冒泡阶段');
});9.4.3 事件委托
typescript
/**
* 事件委托
* 在父容器上监听子对象的事件
*/
const container = new PIXI.Container();
container.eventMode = 'static';
// 添加多个子对象
for (let i = 0; i < 10; i++) {
const sprite = new PIXI.Sprite(texture);
sprite.name = `item_${i}`;
sprite.eventMode = 'static';
container.addChild(sprite);
}
// 在容器上统一处理
container.on('pointerdown', (event) => {
const target = event.target;
if (target !== container) {
console.log('点击了:', target.name);
}
});
// 更复杂的事件委托
class EventDelegator {
private container: PIXI.Container;
private handlers: Map<string, (target: PIXI.DisplayObject, event: PIXI.FederatedPointerEvent) => void> = new Map();
constructor(container: PIXI.Container) {
this.container = container;
this.container.eventMode = 'static';
this.container.on('pointerdown', this.handleEvent.bind(this));
}
register(name: string, handler: (target: PIXI.DisplayObject, event: PIXI.FederatedPointerEvent) => void) {
this.handlers.set(name, handler);
}
private handleEvent(event: PIXI.FederatedPointerEvent) {
let target = event.target as PIXI.DisplayObject;
while (target && target !== this.container) {
if (target.name && this.handlers.has(target.name)) {
this.handlers.get(target.name)!(target, event);
break;
}
target = target.parent;
}
}
}9.5 命中测试
9.5.1 hitArea 命中区域
typescript
/**
* hitArea 自定义命中区域
*/
const sprite = new PIXI.Sprite(texture);
sprite.eventMode = 'static';
// 矩形命中区域
sprite.hitArea = new PIXI.Rectangle(0, 0, 100, 100);
// 圆形命中区域
sprite.hitArea = new PIXI.Circle(50, 50, 50);
// 椭圆命中区域
sprite.hitArea = new PIXI.Ellipse(50, 50, 60, 40);
// 多边形命中区域
sprite.hitArea = new PIXI.Polygon([
0, 0,
100, 0,
100, 100,
0, 100
]);
// 圆角矩形命中区域
sprite.hitArea = new PIXI.RoundedRectangle(0, 0, 100, 100, 10);
// 扩大命中区域(添加边距)
const padding = 10;
sprite.hitArea = new PIXI.Rectangle(
-padding,
-padding,
sprite.width + padding * 2,
sprite.height + padding * 2
);9.5.2 自定义命中测试
typescript
/**
* 自定义命中测试
*/
// 自定义 hitArea 类
class CustomHitArea implements PIXI.IHitArea {
contains(x: number, y: number): boolean {
// 自定义命中逻辑
// 例如:环形区域
const dx = x - 50;
const dy = y - 50;
const distance = Math.sqrt(dx * dx + dy * dy);
return distance >= 30 && distance <= 50;
}
}
sprite.hitArea = new CustomHitArea();
// 基于像素的命中测试
class PixelHitArea implements PIXI.IHitArea {
private imageData: ImageData;
private width: number;
private height: number;
constructor(texture: PIXI.Texture) {
// 从纹理获取像素数据
const canvas = document.createElement('canvas');
canvas.width = texture.width;
canvas.height = texture.height;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(texture.baseTexture.resource.source as HTMLImageElement, 0, 0);
this.imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
this.width = canvas.width;
this.height = canvas.height;
}
contains(x: number, y: number): boolean {
if (x < 0 || x >= this.width || y < 0 || y >= this.height) {
return false;
}
const index = (Math.floor(y) * this.width + Math.floor(x)) * 4;
const alpha = this.imageData.data[index + 3];
return alpha > 0; // 非透明区域
}
}9.5.3 interactiveChildren
typescript
/**
* interactiveChildren 控制子对象交互
*/
const container = new PIXI.Container();
container.eventMode = 'static';
// 禁用子对象的交互
container.interactiveChildren = false;
// 现在只有容器本身可以接收事件
// 子对象不参与命中测试
// 使用场景:模态对话框
class Modal extends PIXI.Container {
constructor() {
super();
// 背景遮罩
const overlay = new PIXI.Graphics();
overlay.beginFill(0x000000, 0.5);
overlay.drawRect(0, 0, 800, 600);
overlay.endFill();
overlay.eventMode = 'static';
overlay.interactiveChildren = false; // 阻止点击穿透
// 对话框内容
const dialog = new PIXI.Container();
dialog.eventMode = 'static';
// ... 添加对话框内容
this.addChild(overlay, dialog);
}
}9.6 拖拽实现
9.6.1 基本拖拽
typescript
/**
* 基本拖拽实现
*/
function makeDraggable(target: PIXI.DisplayObject) {
let dragging = false;
let dragOffset = new PIXI.Point();
target.eventMode = 'static';
target.cursor = 'pointer';
target.on('pointerdown', (event: PIXI.FederatedPointerEvent) => {
dragging = true;
const localPos = event.getLocalPosition(target.parent);
dragOffset.set(target.x - localPos.x, target.y - localPos.y);
target.alpha = 0.8;
});
target.on('globalpointermove', (event: PIXI.FederatedPointerEvent) => {
if (dragging) {
const newPos = event.getLocalPosition(target.parent);
target.x = newPos.x + dragOffset.x;
target.y = newPos.y + dragOffset.y;
}
});
target.on('pointerup', () => {
dragging = false;
target.alpha = 1;
});
target.on('pointerupoutside', () => {
dragging = false;
target.alpha = 1;
});
}
// 使用
const sprite = new PIXI.Sprite(texture);
makeDraggable(sprite);9.6.2 带边界限制的拖拽
typescript
/**
* 带边界限制的拖拽
*/
function makeDraggableWithBounds(
target: PIXI.DisplayObject,
bounds: PIXI.Rectangle
) {
let dragging = false;
let dragOffset = new PIXI.Point();
target.eventMode = 'static';
target.cursor = 'pointer';
target.on('pointerdown', (event: PIXI.FederatedPointerEvent) => {
dragging = true;
const localPos = event.getLocalPosition(target.parent);
dragOffset.set(target.x - localPos.x, target.y - localPos.y);
});
target.on('globalpointermove', (event: PIXI.FederatedPointerEvent) => {
if (dragging) {
const newPos = event.getLocalPosition(target.parent);
// 计算新位置
let newX = newPos.x + dragOffset.x;
let newY = newPos.y + dragOffset.y;
// 限制在边界内
newX = Math.max(bounds.x, Math.min(newX, bounds.right - target.width));
newY = Math.max(bounds.y, Math.min(newY, bounds.bottom - target.height));
target.x = newX;
target.y = newY;
}
});
target.on('pointerup', () => {
dragging = false;
});
target.on('pointerupoutside', () => {
dragging = false;
});
}
// 使用
const bounds = new PIXI.Rectangle(0, 0, 800, 600);
makeDraggableWithBounds(sprite, bounds);9.6.3 拖拽排序
typescript
/**
* 拖拽排序
*/
class DraggableList {
private container: PIXI.Container;
private items: PIXI.DisplayObject[] = [];
private draggingItem: PIXI.DisplayObject | null = null;
private dragStartIndex: number = -1;
private itemHeight: number;
constructor(container: PIXI.Container, itemHeight: number) {
this.container = container;
this.itemHeight = itemHeight;
}
addItem(item: PIXI.DisplayObject) {
item.eventMode = 'static';
item.cursor = 'pointer';
item.on('pointerdown', () => this.startDrag(item));
item.on('globalpointermove', (e) => this.onDrag(e));
item.on('pointerup', () => this.endDrag());
item.on('pointerupoutside', () => this.endDrag());
this.items.push(item);
this.container.addChild(item);
this.updatePositions();
}
private startDrag(item: PIXI.DisplayObject) {
this.draggingItem = item;
this.dragStartIndex = this.items.indexOf(item);
item.alpha = 0.7;
// 移到最上层
this.container.setChildIndex(item, this.container.children.length - 1);
}
private onDrag(event: PIXI.FederatedPointerEvent) {
if (!this.draggingItem) return;
const localPos = event.getLocalPosition(this.container);
this.draggingItem.y = localPos.y - this.itemHeight / 2;
// 计算新索引
const newIndex = Math.floor(localPos.y / this.itemHeight);
const clampedIndex = Math.max(0, Math.min(newIndex, this.items.length - 1));
// 重新排序
const currentIndex = this.items.indexOf(this.draggingItem);
if (clampedIndex !== currentIndex) {
this.items.splice(currentIndex, 1);
this.items.splice(clampedIndex, 0, this.draggingItem);
this.updatePositions(this.draggingItem);
}
}
private endDrag() {
if (this.draggingItem) {
this.draggingItem.alpha = 1;
this.updatePositions();
this.draggingItem = null;
}
}
private updatePositions(except?: PIXI.DisplayObject) {
this.items.forEach((item, index) => {
if (item !== except) {
item.y = index * this.itemHeight;
}
});
}
}9.7 手势识别
9.7.1 双指缩放
typescript
/**
* 双指缩放(Pinch Zoom)
*/
class PinchZoom {
private target: PIXI.Container;
private touches: Map<number, PIXI.Point> = new Map();
private initialDistance: number = 0;
private initialScale: number = 1;
constructor(target: PIXI.Container) {
this.target = target;
this.target.eventMode = 'static';
this.target.on('pointerdown', this.onPointerDown.bind(this));
this.target.on('pointermove', this.onPointerMove.bind(this));
this.target.on('pointerup', this.onPointerUp.bind(this));
this.target.on('pointerupoutside', this.onPointerUp.bind(this));
}
private onPointerDown(event: PIXI.FederatedPointerEvent) {
this.touches.set(event.pointerId, event.global.clone());
if (this.touches.size === 2) {
this.initialDistance = this.getDistance();
this.initialScale = this.target.scale.x;
}
}
private onPointerMove(event: PIXI.FederatedPointerEvent) {
if (!this.touches.has(event.pointerId)) return;
this.touches.set(event.pointerId, event.global.clone());
if (this.touches.size === 2) {
const currentDistance = this.getDistance();
const scale = (currentDistance / this.initialDistance) * this.initialScale;
// 限制缩放范围
const clampedScale = Math.max(0.5, Math.min(scale, 3));
this.target.scale.set(clampedScale);
}
}
private onPointerUp(event: PIXI.FederatedPointerEvent) {
this.touches.delete(event.pointerId);
}
private getDistance(): number {
const points = Array.from(this.touches.values());
if (points.length < 2) return 0;
const dx = points[1].x - points[0].x;
const dy = points[1].y - points[0].y;
return Math.sqrt(dx * dx + dy * dy);
}
}9.7.2 双指旋转
typescript
/**
* 双指旋转
*/
class PinchRotate {
private target: PIXI.Container;
private touches: Map<number, PIXI.Point> = new Map();
private initialAngle: number = 0;
private initialRotation: number = 0;
constructor(target: PIXI.Container) {
this.target = target;
this.target.eventMode = 'static';
this.target.on('pointerdown', this.onPointerDown.bind(this));
this.target.on('pointermove', this.onPointerMove.bind(this));
this.target.on('pointerup', this.onPointerUp.bind(this));
this.target.on('pointerupoutside', this.onPointerUp.bind(this));
}
private onPointerDown(event: PIXI.FederatedPointerEvent) {
this.touches.set(event.pointerId, event.global.clone());
if (this.touches.size === 2) {
this.initialAngle = this.getAngle();
this.initialRotation = this.target.rotation;
}
}
private onPointerMove(event: PIXI.FederatedPointerEvent) {
if (!this.touches.has(event.pointerId)) return;
this.touches.set(event.pointerId, event.global.clone());
if (this.touches.size === 2) {
const currentAngle = this.getAngle();
const deltaAngle = currentAngle - this.initialAngle;
this.target.rotation = this.initialRotation + deltaAngle;
}
}
private onPointerUp(event: PIXI.FederatedPointerEvent) {
this.touches.delete(event.pointerId);
}
private getAngle(): number {
const points = Array.from(this.touches.values());
if (points.length < 2) return 0;
return Math.atan2(
points[1].y - points[0].y,
points[1].x - points[0].x
);
}
}9.7.3 滑动手势
typescript
/**
* 滑动手势
*/
type SwipeDirection = 'left' | 'right' | 'up' | 'down';
class SwipeGesture {
private target: PIXI.DisplayObject;
private startPoint: PIXI.Point | null = null;
private startTime: number = 0;
private minDistance: number;
private maxTime: number;
private onSwipe: (direction: SwipeDirection) => void;
constructor(
target: PIXI.DisplayObject,
onSwipe: (direction: SwipeDirection) => void,
options: { minDistance?: number; maxTime?: number } = {}
) {
this.target = target;
this.onSwipe = onSwipe;
this.minDistance = options.minDistance ?? 50;
this.maxTime = options.maxTime ?? 300;
this.target.eventMode = 'static';
this.target.on('pointerdown', this.onPointerDown.bind(this));
this.target.on('pointerup', this.onPointerUp.bind(this));
}
private onPointerDown(event: PIXI.FederatedPointerEvent) {
this.startPoint = event.global.clone();
this.startTime = Date.now();
}
private onPointerUp(event: PIXI.FederatedPointerEvent) {
if (!this.startPoint) return;
const endPoint = event.global;
const elapsed = Date.now() - this.startTime;
if (elapsed > this.maxTime) {
this.startPoint = null;
return;
}
const dx = endPoint.x - this.startPoint.x;
const dy = endPoint.y - this.startPoint.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < this.minDistance) {
this.startPoint = null;
return;
}
// 判断方向
const angle = Math.atan2(dy, dx);
let direction: SwipeDirection;
if (angle > -Math.PI / 4 && angle <= Math.PI / 4) {
direction = 'right';
} else if (angle > Math.PI / 4 && angle <= 3 * Math.PI / 4) {
direction = 'down';
} else if (angle > -3 * Math.PI / 4 && angle <= -Math.PI / 4) {
direction = 'up';
} else {
direction = 'left';
}
this.onSwipe(direction);
this.startPoint = null;
}
}
// 使用
new SwipeGesture(sprite, (direction) => {
console.log('滑动方向:', direction);
});9.8 鼠标样式
9.8.1 内置鼠标样式
typescript
/**
* 鼠标样式
*/
sprite.eventMode = 'static';
// CSS 鼠标样式
sprite.cursor = 'pointer';
sprite.cursor = 'grab';
sprite.cursor = 'grabbing';
sprite.cursor = 'crosshair';
sprite.cursor = 'move';
sprite.cursor = 'not-allowed';
sprite.cursor = 'text';
sprite.cursor = 'wait';
sprite.cursor = 'help';
sprite.cursor = 'zoom-in';
sprite.cursor = 'zoom-out';
// 调整大小
sprite.cursor = 'n-resize';
sprite.cursor = 's-resize';
sprite.cursor = 'e-resize';
sprite.cursor = 'w-resize';
sprite.cursor = 'ne-resize';
sprite.cursor = 'nw-resize';
sprite.cursor = 'se-resize';
sprite.cursor = 'sw-resize';9.8.2 自定义鼠标样式
typescript
/**
* 自定义鼠标样式
*/
// 使用图片
sprite.cursor = 'url(cursor.png), auto';
// 使用图片并指定热点
sprite.cursor = 'url(cursor.png) 16 16, auto';
// 动态更改鼠标样式
sprite.on('pointerdown', () => {
sprite.cursor = 'grabbing';
});
sprite.on('pointerup', () => {
sprite.cursor = 'grab';
});
// 使用 PixiJS 自定义光标
const cursorTexture = PIXI.Texture.from('cursor.png');
const cursorSprite = new PIXI.Sprite(cursorTexture);
cursorSprite.anchor.set(0.5);
app.stage.eventMode = 'static';
app.stage.hitArea = app.screen;
// 隐藏系统光标
app.renderer.events.cursorStyles.default = 'none';
app.stage.on('globalpointermove', (event) => {
cursorSprite.position.copyFrom(event.global);
});
app.stage.addChild(cursorSprite);9.9 本章小结
核心概念
| 概念 | 说明 |
|---|---|
| eventMode | 事件模式(none, passive, auto, static, dynamic) |
| hitArea | 自定义命中区域 |
| cursor | 鼠标样式 |
| pointerdown/up/move | 指针事件 |
| 事件冒泡 | 从内到外传播 |
| 事件捕获 | 从外到内传播 |
| stopPropagation | 阻止事件传播 |
关键代码
typescript
// 启用交互
sprite.eventMode = 'static';
sprite.cursor = 'pointer';
// 事件监听
sprite.on('pointerdown', (event) => {
console.log(event.global);
});
// 自定义命中区域
sprite.hitArea = new PIXI.Circle(50, 50, 50);
// 阻止传播
sprite.on('pointerdown', (event) => {
event.stopPropagation();
});
// 拖拽
sprite.on('pointerdown', () => { dragging = true; });
sprite.on('globalpointermove', (e) => {
if (dragging) sprite.position.copyFrom(e.global);
});
sprite.on('pointerup', () => { dragging = false; });9.10 练习题
基础练习
实现一个可点击的按钮,带 hover 效果
实现基本的拖拽功能
实现右键菜单
进阶练习
实现拖拽排序列表
实现双指缩放和旋转
挑战练习
- 实现一个完整的画布交互系统(平移、缩放、选择、拖拽)
下一章预告:在第10章中,我们将深入学习纹理管理与资源加载。
文档版本:v1.0
字数统计:约 12,000 字
代码示例:50+ 个
