一、前言
最近沉迷和平精英这款游戏,尽管从最开始的刺激战场转过来的时候,各种不适应,还挥手告别,但是玩了一段时候之后,嗯,真香!!!
不记得组队交流标点系统是什么时候上线的了,但是玩游戏的时候,发现在这个功能实在是太好用了,先来看下官方的宣传图:
图片来源:https://gp.qq.com/main.shtml?ADTAG=media.innerenter.gamecom.navigation
身为一名程序猿,看见一个特别好用的控件时候,不免深思,这个功能是怎么实现的?能不能自己撸一个?实践是检验真理的唯一标准,光说不做假把式,打开AS,撸它一个天荒地老,撸到知其然并知其所以然。
二、原理
话不多说,先看下撸完之后的动态效果图和静待效果图:
看上去就是一个点击按钮(即图上右上角黑色按钮)和一个中间圆盘组成的自定义控件,但是这里涉及到三角函数、直线斜率、直线与圆交点的坐标、两点之间距离等等数学问题,最主要的是圆盘之中红色按钮要跟随手指点击黑色按钮移动而移动,这其中还涉及到红色按钮的显示范围及超过范围的显示方式,并且红色按钮所在扇形的变换形式等,原理讲的差不多了,接下来一步一步来打造一个组队交流标点系统。
三、实现
3.1 黑色按钮、中间圆盘
先确定中间圆盘的半径及圆心坐标和黑色按钮的圆心坐标。
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
centerX = w / 2;
centerY = h / 2;
radius = Math.min(w, h) / 2.0f;
btnCenterX = w / 4 * 3;
btnCenterY = h / 8;
super.onSizeChanged(w, h, oldw, oldh);
}
3.1.1 黑色按钮
黑色按钮是Bitmap,因此先要确定矩形,把Bitmap画在矩形内即可。
/**
* 黑色按钮
*
* @param canvas
*/
private void drawBtn(Canvas canvas) {
float btnLeftX = btnCenterX - btnRadius;
float btnRightX = btnCenterX + btnRadius;
float btnTopY = btnCenterY - btnRadius;
float btnBottomY = btnCenterY + btnRadius;
RectF rectF = new RectF(btnLeftX, btnTopY, btnRightX, btnBottomY);
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.ic_btn);
canvas.drawBitmap(bitmap, null, rectF, null);
}
3.1.2 中间圆盘
中间黑色圆盘由以下几部分组成:外圆、内圆、分割线,扇形内的图标和文字、跟随手指移动的红色按钮。
外圆及分割线
外圆的圆心坐标和半径已经计算出来,画一个圆就简单多了:
canvas.drawCircle(centerX, centerY, radius, outpaint);
分割线通过Path实现,将外圆黑色圆盘平均分成8份,只需要计算出每个扇形的角度即可,注:Math的cos和sin函数,参数都是弧度,而角度转换成弧度的计算方法,Android已经提供了,就是Math.toRadians()函数。
/**
* 把外圆平均分成8份
*
* @param canvas
*/
private void drawLines(Canvas canvas) {
for (int i = 0; i < ARC_NUMBER; i++) {
linePath.moveTo(centerX, centerY);
linePath.lineTo((float) Math.cos(Math.toRadians(i * getArcAngle())) * radius + centerX,
(float) Math.sin(Math.toRadians(i * getArcAngle())) * radius + centerY);
}
canvas.drawPath(linePath, linepaint);
}
先来看下效果:
画内圆
这里需要注意一下,画内圆涉及到PorterDuffXfermode模式,有人可能会说,看上面的静态图效果,直接把画笔设置成白色不就可以了,先别急,对比看一下:
- 改变画笔颜色
//为了方便查看效果,给Activity添加一个背景效果
android:background="@color/colorPrimary"
//外圆
canvas.drawCircle(centerX, centerY, radius, outpaint);
//内圆
outpaint.setColor(Color.WHITE); //改变画笔颜色
canvas.drawCircle(centerX, centerY, radius / 2, outpaint);
- 添加PorterDuffXfermode模式
//为了方便查看效果,给Activity添加一个背景效果
android:background="@color/colorPrimary"
canvas.drawCircle(centerX, centerY, radius, outpaint);
outpaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_OUT));
canvas.drawCircle(centerX, centerY, radius / 2, outpaint);
outpaint.setXfermode(null);
对比很明显,这里就不再赘述,关于PorterDuffXfermode模式的理解,请大家自行了解。
画图标和文字
根据组队交流标点系统静态图可以看出,无论图标还是文字都是和扇形的角度相同,扇形的绘制以**-90°**开始,顺时针方向绘制。
先计算每个图标显示在扇形区域的中心点坐标,很明显每个中心点的角度不同给,因此通过如下方法计算出每个中心点的坐标:
for (int i = 1; i <= 15; i += 2) {
float bitmapCenterX =
(float) (centerX + Math.cos(Math.toRadians((getArcAngle() / 2) * i - 90)) * (radius / 4) * 3);
float bitmapCenterY =
(float) (centerY + Math.sin(Math.toRadians((getArcAngle() / 2) * i - 90)) * (radius / 4 * 3));
}
求出中心点之后,确定图标显示的位置:
RectF rectF = new RectF(
bitmapCenterX - imgWidth / 2,
bitmapCenterY - imgWidth / 2 + (imgWidth / 4),
bitmapCenterX + imgWidth / 2,
bitmapCenterY + imgWidth / 2 + (imgWidth / 4)
计算每个图标和文字的旋转角度:
Matrix matrix = new Matrix();
matrix.setRotate((getArcAngle() / 2) * i, bitmapCenterX, bitmapCenterY);
canvas.setMatrix(matrix);
最后把图标和文字画出来:
canvas.drawBitmap(bitmap, null, rectF, null);
Paint.FontMetricsInt fontMetricsInt = textpaint.getFontMetricsInt();
//文字居中显示
float baseLine =(rectF.bottom + rectF.top - fontMetricsInt.bottom - fontMetricsInt.top) / 2;
canvas.drawText(text,rectF.centerX(), baseLine - radius / 5,textpaint);
效果图:
红点
红色按钮即为跟随手指移动而移动的红点,当与外圈扇形重合时出发组队交流标点系统的语音提示,本文并未实现语音效果,实在找不着小姐姐录制语音,哎!
enum BTN_TYPE {
BTN_DOWN, BTN_MOVE, BTN_UP //手指按下,手指移动,手指抬起
}
- 监听黑色按钮的点击和移动事件
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
x = event.getX();
y = event.getY();
btn_down_x = x;
btn_down_y = y;
if (onDownBtn(x, y)) {
btn_type = BTN_TYPE.BTN_DOWN;
isDrawOnInCircle = true;
postInvalidate();
}
return true;
case MotionEvent.ACTION_MOVE:
x = event.getX();
y = event.getY();
btnMoveRadius =
Math.sqrt((btn_down_x - x) * (btn_down_x - x) + (btn_down_y - y) * (btn_down_y - y));
if (btnMoveRadius <= (radius / 2)) {
btn_type = BTN_TYPE.BTN_MOVE;
isDrawOnInCircle = true;
postInvalidate();
}
break;
}
return super.onTouchEvent(event);
}
当手指点击在黑色按钮时候,记录手指点击的X,Y坐标,当手指移动的时候,记录手指移动的坐标同样是X,Y,并且不断重绘。
btn_down_x = x,btn_down_y = y 记录手指点击的坐标,Math.sqrt((btn_down_x - x) * (btn_down_x - x) + (btn_down_y - y) * (btn_down_y - y)); 获取手指移动的终点和手指点击位置的直线距离。
- 手指按下的瞬间重绘
canvas.drawCircle(centerX, centerY, btnRadius, btnpaint);
因为手指按下的瞬间一定是在中间圆盘的中心显示红点,很好理解,不需要过多讲解。
- 红点跟随手指移动而移动(圆内,此圆指内圆,即中间空白区域)
//点击按钮移动的点到按下的点X Y轴的坐标差
float moveX = x - btn_down_x;
float moveY = y - btn_down_y;
//内圆圆心和点击按钮的坐标比例
float bX = centerX / btnCenterY;
float bY = centerY / btnCenterY;
//中心按钮相对与点击按钮的圆心坐标
float mX = centerX + moveX * bX;
float mY = centerY + moveY * bY;
//中心按钮移动的圆心坐标到内圆圆心坐标的距离
centerBtnMoveRadius =Math.sqrt((mX - centerX) * (mX - centerX) + (mY - centerY) * (mY - centerY));
先计算黑色按钮,手指按下和手指移动终点的X,Y坐标差,求出圆盘圆心坐标和黑色按钮坐标比例,根据黑色按钮移动的距离求出红点跟随手指移动后的终点坐标,最后求出红点的终点坐标与圆盘圆心的距离即centerBtnMoveRadius;
centerBtnMoveRadius 与内圆半径进行比较,会有以下两种情况:
1、在内圆内,即 centerBtnMoveRadius < radius / 2
在圆内比较简单,直接用求出来的mX、mY 圆心坐标,不断画圆即可:
canvas.drawCircle(mX, mY, btnRadius, btnpaint);
2、在内圆外,即 centerBtnMoveRadius >= radius / 2
先计算红点移动的终点与圆心形成的直线与X轴形成的角度大小:
if ((mX > centerX && mY > centerY) || (mX < centerX && mY > centerY)) {
radians = Math.atan2(mY - centerY, mX - centerX);
degrees = radians * (180 / Math.PI);
}
if ((mX > centerX && mY < centerY) || (mX < centerX && mY < centerY)) {
radians = Math.atan2(centerY - mY, mX - centerX);
degrees = radians * (180 / Math.PI);
}
再计算红点移动的终点与圆心形成的直线和内圆交点的坐标,会有两个交点:
参考:https://blog.youkuaiyun.com/sinat_25911307/article/details/86598780
/**
* 计算直线与圆相交的两点坐标
*
* @param x1 起点直线的X轴坐标
* @param y1 起点直线的轴坐标
* @param x2 终点直线的X轴坐标
* @param y2 终点直线的Y轴坐标
* @param r 圆半径
* @param centerx 圆X轴坐标
* @param centery 圆y轴坐标
*/
private void measurePonit(float x1, float y1, float x2, float y2, float r, float centerx,
float centery) {
//直线斜率不存在,垂直与X轴
if ((x1 == x2) && (y1 != y2)) {
if (Math.abs(centerx - x1) < r) {
double y = Math.sqrt(r * r - ((x1 - centerx) * (x1 - centerx)));
j_x_1 = x1;
j_y_1 = (float) (centery + y);
j_x_2 = x1;
j_y_2 = (float) (centery - y);
}
}
//两点重合
else if ((x1 == x2) && (y1 == y2)) {
j_x_1 = x1;
j_y_1 = y1;
j_x_2 = x2;
j_y_2 = y2;
}
//直线斜率为0,平行于X轴
else if ((y1 == y2) && (x1 != x2)) {
double area = Math.abs(centery - y1);
if (area <= r) {
double x = Math.sqrt(r * r - ((y1 - centery) * (y1 - centery)));
j_x_1 = (float) (centerx + x);
j_y_1 = y1;
j_x_2 = (float) (centerx - x);
j_y_2 = y1;
}
} else {
double k = (y2 - y1) / (x2 - x1);
double b = y2 - k * (x2);
double del =
4 * Math.pow((k * b - centerx - k * (centery)), 2) - 4 * (1 + k * k) *
(Math.pow((centerx), 2) + Math.pow((b - centery), 2) - r * r);
if (del > 0) {
double tmp = 2 * (k * b - centerx - k * centery);
j_x_1 = (float) ((-tmp + Math.sqrt(del)) / (2 * (1 + k * k)));
j_y_1 = (float) (k * (j_x_1) + b);
j_x_2 = (float) ((-tmp - Math.sqrt(del)) / (2 * (1 + k * k)));
j_y_2 = (float) (k * (j_x_2) + b);
}
}
}
通过计算之后会得到两个点的坐标为A( j_x_1,j_y_1)和B( j_x_2,j_y_2),其中用到的是直线斜率的知识点,不过关于到底是取A点还是B点的问题,暂时未完全明白,之后再来思考这个问题。
把圆平均分为四等份,如下所示:
上面已经求出直线与X轴形成的角度大小,切记只有直线在上图所示的1、3区域内,形成的角度才会大等于90°,小于等于180°,这个时候交点坐标取B点,反之交点坐标取A点。
if (degrees > 90 && degrees < 180) {
//Y轴左
canvas.drawCircle(j_x_2, j_y_2, btnRadius, btnpaint);
}else{
//Y轴右
canvas.drawCircle(j_x_1, j_y_1, btnRadius, btnpaint);
}
到这里红点跟随手指移动而移动,并且手指怎么移动都不会超过内圆的范围已经实现了,不过还差最后一步,就是红点与扇形重合的时候,需要重新绘制扇形。
绘制扇形
如上图所示,把圆平均分为1、2、3、4个部分,每个部分的计算方式都一样,只不过是角度不同而已,因此这里只讲2部分的实现方式,其他部分依次类推。
根据组队交流标点系统静态图可以看出,2部分有两个扇形,先求出2部分中心点在圆周上的坐标,然后和A点坐标进行比较,注:A活B点根据部分而定,计算角度,当红点与扇形重合时,重新绘制扇形区域。
cX = (float) (centerX + Math.cos(Math.toRadians(-getArcAngle())) * (radius / 2));
if (j_x_1 < cX) {
arcpaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP));
canvas.drawArc(arcRectf, -90, getArcAngle(), true, arcpaint);
} else {
arcpaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP));
canvas.drawArc(arcRectf, -getArcAngle(), getArcAngle(), true, arcpaint);
}
剩余1、3、4部分分别根据此方法一一实现即可,这里就不再赘述,到此仿和平精英组队交流标点系统便撸完了。
四、不足
手指移动时候,红点有时会卡住,并且关于直线斜率和直线和圆交点坐标还不是很了解,关于卡顿问题,有时间会好好研究研究,如果您发现任何问题或者建议,欢迎留言。
五、源码
部分代码:
/**
* 利用混合模式,画两个圆,形成圆环
*
* @param canvas
*/
private void drawCircleView(Canvas canvas) {
//重绘扇形时候需要
arcRectf.left = centerX - radius;
arcRectf.top = centerY - radius;
arcRectf.right = centerX + radius;
arcRectf.bottom = centerY + radius;
int layerId = canvas.saveLayer(0, 0, getWidth(), getHeight(), null, Canvas.ALL_SAVE_FLAG);
canvas.drawCircle(centerX, centerY, radius, outpaint);
drawBitmapAndText(canvas);
outpaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_OUT));
drawLines(canvas);
canvas.drawCircle(centerX, centerY, radius / 2, outpaint);
outpaint.setXfermode(null);
drawOnEventBtn(canvas);
canvas.restoreToCount(layerId);
}
//Y轴右
canvas.drawCircle(j_x_1, j_y_1, btnRadius, btnpaint);
if (j_y_1 > centerY) {
cX = (float) (centerX + Math.cos(Math.toRadians(getArcAngle())) * (radius / 2));
if (j_x_1 < cX) {
arcpaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP));
canvas.drawArc(arcRectf, getArcAngle(), getArcAngle(), true,arcpaint);
} else {
arcpaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP));
canvas.drawArc(arcRectf, 0, getArcAngle(), true, arcpaint);
}
} else {
cX = (float) (centerX + Math.cos(Math.toRadians(-getArcAngle())) * (radius / 2));
if (j_x_1 < cX) {
arcpaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP));
canvas.drawArc(arcRectf, -90, getArcAngle(), true, arcpaint);
} else {
arcpaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP));
canvas.drawArc(arcRectf, -getArcAngle(), getArcAngle(), true,arcpaint);
}
}