前两天玩了玩水果忍者,感觉这种输入方式是非常适合多点触摸屏的,输入直观而且爽快感十足,于是就想到了如何使用haxe+NME实现刀光的效果,今天按照我的想法把效果实现了,感觉还是很逼真的。估计即使不是水果忍者中的真正算法,也差之不远了。
下面介绍一下我的算法。
算法的核心是采用NME中的Graphics.drawTriangles()来实现扭曲位图映射。对应在Flash中,这个API是从Flash10开始提供的。另外我想只要提供类似API的语言,用这个算法都没啥问题。
我看到网上有些类似的文章(http://www.j2megame.com/html/xwzx/ty/3049.html),是采用画线的方式,感觉没我这个好,我这个的算法一个优势就是,只要更换刀光的贴图,就能实现不同样式的刀光,比如五彩的,半透明的等等。
算法说穿了也很简单,大家看看上面的图就明白了。
图中,上面的是刀光的原始贴图,包括两端的尖锐部分,和中间的主体部分。而图中下面则是一个刀光的轨迹,其中,中间的红线是真正的触摸轨迹,每个转折点,就是捕获触摸事件TOUCH_MOVE或MOUSE_MOVE所获得的坐标点。
我们的刀光只会在屏幕上停留短暂的一点时间,也就是说,每个触摸点都是有寿命的,超过寿命的转折点,就要从轨迹中删除,从另一方面触摸点的寿命也直接决定了在该点刀光的粗细,也就是上图中每个触摸点对应的绿色线段的长度,所以我们定义触摸点数据结构如下:
class VolatilePoint {
public var x: Float;
public var y: Float;
public var birth: Float;
public function new(inX: Float, inY: Float) {
x = inX;
y = inY;
birth = Timer.stamp(); //出生时刻以秒为单位,因为是浮点数,所以精度足够
}
}
也就是说,触摸点中除了坐标,还记载了该点的出生时刻,我们随时可以用当前时刻减去这个出生时刻来获得它的寿命。
每次刀光更新时(比如响应ENTER_FRAME事件),我们要遍历轨迹,把其中寿命超过最大寿命(我这里定的是0.3秒)的删掉。
轨迹也就是一个触摸点的列表,其特点是频繁在头部和尾部增删元素,这个特点特别适合用haXe的List<T>数据结构来实现,如下:
private var track: List<VolatilePoint>;
如果想支持多点触摸实现多条刀光,那么只要维护多个track轨迹列表即可。具体请见后面的源代码。
那么上图中的绿色线的两端坐标具体是如何确定的呢?其实它们就是从触摸点出发,和当前触摸点与上一触摸点组成的线段成正负90度的两个矢量,而其长度,前面说过,应该根据触摸点的寿命按照比例计算。
设当前点为p,前一点为prev,那么绿色线两端点可如下计算:
var angle = Math.atan2(p.y - prev.y, p.x - prev.x); // 当前点与前一点确定的直线的角度
var len = (1 - (now - p.birth) / 0.3) * 10; // 确定刀光的半径,这里now是当前时刻,0.3是触摸点的最大寿命(秒),10则是最大刀光半径
var x1 = p.x + len * Math.cos(angle + Math.PI / 2), y1 = p.y + len * Math.sin(angle + Math.PI / 2); //第一端点
var x2 = p.x + len * Math.cos(angle - Math.PI / 2), y2 = p.y + len * Math.sin(angle - Math.PI / 2); //第二端点
好了,有了这些点,我们蓝线的轮廓也就出来了,是真正绘制的刀光,你可以看到,它其实是中部的若干个扭曲矩形,加上两端尖锐部分的两个三角形组成的。
我们可以用NME的Graphics.drawTriangles()方法来一次性绘制多个三角形。刀光两端本身就是三角形,那么扭曲矩形怎么办呢?你看上图中的黄线,对,我们把每个扭曲矩形分解成两个三角形就好了。
对应的,原始贴图(图中上面部分)也类似处理,两端两个三角形,中间矩形对应分解为两个三角形,至于为什么做成4/1而不是3/1的比例,因为纹理坐标比较好表示。
然后就很简单了,所有三角形的顶点坐标以及对应的纹理坐标都可以确定了,只要简单的把它们放进数组里传递给drawTriangles()方法,让它画出来就好了,具体的代码在后面。
下面解释下drawTriangles()这个方法的参数的具体含义。
第一个参数vertices,是这一批三角形的顶点坐标,每个顶点用一对值表示,即x和y坐标,注意顶点数不一定是3的倍数,因为每个顶点可以被多个三角形共享,比如我们的例子中的矩形就是这样,4个顶点被两个三角形共用。
而第二个参数indices就是指示前面的这些顶点如何组成三角形的,indices必须是3的倍数,每三个组成一个三角形,数组中每一个元素都是前面vertices数组中的一个顶点的索引。
第三个参数是纹理坐标,它是和第一个参数vertices一一对应的,即每个顶点在纹理图片上的纹理坐标。大家只要知道在我们的原始贴图上,左上角的纹理坐标是(0, 0),而右下角则是(1, 1),那么其它点的纹理坐标就可以直接算出来了。比如最左面刀光的尖的纹理坐标就是(0, 0.5),而黄线的左端点的坐标则是(0.25, 1)。
=============================================================
刀光实现的haXe源码如下,这是支持多点触摸的版本,已经在小米手机上测试过。
import haxe.Timer;
import net.cnjm.haxe.events.RocTouchEvent;
import net.cnjm.haxe.game.RocGameObject;
import nme.Vector;
import nme.display.BitmapData;
import nme.display.Graphics;
import nme.events.Event;
import nme.events.TouchEvent;
import nme.geom.Rectangle;
/**
* ...
* @author Rocks Wang
*/
private class VolatilePoint {
public var x: Float;
public var y: Float;
public var birth: Float;
public function new(inX: Float, inY: Float) {
x = inX;
y = inY;
birth = Timer.stamp();
}
}
class Slash extends RocGameObject {
private var tracks: Array<List<VolatilePoint>>;
private var touched: Array<Null<Bool>>;
private var bitmap: BitmapData;
public function new() {
super(null);
moveTo(0, 0);
width = 240;
height = 400;
tracks = [];
touched = [];
bitmap = RocUtils.loadBitmapData("res/slash.png");
addEventListener(TouchEvent.TOUCH_BEGIN, onTouch);
addEventListener(TouchEvent.TOUCH_MOVE, onTouch);
addEventListener(TouchEvent.TOUCH_END, onTouch);
}
override public function draw(layerId: Int) {
var gfx = canvas.acquireGraphics();
gfx.beginFill(0x00FFFF, 0.3);
gfx.drawRect(x, y, width, height);
gfx.endFill();
var now = Timer.stamp();
var vertices: Vector<Float> = new Vector<Float>();
var indices: Vector<Int> = new Vector<Int>();
var uvtData: Vector<Float> = new Vector<Float>();
var numpt = 0;
for (track in tracks) {
if (track == null) continue;
var head: VolatilePoint = track.first();
while ((head = track.first()) != null && now - head.birth > 0.3) track.pop();
if (track.length < 4) continue;
var prev: VolatilePoint = null, tail: VolatilePoint = track.last();
var px1 = head.x, py1 = head.y, px2: Null<Float> = null, py2: Null<Float> = null;
for (p in track) {
if (prev != null) {
var angle = Math.atan2(p.y - prev.y, p.x - prev.x) + Math.PI / 2;
var len = (1 - (now - p.birth) / 0.3) * 10;
var x1 = p.x + len * Math.cos(angle), y1 = p.y + len * Math.sin(angle);
var x2 = p.x + len * Math.cos(angle + Math.PI), y2 = p.y + len * Math.sin(angle + Math.PI);
if (p == tail) {
vertices.push(p.x);
vertices.push(p.y);
vertices.push(px1);
vertices.push(py1);
vertices.push(px2);
vertices.push(py2);
indices.push(numpt);
indices.push(numpt + 1);
indices.push(numpt + 2);
uvtData.push(1);
uvtData.push(0.5);
uvtData.push(0.75);
uvtData.push(0);
uvtData.push(0.75);
uvtData.push(1);
numpt += 3;
} else if (px2 != null) { // normal
vertices.push(x1);
vertices.push(y1);
vertices.push(x2);
vertices.push(y2);
vertices.push(px1);
vertices.push(py1);
vertices.push(px2);
vertices.push(py2);
indices.push(numpt);
indices.push(numpt + 2);
indices.push(numpt + 3);
indices.push(numpt);
indices.push(numpt + 1);
indices.push(numpt + 3);
uvtData.push(0.75);
uvtData.push(0);
uvtData.push(0.75);
uvtData.push(1);
uvtData.push(0.25);
uvtData.push(0);
uvtData.push(0.25);
uvtData.push(1);
numpt += 4;
} else { // prev is head
vertices.push(x1);
vertices.push(y1);
vertices.push(x2);
vertices.push(y2);
vertices.push(px1);
vertices.push(py1);
indices.push(numpt);
indices.push(numpt + 1);
indices.push(numpt + 2);
uvtData.push(0.25);
uvtData.push(0);
uvtData.push(0.25);
uvtData.push(1);
uvtData.push(0);
uvtData.push(0.5);
numpt += 3;
}
px1 = x1;
py1 = y1;
px2 = x2;
py2 = y2;
}
prev = p;
}
}
gfx.beginBitmapFill(bitmap);
gfx.drawTriangles(vertices, indices, uvtData);
gfx.endFill();
}
public function onTouch(e: TouchEvent) {
var touchId = e.touchPointID;
//trace(">>>" + e.type + "[" + e.touchPointID + "] = (" + e.localX + "," + e.localY + "),touched=" + touched[touchId]);
if (touched[touchId] == null) {
touched[touchId] = false;
}
if (e.type == TouchEvent.TOUCH_BEGIN) {
touched[touchId] = true;
return; // 手指接触屏幕会同时触发TOUCH_BEGIN和TOUCH_MOVE事件,因此这里返回即可
} else if (e.type == TouchEvent.TOUCH_END) {
touched[touchId] = false;
}
if (!touched[touchId]) return;
var track: List<VolatilePoint>;
if ((track = tracks[touchId]) == null) {
track = tracks[touchId] = new List<VolatilePoint>();
}
var last = track.last(), dx: Float, dy: Float;
if (last != null && (dx = e.localX - last.x) * dx + (dy = e.localY - last.y) * dy < 25) return; //防止两点距离过于接近,可能导致计算误差
track.add(new VolatilePoint(e.localX, e.localY)); // add to end
}
override public function touchTest(inX:Float, inY:Float): Bool {
return true;
}
}