Androidアプリ開発奮闘記 漢字ゲームを作る②

こんにちは、koheiです。
Androidアプリで漢字ゲームを作ろうということで、前回の記事では、文字入力まで作成しました。
Androidアプリ開発奮闘記 漢字ゲームを作る①
前回記事の続きで、今回はゲームの完成形まで作成していきたいと思います。
※前回の記事で作成したソースをベースに処理を追加していきます。
1. 仕様
前回の記事では、文字入力画面まで作成していました。今回の記事では、
・問題表示
・問題スキップボタン
・完了ボタンで文字判定
・正解で報酬機能
などを作っていきたいと思います。

2. レイアウトの対応
まずは、今回追加するボタン等を追加していきます。
activity_draw.xmlに以下のコードを追加します。
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/draw_activity"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.inomacreate.mojigame1.DrawActivity">
<SurfaceView
android:id="@+id/surfaceView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginBottom="60dp"
android:layout_marginEnd="32dp"
android:layout_marginStart="32dp"
android:layout_marginTop="100dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.611" />
<Button
android:id="@+id/button_ansset"
android:layout_width="0dp"
android:layout_height="45dp"
android:layout_marginBottom="8dp"
android:layout_marginEnd="8dp"
android:layout_marginStart="15dp"
android:layout_marginTop="8dp"
android:text="完了"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/button_clear"
app:layout_constraintTop_toBottomOf="@+id/surfaceView"
app:layout_constraintVertical_bias="1.0" />
<Button
android:id="@+id/button_undo"
android:layout_width="0dp"
android:layout_height="45dp"
android:layout_marginBottom="8dp"
android:layout_marginEnd="8dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:text="戻る"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/button_redo"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/surfaceView"
app:layout_constraintVertical_bias="1.0" />
<Button
android:id="@+id/button_redo"
android:layout_width="0dp"
android:layout_height="45dp"
android:layout_marginBottom="8dp"
android:layout_marginEnd="2dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:text="進む"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/button_clear"
app:layout_constraintStart_toEndOf="@+id/button_undo"
app:layout_constraintTop_toBottomOf="@+id/surfaceView"
app:layout_constraintVertical_bias="1.0" />
<Button
android:id="@+id/button_clear"
android:layout_width="0dp"
android:layout_height="45dp"
android:layout_marginBottom="8dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:text="クリア"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/button_redo"
app:layout_constraintTop_toBottomOf="@+id/surfaceView"
app:layout_constraintVertical_bias="1.0" />
<TextView
android:id="@+id/textView2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:text="問題"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/problem_text"
android:layout_width="135dp"
android:layout_height="33dp"
android:layout_marginBottom="8dp"
android:layout_marginEnd="8dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:text="TextView"
app:layout_constraintBottom_toTopOf="@+id/surfaceView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.072"
app:layout_constraintStart_toEndOf="@+id/textView2"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0" />
<TextView
android:id="@+id/textView4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:text="認識文字"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/problem_text" />
<TextView
android:id="@+id/ans_box"
android:layout_width="110dp"
android:layout_height="33dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
app:layout_constraintStart_toEndOf="@+id/textView4"
app:layout_constraintTop_toBottomOf="@+id/problem_text" />
<Button
android:id="@+id/button_skip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:layout_marginEnd="8dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:text="スキップ"
app:layout_constraintBottom_toTopOf="@+id/surfaceView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.755"
app:layout_constraintStart_toEndOf="@+id/ans_box"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.916" />
<TextView
android:id="@+id/textView6"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:text="たまったお金"
app:layout_constraintBottom_toTopOf="@+id/button_skip"
app:layout_constraintEnd_toStartOf="@+id/text_money"
app:layout_constraintStart_toEndOf="@+id/problem_text"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/text_money"
android:layout_width="80dp"
android:layout_height="20dp"
android:layout_marginBottom="8dp"
android:layout_marginEnd="8dp"
android:layout_marginTop="8dp"
app:layout_constraintBottom_toTopOf="@+id/button_skip"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/textView6"
app:layout_constraintTop_toTopOf="parent" />
</android.support.constraint.ConstraintLayout>
3. 問題表示、スキップの対応
次に、問題の表示、スキップ処理の対応を実装していきます。
まずは、問題用のテーブル、スキップボタン用の変数をDrawActivityクラス内に宣言します。
public class DrawActivity extends AppCompatActivity implements View.OnClickListener{
private SurfaceView mSurfaceView;
private SurfaceHolder mHolder;
private Paint mPaint;
private Path mPath;
private Bitmap mLastDrawBitmap;
private Canvas mLastDrawCanvas;
private int offset_x = 0;
private int offset_y = 0;
private Button undobt;
private Button redobt;
private Button clearbt;
private Button skipbt;
private Deque<Path> mUndoStack = new ArrayDeque<Path>();
private Deque<Path> mRedoStack = new ArrayDeque<Path>();
Map<Integer, String> problem_map;
public int problem_cnt = 0;
public TextView problem_txt;
onCreateメソッド内に、問題の設定と表示処理を投入します。
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_draw);
// map登録
problem_map = new HashMap<Integer,String>() {
{
put(0,"音[どく]");
put(1,"花の[え]");
put(2,"字の[かたち]");
put(3,"文を[か]く");
put(4,"名を[し]る");
put(5,"きたと[みなみ]");
put(6,"手を[ちか]づける");
put(7,"[おな]じ色");
put(8,"せが[たか]い");
put(9,"[ほそ]い糸");
put(10,"[ちち]の日");
put(11,"[あに]");
put(12,"[おや]");
put(13,"[とも]だちとあそぶ");
put(14,"[からだ]");
put(15,"うんこは、[し]ぜんのまま");
put(16,"[け]いとでうんこ");
put(17,"きょうはにち[よう]日");
}
};
// 問題表示
problem_txt = (TextView)findViewById(R.id.problem_text);
problem_txt.setText(problem_map.get(problem_cnt));
// ボタンリスナ登録
undobt = (Button) findViewById(R.id.button_undo);
undobt.setOnClickListener(this);
redobt = (Button) findViewById(R.id.button_redo);
redobt.setOnClickListener(this);
clearbt = (Button) findViewById(R.id.button_clear);
clearbt.setOnClickListener(this);
skipbt = (Button) findViewById(R.id.button_skip);
skipbt.setOnClickListener(this);
// SurfaceView取得
mSurfaceView = (SurfaceView) findViewById(R.id.surfaceView);
// SurfaceHolder取得
mHolder = mSurfaceView.getHolder();
// コールバックを設定
mHolder.addCallback(mCallBack);
// ペイントを設定
mPaint = new Paint();
mPaint.setColor(Color.WHITE);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeCap(Paint.Cap.ROUND);
mPaint.setAntiAlias(true);
mPaint.setStrokeWidth(30);
}
onClickメソッドにスキップボタンの処理を実装します。
@Override
public void onClick(View view) {
switch (view.getId()) {
case R.id.button_undo:
undo();
break;
case R.id.button_redo:
redo();
break;
case R.id.button_clear:
reset();
break;
case R.id.button_skip:
problem_cnt++;
if(problem_cnt>problem_map.size()-1)problem_cnt=0;
problem_txt.setText(problem_map.get(problem_cnt));
TextView output2 = (TextView) findViewById(R.id.ans_box);
output2.setText("");
reset();
break;
default:
break;
}
}
4.文字判定処理を組み込む
今回漢字ゲームで手書きで書いた漢字を判定するため、「tess-two」というライブラリを使うことにします。
4-1. ライブラリの設定
まずは、bulid.gradleに以下を追加します。
dependencies {
implementation 'com.rmtheis:tess-two:9.0.0'
}
以下のサイトから文字を判定するための学習データ「jpn.traineddata」をダウンロードします。
tessdata
次に、プロジェクトのassetsフォルダーにtessdataというディレクトリを作り、その中にダウンロードした「jpn.traineddata」を格納します。(以下に手順記載します)
まずは、assetsフォルダーを作ります。appフォルダで右クリック→[New]→[Folder]→[Assets Folder]を選択し、assetsフォルダーを作ります。

