闲来无事用Android写了个文字游戏,为了锻炼编写复杂逻辑的能力。 游戏长这个样子:
自己玩了下还是挺爽的,可以随意定制各种角色和技能就像这样:
接下来我会慢慢把完整代码都贴出来,附上讲解,主要讲解一路开发下来的思路。对这种游戏感兴趣的小伙伴可以帮我定制各种复杂的Hero和Skill。
1、其实一开始并没有想好做个什么样的游戏,就先写个非常简单的角色对战,然后慢慢增加复杂度,后期变化一切随缘。首先就是要把界面写好,一个简单的游戏,最起码要有一个展示界面,和一个输入手段。由于懒得画图,又想极快速的写逻辑,就用纯文字来展示游戏信息,随便几个按钮来做控制。界面代码如下。
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.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:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/LemonChiffon1"
tools:context=".ui.MainActivity">
<!--消息面板-->
<com.myk.game.heroscuffle.ui.GameMsgLayout
android:id="@+id/gml_main_msg"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintHeight_percent="0.618"
app:layout_constraintTop_toTopOf="parent" />
<!--控制面板-->
<com.myk.game.heroscuffle.ui.GameCtrlLayout
android:id="@+id/gml_main_ctrl"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintHeight_percent="0.382"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
分别把消息显示面板和游戏控制面板封装成自定义控件了,其中消息显示面板的代码如下:
import android.content.Context;
import android.graphics.Color;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.style.ForegroundColorSpan;
import android.util.AttributeSet;
import android.widget.FrameLayout;
import android.widget.ScrollView;
import android.widget.TextView;
import com.myk.game.heroscuffle.R;
/**
* 游戏消息面板
*/
public class GameMsgLayout extends FrameLayout {
private String mGameMsg;
private ScrollView scrollView;
private TextView textView;
//初始化
private void init(Context context) {
inflate(context, R.layout.layout_game_msg, this);
mGameMsg = "";
scrollView = findViewById(R.id.sv_gml_container);
textView = findViewById(R.id.tv_gml_msg);
}
/**
* 在消息面板上追加一条信息
*/
public void addMsg(String gameMsg) {
mGameMsg += gameMsg;
textView.setText(mGameMsg);
textView.post(() -> scrollView.fullScroll(ScrollView.FOCUS_DOWN));
}
/**
* 在消息面板上添加一条操作提示文本
* (该文本的颜色和信息文本颜色不同,
* 且始终只会在面板最后一行存在一条文本,
* 当消息面板更新时提示文本就会消失。)
*/
public void setTip(String tip) {
String lTip = "\n#" + tip;
String msgWithTip = mGameMsg + lTip;
SpannableString spannableString = new SpannableString(msgWithTip);
spannableString.setSpan(new ForegroundColorSpan(Color.parseColor("#ffff3366")),
mGameMsg.length(), mGameMsg.length() + lTip.length(),
Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
textView.setText(spannableString);
textView.post(() -> scrollView.fullScroll(ScrollView.FOCUS_DOWN));
}
/**
* 清空消息面板上所有文字
*/
public void clearMsg() {
mGameMsg = "";
textView.setText(mGameMsg);
}
/**
* 获取消息面板上的所有文字
*/
public String getGameMsg() {
return mGameMsg;
}
public GameMsgLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public GameMsgLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}
}
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/sv_gml_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="10dp"
android:padding="10dp"
android:background="@color/colorAccent">
<TextView
android:id="@+id/tv_gml_msg"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</ScrollView>
这个消息面板的功能很简单,每次添加完一段文字后,都会滚动到最后一行,天加操作提示(红色的文本)可以和操作记录分离,最后提取操作记录时就不会保留操作提示了。目前这个控件还不完善,日后可以优化成分段加载,硬盘缓存等。 不过目前测试下来,即使是Android4.0的设备整个游戏结束,TextView上缓存的文本数据都不会占用太多内存。 所以就不急着优化了。控制面板就是封装了四个按钮,这四个按钮的功能在游戏中动态注入。代码如下:
import android.content.Context;
import android.graphics.Color;
import android.util.AttributeSet;
import android.widget.Button;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.myk.game.heroscuffle.R;
import com.myk.game.heroscuffle.utils.FastUtil;
public class GameCtrlLayout extends FrameLayout {
private Button[] buttons;
private int curIndex;
//初始化
private void init(Context context) {
inflate(context, R.layout.layout_game_ctrl, this);
curIndex = 0;
buttons = new Button[getChildCount()];
for (int i = 0; i < getChildCount(); i++) {
buttons[i] = (Button) getChildAt(i);
}
}
/**
* 添加一个操作按钮
*/
public void addButton(String title, Runnable action) {
Button button = buttons[curIndex];
button.setVisibility(VISIBLE);
button.setText(title);
button.setTextColor(Color.BLACK);
button.setOnClickListener(v -> {
if (FastUtil.isNotFast("CtrlClick", 300)) {
action.run();
}
});
curIndex++;
}
/**
* 添加一个操作按钮
*/
public void addButton(String title, boolean usable, Runnable action) {
Button button = buttons[curIndex];
button.setVisibility(VISIBLE);
button.setText(title);
if (usable) {
button.setTextColor(Color.BLACK);
button.setOnClickListener(v -> {
if (FastUtil.isNotFast("CtrlClick", 500)) {
action.run();
}
});
}
else {
button.setTextColor(Color.GRAY);
button.setOnClickListener(null);
}
curIndex++;
}
/**
* 清空所有操作按钮
*/
public void resetAllButtons() {
curIndex = 0;
for (Button button : buttons) {
button.setText("");
button.setTextColor(Color.BLACK);
button.setVisibility(GONE);
button.setOnClickListener(null);
}
}
public GameCtrlLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public GameCtrlLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}
}
<?xml version="1.0" encoding="utf-8"?>
<merge
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:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="10dp"
android:padding="10dp"
tools:ignore="HardcodedText,RtlHardcoded">
<Button
android:id="@+id/btn_gcl_a"
android:layout_width="100dp"
android:layout_height="80dp"
android:layout_gravity="center"
android:layout_marginRight="60dp"
android:layout_marginBottom="50dp"
android:gravity="center"
android:padding="10dp"
app:autoSizeTextType="uniform"
android:visibility="gone"
android:textStyle="bold"
tools:text="按钮A" />
<Button
android:id="@+id/btn_gcl_b"
android:layout_width="100dp"
android:layout_height="80dp"
android:layout_gravity="center"
android:layout_marginLeft="60dp"
android:layout_marginBottom="50dp"
android:gravity="center"
android:padding="10dp"
app:autoSizeTextType="uniform"
android:visibility="gone"
android:textStyle="bold"
tools:text="按钮B" />
<Button
android:id="@+id/btn_gcl_c"
android:layout_width="100dp"
android:layout_height="80dp"
android:layout_gravity="center"
android:layout_marginTop="50dp"
android:layout_marginRight="60dp"
android:gravity="center"
android:padding="10dp"
app:autoSizeTextType="uniform"
android:visibility="gone"
android:textStyle="bold"
tools:text="按钮C" />
<Button
android:id="@+id/btn_gcl_d"
android:layout_gravity="center"
android:layout_marginLeft="60dp"
android:layout_marginTop="50dp"
android:layout_width="100dp"
android:layout_height="80dp"
android:gravity="center"
android:padding="10dp"
app:autoSizeTextType="uniform"
android:visibility="gone"
android:textStyle="bold"
tools:text="按钮D" />
</merge>
然后再把这些控件在主活动中引用,代码如下:
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import android.content.Context;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import com.myk.game.heroscuffle.R;
public class MainActivity extends AppCompatActivity {
private GameMsgLayout gameMsgLayout;
private GameCtrlLayout gameCtrlLayout;
private Context mContext;
private Handler mHandler;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
hideActionBar();
mContext = this;
mHandler = new Handler(Looper.getMainLooper());
gameMsgLayout = findViewById(R.id.gml_main_msg);
gameCtrlLayout = findViewById(R.id.gml_main_ctrl);
}
//显示文本弹窗
private void showTextDialog(String text) {
new AlertDialog.Builder(this).setMessage(text).setCancelable(true).show();
}
//隐藏ActionBar
private void hideActionBar() {
ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.hide();
}
}
}
至此整个游戏界面的架子就搭建好了,后面就可以专心的写游戏逻辑了。
2、在Android上写原生游戏,我不会像写一般应用那样隔离代码。而是这样分:
界面的代码和游戏代码分开,不直接引用,而是通过接口太引用,写了一个GameUI接口:
/**
* 游戏界面接口
*/
public interface GameUI {
/**
* 显示帮助弹窗
*/
void showHelpDialog();
/**
* 显示游戏记录弹窗
*/
void showRecordDialog();
/**
* 显示文本消息弹窗
*/
void toast(String text);
/**
* 非常短暂间隔一小会
*/
void sleepShortly();
/**
* 短暂间隔一小会
*/
void sleepNormal();
/**
* 保存游戏记录
*/
void saveRecordData();
/**
* 紧接末尾追加消息文本
*/
void printMsg(String msg);
/**
* 另起一行追加消息文本
*/
void printlnMsg(String msg);
/**
* 显示当前的操作提示
*/
void setTip(String tip);
/**
* 清空所有消息
*/
void clearMsg();
/**
* 重置操作按钮
*/
void resetCtrl();
/**
* 添加一个操作按钮
*/
void addCtrl(String title, Runnable action);
/**
* 添加一个操作按钮,且控制能否点击
*/
void addCtrl(String title,boolean usable, Runnable action);
/**
* 退出游戏
*/
void exitApp();
}
主活动实现这个接口,并且在onCreate的时候,把GameUI引用传给Game类:
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import android.content.Context;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.SystemClock;
import android.widget.Toast;
import com.blankj.utilcode.util.AppUtils;
import com.blankj.utilcode.util.FileIOUtils;
import com.blankj.utilcode.util.FileUtils;
import com.blankj.utilcode.util.PathUtils;
import com.blankj.utilcode.util.TimeUtils;
import com.blankj.utilcode.util.ToastUtils;
import com.myk.game.heroscuffle.R;
import com.myk.game.heroscuffle.data.GameTextData;
import com.myk.game.heroscuffle.game.Game;
import com.myk.game.heroscuffle.game.GameUI;
import java.io.File;
import java.text.SimpleDateFormat;
import java.util.List;
import java.util.Locale;
public class MainActivity extends AppCompatActivity implements GameUI {
private Game mGame;
private GameMsgLayout gameMsgLayout;
private GameCtrlLayout gameCtrlLayout;
private Context mContext;
private Handler mHandler;
private SimpleDateFormat mDateFormat;
private String mRecordPath;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
hideActionBar();
mContext = this;
mHandler = new Handler(Looper.getMainLooper());
mDateFormat = new SimpleDateFormat("yyyy_MM_dd_HH_mm_ss", Locale.CHINA);
mRecordPath = PathUtils.getExternalAppDataPath() + "/record/";
gameMsgLayout = findViewById(R.id.gml_main_msg);
gameCtrlLayout = findViewById(R.id.gml_main_ctrl);
mGame = new Game(this);
mGame.initHome();
}
@Override
public void showHelpDialog() {
showTextDialog(GameTextData.GAME_RULE);
}
@Override
public void showRecordDialog() {
略...
}
@Override
public void saveRecordData() {
略...
}
@Override
public void printMsg(String msg) {
mHandler.post(() -> gameMsgLayout.addMsg(msg));
}
@Override
public void printlnMsg(String msg) {
mHandler.post(() -> gameMsgLayout.addMsg("\n" + msg));
}
@Override
public void setTip(String tip) {
mHandler.post(() -> gameMsgLayout.setTip(tip));
}
@Override
public void clearMsg() {
mHandler.post(() -> gameMsgLayout.clearMsg());
}
@Override
public void resetCtrl() {
mHandler.post(() -> gameCtrlLayout.resetAllButtons());
}
@Override
public void addCtrl(String title, Runnable action) {
mHandler.post(() -> gameCtrlLayout.addButton(title, action));
}
@Override
public void addCtrl(String title, boolean usable, Runnable action) {
mHandler.post(() -> gameCtrlLayout.addButton(title, usable, action));
}
@Override
public void toast(String text) {
mHandler.post(() -> Toast.makeText(this, text, Toast.LENGTH_SHORT).show());
}
@Override
public void sleepShortly() {
SystemClock.sleep(80);
}
@Override
public void sleepNormal() {
SystemClock.sleep(500);
}
@Override
public void exitApp() {
finish();
System.exit(0);
}
//显示文本弹窗
private void showTextDialog(String text) {
new AlertDialog.Builder(this).setMessage(text).setCancelable(true).show();
}
//隐藏ActionBar
private void hideActionBar() {
ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.hide();
}
}
}
而Game类就是这个游戏的核心控制类,主活动只视为整个游戏应用的一个部分,前面写的界面代码用法如下:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* 游戏核心类
*/
public class Game {
private final ExecutorService executor; //执行线程
public GameUI ui; //游戏界面
public HeroPool pool;//上场的英雄集合容器
public EventsCenter events; //游戏过程事件中心
public int round; //回合计数
public Game(GameUI gameUI) {
this.ui = gameUI;
this.executor = Executors.newSingleThreadExecutor();
}
//进入首页
public void initHome() {
executor.submit(() -> {
ui.clearMsg();
ui.resetCtrl();
ui.printMsg("欢迎来到《英雄乱斗》");
ui.sleepNormal();
ui.printlnMsg("请不要太过激动,");
ui.sleepNormal();
ui.printlnMsg("这只是个无聊的游戏。");
ui.sleepNormal();
ui.setTip("点击“开始游戏”按钮进行游戏");
ui.addCtrl("开始", this::startGame);
ui.addCtrl("帮助", ui::showHelpDialog);
ui.addCtrl("记录", ui::showRecordDialog);
ui.addCtrl("退出", ui::exitApp);
});
}
其它方法暂时略过...
}
Game类获取GameUI的引用,只要涉及到界面和Android程序特性的代码都用ui对象来调用。 ui.clearMsd()和ui.resetCtrl()就是清空界面还原状态,ui.addCtrl就是为控制面板添加点击事件。 由于想要实现游戏文本消息的字幕动画,所以Game里的所有方法,都要用子线程来执行,用线程睡眠来实现间断效果,即都用executor.submit来执行游戏逻辑相关的代码。 主活动里涉及的界面更新的代码都用mHandler.post来执行。