網頁

2010年12月21日 星期二

Android: 讀書心得#6 2D繪圖實例

接續上篇: 花栗鼠柑仔店: Android筆記: 讀書心得#5

參考資料: Amazon.com: Hello, Android: Introducing Google's Mobile Development Platform (Pragmatic Programmers) (9781934356562): Ed Burnette: Books

這段內容主要為:
第4章 2D繪圖
4.2 將類別繪圖加入到Sudoku遊戲
4.3 定義遊戲類別

第4章: 2D繪圖(Exploring 2D Graphics)

4.2 將繪圖類別加入到Sudoku遊戲
回到Sudoku遊戲, 原先的基本架構已經完成, 現在把遊戲本身最重要的部分: 遊戲本身! 利用內建的2D繪圖函式庫來實現.

首先補完Sudoku.java中startGame()方法, startGame()接收一個參數, 此參數代表遊戲難度, 新的定義如下:

## src/org/example/sudoku/Sudoku.java
private void startGame(int i) {
  Log.d(TAG, "clicked on " + i);
  Intent intent = new Intent(Sudoku.this, Game.class);
  intent.putExtra(Game.KEY_DIFFICULTY, i);
  startActivity(intent);
}

有關Sudoku遊戲的部分把他放在另外一個activity叫做Game, 所以我們可以建立一個新的intent來啟動Game. 我們把困難指數(使用者所選的)放在extraData區也提供給intent作為啟動Game activity的額外參數, extraData裡面存放key/value map, key是字串, value可以放一些基礎型別(primitive type, 如int, float...), 基礎型別array, Bundle,或Serializable與Parcelable的子類別.


4.3 定義遊戲類別
至此, 我把接下來整個程式要呈現的模型做一個整理:

首先要定義一個Game的Activity, 在系統中類似一個Thread的角色, 定義一個遊戲的開始與結束. 裡頭會定義一些所需用到的基本設定參數, 如遊戲的困難度(DIFFICULTY), 還有遊戲中會用到的資料結構, 如puzzle[]陣列, 代表Sudoku遊戲中9x9的方格, 陣列中每個元素存放一個方格(Rect). 遊戲的主畫面設定為PizzleView, 類似一個網頁的頁面, 顯示在Game的內容中央(setContentView(...)).

4.3.1 定義Game activity類別

底下是Game activity類別的程式碼:

## src/org/example/sudoku/Game.java
package org.example.sudoku;
import android.app.Activity;
import android.app.Dialog;
import android.os.Bundle;
import android.util.Log;
import android.view.Gravity;
import android.widget.Toast;

public class Game extends Activity {
  private static final String TAG = "Sudoku" ;
  public static final String KEY_DIFFICULTY = "org.example.sudoku.difficulty" ;
  public static final int DIFFICULTY_EASY = 0;
  public static final int DIFFICULTY_MEDIUM = 1;
  public static final int DIFFICULTY_HARD = 2;
  private int puzzle[] = new int[9 * 9];
  private PuzzleView puzzleView;

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    Log.d(TAG, "onCreate" );
    int diff = getIntent().getIntExtra(KEY_DIFFICULTY, DIFFICULTY_EASY);
    puzzle = getPuzzle(diff);
    calculateUsedTiles();
    puzzleView = new PuzzleView(this);
    setContentView(puzzleView);
    puzzleView.requestFocus();
  }
// ...
}

首先onCreate()讀取intent中的困難變數, KEY_DIFFICULTY是用Key來存取對應到的值, DIFFICULTY_EASY是預設值, getpuzzle()根據難度取出對應的puzzle. calculateUsedTiles()在之後會提到, 主要用來計算9x9的數字有符合Sudoku的原則, 這函式會檢查哪幾格不合法, 該數字已經被直行或橫列某格衝突到.

接下來, 在AndroidManifest.xml要註冊Game, 這樣Game activity才可以執行:

## AndroidManifest.xml
<activity android:name=".Game" android:label="@string/game_title" />


