1. 程式人生 > >自定義控制元件之 Gamepad (遊戲手柄)

自定義控制元件之 Gamepad (遊戲手柄)

這段時間自己在復刻一個小時候玩過的小遊戲——魔塔,在人物操控的時候剛開始用的感覺 low low 的上下左右四個方向鍵,後來受王者農藥啟發,決定採用現在很多遊戲中的那種遊戲手柄,網上也有例子,不過最近自己對自定義控制元件很感興趣,決定自己擼一個,最後實現的效果是這樣的:



看到這樣的需要實現的效果應該就有個大致的思路了,首先需要畫兩個圓,一大一小,然後小圓可以被拖動,但是圓心不能在大圓外,最後我們需要能夠監聽到它是向哪個方向移動的。


1 準備工作

先定義好大圓小圓的半徑,以及將圓心設定在控制元件的中心位置。
    private int width = 320;    //控制元件寬
    private int height = 320;   //控制元件高
    private Paint mPaint;   //畫筆
    private float bigCircleX = 160; //大圓在 x 軸的座標
    private float bigCircleY = 160; //大圓在 y 軸的座標
    private float smallCircleX = 160;   //小圓在 x 軸的座標
    private float smallCircleY = 160;   //小圓在 y 軸的座標
    private float bigCircleR = 120; //大圓的半徑
    private float smallCircleR = 40;    //小圓的半徑
    private OnDirectionListener mOnDirectionListener;   //移動方向監聽器

    public void setOnDirectionListener(OnDirectionListener onDirectionListener) {
        this.mOnDirectionListener = onDirectionListener;
    }

    public Gamepad(Context context) {
        this(context, null);
    }

    public Gamepad(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public Gamepad(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        //新建畫筆
        mPaint = new Paint();
        //設定畫筆粗細
        mPaint.setStrokeWidth(2);
        //設定抗鋸齒
        mPaint.setAntiAlias(true);
        //設定畫筆樣式
        mPaint.setStyle(Paint.Style.STROKE);
        //設定畫筆顏色
        mPaint.setColor(Color.BLACK);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        setMeasuredDimension(width, height);
    }

2 畫圓

第一步非常 easy,根據已確定的大圓小圓半徑,在 onDraw() 方法中畫出兩個圓:

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //畫大圓
        canvas.drawCircle(bigCircleX, bigCircleY, bigCircleR, mPaint);
        //畫小圓
        canvas.drawCircle(smallCircleX, smallCircleY, smallCircleR, mPaint);
    }

效果如下:



3 拖動小圓

接下來需要重寫這個控制元件的 onTouchEvent() 方法,在按下和移動的時候記錄下座標,根據這個座標來重繪小圓的位置,在擡起的時候將小圓的位置還原:

    @Override
    public boolean onTouchEvent(MotionEvent motionEvent) {
        if (motionEvent.getAction() == MotionEvent.ACTION_DOWN || motionEvent.getAction() == MotionEvent.ACTION_MOVE) {
            //記錄觸控的位置,根據這個位置來重繪小圓
            smallCircleX = motionEvent.getX();
            smallCircleY = motionEvent.getY();
        } else if (motionEvent.getAction() == MotionEvent.ACTION_UP) {
            //在手指擡起離開屏幕後將小圓的位置還原
            smallCircleX = width / 2;
            smallCircleY = height / 2;
        }
        //重繪
        invalidate();
        return true;
    }

效果如下:



可以看到實現了拖動小圓的效果,但是同時也可以看到小圓會被拖到大圓外面,導致一部分顯示不全,所以我們通過計算來保證小圓的圓心不能在大圓外。

當觸控點在大圓內的時候其實是無所謂的,我們只需要判斷當觸控點在大圓外時,設定小圓圓心在該觸控點到大圓圓心的連線線與大圓邊界的交點處,有點繞,先看一張圖:



以上圖為一個例子,我們以大圓圓心為原點建立座標系,當觸控點為紅色點時已經在大圓外了,所以我們要讓這時的小圓圓心在藍色點,如何計算這就要用到數學知識了,這應該是高中的數學知識。先說說如何判斷觸控點在大圓外,這個很簡單,利用勾股定理即可:Math.sqrt(Math.pow(motionEvent.getX() - width / 2, 2) + Math.pow(height / 2 - motionEvent.getY(), 2)) > bigCircleR,當紅色三角形的斜邊大於大圓半徑時,即在大圓外。然後計算出這個紅色三角形的 sin 值:double sin = (height / 2 - motionEvent.getY()) / Math.sqrt(Math.pow(motionEvent.getX() - width / 2, 2) + Math.pow(height / 2 - motionEvent.getY(), 2)) ,
以及 cos 值 :double cos = (motionEvent.getX() - width / 2) / Math.sqrt(Math.pow(motionEvent.getX() - width / 2, 2) + Math.pow(height / 2 - motionEvent.getY(), 2)),通過sin 和 cos 就能計算出藍色三角形的鄰邊(在 x 軸的座標)和對邊(在 y 軸的座標):smallCircleX = (float) (cos * bigCircleR + width / 2),smallCircleY = (float) (height / 2 - sin * bigCircleR),需要注意的是上面的計算中有對 width 和 height 進行的計算,這是因為我們上面自己假設了一個座標系,但是畫圖的座標系其實並不是我們假設的這個座標系,畫圖的座標系的原點是在左上角。