assetsフォルダーで右クリック→[New]→[Directory]で、tessdataというディレクトリをつくります。

作成したtessdata内に、ダウンロードした「jpn.traineddata」をドラッグして格納します。以下のような構成にしましょう。

4-2. 文字判定処理実装
まずは、正解判定用のテーブル、完了ボタン、tess-two用の変数などをDrawActivityに追加します。
public class DrawActivity extends AppCompatActivity implements View.OnClickListener{
private SurfaceView mSurfaceView;
private SurfaceHolder mHolder;
private Paint mPaint;
private Path mPath;
private Bitmap mLastDrawBitmap;
private Canvas mLastDrawCanvas;
private int offset_x = 0;
private int offset_y = 0;
private Button undobt;
private Button redobt;
private Button clearbt;
private Button skipbt;
private Button anssetbt;
private Deque<Path> mUndoStack = new ArrayDeque<Path>();
private Deque<Path> mRedoStack = new ArrayDeque<Path>();
Map<Integer, String> problem_map;
Map<Integer, String> ans_map;
public int problem_cnt = 0;
public TextView problem_txt;
TessBaseAPI tessBaseAPI;
次に、onCreateメソッド内に、正解判定テーブル、完了ボタンのリスナー登録などを実装します。
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_draw);
// map登録
problem_map = new HashMap<Integer,String>() {
{
put(0,"音[どく]");
put(1,"花の[え]");
put(2,"字の[かたち]");
put(3,"文を[か]く");
put(4,"名を[し]る");
put(5,"きたと[みなみ]");
put(6,"手を[ちか]づける");
put(7,"[おな]じ色");
put(8,"せが[たか]い");
put(9,"[ほそ]い糸");
put(10,"[ちち]の日");
put(11,"[あに]");
put(12,"[おや]");
put(13,"[とも]だちとあそぶ");
put(14,"[からだ]");
put(15,"うんこは、[し]ぜんのまま");
put(16,"[け]いとでうんこ");
put(17,"きょうはにち[よう]日");
}
};
ans_map = new HashMap<Integer, String>() {
{
put(0,"読");
put(1,"絵");
put(2,"形");
put(3,"書");
put(4,"知");
put(5,"南");
put(6,"近");
put(7,"同");
put(8,"高");
put(9,"細");
put(10,"父");
put(11,"兄");
put(12,"親");
put(13,"友");
put(14,"体");
put(15,"自");
put(16,"毛");
put(17,"曜");
}
};
// 問題表示
problem_txt = (TextView)findViewById(R.id.problem_text);
problem_txt.setText(problem_map.get(problem_cnt));
// ボタンリスナ登録
undobt = (Button) findViewById(R.id.button_undo);
undobt.setOnClickListener(this);
redobt = (Button) findViewById(R.id.button_redo);
redobt.setOnClickListener(this);
clearbt = (Button) findViewById(R.id.button_clear);
clearbt.setOnClickListener(this);
skipbt = (Button) findViewById(R.id.button_skip);
skipbt.setOnClickListener(this);
anssetbt = (Button) findViewById(R.id.button_ansset);
anssetbt.setOnClickListener(this);
// SurfaceView取得
mSurfaceView = (SurfaceView) findViewById(R.id.surfaceView);
// SurfaceHolder取得
mHolder = mSurfaceView.getHolder();
// コールバックを設定
mHolder.addCallback(mCallBack);
// ペイントを設定
mPaint = new Paint();
mPaint.setColor(Color.WHITE);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeCap(Paint.Cap.ROUND);
mPaint.setAntiAlias(true);
mPaint.setStrokeWidth(30);
}
文字の判定処理はすごく時間がかかる処理なので、AsyncTaskという仕組みを使ってバックグラウンドで処理させたいと思います。
以下のように、DrawActivityクラス内にAsyncTaskを継承したOCRchecktaskを実装します。
private class OCRchecktask extends AsyncTask<String, String, String> {
String filepath = getFilesDir() + "/tesseract/";
@Override
public String doInBackground(String... Params) {
String result = "";
result = OCRcheck();
return result;
}
@Override
public void onPostExecute(String result) {
TextView output = findViewById(R.id.ans_box);
output.setText(result);
// 正解判定
if(result.equals(ans_map.get(problem_cnt))) {
Toast.makeText(DrawActivity.this, "正解だよー!", Toast.LENGTH_LONG).show();
reset();
output.setText("");
}
else {
Toast.makeText(DrawActivity.this, "残念!もう一回チャレンジだ!", Toast.LENGTH_SHORT).show();
reset();
return;
}
problem_cnt++;
if(problem_cnt>problem_map.size()-1) problem_cnt=0;
problem_txt.setText(problem_map.get(problem_cnt));
}
private String OCRcheck() {
filepath = getFilesDir() + "/tesseract/";
Log.i("debug",filepath);
tessBaseAPI = new TessBaseAPI();
checkFile(new File(filepath + "tessdata/"));
tessBaseAPI.init(filepath,"jpn");
tessBaseAPI.setPageSegMode(TessBaseAPI.PageSegMode.PSM_SINGLE_CHAR);
tessBaseAPI.setRectangle(0,0,mSurfaceView.getWidth(),mSurfaceView.getHeight());
tessBaseAPI.setImage(mLastDrawBitmap);
String result = tessBaseAPI.getUTF8Text();
Log.i("debug","認識結果="+result);
return result;
}
private void checkFile(File file) {
if (!file.exists() && file.mkdirs()){
copyFiles();
}
if(file.exists()) {
String datafilepath = filepath+ "/tessdata/jpn.traineddata";
File datafile = new File(datafilepath);
if (!datafile.exists()) {
copyFiles();
}
}
}
private void copyFiles() {
try {
String datapath = filepath + "/tessdata/jpn.traineddata";
InputStream instream = getAssets().open("tessdata/jpn.traineddata");
OutputStream outstream = new FileOutputStream(datapath);
byte[] buffer = new byte[1024];
int read;
while ((read = instream.read(buffer)) != -1) {
outstream.write(buffer, 0, read);
}
outstream.flush();
outstream.close();
instream.close();
File file = new File(datapath);
if (!file.exists()) {
throw new FileNotFoundException();
}
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
最後に、完了ボタンを実装します。完了ボタンが押されたら、さっき作成した「OCRchecktask」を起動するようにします。
@Override
public void onClick(View view) {
switch (view.getId()) {
case R.id.button_undo:
undo();
break;
case R.id.button_redo:
redo();
break;
case R.id.button_clear:
reset();
break;
case R.id.button_skip:
problem_cnt++;
if(problem_cnt>problem_map.size()-1)problem_cnt=0;
problem_txt.setText(problem_map.get(problem_cnt));
TextView output2 = (TextView) findViewById(R.id.ans_box);
output2.setText("");
reset();
break;
case R.id.button_ansset:
// 答え合わせ
TextView output = (TextView) findViewById(R.id.ans_box);
output.setText("判定中");
OCRchecktask ocRchecktask = new OCRchecktask();
ocRchecktask.execute();
break;
default:
break;
}
}
5.報酬機能の追加
子どもたちのテンションを上げるため、正解したら、難易度に応じて報酬がもらえるようにしてみました。
まずは、もらえた報酬額を表示するテキストビューと報酬額用テーブルを宣言します。
public class DrawActivity extends AppCompatActivity implements View.OnClickListener{
private SurfaceView mSurfaceView;
private SurfaceHolder mHolder;
private Paint mPaint;
private Path mPath;
private Bitmap mLastDrawBitmap;
private Canvas mLastDrawCanvas;
private int offset_x = 0;
private int offset_y = 0;
private Button undobt;
private Button redobt;
private Button clearbt;
private Button skipbt;
private Button anssetbt;
private Deque<Path> mUndoStack = new ArrayDeque<Path>();
private Deque<Path> mRedoStack = new ArrayDeque<Path>();
Map<Integer, String> problem_map;
Map<Integer, String> ans_map;
public int problem_cnt = 0;
public TextView problem_txt;
TessBaseAPI tessBaseAPI;
public TextView money_txt;
Map<Integer,Integer> money_map;
獲得した報酬は、ゲームが終わってもAndroid上に保存しておくようにします。
SharedPreferencesという仕組みを使えば、データを保存することができます。
まずは、報酬額を保存する処理と取得する処理を作成します。DrawActivity内に以下の処理を実装しましょう。
public void money_save(int money) {
SharedPreferences data = getSharedPreferences("DataSave", Context.MODE_PRIVATE);
SharedPreferences.Editor editor = data.edit();
editor.putInt("Money",money);
editor.apply();
}
public int money_get() {
SharedPreferences data = getSharedPreferences("DataSave",Context.MODE_PRIVATE);
int money = data.getInt("Money",0);
return money;
}
onCreate処理に、報酬額のテーブルと、テキスト表示処理を実装します。
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_draw);
// map登録
problem_map = new HashMap<Integer,String>() {
{
put(0,"音[どく]");
put(1,"花の[え]");
put(2,"字の[かたち]");
put(3,"文を[か]く");
put(4,"名を[し]る");
put(5,"きたと[みなみ]");
put(6,"手を[ちか]づける");
put(7,"[おな]じ色");
put(8,"せが[たか]い");
put(9,"[ほそ]い糸");
put(10,"[ちち]の日");
put(11,"[あに]");
put(12,"[おや]");
put(13,"[とも]だちとあそぶ");
put(14,"[からだ]");
put(15,"うんこは、[し]ぜんのまま");
put(16,"[け]いとでうんこ");
put(17,"きょうはにち[よう]日");
}
};
ans_map = new HashMap<Integer, String>() {
{
put(0,"読");
put(1,"絵");
put(2,"形");
put(3,"書");
put(4,"知");
put(5,"南");
put(6,"近");
put(7,"同");
put(8,"高");
put(9,"細");
put(10,"父");
put(11,"兄");
put(12,"親");
put(13,"友");
put(14,"体");
put(15,"自");
put(16,"毛");
put(17,"曜");
}
};
money_map = new HashMap<Integer, Integer>() {
{
put(0,500);
put(1,300);
put(2,300);
put(3,200);
put(4,300);
put(5,100);
put(6,100);
put(7,100);
put(8,100);
put(9,200);
put(10,100);
put(11,100);
put(12,500);
put(13,100);
put(14,100);
put(15,100);
put(16,100);
put(17,500);
}
};
// 問題表示
problem_txt = (TextView)findViewById(R.id.problem_text);
problem_txt.setText(problem_map.get(problem_cnt));
// お金表示
money_txt = (TextView)findViewById(R.id.text_money);
money_txt.setText(String.valueOf(money_get()));
正解した時の処理(onPostExecute)に報酬額を取得する処理を実装します。
@Override
public void onPostExecute(String result) {
TextView output = findViewById(R.id.ans_box);
output.setText(result);
// 正解判定
if(result.equals(ans_map.get(problem_cnt))) {
Toast.makeText(DrawActivity.this, "正解だよー!お金"+String.valueOf(money_map.get(problem_cnt))+"円ゲット!!", Toast.LENGTH_LONG).show();
reset();
output.setText("");
int money = money_get() + money_map.get(problem_cnt);
money_save(money);
money_txt.setText(String.valueOf(money_get()));
}
else {
Toast.makeText(DrawActivity.this, "残念!もう一回チャレンジだ!", Toast.LENGTH_SHORT).show();
reset();
return;
}
problem_cnt++;
if(problem_cnt>problem_map.size()-1) problem_cnt=0;
problem_txt.setText(problem_map.get(problem_cnt));
}
以上で実装が完了しました。
試しにシミュレータで動かしてみましょう!
ちゃんと動作しました!
最後に
以上、Androidアプリの漢字ゲームを作ってみました。
今回文字判定は、「tess-two」というライブラリを使いました。結構丁寧に文字を書かないとなかなか正解してくれなかったり、文字判定に時間かかったりとイマイチな部分もありますが、とりあえず完成したのでよかったです。
他にもいろんなAndroidアプリ制作に挑戦してみたいと思います!
それでは!!

