第31章Scrabblet :多玩家的猜字游戏
第31章 Scrabblet:多玩家的猜字游戏
Scrabblet是一个多玩家,网络版,客户/服务器架构的游戏。它是本书中最复杂的小应用程序,它处理了Java编程中最难以处理的一些问题。Scrabblet包括11个类,1400多行代码。其中两个类是服务器端的小应用程序。其余的9个由Web浏览器下载,作为游戏的仿真部分。本书详细描述了游戏中的全部代码。在本章,我们将解剖每个类,向读者展示编写一个多玩家的游戏是件多么简单的事。
31.1 网络安全问题
现在大多数的网上小应用程序在下载之后就与网络无关了。其中一个原因是网络使得Java安全问题难以处理。大多数的Java小应用程序环境,例如Netscape Navigator 和Microsoft Internet Explorer,严格限制小应用程序对网络的使用权限。这种情况是由于TCP/IP在其多数的基本
协议
离婚协议模板下载合伙人协议 下载渠道分销协议免费下载敬业协议下载授课协议下载
中缺乏认证机制而造成的。很多公司希望通过使用防火墙保护自己的私有数据,因此小心的管理这个Internet固有的缺陷。所谓防火墙,就是一个在Internet和私有网络之间的一台计算机。Internet与私有网络之间的所有连接都必须通过防火墙,而防火墙可以过滤或是拒绝所有进入、流出私有网络的连接和数据包。通过这种方法,一个试图访问一个内部网络端口的防火墙外的程序将被防火墙拒绝。如果没有防火墙时,系统管理员则必须审查内部网络每台机器的安全。在一个防火墙保护的网络中,只有防火墙自己需要安全措施,其余的机器都可以被认为是“友好的”,内部的机器之间不作任何防范。
这是Java可能造成安全隐患的地方。如果一个支持Java的浏览器允许小应用程序连接任何一个Internet地址,这个小应用程序就可以成为防火墙外的恶意程序的代理。一旦下载了这个小应用程序,这个小应用程序在web浏览器中自动运行,它可以连接邻近的机器和服务器。这些机器对内部机器没有任何防范,所以接收了这些连接。这样,这个小应用程序就可以自由地将敏感数据偷出,并将这些数据传送出防火墙,到达Internet中的恶意主机上。
因为存在这种情况,小应用程序只被允许连接一个主机:就是下载小应用程序的源主机。这可限制小应用程序在内部网中探听其他的消息。普林斯顿大学的研究者公布了一个非常知名的“Java安全攻击方法”,这个方法欺骗Java运行时系统,允许一个小应用程序打开一个通向被禁止机器的网络套接字。幸运的是,这个方法非常的难以重复,随后这个漏洞被堵上了。
一个多玩家的游戏都必须处理哪些安全问题呢,很多。最简单的方法是编写一个可以直接在游戏者之间通信的“点对点”网络环境中的多玩家游戏。这种方法,游戏并不依赖于任何服务器软件的运行。不幸的是,小应用程序只能和加载它的服务器相连。这意味着两个游戏者必须通过服务器才能彼此通信。
在本章中,展示一个简单服务器的源代码,这个服务器程序管理一个连接客户的列表,
700 第4部分 应用Java
并在客户之间传送消息。服务器对游戏的大部分内容并不了解。它只是愉快的在A点和B点之间传递消息。这个功能由两个类,Server和ClientConnection完成。在本章的结尾将详细描述这两个类。
31.2 游 戏
在玩家开始游戏之前,必须先选择一个对手。不需要在游戏之前先打个电话来找个朋友一起玩,这个小应用程序以其他方法解决这个问题。在它启动时,它提示用户输入他或是她的名字(见图31-1)。这个名字被传回服务器,服务器将参加者的名字广播给其他潜在的游戏者。然后这个用户可以看到一个全部可挑战的参加者列表(见图31-2),从中选择一个,并点击Challenge按钮。现在,不需要任何确认或是否认挑战的方法,挑战自动被接受。一旦完成接受挑战的工作,两个游戏者都能看到游戏画面,而其他的游戏参加者只是发现这两个游戏者的名字从可挑战的列表中消失了。
图31-1 游戏者必须在游戏开始时键入他或她的名字
第31章 Scrabblet:多玩家的猜字游戏 701
图31-2 可选的对手名单
这是一个很简单的游戏,但很难战胜一个熟练的对手。呈现在游戏者面前的是一个15×15的正方形的网格,分配给每个游戏者一组带字母的七个方块(见图31-3)。这些方块是从100个方块的口袋中随机挑选出来的。这个方块可以用鼠标点击,拖曳到网格中的对应方块上。如果网格上的位置已经被占领了,那么这个方块会回到它的起始位置。在游戏者的一次操作中还可以调整位置,但是一次操作结束不能再移动方块了。
图31-3 Patrick和Herb准备开始比赛
702 第4部分 应用Java
第一个游戏者在棋盘上将方块排成一行,并组成一个英语单词。第一个单词必须覆盖中心的方块。其余的单词必须至少使用一个已经在棋盘上的方块。游戏者点击Done按钮结束自己的一次操作。如果游戏者找不到合适的单词,可以连击两次Done按钮。两个游戏者轮流拼单词直到方块用光。
棋盘如图31-3所示,在板上用简单的字母表明了分值。2L意味着加倍放置在这个格子中的方块分值。3L意味着可以三倍分值。2W意味着可以得到这个单词分值的两倍。3W意味着单词分值的三倍。如果小应用程序的的尺寸再大些,它会用更具有描述性的标记说明分值的计算方法,如图31-4所示。
图31-4 在尺寸较大的小应用程序中,显示更详细
31.2.1 计分方法
每个轮回之后计算分值。每个方块在字母旁边都有一个分值。这个分值可能会按方块放置的位置进行加倍或是三倍计分。如果单词中的一个方块放置在合适(颜色)的位置上,这个单词的分值可能会是两倍或是三倍。如果单词和其他与之相连的方块可以组成另外的单词,则分别计算分值。如果游戏参加者一次就使用了全部的七个单词,则有50分的鼓励。在游戏结束的时,得分高的人是赢家。
图31-5显示了一个已经经过若干回合的棋盘。Patrick以SIRE单词开始,得到8分。其中每个方块值一分,共四分,中心方块占据了一个可以双倍单词计分的位置,因此总分8分。接着,Herb拼出一个HIRE,HIRE中的I就用了SIRE中的I。这个单词共得七分,是四个方块分值的总合。注意Herb重复利用的Patrick的I是计分的,但是I方块下的双倍单词计分就失效了。在图31-5中,Patrick又拼出GREAT并按下了Done按钮完成他这一回合的操作。注意正在使用的方块的颜色比已经放置好的方块要明亮(见图31-5)。
第31章 Scrabblet:多玩家的猜字游戏 703
图31-5 比赛开始时的Scrabblet
图31-6 Herb正在放置D字母方块,完成单词HEATED的拼写
在游戏的过程中,游戏者可以通过在小应用程序顶部的文本输入区域进行文字交谈(见图31-7)。这些消息在一定时间后出现在另一个游戏者者浏览器的状态栏中,状态栏通常在浏览器的底部(见图31-8)。
704 第4部分 应用Java
图31-7 Patrick正在抱怨被Q粘住了,但却没有U
图31-8 Herb回答。注意屏幕底部Patrick的上一条消息
在分析源代码之前再解释一下这个游戏。要想取得高分,应该在一个方向构成单词的同时在另一个方向也构成一个单词。这第二个单词往往很短小,是两个字母的单词,但是分值可以累计。在图31-9中,Patrick的DEITY共得到21分,其中单词字面9分,加倍变为18
第31章 Scrabblet:多玩家的猜字游戏 705
分,加上在垂直方向构成了AD这个单词,再得三分。记住,每次拼写的单词必须是一个真实的单词。最终,这个游戏会放松对有争议单词的界限或是自动的按字典检查以解决冲突。
图31-9 Patrick在两个方向都得了分
31.3 源 代 码
现在已经了解了游戏的使用方法,应该是学习这个游戏的源代码的时候了。因为一些类的代码非常长,本章将解释插入源代码中,而不是到最后才列出源码。 31.3.1 APPLET标记
这个游戏的APPLET标记很简单。只有主类的名字和小应用程序的尺寸。Scrabblet没有任何的
标记。注意,小应用程序的尺寸越大,棋盘就越好看。这个小应用程序的长宽比例应该是高度略大于宽度。
31.3.2 Scrabblet.java
主要的applet类包含在Scrabblet.java文件中。虽然大部分的游戏规则都在本章后面部分会描述的Board类中,这个文件仍有大约300行,是个比较复杂的小应用程序类。
Scrabblet.java文件的开头是普通的输入语句,几乎加载了每一个标准Java包。然后,声明Scrabblet是实现ActionListener的Applet子类。
706 第4部分 应用Java
import java.io.*;
import java.net.*;
import java.awt.*;
import java.awt.event.*;
import java.applet.*;
public class Scrabblet extends Applet implements ActionListener {
接着,声明一个很大的实例变量集合。server是与运行游戏服务器的Web服务器的连接。机器的名字是ServerName,bag是游戏中共用的字母袋。两个游戏参与者有各自的装字母块的口袋,这两个口袋被初始化为相同的随机序列以此保持同步。board是棋盘的副本。参加游戏的两个人各有一个棋盘,在每一个回合后,由游戏保持这两个棋盘的同步。
如果不能访问网络服务器,则设置single标志,游戏可以在单用户模式下进行。在轮到这一方操作时,布尔变量ourturn为true。如果游戏者找不到合适的单词,可以不在棋盘上放置任何字母方块的情况下连续按下两次Done按钮。用seen_pass变量标记是否已经按下过Done按钮。
为了管理与远程对手的棋盘间的同步,在theirs中保存了已选择的方块拷贝。主要用theirs检查对手是否有作弊,因此小应用程序并不显示theirs的内容。两个字符串,name 和others_name,分别保持自己和对手的名字。
private ServerConnection server;
private String serverName;
private Bag bag;
private Board board;
private boolean single = false;
private boolean ourturn;
private boolean seen_pass = false;
private Letter theirs[] = new Letter[7];
private String name;
private String others_name;
接着,声明8个变量,用于用户界面管理。这些都是AWT的组件,由小应用程序操作控制。topPanel保存prompt ,而namefield保存在启动时得到的用户名。done按钮用来指示回合的结束。chat TextField用于输入对话的消息。idList显示可选的对手。用challenge按钮将自己和对手连接在一起。ican Canvas保存起始阶段显示的游戏名和版权声明。
private Panel topPanel;
private Label prompt;
private TextField namefield;
private Button done;
private TextField chat;
private List idList;
private Button challenge;
private Canvas ican;
init( )
init( )方法只被调用一次,这个方法建立BorderLayout,找出是从哪个Internet主机上下
第31章 Scrabblet:多玩家的猜字游戏 707
载的这个小应用程序,然后创建一个splash屏幕画布。
public void init() {
setLayout(new BorderLayout());
serverName = getCodeBase().getHost();
if (serverName.equals(""))
serverName = "localhost";
ican = new IntroCanvas();
}
start( )
在浏览器重新显示小应用程序所在的页面时调用start( )方法。在方法的开始部分的try块用来捕获网络连接错误。如果能够建立一个新的ServerConnection,则意味着以前没有运行过start( )方法,所以创建提示用户输入姓名的屏幕。在此时,经the splash screen, ican,放在窗口的中央。如果name非空,则意味着用户离开过这个页面,现在是重新返回到这个页面的。游戏假定已经获得用户的名字,可以跳过nameEntered( )。当用户在名字输入区域敲击返回时,调用这个nameEntered( )方法。在结尾处的validate( ) 方法确保所有AWT组件都被正确更新。
如果产生异常,则认为网络连接失败,进入单用户模式。调用start_game( )启动游戏。
public void start() {
try {
showStatus("Connecting to " + serverName);
server = new ServerConnection(this, serverName);
server.start();
showStatus("Connected: " + serverName);
if (name == null) {
prompt = new Label("Enter your name here:");
namefield = new TextField(20);
namefield.addActionListener(this);
topPanel = new Panel();
topPanel.setBackground(new Color(255, 255, 200));
topPanel.add(prompt);
topPanel.add(namefield);
add("North", topPanel);
add("Center", ican);
} else {
if (chat != null) {
remove(chat);
remove(board);
remove(done);
}
nameEntered(name);
}
validate();
} catch (Exception e) {
single = true;
start_Game((int)(0x7fffffff * Math.random()));
}
}
708 第4部分 应用Java
stop( )
在用户离开小应用程序所在页面时调用stop( )方法。这里,程序仅仅通知服务器用户已经离开。如果在用户重新返回页面,则在start( )方法中重新创建网络连接。
public void stop() {
if (!single)
server.quit();
}
add( )
在新用户进入游戏时调用add( )方法。将用户名加入List对象。特别注意add( )方法的字
符串格式,在后面将使用这个字符串从选手列表中抽取ID。
void add(String id, String hostname, String name) {
delete(id); // in case it is already there.
idList.add("(" + id + ") " + name + "@" + hostname);
showStatus("Choose a player from the list");
}
delete( )
在用户不想将自己标志为可选对手时调用delete( )方法。当用户退出或是选好对手准备开始游戏时调用这个方法。在这个方法里,通过提取圆括号中的值在列表中逐个的寻找id字符串。如果列表中没有名字(而且并没有开始游戏:bag == null),则小应用程序显示一个特殊的消息通知用户挂起,直至找到对手。
void delete(String id) {
for (int i = 0; i < idList.getItemCount(); i++) {
String s = idList.getItem(i);
s = s.substring(s.indexOf("(") + 1, s.indexOf(")"));
if (s.equals(id)) {
idList.remove(i);
break;
}
}
if (idList.getItemCount() == 0 && bag == null)
showStatus("Wait for other players to arrive.");
}
getName( )
getName( )方法和delete( )很类似,除了它只是简单的提取名字然后返回。如果没有找
到id,则返回null。
private String getName(String id) {
for (int i = 0; i < idList.getItemCount(); i++) {
String s = idList.getItem(i);
String id1 = s.substring(s.indexOf("(") + 1, s.indexOf(")"));
if (id1.equals(id)) {
return s.substring(s.indexOf(" ") + 3, s.indexOf("@"));
}
第31章 Scrabblet:多玩家的猜字游戏 709
}
return null;
}
challenge( )
在另一个用户向本地用户挑战时由ServerConnection调用challenge( )方法。本来可以将这个方法实现得更为复杂,允许提示用户接受或是拒绝挑战,但是这个方法实现为自动的接受挑战。注意,用来启动游戏的随机数初值,被传送到对手的accept( )方法中。这样,两方初始化了相同随机状态的两个字母方块袋以确保游戏的同步性。调用server.delete( )确保
false放弃了先手。 不再接受其他游戏者的挑战。同时注意,本地游戏者通过设置ourturn 为
// we've been challenged to a game by "id".
void challenge(String id) {
ourturn = false;
int seed = (int)(0x7fffffff * Math.random());
others_name = getName(id); // who was it?
showStatus("challenged by " + others_name);
// put some confirmation here...
server.accept(id, seed);
server.delete();
start_Game(seed);
}
accept( )
accept( )是远方用户回应刚才提到的server.accept( )调用的方法。与对手一样,必须调用server.delete( )将自己从可选的游戏者列表中删除。通过将ourturn设为true这个用户首先开始拼写。
// our challenge was accepted.
void accept(String id, int seed) {
ourturn = true;
others_name = getName(id);
server.delete();
start_Game(seed);
}
chat( )
当对手在他或是她的对话窗口中输入时,服务器调用chat( )方法。在这个方法的实现中,只是简单的在浏览器的状态栏中显示对话消息。在将来的实现版本中,应改进为将消息记录在一个文本域内。
void chat(String id, String s) {
showStatus(others_name + ": " + s);
}
710 第4部分 应用Java
move( )
对手每放置一个方块调用一次move( )方法。这个方法搜寻theirs找到使用的字母。如果
那个格子已经被占用,方块被返回到原来放置的位置。否则,对手的字母永久移入棋盘上。
)在theirs中重新放置方块。如果bag空了,则显示一个状态消息。重接着,使用bag.takeOut(
画棋盘显示新的方块。注意此时还没有基于方块位置的记分。直到调用turn( ),小应用程序
才计算总的分数。
// the other guy moved, and placed 'letter' at (x, y).
void move(String letter, int x, int y) {
for (int i = 0; i < 7; i++) {
if (theirs[i] != null && theirs[i].getSymbol().equals(letter)) {
Letter already = board.getLetter(x, y);
if (already != null) {
board.moveLetter(already, 15, 15); // on the tray.
}
board.moveLetter(theirs[i], x, y);
board.commitLetter(theirs[i]);
theirs[i] = bag.takeOut();
if (theirs[i] == null)
showStatus("No more letters");
break;
}
}
board.repaint();
}
turn( )
在对手的方块全部移动完毕之后调用turn( )。远程的Scrabblet实例计算分数,并发送到
本地,因此本地只需拷贝而无需再计算一遍。分数显示在状态栏中,setEnabled方法允许本
地开始拼写。othersTurn( )将分数通知棋盘。棋盘此时显示新的分数。
void turn(int score, String words) {
showStatus(others_name + " played: " + words + " worth " +
score);
done.setEnabled(true);
board.othersTurn(score);
}
quit( )
当另一方明确退出时,调用quit( )方法。这个方法删除游戏的AWT组件,跳转回接下
来描述的nameEntered( ),重新加入游戏者列表。
void quit(String id) {
showStatus(others_name + " just quit.");
remove(chat);
remove(board);
remove(done);
nameEntered(name);
}
第31章 Scrabblet:多玩家的猜字游戏 711 nameEntered( )
当提示输入用户名后按下回车时actionPerformed( )调用nameEntered( )。在此时先删除
存在的任何AWT组件,然后创建新的List对象,idList,用以存储对手的名字。这个方法还
)通知服务器,用户已经准在页面顶部增加了一个名为challenge的按钮,然后利用setName(
备好了。
private void nameEntered(String s) {
if (s.equals(""))
return;
name = s;
if (ican != null)
remove(ican);
if (idList != null)
remove(idList);
if (challenge != null)
remove(challenge);
idList = new List(10, false);
add("Center", idList);
challenge = new Button("Challenge");
challenge.addActionListener(this);
add("North", challenge);
validate();
server.setName(name);
showStatus("Wait for other players to arrive.");
if (topPanel != null)
remove(topPanel);
}
wepick( )和theypick( )
简单的调用wepick( ) 和theypick( )方法开始游戏,这两个方法分别给每个游戏者分配
七个方块。特别注意挑战的双方必须按正确的顺序调用过程,这个顺序取决于谁先开始。
调用bag.takeOut( )可以在共享的口袋里取出单个字母。调用board.addLetter( )将方块放在盘
子里,在另一方,theypick( )将字母保持在theirs中。
private void wepick() {
for (int i = 0; i < 7; i++) {
Letter l = bag.takeOut();
board.addLetter(l);
}
}
private void theypick() {
for (int i = 0; i < 7; i++) {
Letter l = bag.takeOut();
theirs[i] = l;
}
}
712 第4部分 应用Java
start_Game( )
在单用户模式中,start_Game( )在帧窗口中弹出splash图形。然后创建一个棋盘,在棋
盘的构造函数中没有任何参数,指示是单用户模式。
在一对一的模式中,删除可选对手列表组件,然后在小应用程序中增加chat窗口,然
),后增加棋盘和一个Done按钮。接着,创建一个口袋,如果ourturn为真,则先调用wepick(
然后调用theypick( )。在本用户不是先手的情况下,禁止棋盘和Done按钮,然后先调用
theypick( )后调用wepick( )。最后强制重画棋盘。
private void start_Game(int seed) {
if (single) {
Frame popup = new Frame("Scrabblet");
popup.setSize(400, 300);
popup.add("Center", ican);
popup.setResizable(false);
popup.show();
board = new Board();
showStatus("no server found, playing solo");
ourturn = true;
} else {
remove(idList);
remove(challenge);
board = new Board(name, others_name);
chat = new TextField();
chat.addActionListener(this);
add("North", chat);
showStatus("playing against " + others_name);
}
add("Center", board);
done = new Button("Done");
done.addActionListener(this);
add("South", done);
validate();
bag = new Bag(seed);
if (ourturn) {
wepick();
if (!single)
theypick();
} else {
done.setEnabled(false);
theypick();
wepick();
}
board.repaint();
}
challenge_them( )
在按下challenge按钮时调用challenge_them( )方法。这个方法在idList中找到选中的对
手,向他或她发送一个challenge( )消息。删除列表和按钮,准备开始游戏。
第31章 Scrabblet:多玩家的猜字游戏 713
private void challenge_them() {
String s = idList.getSelectedItem();
if (s == null) {
showStatus("Choose a player from the list then press Challenge");
} else {
remove(challenge);
remove(idList);
String destid = s.substring(s.indexOf('(')+1,
s.indexOf(')'));
showStatus("challenging: " + destid);
server.challenge(destid); // accept will get called if
// they accept.
validate();
}
}
our_turn( )
在按下Done钮时,调用our_turn( )。首先它调用board.findwords( )检查方块是否放在合
法的位置上,将结果存入word。如果word为空,则这个方块有问题,方法在状态栏中显示
这个情况。如果word为“”,意味着本回合没有放置方块。在单用户模式,这可以忽略,
在对抗模式中,如果接连两次Done按钮而没有放置方块,则将放弃本次机会轮到对手操作。 如果方块放置在合法位置,则本次回合结束,ourturn( )将字母提交到棋盘上。注意
commit( )以server为参数。它用这种方法通知远程用户每个新字母的位置。然后方法重新放
置使用过的字符。在多用户模式下,禁止己方的棋盘,调用server.turn( )通知另一方轮到他
们行动了。
private void our_turn() {
String word = board.findwords();
if (word == null) {
showStatus("Illegal letter positions");
} else {
if ("".equals(word)) {
if (single)
return;
if (seen_pass) {
done.setEnabled(false);
server.turn("pass", 0);
showStatus("You passed");
seen_pass = false;
} else {
showStatus("Press done again to pass");
seen_pass = true;
return;
}
} else {
seen_pass = false;
}
showStatus(word);
board.commit(server);
for (int i = 0; i < 7; i++) {
if (board.getTray(i) == null) {
714 第4部分 应用Java
Letter l = bag.takeOut();
if (l == null)
showStatus("No more letters");
else
board.addLetter(l);
}
}
if (!single) {
done.setEnabled(false);
server.turn(word, board.getTurnScore());
}
board.repaint();
}
}
actionPerformed( )
使用actionPerformed( )方法获取小应用程序使用的各个组件的输入。它处理Challenge
和Done按钮,名字输入框和对话输入框。
public void actionPerformed(ActionEvent ae) {
Object source = ae.getSource();
if(source == chat) {
server.chat(chat.getText());
chat.setText("");
}
else if(source == challenge) {
challenge_them();
}
else if(source == done) {
our_turn();
}
else if(source == namefield) {
TextComponent tc = (TextComponent)source;
nameEntered(tc.getText());
}
}
}
31.3.3 IntroCanvas.java
Canvas的IntroCanvas子类非常简单。它仅重载paint( )以显示小应用程序的名字和一个
简短的版权专用明。它创建一些定制的颜色和字体。为了清楚起见,显示字符串保存在静
态变量中。
import java.awt.*;
import java.awt.event.*;
class IntroCanvas extends Canvas {
private Color pink = new Color(255, 200, 200);
private Color blue = new Color(150, 200, 255);
private Color yellow = new Color(250, 220, 100);
private int w, h;
第31章 Scrabblet:多玩家的猜字游戏 715
private int edge = 16;
private static final String title = "Scrabblet";
private static final String name =
"Copyright 1999 - Patrick Naughton";
private static final String book =
"Chapter 31 from 'Java: The Complete Reference'";
private Font namefont, titlefont, bookfont;
IntroCanvas() {
setBackground(yellow);
titlefont = new Font("SansSerif", Font.BOLD, 58);
namefont = new Font("SansSerif", Font.BOLD, 18);
bookfont = new Font("SansSerif", Font.PLAIN, 12);
addMouseListener(new MyMouseAdapter());
}
d( )
私有方法d( )是以按选择的等量偏移绘制居中文字。先向左偏移1绘制一个白色的字符
串,然后向右偏移1绘制一个黑色的字符串,然后至少用粉红色不偏移的再画一次字符串,
以此完成主标题的高亮度/阴影效果。
private void d(Graphics g, String s, Color c, Font f, int y,
int off) {
g.setFont(f);
FontMetrics fm = g.getFontMetrics();
g.setColor(c);
g.drawString(s, (w - fm.stringWidth(s)) / 2 + off, y + off);
}
public void paint(Graphics g) {
Dimension d = getSize();
w = d.width;
h = d.height;
g.setColor(blue);
g.fill3DRect(edge, edge, w - 2 * edge, h - 2 * edge, true);
d(g, title, Color.black, titlefont, h / 2, 1);
d(g, title, Color.white, titlefont, h / 2, -1);
d(g, title, pink, titlefont, h / 2, 0);
d(g, name, Color.black, namefont, h * 3 / 4, 0);
d(g, book, Color.black, bookfont, h * 7 / 8, 0);
}
mousePressed( )
在下列的代码段中,注意MyMouseAdapter是扩展MouseAdapter的一个内部类。如果它
被点击,它将重载mousePressed( )方法,从而引起画布父类调用hide( )方法。这个方法只在
单用户模式有用,取消弹出的帧。
class MyMouseAdapter extends MouseAdapter {
public void mousePressed(MouseEvent me) {
((Frame)getParent()).setVisible(false);
}
}
716 第4部分 应用Java
}
31.3.4 Board.java
Board类封装了大部分的游戏规则和棋盘的外观。这是这个游戏中最大的类,有大约500
15×15 的Letters数组存储棋盘行的代码。一些私有变量存储游戏的状态。一个名为board的
上每个格子上的方块。tray数组保存当前棋盘上的Letters。回想一下,Scrabblet 小应用程序类保存着对手的7个Letters。Point对象orig 和here用来记住字母的位置。用name和others_name变量简单地显示计分牌上的名字。在单用户模式,这两个变量都为空。两个用户的得分存放在total_score 和others_score中,上一个回合的分数放在turn_score中。这个类有两个构造函数,一个构造函数创建用户的名字,否则在单用户模式中为空。
import java.awt.*;
import java.awt.event.*;
class Board extends Canvas {
private Letter board[][] = new Letter[15][15];
private Letter tray[] = new Letter[7];
private Point orig = new Point(0,0);
private Point here = new Point(0,0);
private String name;
private int total_score = 0;
private int turn_score = 0;
private int others_score = 0;
private String others_name = null;
Board(String our_name, String other_name) {
name = our_name;
others_name = other_name;
addMouseListener(new MyMouseAdapter());
addMouseMotionListener(new MyMouseMotionAdapter());
}
Board() {
addMouseListener(new MyMouseAdapter());
addMouseMotionListener(new MyMouseMotionAdapter());
}
othersTurn( ), getTurnScore( ) 和getTray( )
用这三个方法控制对几个私有变量的访问。首先,在其他游戏者结束回合时小应用程序调用othersTurn( )。这个方法增加游戏者的分数,重画棋盘反映分数的变换。在计分牌重画需要分数值时,getTurnScore( )方法返回存储的上个回合的分数。小应用程序使用这个方法将分数传递给对手,对手最后会在远程机器上调用othersTurn( )。getTray( )方法提供一个对私有tray数组的只读访问。
void othersTurn(int score) {
others_score += score;
paintScore();
repaint();
}
第31章 Scrabblet:多玩家的猜字游戏 717
int getTurnScore() {
paintScore();
return turn_score;
}
Letter getTray(int i) {
return tray[i];
}
addLetter( )
使用addLetter( )方法在游戏者的盘子里放置字母。字母被放在第一个空格子里,如果这个方法找不到空格子,则返回false。
synchronized boolean addLetter(Letter l) {
for (int i = 0; i < 7; i++) {
if (tray[i] == null) {
tray[i] = l;
moveLetter(l, i, 15);
return true;
}
}
return false;
}
existingLetterAt( )
使用私有方法existingLetterAt( )检查棋盘上是否有不是本回合的字母。随后的findwords( )方法使用这个方法保证本回合拼写的单词中至少有一个字母是已经存在的字母。
private boolean existingLetterAt(int x, int y) {
Letter l = null;
return (x >= 0 && x <= 14 && y >= 0 && y <= 14
&& (l = board[y][x]) != null && l.recall() == null);
}
findwords( )
findwords( )是一个非常大的方法,检查棋盘的每一回合的状态。如果违反了字母放置规则,则返回null。如果本回合没有拼写任何单词,则返回“”。如果本回合的所有字母放置都合法,则返回这些字母组成的单词字符串,每个单词用空格隔开。更新turn_score 和total_score变量的实例,反映刚刚拼写出的单词的分值。
首先,findwords( )计算字母的分值ntiles,将其存放在一个名为atplay的数组中。接着,它检查头两个字母(如果本次拼出的单词多于一个字母)确定这个单词是垂直的或是水平的。然后,检查本回合的所以其他的字母,要确定它们在同一行。如果任何一个字母超出行或列,方法返回null。
synchronized String findwords() {
String res = "";
718 第4部分 应用Java
turn_score = 0;
int ntiles = 0;
Letter atplay[] = new Letter[7];
for (int i = 0; i < 7; i++) {
if (tray[i] != null && tray[i].recall() != null) {
atplay[ntiles++] = tray[i];
}
}
if (ntiles == 0)
return res;
boolean horizontal = true; // if there's one tile,
// call it horizontal
boolean vertical = false;
if (ntiles > 1) {
int x = atplay[0].x;
int y = atplay[0].y;
horizontal = atplay[1].y == y;
vertical = atplay[1].x == x;
if (!horizontal && !vertical) // diagonal...
return null;
for (int i = 2; i < ntiles; i++) {
if (horizontal && atplay[i].y != y
|| vertical && atplay[i].x != x)
return null;
}
}
接着,这个方法查看每个字母以确保至少有一个字母是使用了已经存在的字母。一个
特殊的情况是在游戏的开头,如果覆盖了中心的位置,且使用了多个方块,则这个回合合
法。
// make sure that at least one played tile is
// touching at least one existing tile.
boolean attached = false;
for (int i = 0; i < ntiles; i++) {
Point p = atplay[i].recall();
int x = p.x;
int y = p.y;
if ((x == 7 && y == 7 && ntiles > 1) ||
existingLetterAt(x-1, y) || existingLetterAt(x+1, y) ||
existingLetterAt(x, y-1) || existingLetterAt(x, y+1)) {
attached = true;
break;
}
}
if (!attached) {
return null;
}
下一个循环遍历检查整个单词的每个字母,(i == –1),然后检查每个字母(i == 0..ntiles)
是否可能在与主方向垂直的另一个方向上构成新的单词,方向由horizontal管理。
第31章 Scrabblet:多玩家的猜字游戏 719
// we use -1 to mean check the major direction first
// then 0..ntiles checks for words orthogonal to it.
for (int i = -1; i < ntiles; i++) {
Point p = atplay[i==-1?0:i].recall(); // where is it?
int x = p.x;
int y = p.y;
int xinc, yinc;
if (horizontal) {
xinc = 1;
yinc = 0;
} else {
xinc = 0;
yinc = 1;
}
int mult = 1;
String word = "";
int word_score = 0;
然后该方法选出每个字符,向左或向上移动找到每个单词的第一个字母。一旦找到单
词的开头,方法向右或是向下移动,检查单词中的每个字母。在letters_seen中计算字母的
分值。每个字母的分值由其下的增倍因子决定。仅在第一次使用这个格子时才应用增倍因
子,否则按字母的分值计算。这些分数累计在word_score中。
// here we back up to the top/left-most letter
while (x >= xinc && y >= yinc &&
board[y-yinc][x-xinc] != null) {
x -= xinc;
y -= yinc;
}
int n = 0;
int letters_seen = 0; // letters we've just played.
Letter l;
while (x < 15 && y < 15 && (l = board[y][x]) != null) {
word += l.getSymbol();
int lscore = l.getPoints();
if (l.recall() != null) { // one we just played...
Color t = tiles[y < 8 ? y : 14 - y][x < 8 ? x : 14 - x];
if (t == w3)
mult *= 3;
else if (t == w2)
mult *= 2;
else if (t == l3)
lscore *= 3;
else if (t == l2)
lscore *= 2;
if (i == -1) {
letters_seen++;
}
}
word_score += lscore;
n++;
720 第4部分 应用Java
x += xinc;
y += yinc;
}
word_score *= mult;
只对主要单词进行最后的错误检查。在遇到空白格子或是棋盘的边缘时循环结束,它应该覆盖所有的刚放置的字母方块以及以前放置的方块。如果它少检查了字母,这意味这字母之间可能有空格,是个非法位置,则返回空,如果通过检查,方法同时检查是否同时使用了7个方块,同时使用7个方块可以得到50分的奖励。在检查这个单词之后,findwords( )转换horizontal的方向,开始检查垂直方向的单词。
if (i == -1) { // first pass...
// if we didn't see all the letters, then there was a gap,
// which is an illegal tile position.
if (letters_seen != ntiles) {
return null;
}
if (ntiles == 7) {
turn_score += 50;
}
// after the first pass, switch to looking the other way.
horizontal = !horizontal;
}
在findwords( )遍历整个单词时,必须确保只有至少由两个字母构成的单词才能计分。在这种情况下,将word_score加到turn_score上,将这个单词添加到结果字符串中。一旦遍历全部字母,统计总分数并返回。
if (n < 2) // don't count single letters twice.
continue;
turn_score += word_score;
res += word + " ";
}
total_score += turn_score;
return res;
}
commit( ) 和commitLetter( )
commitLetter( )方法提交已经暂时放在棋盘上的字母。从游戏者的盘子中删commit( )和
除这些字母,在板上用深颜色画出。在字母提交后,commit( )调用move( )通知服务器每个字母的位置,根据这个消息更新对手的棋盘。
synchronized void commit(ServerConnection s) {
for (int i = 0 ; i < 7 ; i++) {
Point p;
if (tray[i] != null && (p = tray[i].recall()) != null) {
if (s != null) // there's a server connection
第31章 Scrabblet:多玩家的猜字游戏 721
s.move(tray[i].getSymbol(), p.x, p.y);
commitLetter(tray[i]); // marks this as not in play.
tray[i] = null;
}
}
}
void commitLetter(Letter l) {
if (l != null && l.recall() != null) {
l.paint(offGraphics, Letter.DIM);
l.remember(null); // marks this as not in play.
}
}
update( ) 和paint( )
在这里声明了多个私有变量提供访问棋盘的各个位置。这段代码同时定义了两个屏幕外缓冲区,一个用于缓存棋盘图像和所有的临时放置的字母方块,另一个是显示图像的备份。update( )方法调用paint( )以避免闪烁。paint( )方法快速调用checksize( )确保所有缓冲区都已创建,然后通过pick != null检查用户是否正在拖曳字母。如果正在拖曳字母,则paint( )拷贝屏幕外图像内容,画出正在拖曳的字母的外形,x0, y0, w0, h0。接着在屏幕图像内容上剪切同样的矩形。这种方法将每次移动鼠标时不得不移动的像素降为最少。
为了绘制屏幕,拷贝背景图像offscreen,然后按正常设置(NORMAL)绘制游戏者盘子里的字母块。正在拖曳的字母块按BRIGHT模式绘制。最后,拷贝备份缓冲区图像offscreen2到屏幕。
private Letter pick; // the letter being dragged around.
private int dx, dy; // offset to topleft corner of pick.
private int lw, lh; // letter width and height.
private int tm, lm; // top and left margin.
private int lt; // line thickness (between tiles).
private int aw, ah; // letter area size.
private Dimension offscreensize;
private Image offscreen;
private Graphics offGraphics;
private Image offscreen2;
private Graphics offGraphics2;
public void update(Graphics g) {
paint(g);
}
public synchronized void paint(Graphics g) {
Dimension d = checksize();
Graphics gc = offGraphics2;
if (pick != null) {
gc = gc.create();
gc.clipRect(x0, y0, w0, h0);
g.clipRect(x0, y0, w0, h0);
}
gc.drawImage(offscreen, 0, 0, null);
722 第4部分 应用Java
for (int i = 0 ; i < 7 ; i++) {
Letter l = tray[i];
if (l != null && l != pick)
l.paint(gc, Letter.NORMAL);
}
if (pick != null)
pick.paint(gc, Letter.BRIGHT);
g.drawImage(offscreen2, 0, 0, null);
}
LetterHit( )
LetterHit( )返回在x,y点的字母,如果那里没有字母则返回空。
Letter LetterHit(int x, int y) {
for (int i = 0; i < 7; i++) {
if (tray[i] != null && tray[i].hit(x, y)) {
return tray[i];
}
}
return null;
}
unplay( )
这个简单方法删除在棋盘上放置但没有提交的字母。
private void unplay(Letter let) {
Point p = let.recall();
if (p != null) {
board[p.y][p.x] = null;
let.remember(null);
}
}
moveToTray( )
moveToTray( )方法简单的计算游戏者盘子中字母的屏幕位置。
private void moveToTray(Letter l, int i) {
int x = lm + (lw + lt) * i;
int y = tm + ah - 2 * lt;
l.move(x, y);
}
dropOnTray( )
在向盘子里放方块或是将方块脱离棋盘时使用dropOnTray( )方法。这允许改变盘子中
的方块顺序或是简单的从棋盘中返回字母方块。
private void dropOnTray(Letter l, int x) {
unplay(l); // unhook where we were.
// find out what slot this letter WAS in.
第31章 Scrabblet:多玩家的猜字游戏 723
int oldx = 0;
for (int i = 0 ; i < 7 ; i++) {
if (tray[i] == l) {
oldx = i;
break;
}
}
// if the slot we dropped on was empty,
// find the rightmost occupied slot.
if (tray[x] == null) {
for (int i = 6 ; i >= 0 ; i--) {
if (tray[i] != null) {
x = i;
break;
}
}
}
// if the slot we dropped on was from a tile already
// played on the board, just swap slots with it.
if (tray[x].recall() != null) {
tray[oldx] = tray[x];
} else {
// we are just rearranging a tile already on the tray.
if (oldx < x) { // shuffle left.
for (int i = oldx ; i < x ; i++) {
tray[i] = tray[i+1];
if (tray[i].recall() == null)
moveToTray(tray[i], i);
}
} else { // shuffle right.
for (int i = oldx ; i > x ; i--) {
tray[i] = tray[i-1];
if (tray[i].recall() == null)
moveToTray(tray[i], i);
}
}
}
tray[x] = l;
moveToTray(l, x);
}
getLetter( )
getLetter( )是一个简单的针对游戏板数组的只读包装方法。
Letter getLetter(int x, int y) {
return board[y][x];
}
moveLetter( )
moveLetter( )方法处理将字母方块移到棋盘的指定位置或是将其放回游戏者盘子的情
况。如果x,y点超出棋盘范围,则使用游戏者的盘子。当字母方块被移入棋盘,则必须是一
724 第4部分 应用Java
个空白的格子,否则字母方块被送回原地,原地的坐标由orig指定。
void moveLetter(Letter l, int x, int y) {
if (y > 14 || x > 14 || y < 0 || x < 0) {
// if we are off the board.
if (x > 6)
x = 6;
if (x < 0)
x = 0;
dropOnTray(l, x);
} else {
if (board[y][x] != null) {
x = orig.x;
y = orig.y;
} else {
here.x = x;
here.y = y;
unplay(l);
board[y][x] = l;
l.remember(here);
// turn it back into pixels
x = lm + (lw + lt) * x;
y = tm + (lh + lt) * y;
}
l.move(x, y);
}
}
checksize( )
这个方法有个会误导人的名字。除了验证小应用程序的大小外,checksize( )方法作了
很多工作,在确认小应用程序尺寸时,这个方法做一次性的初始化。这个方法包括主游戏
模式的绘制编码。它绘制所有的格子,包括颜色和计分区域的文字。
private Color bg = new Color(175, 185, 175);
private Color w3 = new Color(255, 50, 100);
private Color w2 = new Color(255, 200, 200);
private Color l3 = new Color(75, 75, 255);
private Color l2 = new Color(150, 200, 255);
private Color tiles[][] = {
{w3, bg, bg, l2, bg, bg, bg, w3},
{bg, w2, bg, bg, bg, l3, bg, bg},
{bg, bg, w2, bg, bg, bg, l2, bg},
{l2, bg, bg, w2, bg, bg, bg, l2},
{bg, bg, bg, bg, w2, bg, bg, bg},
{bg, l3, bg, bg, bg, l3, bg, bg},
{bg, bg, l2, bg, bg, bg, l2, bg},
{w3, bg, bg, l2, bg, bg, bg, w2}
};
private Dimension checksize() {
Dimension d = getSize();
int w = d.width;
第31章 Scrabblet:多玩家的猜字游戏 725
int h = d.height;
if (w < 1 || h < 1)
return d;
if ((offscreen == null) ||
(w != offscreensize.width) ||
(h != offscreensize.height)) {
System.out.println("updating board: " + w + " x " + h + "\r");
offscreen = createImage(w, h);
offscreensize = d;
offGraphics = offscreen.getGraphics();
offscreen2 = createImage(w, h);
offGraphics2 = offscreen2.getGraphics();
offGraphics.setColor(Color.white);
offGraphics.fillRect(0,0,w,h);
// lt is the thickness of the white lines between tiles.
// gaps is the sum of all the whitespace.
// lw, lh are the dimensions of the tiles.
// aw, ah are the dimensions of the entire board
// lm, tm are the left and top margin to center aw, ah in the applet.
lt = 1 + w / 400;
int gaps = lt * 20;
lw = (w - gaps) / 15;
lh = (h - gaps - lt * 2) / 16; // compensating for tray height;
aw = lw * 15 + gaps;
ah = lh * 15 + gaps;
lm = (w - aw) / 2 + lt;
tm = (h - ah - (lt * 2 + lh)) / 2 + lt;
offGraphics.setColor(Color.black);
offGraphics.fillRect(lm,tm,aw-2*lt,ah-2*lt);
lm += lt;
tm += lt;
offGraphics.setColor(Color.white);
offGraphics.fillRect(lm,tm,aw-4*lt,ah-4*lt);
lm += lt;
tm += lt;
int sfh = (lh > 30) ? lh / 4 : lh / 2;
Font font = new Font("SansSerif", Font.PLAIN, sfh);
offGraphics.setFont(font);
for (int j = 0, y = tm; j < 15; j++, y += lh + lt) {
for (int i = 0, x = lm; i < 15; i++, x += lw + lt) {
Color c = tiles[j < 8 ? j : 14 - j][i < 8 ? i : 14 - i];
offGraphics.setColor(c);
offGraphics.fillRect(x, y, lw, lh);
offGraphics.setColor(Color.black);
if (lh > 30) {
String td = (c == w2 || c == l2) ? "DOUBLE" :
(c == w3 || c == l3) ? "TRIPLE" : null;
String wl = (c == l2 || c == l3) ? "LETTER" :
(c == w2 || c == w3) ? "WORD" : null;
726 第4部分 应用Java
if (td != null) {
center(offGraphics, td, x, y + 2 + sfh, lw);
center(offGraphics, wl, x, y + 2 * (2 + sfh), lw);
center(offGraphics, "SCORE", x, y + 3 * (2 + sfh), lw);
}
} else {
String td = (c == w2 || c == l2) ? "2" :
(c == w3 || c == l3) ? "3" : null;
String wl = (c == l2 || c == l3) ? "L" :
(c == w2 || c == w3) ? "W" : null;
if (td != null) {
center(offGraphics, td + wl, x,
y + (lh - sfh) * 4 / 10 + sfh, lw);
}
}
}
}
Color c = new Color(255, 255, 200);
offGraphics.setColor(c);
offGraphics.fillRect(lm, tm + ah - 3 * lt, 7 * (lw + lt), lh +
2 * lt);
Letter.resize(lw, lh);
// if we already have some letters, place them.
for (int i = 0; i < 7; i++) {
if (tray[i] != null) {
moveToTray(tray[i], i);
}
}
paintScore();
}
return d;
}
center( )
checksize( )使用center( )将“Double Letter Score”文字居中显示。
private void center(Graphics g, String s, int x, int y, int w) {
x += (w - g.getFontMetrics().stringWidth(s)) / 2;
g.drawString(s, x, y);
}
paintScore( )
paintScore( )方法显示两个游戏者的分数或者是单用户模式下的一个用户分数。
private void paintScore() {
int x = lm + (lw + lt) * 7 + lm;
int y = tm + ah - 3 * lt;
int h = lh + 2 * lt;
Font font = new Font("TimesRoman", Font.PLAIN, h/2);
offGraphics.setFont(font);
FontMetrics fm = offGraphics.getFontMetrics();
第31章 Scrabblet:多玩家的猜字游戏 727
offGraphics.setColor(Color.white);
offGraphics.fillRect(x, y, aw, h);
offGraphics.setColor(Color.black);
if (others_name == null) {
int y0 = (h - fm.getHeight()) / 2 + fm.getAscent();
offGraphics.drawString("Score: " + total_score, x, y + y0);
} else {
h/=2;
int y0 = (h - fm.getHeight()) / 2 + fm.getAscent();
offGraphics.drawString(name + ": " + total_score, x, y + y0);
offGraphics.drawString(others_name + ": " + others_score,
x, y + h + y0);
}
}
private int x0, y0, w0, h0;
selectLetter( )
selectLetter( )方法检查鼠标位置,看是否在字母上。如果在,则将其存放在pick中,并
计算鼠标和左上角的字母的距离,这个距离存放在dx,dy中,同时在orig中保存字母的原始
位置。
private void selectLetter(int x, int y) {
pick = LetterHit(x, y);
if(pick != null) {
dx = pick.x - x;
dy = pick.y - y;
orig.x = pick.x;
orig.y = pick.y;
}
repaint();
}
dropLetter( )
在dropLetter( )方法中,用户放下正在移动的字母。这个方法决定字母放下时占据的棋
盘的格子。调用moveLetter( )将字母移进对应的格子。
private void dropLetter(int x, int y) {
if(pick != null) {
// find the center of the tile
x += dx + lw / 2;
y += dy + lh / 2;
// find the tile index
x = (x - lm) / (lw + lt);
y = (y - tm) / (lh + lt);
moveLetter(pick, x, y);
pick = null;
repaint();
}
728 第4部分 应用Java
}
dragLetter( )
dragLetter( )方法与其他的鼠标相关事件的处理方式不同。这主要与性能有关。目标是尽可能的平滑与用户之间的交互作用。dragLetter( )计算在字符拖动前与当前位置的区域长宽。然后直接调用paint(getGraphics( ))。这是非标准的Java 小应用程序编程方式,但是性能可靠。
private void dragLetter(int x, int y) {
if (pick != null) {
int ox = pick.x;
int oy = pick.y;
pick.move(x + dx, y + dy);
x0 = Math.min(ox, pick.x);
y0 = Math.min(oy, pick.y);
w0 = pick.w + Math.abs(ox - pick.x);
h0 = pick.h + Math.abs(oy - pick.y);
paint(getGraphics());
}
}
mousePressed( )
在下面的代码段中,注意MyMouseAdapter是扩展MouseAdapter的内部类。它重载了mousePressed( ) 和mouseReleased( )方法。
mousePressed( )方法调用selectLetter( )方法做必要的处理。当前鼠标位置的x和y坐标由mousePressed( )方法以参数形式提供。
class MyMouseAdapter extends MouseAdapter {
public void mousePressed(MouseEvent me) {
selectLetter(me.getX(), me.getY());
}
mouseReleased( )
mouseReleased( )方法调用dropLetter( )方法作必要的处理。当前鼠标位置的x和y坐标由mouseReleased( )方法以参数形式提供。
public void mouseReleased(MouseEvent me) {
dropLetter(me.getX(), me.getY());
}
}
mouseDragged( )
在下面的代码段中,注意MyMouseMotionAdapter是扩展MouseMotionAdapter的内部子类。它重载了mouseDragged( )方法。
mouseDragged( )方法调用dropLetter( )方法作必要的处理。当前鼠标位置的x和y坐标由mouseDragged( )方法以参数形式提供。
class MyMouseMotionAdapter extends MouseMotionAdapter {
第31章 Scrabblet:多玩家的猜字游戏 729
public synchronized void mouseDragged(MouseEvent me) {
dragLetter(me.getX(), me.getY());
}
}
}
31.3.5 Bag.java
相当于Board,Bag类非常简单明了。它是口袋字母的简单抽象。当创建Bag类时,传入一个随机数初值,这个相同的初值随机创建两个相同的口袋。随机数生成器存放在rand中。这个类包含两个有点怪的整数数组,名字分别是letter_counts 和letter_points。两个数组都是27个元素。数组的第0个元素代表空白方块,第1到26个元素代表A到Z。letter_counts数组代表口袋中的每个字母的个数。例如,letter_counts[1]为9,这就是说口袋里有9个A。同样,letter_points数组在字母和它们的分值之间做映射。A字母方块仅值1分,但一个Z值10分。100个字母存放在一个名为letters的数组中。在游戏中,口袋中实际剩余的字母数目存放在n中。
import java.util.Random;
class Bag {
private Random rand;
private int letter_counts[] = {
2, 9, 2, 2, 4, 12, 2, 3, 2, 9, 1, 1, 4, 2,
6, 8, 2, 1, 6, 4, 6, 4, 2, 2, 1, 2, 1
};
private int letter_points[] = {
0, 1, 3, 3, 2, 1, 4, 2, 4, 1, 8, 5, 1, 3,
1, 1, 3, 10, 1, 1, 1, 1, 4, 4, 8, 4, 10
};
private Letter letters[] = new Letter[100];
private int n = 0;
Bag( )
Bag的构造函数有一个初值,方法使用这个随机初值生成一个Random对象。然后遍历letter_counts数组,构造合适数目的新的Letter对象,注意将空白的字母方块用星号代替。然后为每个字母调用putBack( ),将其放入口袋。
Bag(int seed) {
rand = new Random(seed);
for (int i = 0; i < letter_counts.length; i++) {
for (int j = 0; j < letter_counts[i]; j++) {
Letter l = new Letter(i == 0 ? '*' : (char)('A' + i - 1),
letter_points[i]);
putBack(l);
}
}
}
730 第4部分 应用Java
takeOut( )
这个方法比较聪明,但是效率不高,还不是致命错陷。takeOut( )在0~n-1之间挑选一个
)随机数。然后用这个随机数做偏移量从letters数组中抽取字母。它调用System.arraycopy(
填补letters中的空洞。然后n减1,返回字符。
synchronized Letter takeOut() {
if (n == 0)
return null;
int i = (int)(rand.nextDouble() * n);
Letter l = letters[i];
if (i != n - 1)
System.arraycopy(letters, i + 1, letters, i, n - i - 1);
n--;
return l;
}
putBack( )
putBack( )方法是构造函数用来将字母方块放入原始口袋中的。在将来的游戏增强版中,也可以用这个方法让游戏者将不喜欢的字母放回袋中,当然这样做要以放弃一个回合为代价。这个方法简单地将字母放在数组的结尾。
synchronized void putBack(Letter l) {
letters[n++] = l;
}
}
31.3.6 Letter.java
Letter类比较简单,它并不涉及游戏或棋盘。它只是封装位置和一个单个字母外观。它使用几个静态变量保持字体和大小的信息。这样做使得小应用程序不会一次在内存中装入100种字体。这还有一个副作用,那就是浏览器不能包含两个不同大小的Scrabblet 小应用程序实例。第二个Scrabblet 小应用程序的初始化必须覆盖这些静态变量的值。
变量w和h保存的是每个字符的宽度和高度。变量font和smfont是AWT字体对象的大字体和小字体的值。整数y0和ys0分别存放字母基线的偏移量和点数。几个常数传入paint( )描述绘画的颜色状态:NORMAL,DIM和BRIGHT模式。
import java.awt.*;
class Letter {
static int w, h;
private static Font font, smfont;
private static int y0, ys0;
private static int lasth = -1;
static final int NORMAL = 0;
static final int DIM = 1;
static final int BRIGHT = 2;
第31章 Scrabblet:多玩家的猜字游戏 731
colors[ ], mix( ), gain( ) 和clamp( )
colors数组被用9个颜色对象——3个颜色一组的3组——静态初始化。mix( )方法的输入
250, 220, 100,然后将它们转换为三个颜色,用以提供3-D效果的高亮度和是一组RGB值如
低亮度。mix( )方法调用gain( )加重或降低一个给定颜色的亮度,调用clamp( )以确保仍处在
合法范围之内。
private static Color colors[][] = {
mix(250, 220, 100), // normal
mix(200, 150, 80), // dim
mix(255, 230, 150) // bright
};
private static Color mix(int r, int g, int b)[] {
Color arr[] = new Color[3];
arr[NORMAL] = new Color(r, g, b);
arr[DIM] = gain(arr[0], .71);
arr[BRIGHT] = gain(arr[0], 1.31);
return arr;
}
private static int clamp(double d) {
return (d < 0) ? 0 : ((d > 255) ? 255 : (int) d);
}
private static Color gain(Color c, double f) {
return new Color(
clamp(c.getRed() * f),
clamp(c.getGreen() * f),
clamp(c.getBlue() * f));
}
变量实例
在第一次绘制Letter时,使用valid标志确保所有与大小相关的变量只被创建一次。这里
缓存了多个变量以减少每次小应用程序绘制屏幕时的计算量,如x0,w0,xs0,ws0和gap
这些都将在下面介绍。使用tile Point对象保存Letter所占据的15×15棋盘上的格子。如果这
个变量为空,则Letter不在棋盘上。使用x,y对准确定位Letter。
private boolean valid = false;
// quantized tile position of Letter. (just stored here).
private Point tile = null;
int x, y; // position of Letter.
private int x0; // offset of symbol on tile.
private int w0; // width in pixels of symbol.
private int xs0; // offset of points on tile.
private int ws0; // width in pixels of points.
private int gap = 1; // pixels between symbol and points. Letter( ), getSymbol( ) 和getPoints( )
symbol是保持要显示字母的字符串,points是这个字符的分数值。这两个变量都在构造
732 第4部分 应用Java
函数中初始化,分别由包装方法getSymbol( ) 和getPoints( )返回。
private String symbol;
private int points;
Letter(char s, int p) {
symbol = "" + s;
points = p;
}
String getSymbol() {
return symbol;
}
int getPoints() {
return points;
}
move( ), remember( )和recall( )
)方法指示字母方块绘制的位置。但是,remember( )方法就复杂一些。如果它用move(
的参数为空,则意味着这个方块“忘记”了它原来的位置。这说明这个字符并没有使用。
否则,则这个方法指示这个字母占据的棋盘位置坐标。调用recall( )可检查此状态。
void move(int x, int y) {
this.x = x;
this.y = y;
}
void remember(Point t) {
if (t == null) {
tile = t;
} else {
tile = new Point(t.x, t.y);
}
}
Point recall() {
return tile;
}
resize( )
棋盘调用resize( )方法一次,指示字母的大小。记住,w和h是静态的,所以这立刻影响
所有的Letter实例。
static void resize(int w0, int h0) {
w = w0;
h = h0;
}
hit( )
如果xp,yp对落在这个Letter的范围内,则hit( )方法返回true。
boolean hit(int xp, int yp) {
第31章 Scrabblet:多玩家的猜字游戏 733
return (xp >= x && xp < x + w && yp >= y && yp < y + h);
}
validate( )
使用validate( )方法装载字体,确定字体大小,决定绘制的位置。这些信息缓存在前面
讨论过的私有变量中。这些计算的结果在下面的paint( )中使用。
private int font_ascent;
void validate(Graphics g) {
FontMetrics fm;
if (h != lasth) {
font = new Font("SansSerif", Font.BOLD, (int)(h * .6));
g.setFont(font);
fm = g.getFontMetrics();
font_ascent = fm.getAscent();
y0 = (h - font_ascent) * 4 / 10 + font_ascent;
smfont = new Font("SansSerif", Font.BOLD, (int)(h * .3));
g.setFont(smfont);
fm = g.getFontMetrics();
ys0 = y0 + fm.getAscent() / 2;
lasth = h;
}
if (!valid) {
valid = true;
g.setFont(font);
fm = g.getFontMetrics();
w0 = fm.stringWidth(symbol);
g.setFont(smfont);
fm = g.getFontMetrics();
ws0 = fm.stringWidth("" + points);
int slop = w - (w0 + gap + ws0);
x0 = slop / 2;
if (x0 < 1)
x0 = 1;
xs0 = x0 + w0 + gap;
if (points > 9)
xs0--;
}
}
paint( )
棋盘调用paint( )方法。一个整数i,被传入方法,这个i代表NORMAL, BRIGHT 或DIM
中的一个。这个i是colors数组的下标,可以选择一个基础颜色。填充一个矩形创建一个3D
外观的高亮度带阴影的按钮。如果points比零大,指明一个非空的字母,在绘制完主要字母
后,在字母旁边绘制这个分数值。
void paint(Graphics g, int i) {
Color c[] = colors[i];
validate(g);
g.setColor(c[NORMAL]);
734 第4部分 应用Java
g.fillRect(x, y, w, h);
g.setColor(c[BRIGHT]);
g.fillRect(x, y, w - 1, 1);
g.fillRect(x, y + 1, 1, h - 2);
g.setColor(Color.black);
g.fillRect(x, y + h - 1, w, 1);
g.fillRect(x + w - 1, y, 1, h - 1);
g.setColor(c[DIM]);
g.fillRect(x + 1, y + h - 2, w - 2, 1);
g.fillRect(x + w - 2, y + 1, 1, h - 3);
g.setColor(Color.black);
if (points > 0) {
g.setFont(font);
g.drawString(symbol, x + x0, y + y0);
g.setFont(smfont);
g.drawString("" + points, x + xs0, y + ys0);
}
}
}
31.3.7 ServerConnection.java
这个小应用程序客户方的最后一个类是ServerConnection,这个类封装与服务器和对手之间的通信。在类的开始部分声明了多个变量。与服务器相连的套接字端口号为6564。CRLF是Internet上的常数字符串,代表行结束。与服务器之间的I/O流分别为in和out。服务器给这个连接分配的惟一的ID存放在id中。对手的ID存放在toid中。连接的Scrabblet 小应用程序为scrabblet。
import java.io.*;
import java.net.*;
import java.util.*;
class ServerConnection implements Runnable {
private static final int port = 6564;
private static final String CRLF = "\r\n";
private BufferedReader in;
private PrintWriter out;
private String id, toid = null;
private Scrabblet scrabblet;
ServerConnection( )
ServerConnection构造函数利用一个Internet站点名,打开一个套接字连接对应主机上的端口。如果成功,它用InputStreamReader 和BufferedReader包装输入,用PrintWriter包装输出。如果连接失败,向调用者引发一个异常。
public ServerConnection(Scrabblet sc, String site) throws
IOException {
scrabblet = sc;
Socket server = new Socket(site, port);
in = new BufferedReader(new
InputStreamReader(server.getInputStream()));
out = new PrintWriter(server.getOutputStream(), true);
第31章 Scrabblet:多玩家的猜字游戏 735
}
readline( )
)中的IOException变为一个为null 的返回值。 readline( ) 方法仅将原来readLine(
private String readline() {
try {
return in.readLine();
} catch (IOException e) {
return null;
}
}
setName( ) 和delete( )
setName( )方法通知服务器本地游戏者的名字,使用delete( )方法将自己从服务器保持
的列表上删除。
void setName(String s) {
out.println("name " + s);
}
void delete() {
out.println("delete " + id);
}
setTo( ) 和send( )
setTo( )方法绑定对手的ID。将来的send( )调用转移到游戏者。
void setTo(String to) {
toid = to;
}
void send(String s) {
if (toid != null)
out.println("to " + toid + " " + s);
}
challenge( ), accept( ), chat( ), move( ), turn( )和quit( ) 下列的短小方法都是从客户端向服务器发送一行消息,然后服务器将这些消息发送给
对手。用challenge消息初始启动游戏,accept消息用来响应挑战。每次移动一个字母,发送
一个move消息,然后在每次回合结束时发送turn消息。如果客户退出或是离开小应用程序
所在的页面,则发送quit消息。
void challenge(String destid) {
setTo(destid);
send("challenge " + id);
}
void accept(String destid, int seed) {
setTo(destid);
736 第4部分 应用Java
send("accept " + id + " " + seed);
}
void chat(String s) {
send("chat " + id + " " + s);
}
void move(String letter, int x, int y) {
send("move " + letter + " " + x + " " + y);
}
void turn(String words, int score) {
send("turn " + score + " " + words);
}
void quit() {
send("quit " + id); // tell other player
out.println("quit"); // unhook
}
start( )
这个方法简单的启动线程管理客户方面的网络。
// reading from server...
private Thread t;
void start() {
t = new Thread(this);
t.start();
}
Keywords
在这里显示的静态变量和静态块被用来初始化keys Hashtable,这是使用这个散列表在
keystrings中的字符串和数组位置之间映射——例如,keys.get("move") == MOVE来实现的。
lookup( )方法负责将Integer对象解开为正确的整数,如果是-1,表示没有找到关键字。
private static final int ID = 1;
private static final int ADD = 2;
private static final int DELETE = 3;
private static final int MOVE = 4;
private static final int CHAT = 5;
private static final int QUIT = 6;
private static final int TURN = 7;
private static final int ACCEPT = 8;
private static final int CHALLENGE = 9;
private static Hashtable keys = new Hashtable();
private static String keystrings[] = {
"", "id", "add", "delete", "move", "chat",
"quit", "turn", "accept", "challenge"
};
第31章 Scrabblet:多玩家的猜字游戏 737
static {
for (int i = 0; i < keystrings.length; i++)
keys.put(keystrings[i], new Integer(i));
}
private int lookup(String s) {
Integer i = (Integer) keys.get(s);
return i == null ? -1 : i.intValue();
}
run( )
run( )是游戏连接服务器的主循环。它进入一个阻塞调用的readline( ),这个调用在服务
器返回一行文字时返回一个字符串。它使用StringTokenizer将一行文字拆为单词。switch语
句基于输入行的第一个单词分配合适的代码。在协议中每个关键字解析不同的输入行,多
数处理重新调用Scrabblet类完成工作。
public void run() {
String s;
StringTokenizer st;
while ((s = readline()) != null) {
st = new StringTokenizer(s);
String keyword = st.nextToken();
switch (lookup(keyword)) {
default:
System.out.println("bogus keyword: " + keyword + "\r");
break;
case ID:
id = st.nextToken();
break;
case ADD: {
String id = st.nextToken();
String hostname = st.nextToken();
String name = st.nextToken(CRLF);
scrabblet.add(id, hostname, name);
}
break;
case DELETE:
scrabblet.delete(st.nextToken());
break;
case MOVE: {
String ch = st.nextToken();
int x = Integer.parseInt(st.nextToken());
int y = Integer.parseInt(st.nextToken());
scrabblet.move(ch, x, y);
}
break;
case CHAT: {
String from = st.nextToken();
scrabblet.chat(from, st.nextToken(CRLF));
}
break;
case QUIT: {
String from = st.nextToken();
738 第4部分 应用Java
scrabblet.quit(from);
}
break;
case TURN: {
int score = Integer.parseInt(st.nextToken());
scrabblet.turn(score, st.nextToken(CRLF));
}
break;
case ACCEPT: {
String from = st.nextToken();
int seed = Integer.parseInt(st.nextToken());
scrabblet.accept(from, seed);
}
break;
case CHALLENGE: {
String from = st.nextToken();
scrabblet.challenge(from);
}
break;
}
}
}
}
31.4 服务器程序代码
最后的这两个类不是小应用程序的部分。但是,必须加载并运行在小应用程序类下载的源web服务器上。在web站点上安装并运行这种叫做“守护程序”的程序是需要安全权限的,这个权限一般人是没有的。但幸运的是,多数读者使用这个游戏时,不必建立自己的类似服务器,只需连接到那些已经存在的服务器上。
31.4.1 Server.java
Server是Scrabblet游戏服务端方的主类。一旦在web服务器上安装了这个类,就可以使用服务器系统上的Java解释器的命令行(例如java.exe, jview.exe, 或是sj.exe)运行这个程序,下面是Windows 95/98/NT/2000上的例子:
C:\java\Scrabblet> jview Server
在运行期间,Server以下列消息响应:
Server listening on port 6564
Server类开头定义了几个变量。端口号必须相同,6564,如在ServerConnection中看到的那样。使用idcon Hashtable存储与所有的客户的连接。用散列表而不是数组更方便经常的插入和删除操作,这些操作需要大量的数组拷贝。在接受每个新连接的时候,id递增。这对应于前面在客户端看到的id实例变量。
import java.net.*;
import java.io.*;
第31章 Scrabblet:多玩家的猜字游戏 739
import java.util.*;
public class Server implements Runnable {
private int port = 6564;
private Hashtable idcon = new Hashtable();
private int id = 0;
static final String CRLF = "\r\n";
addConnection( )
每当有新的客户连接到我们的小应用程序时就调用addConnection( )。这个方法创建一
个ClientConnection的新实例(将在下面介绍)来管理客户。它传递给Server一个引用、客
户连接的套接字和当前的id。最后,它增加id的值准备接收下一个连接。
synchronized void addConnection(Socket s) {
ClientConnection con = new ClientConnection(this, s, id);
// we will wait for the ClientConnection to do a clean
// handshake setting up its "name" before calling
// set() below, which makes this connection "live."
id++;
}
set( )
)方法响应客户告诉了我们它的“名字”。set( )方法跟踪从ClientConnection中调用set(
所有在idcon散列表中的连接,然后从这个表中删除这个id防止重复接受客户的名字。这个
方法调用setBusy(false)表示这个连接已经可以开始游戏了。然后它通过列举idcon散列表的
关键字的方法遍历所有的连接。对所有非忙的连接(那些等待对手的游戏者),set( )发出
一个“add”消息通知这些游戏者这个新连接。
synchronized void set(String the_id, ClientConnection con) {
idcon.remove(the_id) ; // make sure we're not in there twice.
con.setBusy(false);
// tell this one about the other clients.
Enumeration e = idcon.keys();
while (e.hasMoreElements()) {
String id = (String)e.nextElement();
ClientConnection other = (ClientConnection) idcon.get(id);
if (!other.isBusy())
con.write("add " + other + CRLF);
}
idcon.put(the_id, con);
broadcast(the_id, "add " + con);
}
sendto( )
在响应“to”消息时调用sendto( )方法。它将body字符串的内容直接写入由dest标志的
连接。
synchronized void sendto(String dest, String body) {
ClientConnection con = (ClientConnection)idcon.get(dest);
if (con != null) {
740 第4部分 应用Java
con.write(body + CRLF);
}
}
broadcast( )
在body中,使用broadcast( )方法向所有的除了在exclude中(通常是发送者)标识的单
个连接发送一个消息。
synchronized void broadcast(String exclude, String body) {
Enumeration e = idcon.keys();
while (e.hasMoreElements()) {
String id = (String)e.nextElement();
if (!exclude.equals(id)) {
ClientConnection con = (ClientConnection) idcon.get(id);
con.write(body + CRLF);
}
}
}
delete( )
使用delete( )方法通知所有连接的客户忘掉曾经听过的the_id。在客户已经开始游戏,
并将自己从可选的挑战者表中删除时使用。
synchronized void delete(String the_id) {
broadcast(the_id, "delete " + the_id);
}
kill( )
当一个客户发送“quit”消息,明确的退出游戏时,或是当用户简单的退出浏览器时调
用kill( )方法。
synchronized void kill(ClientConnection c) {
if (idcon.remove(c.getId()) == c) {
delete(c.getId());
}
}
run( )
run( )方法是服务器的主循环。它在端口6564建立一个新套接字,然后进入无限循环等
待来自客户的套接字连接。在接受连接时调用addConnection( )。
public void run() {
try {
ServerSocket acceptSocket = new ServerSocket(port);
System.out.println("Server listening on port " + port);
while (true) {
Socket s = acceptSocket.accept();
addConnection(s);
}
} catch (IOException e) {
第31章 Scrabblet:多玩家的猜字游戏 741
System.out.println("accept loop IOException: " + e);
}
}
main( )
main( )当然是由Java命令行解释器运行的方法。它创建一个Server实例,然后启动一个新的线程来运行。
public static void main(String args[]) {
new Thread(new Server()).start();
try {
Thread.currentThread().join();
} catch (InterruptedException e) { }
}
}
31.4.2 ClientConnection.java
这个类是小应用程序中ServerConnection的镜像。为每个客户创建一个连接。它的工作是管理与客户之间的I/O操作。私有变量实例保持着这个客户的状态。Socket存储在sock中。缓冲区阅读器和输出流存放在in和out中。客户机的主机名保存在host中。创建这个客户的Server实例的引用变量存放在server中。在客户机上的游戏者名字存放在name中,自动分配的游戏者ID存放在id中。布尔变量busy保持这个客户是否已经开始游戏的信息。
import java.net.*;
import java.io.*;
import java.util.*;
class ClientConnection implements Runnable {
private Socket sock;
private BufferedReader in;
private OutputStream out;
private String host;
private Server server;
private static final String CRLF = "\r\n";
private String name = null; // for humans
private String id;
private boolean busy = false;
ClientConnection( )
构造函数保存服务器的引用变量,套接字和惟一的ID。将InputStreamReader 和BufferedReader包装在输入外,这样可以调用readLine( )。然后这个方法将id写回给客户以便让客户知道分配给它的号码。最后创建并启动一个新线程处理连接。
public ClientConnection(Server srv, Socket s, int i) {
try {
server = srv;
sock = s;
in = new BufferedReader(new
InputStreamReader(s.getInputStream()));
742 第4部分 应用Java
out = s.getOutputStream();
host = s.getInetAddress().getHostName();
id = "" + i;
// tell the new one who it is...
write("id " + id + CRLF);
new Thread(this).start();
} catch (IOException e) {
System.out.println("failed ClientConnection " + e);
}
}
toString( )
重载toString( ) 这样可以清楚的表示一个连接记录。
public String toString() {
return id + " " + host + " " + name;
}
getHost( ), getId( ), isBusy( ) 和setBusy( )
将host, id, 和busy 包装在公用方法中允许只读访问。
public String getHost() {
return host;
}
public String getId() {
return id;
}
public boolean isBusy() {
return busy;
}
public void setBusy(boolean b) {
busy = b;
}
close( )
如果客户显式退出或是在读套接字时捕获到一个异常则调用close( )方法。调用在服务
器中的kill( )方法,将自己从任何列表中删除。然后关闭套接字,同时也就关闭输入流和输
出流。
public void close() {
server.kill(this);
try {
sock.close(); // closes in and out too.
} catch (IOException e) { }
}
第31章 Scrabblet:多玩家的猜字游戏 743 write( )
为了将字符串写入流,不得不使用getBytes( )方法将它转换成一个字节数组。
public void write(String s) {
byte buf[];
buf = s.getBytes();
try {
out.write(buf, 0, buf.length);
} catch (IOException e) {
close();
}
}
readline( )
readline( ) 方法仅将原来readLine( )中的IOException转换为返回一个null值。
private String readline() {
try {
return in.readLine();
} catch (IOException e) {
return null;
}
}
Keywords
它和ServerConnection类中的相同部分类似,解析另一方的消息。用这个散列表在
keystrings中的字符串和数组位置之间的映射——例如,keys.get("quit") == QUIT,使用在这
里显示的静态变量和静态块初始化keys Hashtable, lookup( )方法负责将Integer对象解开为
适当的整数,如果是-1,表示没有找到关键字。
static private final int NAME = 1;
static private final int QUIT = 2;
static private final int TO = 3;
static private final int DELETE = 4;
static private Hashtable keys = new Hashtable();
static private String keystrings[] = {
"", "name", "quit", "to", "delete"
};
static {
for (int i = 0; i < keystrings.length; i++)
keys.put(keystrings[i], new Integer(i));
}
private int lookup(String s) {
Integer i = (Integer) keys.get(s);
return i == null ? -1 : i.intValue();
}
744 第4部分 应用Java
run( )
run( )循环管理所有与客户通信的消息。它使用StringTokenizer解析输入的信息行,每行的第一个单词是关键字。使用刚刚介绍的lookup( )方法在keys散列表中查询第一个单词。然后基于关键字的整数值变换。在客户第一次获得人名标志时发送NAME消息。在服务器上调用set( )建立连接。当客户希望终止这个服务器会话时,发送QUIT消息。TO消息包含一个目标ID和发送给客户的消息体。在服务器上调用sendto( )向前传送消息。最后一个消息是DELETE,当用户希望继续连接但是不希望出现在可选的游戏者列表中时发送。run( )设置忙(busy)标志然后调用服务器上的delete( ),通知用户不再希望被调用。
public void run() {
String s;
StringTokenizer st;
while ((s = readline()) != null) {
st = new StringTokenizer(s);
String keyword = st.nextToken();
switch (lookup(keyword)) {
default:
System.out.println("bogus keyword: " + keyword + "\r");
break;
case NAME:
name = st.nextToken() +
(st.hasMoreTokens() ? " " + st.nextToken(CRLF) : "");
System.out.println("[" + new Date() + "] " + this + "\r");
server.set(id, this);
break;
case QUIT:
close();
return;
case TO:
String dest = st.nextToken();
String body = st.nextToken(CRLF);
server.sendto(dest, body);
break;
case DELETE:
busy = true;
server.delete(id);
break;
}
}
close();
}
}
31.5 改进Scrabblet
这个小应用程序代表一个复杂的客户/服务器,多玩家棋盘游戏。在将来的版本中,将以多种方式扩展Server和ServerConnection中的代码。它能够支持其他基于回合的游戏。它能跟踪并保持记录每个游戏的最高得分榜。它应能够动态扩展以支持新的协议。例如以本
第31章 Scrabblet:多玩家的猜字游戏 745
章介绍的游戏为例,游戏应该增加一个查找功能,能够根据存储在服务器上的字典检查一组提交的单词。服务器因此能够仲裁这样的纠纷:xyzy是不是一个合法的单词。也可以构造一个单词机器人,这个机器人在服务器上,但是可以扮演一个游戏对手,使用字典从当前的七个单词组中生成一个最佳的单词。这个机器人甚至可以利用一个经典对话表在对话窗口中与对手聊天。这些增强功能希望能由读者自己完成。
这个小应用程序是为了娱乐和教学目的而制作的。如与任何商业产品雷同,纯属巧合。