上面是以第一象限為例子,其他象限的計算稍微有點不同,但是思維是一樣的,就不一一舉例了,這一部分的具體程式碼可以在最後整體原始碼中檢視。

至此,效果就已經如博文開頭那樣了。


4 新增移動方向監聽器

同樣在 onTouchEvent() 方法中,我們在最後判斷一下觸控點的相對位置,然後自己設立一個標準,通過回撥,可以實現移動方向的監聽:
        //新增移動方向監聽器
        if (mOnDirectionListener != null) {
            if (motionEvent.getY() < height / 4) {
                mOnDirectionListener.onUp();
            } else if (motionEvent.getY() > height / 4 * 3) {
                mOnDirectionListener.onDown();
            } else if (motionEvent.getX() < width / 4) {
                mOnDirectionListener.onLeft();
            } else if (motionEvent.getX() > width / 4 * 3) {
                mOnDirectionListener.onRight();
            }
        }

在 MainActivity 中設定監聽試試:
public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Gamepad gpGamepad= (Gamepad) findViewById(R.id.gp_gamepad);
        gpGamepad.setOnDirectionListener(new Gamepad.OnDirectionListener() {
            @Override
            public void onUp() {
                Log.i("Gamepad","Up");
            }

            @Override
            public void onDown() {
                Log.i("Gamepad","Down");
            }

            @Override
            public void onLeft() {
                Log.i("Gamepad","Left");
            }

            @Override
            public void onRight() {
                Log.i("Gamepad","Right");
            }
        });
    }
}

然後像下圖這樣移動:
列印如下(部分列印):


5 總結

以前總是聽到數學在理科中很重要,但是之前在程式設計開發中並沒有很深的體會,直到最近研究自定義控制元件才感受到,數學真的很重要,上面計算小圓圓心超出大圓後的應該在的位置還可以用相似三角形來做。學一個東西就能發現自己在很多方面的不足,自己的路還很長,給自己一點信心和動力,我還可以做得更好。

6 原始碼

最後附上完整程式碼:

public class Gamepad extends View {
    private int width = 320;    //控制元件寬
    private int height = 320;   //控制元件高
    private Paint mPaint;   //畫筆
    private float bigCircleX = 160; //大圓在 x 軸的座標
    private float bigCircleY = 160; //大圓在 y 軸的座標
    private float smallCircleX = 160;   //小圓在 x 軸的座標
    private float smallCircleY = 160;   //小圓在 y 軸的座標
    private float bigCircleR = 120; //大圓的半徑
    private float smallCircleR = 40;    //小圓的半徑
    private OnDirectionListener mOnDirectionListener;   //移動方向監聽器

    public void setOnDirectionListener(OnDirectionListener onDirectionListener) {
        this.mOnDirectionListener = onDirectionListener;
    }

    public Gamepad(Context context) {
        this(context, null);
    }