在strings.xml中也加入一些Game activity的相關資訊:

## res/values/strings.xml
<string name="game_title">Game</string>
<string name="no_moves_label">No moves</string>
<string name="keypad_title">Keypad</string>


4.3.2 定義PuzzleView類別

由於PuzzleView是完全自訂的外觀, 所以不用XML來撰寫介面, 用Java程式碼來自訂外觀, 底下是PuzzleView類別的程式碼:

## src/org/example/sudoku/PuzzleView.java
package org.example.sudoku;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.Paint.FontMetrics;
import android.graphics.Paint.Style;
import android.util.Log;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.animation.AnimationUtils;

public class PuzzleView extends View {
  private static final String TAG = "Sudoku" ;
  private final Game game;

  public PuzzleView(Context context) {
super(context);
// TODO Auto-generated constructor stub

this.game = (Game) context;

// 設定使用者可以輸入資訊
setFocusable(true);
setFocusableInTouchMode(true);

  }

// ...
}


以上PuzzleView的建構子中保留一個參考指回原本Game類別, 並且設定允許使用者可以在這個View輸入(setFocusableInTouchMode(true);). 在PuzzleView中我們首先要實作onSizeChanged()方法, 這方法會在View被產生後呼叫, 我們在這邊對照Android給的View長寬大小, 來計算PuzzleView中的元素大小.

## src/org/example/sudoku/PuzzleView.java
private float width; // width of one tile
private float height; // height of one tile
private int selX; // X index of selection
private int selY; // Y index of selection
private final Rect selRect = new Rect();

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
// TODO Auto-generated method stub
super.onSizeChanged(w, h, oldw, oldh);

// 計算一格的長寬
width = w / 9f;
height = h / 9f;

// 計算使用者選到的格子的四個座標
getRect(selX, selY, selRect);
Log.d(TAG, "onSizeChanged: width " + width + ", height " + height);
}

private void getRect(int x, int y, Rect rect) {
rect.set((int) (x * width), (int) (y * height),
(int) (x * width + width), (int) (y * height + height));
}

至此我們建立了基本的PuzzleView, 也知道了整個畫面的長寬大小, 接下來可以在畫面上把格子畫出來.


4.3.3 將整個遊戲版面畫出來

當view需要更新其畫面時, Android會呼叫onDraw(), 簡化來看, onDraw()架設整個畫面每次均要整個重繪, 但實際上有需要的話, 可以只定義視窗某個小部分重繪就好. 一開始先定義會用到的顏色:

## res/values/colors.xml:

<color name="puzzle_background">#ffe6f0ff</color>
<color name="puzzle_hilite">#ffffffff</color>
<color name="puzzle_light">#64c6d4ef</color>
<color name="puzzle_dark">#6456648f</color>
<color name="puzzle_foreground">#ff000000</color>
<color name="puzzle_hint_0">#64ff0000</color>
<color name="puzzle_hint_1">#6400ff80</color>
<color name="puzzle_hint_2">#2000ff80</color>
<color name="puzzle_selected">#64ff8000</color>

底下是基本的onDraw():

## src/org/example/sudoku/PuzzleView.java
@Override
protected void onDraw(Canvas canvas) {
// Draw the background...
Paint background = new Paint();
background.setColor(getResources().getColor(R.color.puzzle_background));
canvas.drawRect(0, 0, getWidth(), getHeight(), background);

// Draw the board...
// Draw the numbers...
// Draw the hints...
// Draw the selection...
}


第一個參數Canvas是用來"畫東西"上去的類別, 這邊我們取出背景顏色的定義(R.color.puzzle_background), 把背景先畫出來. 接下來畫格線:

## src/org/example/sudoku/PuzzleView.java

// Draw the board...
// Define colors for the grid lines

// 深色
Paint dark = new Paint();
dark.setColor(getResources().getColor(R.color.puzzle_dark));

// 加亮
Paint hilite = new Paint();
hilite.setColor(getResources().getColor(R.color.puzzle_hilite));

