用Android写的文字游戏

790 阅读6分钟

闲来无事用Android写了个文字游戏,为了锻炼编写复杂逻辑的能力。 游戏长这个样子:

S9{EZ`(E@UB@%FE}M2J2_YR.png image.png

1615780288.jpg image.png

image.png image.png

image.png image.png

image.png

自己玩了下还是挺爽的,可以随意定制各种角色和技能就像这样:

image.png

image.png

接下来我会慢慢把完整代码都贴出来,附上讲解,主要讲解一路开发下来的思路。对这种游戏感兴趣的小伙伴可以帮我定制各种复杂的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上写原生游戏,我不会像写一般应用那样隔离代码。而是这样分:

1618800474(1).jpg

界面的代码和游戏代码分开,不直接引用,而是通过接口太引用,写了一个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来执行。