    public Gamepad(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public Gamepad(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        //新建畫筆
        mPaint = new Paint();
        //設定畫筆粗細
        mPaint.setStrokeWidth(2);
        //設定抗鋸齒
        mPaint.setAntiAlias(true);
        //設定畫筆樣式
        mPaint.setStyle(Paint.Style.STROKE);
        //設定畫筆顏色
        mPaint.setColor(Color.BLACK);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        if (widthMode == MeasureSpec.EXACTLY) {
            width = widthSize;
        }

        if (heightMode == MeasureSpec.EXACTLY) {
            height = heightSize;
        }
        setMeasuredDimension(width, height);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //畫大圓
        canvas.drawCircle(bigCircleX, bigCircleY, bigCircleR, mPaint);
        //畫小圓
        canvas.drawCircle(smallCircleX, smallCircleY, smallCircleR, mPaint);
    }

    @Override
    public boolean onTouchEvent(MotionEvent motionEvent) {
        if (motionEvent.getAction() == MotionEvent.ACTION_DOWN || motionEvent.getAction() == MotionEvent.ACTION_MOVE) {
            //記錄觸控的位置,根據這個位置來重繪小圓
            if (motionEvent.getX() > width / 2 && motionEvent.getY() < height / 2) {    //第一象限
                if (Math.sqrt(Math.pow(motionEvent.getX() - width / 2, 2) + Math.pow(height / 2 - motionEvent.getY(), 2)) > bigCircleR) {   //圓外
                    double sin = (height / 2 - motionEvent.getY()) / Math.sqrt(Math.pow(motionEvent.getX() - width / 2, 2) + Math.pow(height / 2 - motionEvent.getY(), 2));
                    double cos = (motionEvent.getX() - width / 2) / Math.sqrt(Math.pow(motionEvent.getX() - width / 2, 2) + Math.pow(height / 2 - motionEvent.getY(), 2));
                    smallCircleX = (float) (cos * bigCircleR + width / 2);
                    smallCircleY = (float) (height / 2 - sin * bigCircleR);
                } else {    //圓內
                    smallCircleX = motionEvent.getX();
                    smallCircleY = motionEvent.getY();
                }
            } else if (motionEvent.getX() < width / 2 && motionEvent.getY() < height / 2) { //第二象限
                if (Math.sqrt(Math.pow(width / 2 - motionEvent.getX(), 2) + Math.pow(height / 2 - motionEvent.getY(), 2)) > bigCircleR) {   //圓外
                    double sin = (height / 2 - motionEvent.getY()) / Math.sqrt(Math.pow(width / 2 - motionEvent.getX(), 2) + Math.pow(height / 2 - motionEvent.getY(), 2));
                    double cos = (width / 2 - motionEvent.getX()) / Math.sqrt(Math.pow(width / 2 - motionEvent.getX(), 2) + Math.pow(height / 2 - motionEvent.getY(), 2));
                    smallCircleX = (float) (width / 2 - cos * bigCircleR);
                    smallCircleY = (float) (height / 2 - sin * bigCircleR);
                } else {    //圓內
                    smallCircleX = motionEvent.getX();
                    smallCircleY = motionEvent.getY();
                }
            } else if (motionEvent.getX() < width / 2 && motionEvent.getY() > height / 2) { //第三象限
                if (Math.sqrt(Math.pow(width / 2 - motionEvent.getX(), 2) + Math.pow(motionEvent.getY() - height / 2, 2)) > bigCircleR) {   //圓外
                    double sin = (motionEvent.getY() - height / 2) / Math.sqrt(Math.pow(width / 2 - motionEvent.getX(), 2) + Math.pow(motionEvent.getY() - height / 2, 2));
                    double cos = (width / 2 - motionEvent.getX()) / Math.sqrt(Math.pow(width / 2 - motionEvent.getX(), 2) + Math.pow(motionEvent.getY() - height / 2, 2));
                    smallCircleX = (float) (width / 2 - cos * bigCircleR);
                    smallCircleY = (float) (height / 2 + sin * bigCircleR);
                } else {    //圓內
                    smallCircleX = motionEvent.getX();
                    smallCircleY = motionEvent.getY();
                }
            } else if (motionEvent.getX() > width / 2 && motionEvent.getY() > height / 2) { //第四象限
                if (Math.sqrt(Math.pow(motionEvent.getX() - width / 2, 2) + Math.pow(motionEvent.getY() - height / 2, 2)) > bigCircleR) {   //圓外
                    double sin = (motionEvent.getY() - height / 2) / Math.sqrt(Math.pow(motionEvent.getX() - width / 2, 2) + Math.pow(motionEvent.getY() - height / 2, 2));
                    double cos = (motionEvent.getX() - width / 2) / Math.sqrt(Math.pow(motionEvent.getX() - width / 2, 2) + Math.pow(motionEvent.getY() - height / 2, 2));
                    smallCircleX = (float) (width / 2 + cos * bigCircleR);
                    smallCircleY = (float) (height / 2 + sin * bigCircleR);
                } else {    //圓內
                    smallCircleX = motionEvent.getX();
                    smallCircleY = motionEvent.getY();
                }
            }
        } else if (motionEvent.getAction() == MotionEvent.ACTION_UP) {
            //在手指擡起離開屏幕後將小圓的位置還原
            smallCircleX = width / 2;
            smallCircleY = height / 2;
        }
        //新增移動方向監聽器
        if (mOnDirectionListener != null) {
            if (motionEvent.getY() < height / 4) {
                mOnDirectionListener.onUp();
            } else if (motionEvent.getY() > height / 4 * 3) {
                mOnDirectionListener.onDown();
            } else if (motionEvent.getX() < width / 4) {
                mOnDirectionListener.onLeft();
            } else if (motionEvent.getX() > width / 4 * 3) {
                mOnDirectionListener.onRight();
            }
        }
        //重繪
        invalidate();
        return true;
    }

    public interface OnDirectionListener {
        void onUp();

        void onDown();

        void onLeft();

        void onRight();
    }
}