// 淡色
Paint light = new Paint();
light.setColor(getResources().getColor(R.color.puzzle_light));

// Draw the minor grid lines
for (int i = 0; i < 9; i++) {
canvas.drawLine(0, i * height, getWidth(), i * height, light);
canvas.drawLine(0, i * height + 1, getWidth(), i * height + 1, hilite);

canvas.drawLine(i * width, 0, i * width, getHeight(), light);
canvas.drawLine(i * width + 1, 0, i * width + 1, getHeight(), hilite);
}

// Draw the major grid lines, 每三格畫一個粗線
for (int i = 0; i < 9; i++) {
if (i % 3 != 0)
continue;
canvas.drawLine(0, i * height, getWidth(), i * height, dark);
canvas.drawLine(0, i * height + 1, getWidth(), i * height + 1, hilite);
canvas.drawLine(i * width, 0, i * width, getHeight(), dark);
canvas.drawLine(i * width + 1, 0, i * width + 1, getHeight(), hilite);
}


上面的程式碼用到了三個顏色: 淡色(light)畫在每個格子間, 每三格會畫一條深色線(dark), 高亮度的線(hilite)會畫在每格邊緣中, 這樣看起來會較有立體感. 線畫的順序很重要, 後來畫的會蓋過先前畫的, 所以要注意, 底下把上面畫線程式碼的模型整理如下:



程式的執行結果如下:



4.3.4 畫上數字

底下這段程式將數字畫在每個格子中, 比較需要注意的是要計算每個數字的位置將其擺在格子正中央.

## src/org/example/sudoku/PuzzleView.java
// Draw the numbers...
// Define color and style for numbers
// 反鋸尺
Paint foreground = new Paint(Paint.ANTI_ALIAS_FLAG);
// 顏色
foreground.setColor(getResources().getColor(R.color.puzzle_foreground));
// 樣式: 填滿
foreground.setStyle(Style.FILL);
// 文字高度: 格子的3/4
foreground.setTextSize(height * 0.75f);
// 文字長寬比
foreground.setTextScaleX(width / height);
// 文字對齊方式
foreground.setTextAlign(Paint.Align.CENTER);

// Draw the number in the center of the tile
FontMetrics fm = foreground.getFontMetrics();
// Centering in X: use alignment (and X at midpoint)
float x = width / 2;
// Centering in Y: measure ascent/descent first
float y = height / 2 - (fm.ascent + fm.descent) / 2;
for (int i = 0; i < 9; i++) {
  for (int j = 0; j < 9; j++) {
    canvas.drawText(this.game.getTileString(i, j), i
    * width + x, j * height + y, foreground);
  }
}

一開始先設定數字的樣式, 如反鋸尺(Paint.ANTI_ALIAS_FLAG), 顏色(R.color.puzzle_foreground), 樣式塗滿(Style.FILL), 文字高度是格子高度的3/4, 縮放比例根據格子的長寬比來設定(width/height), 使用置中對齊等等.

接下來要決定每個數字的位置, 這邊將每個數字放置在格子中央, 對齊x軸很簡單, 只要計算寬度除以2即可, 但y軸有一點複雜, 因為長度除以2去畫的位置會是數字的底部的位子, 會造成數字略高於中線, 所以這邊需要用FontMetrics去計算將要畫數字的底線略微向下拉, FontMetrics可以取得數字的建議上下標大小資訊(ascent, descent), 將這值平均則為需要修正的大小. 最後使用getTileString()來判定哪些格子需要畫(之後會提到).

有關FontMetrics, 可以參考Java的原始文件(因為android的API文件說明的很模糊): http://download.oracle.com/javase/6/docs/api/java/awt/FontMetrics.html

主要是底下這張圖:

P的中間那條線就是Baseline, 以上是ascent, 以下是descent, 所以兩者加起來除以2就可以獲得P這字的中心點位置.

最後結果會如下:


(第4章 2D繪圖 未完待續)

沒有留言:

張貼留言