联网的多玩家 tic-tac-toe 游戏
休闲游戏十分流行,而且发展空间巨大,原因很显然。并非所有年龄段的所有人都对在线游戏感兴趣,第一人称射击游戏只适合反应快速的青少年群体。有时候,玩有时间思考和制定战略或者目标是与他人合作取得胜利的游戏更加吸引人。
从开发人员的角度看,休闲游戏的好处是,它们比图形密集的第一人称设计或运动游戏更容易打造。因此,一位或一群开发人员更容易做出全新的游戏。
在本文中,我们讲述了创建一个休闲、联网的多玩家 tic-tac-toe 游戏的基础。游戏服务器是一个基于 MySQL 和 PHP、带有 XML 接口的 Web 应用程序。前端是一个运行在 Android 手机上的本机 Android 应用程序。
回页首
构建后端
后端从一个有两个表的简单 MySQL 数据库开始。清单 1 显示了该数据库的模式。
TABLE IF EXISTS games;TABLE games( id INT NOT NULL AUTO_INCREMENT, primary key ( id ) ); TABLE IF EXISTS moves;TABLE moves( id INT NOT NULL AUTO_INCREMENT, game INT NOT NULL, x INT NOT NULL, y INT NOT NULL, color INT NOT NULL, primary key ( id ) ); |
第一个表是 games 表,用于保存游戏的惟一 ID。在生产应用程序中,您可能有一个用户表,而 games 表正是包含了两位玩家的用户 ID。但为了让事情变得简单,我放弃使用这种方法,而是集中讲述存储游戏数据、在客户端与服务器之间通信,以及打造前端的基础知识。
第二个表是 moves 表,它用于保存给定游戏的步着,因此共有五列。第一列是步着的惟一 ID。第二列是这一步着应用的游戏 ID。然后是动作所在的 x 和 y 坐标。因为格子是 3x3 的,这些值应该在 0 与 2 之间。最后一个字段是步着的 “颜色”,它是一个值为 X 或 O 的整数。
为了构造数据库,首先使用 mysqladmin 创建它,然后使用 mysql 命令运行 db.sql 脚本,如下所示:
% mysqladmin --user=root --password=foo create ttt % mysql --user=root --password=foo ttt < db.sql |
这个步骤创建了一个名为 “ttt” 的新数据库,用于保存 tic-tac-toe 模式。
现在有了模式,您需要创建启动游戏的途径。为此,您有一个 start.php 脚本,如 清单 2 中所示。
<?php( 'Content-Type:text/xml' ); $dd = new PDO('mysql:host=localhost;dbname=ttt', 'root', ''); $sql = 'INSERT INTO games VALUES ( 0 )'; $sth = $dd->prepare($sql); $sth->execute( array() ); $qid = $dd->lastInsertId(); $doc = new DOMDocument(); $r = $doc->createElement( "game" ); $r->setAttribute( 'id', $qid ); $doc->appendChild( $r ); $doc->saveXML(); ?> |
此脚本从连接数据库开始,然后对 games 表执行一条 INSERT 语句,并获取生成的 ID。接下来,它创建一个 XML 文档,将 ID 添加给一个游戏标签,并导出 XML。
您需要运行此脚本以在数据库中插入一个游戏,因为简单的 Android 应用程序没有用于创建游戏的界面。代码如下所示:
$ php start.php <?xml version="1.0"?> <game id="1"/> $ |
现在您有了自己的第一个游戏。要查看游戏列表,使用 清单 3 中的 games.php 脚本。
<?php( 'Content-Type:text/xml' ); $dbh = new PDO('mysql:host=localhost;dbname=ttt', 'root', ''); $sql = 'SELECT * FROM games'; $q = $dbh->prepare( $sql ); $q->execute( array() ); $doc = new DOMDocument(); $r = $doc->createElement( "games" ); $doc->appendChild( $r ); ( $q->fetchAll() as $row) { $e = $doc->createElement( "game" ); $e->setAttribute( 'id', $row['id'] ); $r->appendChild( $e ); } $doc->saveXML(); ?> |
和 start.php 脚本一样,这个脚本也从连接数据库开始。然后它查询 games 表中的可用游戏。之后它创建一个新的 XML 文档,添加一个 games 标签,然后为每个可用的游戏添加游戏标签。
当您从命令行运行这个脚本时,将看到如下内容:
$ php games.php <?xml version="1.0"?> <games><game id="1"/></games> $ |
您还可以从 Web 浏览器运行这个脚本,得到的输出相同。
很好!由于游戏 API 已经就绪,是时候编写处理步着的服务器代码了。这段代码一开始构建一个名为 show_moves 的 helper 脚本,用于获得给定游戏的当前步着,并把它们导出为 XML。 清单 4 显示了这个 helper 函数的 PHP 代码。
<?phpshow_moves( $dbh, $game ) { $sql = 'SELECT * FROM moves WHERE game=?'; $q = $dbh->prepare( $sql ); $q->execute( array( $game ) ); $doc = new DOMDocument(); $r = $doc->createElement( "moves" ); $doc->appendChild( $r ); foreach ( $q->fetchAll() as $row) { $e = $doc->createElement( "move" ); $e->setAttribute( 'x', $row['x'] ); $e->setAttribute( 'y', $row['y'] ); $e->setAttribute( 'color', $row['color'] ); $r->appendChild( $e ); } print $doc->saveXML(); } ?> |
此脚本获得了一个数据库句柄和游戏 ID。之后,它执行 SQL 来获取步着列表。然后,它使用给定游戏的步着创建了一个 XML 文档。
创建这个 helper 函数的原因是有两个脚本将会用到它。第一个脚本是 moves.php 脚本,用于返回指定游戏的当前步着。 清单 5 显示了这个脚本。
<?php_once( 'show_moves.php' ); ( 'Content-Type:text/xml' ); $dbh = new PDO('mysql:host=localhost;dbname=ttt', 'root', ''); _moves( $dbh, $_REQUEST['game'] ); ?> |
这个简单的脚本包含 helper 函数代码,连接到数据库,然后使用指定的游戏 ID 调用 show_moves 函数。要测试这段代码,使用 curl 命令从命令行调用服务器上的脚本:
$ curl "http://localhost/ttt/moves.php?game=1" <?xml version="1.0"?> <moves/> $ |
遗憾的是,您尚未走出任何步着,因此输出没有什么特别意义。 为了弥补这一点,您需要给服务器 API 添加最后一个脚本。 清单 6显示了 move.php 脚本。
<?php_once( 'show_moves.php' ); ( 'Content-Type:text/xml' ); $dbh = new PDO('mysql:host=localhost;dbname=ttt', 'root', ''); $sql = 'DELETE FROM moves WHERE game=? AND x=? AND y=?'; $sth = $dbh->prepare($sql); $sth->execute( array( $_REQUEST['game'], $_REQUEST['x'], $_REQUEST['y'] ) ); $sql = 'INSERT INTO moves VALUES ( 0, ?, ?, ?, ? )'; $sth = $dbh->prepare($sql); $sth->execute( array( $_REQUEST['game'], $_REQUEST['x'], $_REQUEST['y'], $_REQUEST['color'] ) ); _moves( $dbh, $_REQUEST['game'] ); ?> |
此脚本首先包含 helper 函数并连接数据库,然后执行两条 SQL 语句。第一条 SQL 语句用于删除可能与送入的步着相冲突的所有步着。第二条用于为指定步着在 moves 表中插入一个新行。该脚本接着将步着列表返回给客户端。这个步骤让客户端可以不必在每走出一个步着时都发出两个请求。带宽并不便宜,因此请求数量应该尽量减少。
为了测试以上内容有效,您可以走一个步着:
$ curl "http://localhost/ttt/move.php?game=1&x=1&y=2&color=1" <?xml version="1.0"?> <moves><move x="1" y="2" color="1"/></moves> |
完成游戏服务器代码之后,您可以构建这个多玩家联网游戏的 Android 前端。
回页首
构建 Android 前端
首先安装 Android SDK 和一些 Android 平台版本,最后是一些 Eclipse 和 Android Eclipse 插件。幸运的是,所有这些软件均在 Android 网站(参见 参考资料 中的链接)上可以找到。 关于如何建立开发环境的深入描述已经超出了本文的范围。
建立起开发环境之后,启动 Eclipse 并创建一个新的 Android 项目。您看到的画面应该与 图 1 类似。
图 1 显示了 Android 应用程序的项目向导。输入一个项目名称,选择 Create new project in workspace 单选按钮,并指定 UI 元素的代码位置。在 Build Target 一览表中,选择一个 Android 平台。对于这段代码,我使用的是 Android 2.3.1。代码十分简单,您可以使用喜欢的任意版本。如果没有看到有任何平台列出,您需要下载并安装 Android SDK 安装说明中所提的平台。要注意,下载所有这些平台需要花费很长时间。
在 Properties 部分,填写应用程序名称和包名称。我使用的分别是 “Tic Tac Toe” 和 “com.jherrington.tictactoe”。接下来,选择Create Activity 复选框,并输入活动名称。我使用 “TicTacToeActivity” 作为活动名称。
单击 Finish 可以看到类似于 图 2 的一个新项目。
图 2 显示了一个 Android 应用程序的顶级目录和文件(目录有 src、gen、Android 2.3.1 和 res,文件有 assets、.xml、default.properties 和 proguard.cfg)。其中重要的几项包括:
首先要编辑的是清单文件。大多数文件已经是正确的,但您需要添加 Internet 权限,以便让应用程序能够通过 Internet 发送请求。清单 7 显示了完成之后的清单文件。
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" android:versionCode="1" android:versionName="1.0" package="com.jherrington.tictactoe"> <uses-permission android:name="android.permission.INTERNET" /> <uses-sdk android:minSdkVersion="5" /> <application android:icon="@drawable/icon" android:label="@string/app_name"> <activity android:name="TicTacToeActivity" android:label="@string/app_name"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest> |
惟一的变化是在文件顶部增加了 uses-permission 标签。
您的下一个任务是设计 UI。为此,调整位于 res/layout 目录中的 layout.xml 文件。清单 8 显示了这个文件的新内容。
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent" > <LinearLayout android:layout_height="wrap_content" android:layout_width="match_parent" android:id="@+id/linearLayout1"> <Button android:text="Play X" android:id="@+id/playx" android:layout_width="wrap_content" android:layout_height="wrap_content"></Button> <Button android:text="Play O" android:id="@+id/playo" android:layout_width="wrap_content" android:layout_height="wrap_content"></Button> </LinearLayout> <com.jherrington.tictactoe.BoardView android:id="@+id/bview" android:layout_width="wrap_content" android:layout_height="wrap_content" ></com.jherrington.tictactoe.BoardView> </LinearLayout> |
这是一种直观的布局。顶部是一组封装在水平方向的线形布局中的两个按钮。这两个按钮就是用户用于指定玩游戏时所用颜色的 X 和 O 按钮。
余下代码是 BoardView 类,用于显示当前游戏的 Tic Tac Toe 面板。BoardView 类的代码如清单 11 所示。
布局就绪之后,是时候为应用程序编写一些 Java 代码了。首先编写 清单 9 中的 TicTacToeActivity 类。活动是 Android 应用程序的基本构建块。每个应用程序都有一个或多个活动,代表着应用程序的各种状态。当您在应用程序中导航时,就创建了一个活动堆栈,使用手机上的后退按钮就就可从该堆栈中出来。TicTacToe 应用程序只有一个活动。
com.jherrington.tictactoe; java.util.Timer; android.app.Activity; android.os.Bundle; android.view.View; android.view.View.OnClickListener; android.view.ViewGroup.LayoutParams; android.widget.Button; android.widget.Gallery; android.widget.LinearLayout; class TicTacToeActivity extends Activity implements OnClickListener { @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main); Button playx = (Button)this.findViewById(R.id.playx); playx.setOnClickListener( this ); Button playo = (Button)this.findViewById(R.id.playo); playo.setOnClickListener( this ); Timer timer = new Timer(); UpdateTimer ut = new UpdateTimer(); ut.boardView = (BoardView)this.findViewById(R.id.bview); timer.schedule( ut, 200, 200 ); } public void onClick(View v) { BoardView board = (BoardView)this.findViewById(R.id.bview); if ( v.getId() == R.id.playx ) { board.setColor( 2 ); } if ( v.getId() == R.id.playo ) { board.setColor( 1 ); } } } |
活动有两个方法。第一个是 onCreate 方法,用于构建用户界面,将 onClick 处理程序关联到 X 与 O 按钮,并启动更新定时器。更新定时器用于每 200 毫秒刷新一次游戏状态。这项功能允许两位玩家在对方出着时进行观察。
onClick 处理程序根据用户点击的是 X 还是 O 按钮设置面板的当前颜色。
清单 10 中的 GameService 类是一个单例类,代表给定游戏的游戏服务器和当前状态。
com.jherrington.tictactoe; java.util.ArrayList;java.util.List; javax.xml.parsers.DocumentBuilder;javax.xml.parsers.DocumentBuilderFactory; org.apache.http.HttpResponse; org.apache.http.NameValuePair; org.apache.http.client.HttpClient; org.apache.http.client.entity.UrlEncodedFormEntity; org.apache.http.client.methods.HttpPost; org.apache.http.impl.client.DefaultHttpClient; org.apache.http.message.BasicNameValuePair; org.w3c.dom.Document; org.w3c.dom.Element; org.w3c.dom.NodeList; android.util.Log; class GameService { private static GameService _instance = new GameService(); public int[][] positions = new int[][] { { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 } }; public static GameService getInstance() { return _instance; } private void updatePositions( Document doc ) { for( int x = 0; x < 3; x++ ) { for( int y = 0; y < 3; y++ ) { positions[x][y] = 0; } } doc.getDocumentElement().normalize(); NodeList items = doc.getElementsByTagName("move"); for (int i=0;i<items.getLength();i++){ Element me = (Element)items.item(i); int x = Integer.parseInt( me.getAttribute("x") ); int y = Integer.parseInt( me.getAttribute("y") ); int color = Integer.parseInt( me.getAttribute("color") ); positions[x][y] = color; } } public void startGame( int game ) { HttpClient httpclient = new DefaultHttpClient(); HttpPost httppost = new HttpPost("http://10.0.2.2/ttt/moves.php"); try { List<NameValuePair> nameValuePairs = new ArrayList<NameValuePair>(2); nameValuePairs.add(new BasicNameValuePair("game", Integer.toString(game))); httppost.setEntity(new UrlEncodedFormEntity(nameValuePairs)); HttpResponse response = httpclient.execute(httppost); DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); DocumentBuilder db = dbf.newDocumentBuilder(); updatePositions( db.parse(response.getEntity().getContent()) ); } catch (Exception e) { Log.v("ioexception", e.toString()); } } public void setPosition( int game, int x, int y, int color ) { HttpClient httpclient = new DefaultHttpClient(); HttpPost httppost = new HttpPost("http://10.0.2.2/ttt/move.php"); positions[x][y] = color; try { List<NameValuePair> nameValuePairs = new ArrayList<NameValuePair>(2); nameValuePairs.add(new BasicNameValuePair("game", Integer.toString(game))); nameValuePairs.add(new BasicNameValuePair("x", Integer.toString(x))); nameValuePairs.add(new BasicNameValuePair("y", Integer.toString(y))); nameValuePairs.add(new BasicNameValuePair("color", Integer.toString(color))); httppost.setEntity(new UrlEncodedFormEntity(nameValuePairs)); HttpResponse response = httpclient.execute(httppost); DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); DocumentBuilder db = dbf.newDocumentBuilder(); updatePositions( db.parse(response.getEntity().getContent()) ); } catch (Exception e) { Log.v("ioexception", e.toString()); } } } |
这段代码是应用程序中最有意思的一些代码。首先,使用 updatePositions 方法获取服务器返回的 XML并查找步着元素,然后使用当前的步着集合更新位置数组。该位置数组对面板上的每个位置都有一个相应的值,0 表示空白,1 表示 “O”,而 2 表示 “X”。
其他两个函数 startGame 与 setPosition 用于和服务器通信。startGame 方法从服务器请求当前的步着集合,并更新位置列表。setPosition 方法创建一个 HTTP post 请求,并使用一个名称/值对的数组(传输时将进行编码)为这个 post 请求装配数据,将步着发送给服务器。它接着会解析响应 XML 来更新位置列表。
如果您仔细观察,用于连接服务器的 IP 实际上很有意思。它不是 “localhost” 或 “127.0.0.1”,而是 “10.0.2.2”,即运行仿真器的计算机的别名。因为 Android 手机本身是 UNIX® 系统,它自己的服务位于 localhost 上。这很吸引人,不是吗?很明显,手机本身实际上并非手机,而是一台功能完备、手掌大小的计算机,只不过恰好内置了手机功能而已,这种情况还真不常见。
我们进展到哪儿了呢?我们已经有了应用程序的主要组件,即活动,设置了 UI 布局,编写了 Java 代码来连接到服务器。现在,您需要绘制游戏面板,而这需要通过 清单 11 中的 BoardView 类来完成。
com.jherrington.tictactoe; android.content.Context; android.graphics.Canvas; android.graphics.Color; android.graphics.Paint; android.graphics.Rect; android.util.AttributeSet; android.view.MotionEvent; android.view.View; class BoardView extends View { private int _color = 1; public void setColor( int c ) { _color = c; } public BoardView(Context context) { super(context); GameService.getInstance().startGame(0); } public BoardView(Context context, AttributeSet attrs) { super(context,attrs); GameService.getInstance().startGame(0); } public BoardView(Context context, AttributeSet attrs, int defStyle) { super(context,attrs,defStyle); GameService.getInstance().startGame(0); } public boolean onTouchEvent( MotionEvent event ) { if ( event.getAction() != MotionEvent.ACTION_UP ) return true; int offsetX = getOffsetX(); int offsetY = getOffsetY(); int lineSize = getLineSize(); for( int x = 0; x < 3; x++ ) { for( int y = 0; y < 3; y++ ) { Rect r = new Rect( ( offsetX + ( x * lineSize ) ), ( offsetY + ( y * lineSize ) ), ( ( offsetX + ( x * lineSize ) ) + lineSize ), ( ( offsetY + ( y * lineSize ) ) + lineSize ) ); if ( r.contains( (int)event.getX(), (int)event.getY() ) ) { GameService.getInstance().setPosition(0, x, y, _color); invalidate(); return true; } } } return true; } private int getSize() { return (int) ( (float) ( ( getWidth() < getHeight() ) ? getWidth() : getHeight() ) * 0.8 ); } private int getOffsetX() { return ( getWidth() / 2 ) - ( getSize( ) / 2 ); } private int getOffsetY() { return ( getHeight() / 2 ) - ( getSize() / 2 ); } private int getLineSize() { return ( getSize() / 3 ); } protected void onDraw(Canvas canvas) { Paint paint = new Paint(); paint.setAntiAlias(true); paint.setColor(Color.BLACK); canvas.drawRect(0,0,canvas.getWidth(),canvas.getHeight(), paint); int size = getSize(); int offsetX = getOffsetX(); int offsetY = getOffsetY(); int lineSize = getLineSize(); paint.setColor(Color.DKGRAY); paint.setStrokeWidth( 5 ); for( int col = 0; col < 2; col++ ) { int cx = offsetX + ( ( col + 1 ) * lineSize ); canvas.drawLine(cx, offsetY, cx, offsetY + size, paint); } for( int row = 0; row < 2; row++ ) { int cy = offsetY + ( ( row + 1 ) * lineSize ); canvas.drawLine(offsetX, cy, offsetX + size, cy, paint); } int inset = (int) ( (float)lineSize * 0.1 ); paint.setColor(Color.WHITE); paint.setStyle(Paint.Style.STROKE); paint.setStrokeWidth( 10 ); for( int x = 0; x < 3; x++ ) { for( int y = 0; y < 3; y++ ) { Rect r = new Rect( ( offsetX + ( x * lineSize ) ) + inset, ( offsetY + ( y * lineSize ) ) + inset, ( ( offsetX + ( x * lineSize ) ) + lineSize ) - inset, ( ( offsetY + ( y * lineSize ) ) + lineSize ) - inset ); if ( GameService.getInstance().positions[ x ][ y ] == 1 ) { canvas.drawCircle( ( r.right + r.left ) / 2, ( r.bottom + r.top ) / 2, ( r.right - r.left ) / 2, paint); } if ( GameService.getInstance().positions[ x ][ y ] == 2 ) { canvas.drawLine( r.left, r.top, r.right, r.bottom, paint); canvas.drawLine( r.left, r.bottom, r.right, r.top, paint); } } } } } |
这里的大部分工作都是在 onTouch 和 onDraw 方法中完成的,前者可以响应用户触摸游戏面板上的特定格子,而后者使用 Android 的绘制机制绘制游戏面板。
onTouch 方法使用尺寸调整函数将每个格子的位置识别为一个矩形,然后使用矩形的 contains 方法查看用户的点击是否落在格子内部。如果落在格子内,它就会请求游戏服务走出步着。
onDraw 函数使用尺寸调整函数来绘制面板的线条和所有已经选定的 X 和 O。GameServer 单例用于它的位置数组,其中保存了游戏面板上每个格子的当前状态。
您需要的最后一个类是 UpdateTimer,它使用游戏服务将面板位置更新为最新值。清单 12 显示了定时器的代码。
com.jherrington.tictactoe; java.util.TimerTask; class UpdateTimer extends TimerTask { public BoardView boardView; @Override public void run() { GameService.getInstance().startGame( 0 ); boardView.post(new Runnable(){ public void run(){ boardView.invalidate(); } }); } } |
当应用程序启动时,TicTacToeActivity 类对定时器进行初始化。这个定时器是一种轮询机制。这并非在客户端与服务器之间进行通信的最有效方式,但却最简单和最可靠。最有效的方式是使用 HTTP 协议的 1.1 版本保持连接打开,并在走出步着时让服务器把更新发送给客户端。这种方法要复杂得多。它需要客户端与服务器都支持 1.1 协议,而且连接数量也存在可伸缩性的问题。该方法已经超出了本文的范围。对于像这样的简单演示游戏,轮询机制已经足够满足要求。
代码完成之后,您可以对应用程序进行测试。这意味着要启动仿真器。启动后,您应该看到类似于 图 3 的画面。
这个仿真器正在加载迷人的 “A N D R O I D” 界面。加载完毕之后,您将看到如 图 4 所示的启动画面。
要进入手机,将锁定图标滑动到右侧。之后便可看到主屏,您正在调试的应用程序启动。在这个例子中,此操作会显示 图 5 中的游戏画面。
根据服务器的状态,您可以看到或看不到任何步着。在这个例子中,游戏是空的。Play X 与 Play O 按钮显示在显示屏中间 tic-tac-toe 游戏面板的顶部。接下来,点击 Play X 按钮,再点击中心格子,就可以看到如 图 6 所示的画面。
图 6 显示了中心方格填充 X 之后的游戏面板。要验证已连接上服务器,您可以在服务器上通过 curl 命令来执行 moves.php 脚本,从而获取游戏动作的最新列表。
为了测试 O 能够运作,点击 Play O 按钮,然后选择一个角上的方格,如 图 7 所示。
玩游戏时既可以使用 X,也可以使用 O。应用程序连接到服务器,在一个共享位置中保存游戏状态。由于更新定时器的存在,每个用户都能看到对方的举动。
结束语
这个游戏完成了吗?并不尽然。目前还缺少胜利条件检查,玩家可以覆盖位置,而且没有轮流检查。但基本的技术组件都已经完成:一台在玩家之间共享存储状态的游戏服务器,连接到游戏服务器的移动设备上的一个本机图形化应用程序,用于为游戏提供界面。您可以使用这个游戏作为您自己游戏的一个起点,并按照自己的意愿来打造游戏。只要记住保持它的休闲性和娱乐性,您就可能打造出下一个 Words With Friends 或多玩家版本的 Angry Birds。
【转自】http://www.ibm.com/developerworks/cn/xml/x-androidtictactoe/