内容简介:原文:作者:译者:kmyhy
原文: How to Make a Chess Game with Unity
作者: Brian Broom
译者:kmyhy
并不是所有成功的游戏都包括打外星人或拯救世界。棋盘游戏,尤其是国际象棋,有着数千年的历史。它们不仅玩起来很有趣,而且将它们从现实生活中转变成视频游戏也很有趣。
在本教程中,你将用 Unity 编写一个 3D 象棋游戏。在这个过程中,你将学习:
- 选择要移动的棋子
- 判断移动是否合法
- 切换玩家
- 判断输赢
当您完成本教程时,你将创建一个富有功能的棋类游戏,你可以将它作为其他棋盘游戏的起点。
注意:你应该具有 Unity 和 C# 语言基础。如果你需要学习 C#,那么 Unity C# 初阶屏播系列 很适合初学者。
开始
请下载本教程的开始项目。你可以在本文顶部或底部找到下载链接。用 Unity 打开开始项目。
象棋通常会做成简单的 2D 游戏。但是,本 3D 版本模拟了你和朋友坐在桌子旁边下棋。此外…… 3D 比较棒了。
打开 Scenes 文件夹中的 Main 场景。你会看到一个表示棋盘的 Board 对象和一个 GameManager 对象。这些对象都已经绑定了脚本。
- Prefabs: 包含了棋盘、 棋子、和移动过程中的指示方块。
- Materials: 包含了棋盘、棋子和瓦片的材质。
- Scripts: 包含了在结构视图中已经绑定到对象中的组件。
- Board: 记录棋子的可视化状态。这个组件还会处理每颗棋子的高亮状态。
- Geometry.cs: 工具类,负责处理行列转换和 Vector3 点。
- Player.cs: 记录玩家的棋子,玩家手执的棋子。保存玩家棋子移动的方向,比如小兵。
- Piece.cs: 一个基类,定义了所有实例化的棋子对象的枚举。它还包含确定游戏中有效移动的逻辑。
- GameManager.cs: 存储游戏逻辑,比如允许的移动,游戏一开始时棋子的位置等。它是一个单例对象,所以其他类很容易调用它。
GameManager 的 pieces 保存了一个 2D 数组,记录了棋子在棋盘上的位置。可以看一下 AddPiece、PieceAtGrid 和 GridForPiece 的逻辑。
进入试玩模式,你会看到一个棋盘,棋子准备好后就可以下棋了。
移动棋子
首先需要找出要移动哪枚棋子。
射线查找可用于找出用户鼠标正在经过哪一块瓦片。如果你不熟悉 Unity 的射线查找,请查看我们的 Unity 脚本教程入门 或者我们的热门教程 炸弹超人 。
一旦玩家选择了一个棋子,你需要在棋子可以移动的地方生成有效的瓦片。然后,你选择其中一个瓦片。你将添加两个新脚本来实现这个功能。TileSelector 用于将选择移动的棋子,MoveSelector 用于选择目的地。
两个组件的基本方法是类似的:
- Start: 进行一次性的设置。
- EnterState: 进行本次动作的设置。
- Update: 当鼠标移动时,执行射线查找。
- ExitState: 清除本次状态,调用下一状态的 EnterState。
这实现了一个基本的状态机。如果你有更多状态,可以写得更规范点,当然代码也会更复杂。
选择瓦片
在结构视图中选择棋盘。然后在检视器窗口,点击 Add Component 按钮。然后在文本框中输入 TileSelector 并点击 New Script。最后,点击 Create and Add,绑定脚本。
注:创建新脚本的时候,记得将它们移动到正确的目录。保持 Assets 文件夹的合理有序。
高亮选中瓦片
双击 TileSelector.cs,打开文件,添加变量:
public GameObject tileHighlightPrefab; private GameObject tileHighlight;
这两个变量构成了一个透明遮罩,用于凸显你所选中的瓦片。预制件将在编辑模式下被赋值,而组件负责跟踪和移动高亮状态。
然后在 Start 方法中添加下列代码:
Vector2Int gridPoint = Geometry.GridPoint(0, 0); Vector3 point = Geometry.PointFromGrid(gridPoint); tileHighlight = Instantiate(tileHighlightPrefab, point, Quaternion.identity, gameObject.transform); tileHighlight.SetActive(false);
Start 方法初始化高亮瓦片的行和列,将其转换为 point,并通过预制件创建一个游戏对象。这个对象一开始是未激活的,所以在需要时才会显示。
注:以行列引用坐标是很有用的,它是一个 Vector2Int 类型,即一个 GridPoint。Vector2Int 有两个整数值:x和y。当你需要在游戏场景中放入一个对象时,你需要用 Vector3 坐标。Vector3 坐标包含三个浮点值:x、y 和 z。
在 Geometry.cs 有进行两者间转换的实用方法:
* GridPoint(int col, int row): gives you a GridPoint for a given column and row.
* PointFromGrid(Vector2Int gridPoint): turns a GridPoint into a Vector3 actual point in the scene.
* GridFromPoint(Vector3 point): gives the GridPoint for the x and z value of that 3D point, and the y value is ignored.
然后是 EnterState 方法:
public void EnterState() { enabled = true; }
当选择另一颗棋子时,重新启用组件。
然后是 Update 方法:
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(ray, out hit)) { Vector3 point = hit.point; Vector2Int gridPoint = Geometry.GridFromPoint(point); tileHighlight.SetActive(true); tileHighlight.transform.position = Geometry.PointFromGrid(gridPoint); } else { tileHighlight.SetActive(false); }
这里,你从镜头中创建了一束射线,经过鼠标点,射向无限远!
Physics.Raycast 会检测射线是否和系统中的物理碰撞体发生相交。由于棋盘是唯一拥有碰撞体的对象,你不必担心棋子互相遮挡。
如果射线和碰撞体相交,RaycastHit 中会包含交点数据。你将交点转换成 GridPoint(使用助手方法),然后设置高亮瓦片的位置。
由于鼠标指针位于棋盘上方,你可以激活高光瓦片,以便让它显示。
最后,在结构视图中选择 Board 然后在项目窗口中单击 Prefabs。然后,将 Selection-Yellow 预制件拖到棋盘的 Tile Selector 组件的 Tile Hightlight Prefab 方框中。
进入游戏试玩模式,当鼠标指针移动时会有一个黄色的高亮瓦片跟随。
选择棋子
要选中某颗棋子,你需要判断按下的鼠标按钮是哪一颗。在激活完瓦片的 hightlight之后,用一个 if 语句中添加一个判断:
if (Input.GetMouseButtonDown(0)) { GameObject selectedPiece = GameManager.instance.PieceAtGrid(gridPoint); if(GameManager.instance.DoesPieceBelongToCurrentPlayer(selectedPiece)) { GameManager.instance.SelectPiece(selectedPiece); // Reference Point 1: add ExitState call here later } }
如果鼠标按钮被按下,GameManager 就会为你获取该位置的棋子。你还必须确保这个棋子是属于当前玩家的,因为玩家不允许移动对手的棋子。
注:在一个复杂的游戏中,最好为组件清晰地划分职责。棋盘负责显示和凸显棋子。GameManager 负责保棋子的 GridPoint。助手方法负责告诉棋子在哪里以及它们属于哪个玩家。
进入试玩模式,选择一枚棋子。
现在你手上已经有棋子了,把它移动到别的地方吧。
选择移动目标
现在,TileSelector 已经完。接下来是另一个组件:MoveSelector。
这个组件和 TileSelector 类似。和之前一样,在结构视图中选中 Board 对象,添加一个新组件,名为 MoveSelector。
传递控制
第一件事情是将控制从 TileSelector 传递给 MoveSelector。这样我们就需要用到 ExitState 了。在 TileSelector.cs 中,添加一个方法:
private void ExitState(GameObject movingPiece) { this.enabled = false; tileHighlight.SetActive(false); MoveSelector move = GetComponent<MoveSelector>(); move.EnterState(movingPiece); }
这里我们隐藏 overlay 瓦片,然后禁用 TileSelector 组件。在 Unity 中,在禁用组件上你无法调用 Update 方法。因为你想调用另外一个组件的 Update 方法,所以就禁用原组件,防止干扰。
在 Update 方法的 Referenct point 1 之后调用这个方法。
ExitState(selectedPiece);
然后打开 MoveSelector 添加一个实例变量:
public GameObject moveLocationPrefab; public GameObject tileHighlightPrefab; public GameObject attackLocationPrefab; private GameObject tileHighlight; private GameObject movingPiece;
这些变量用于保存鼠标高亮、移动点和攻击点的瓦片图层,以及原先的高亮瓦片以及前面选中的棋子。
然后,在 Start 方法中加入:
this.enabled = false; tileHighlight = Instantiate(tileHighlightPrefab, Geometry.PointFromGrid(new Vector2Int(0, 0)), Quaternion.identity, gameObject.transform); tileHighlight.SetActive(false);
这个组件一开始必须 disabled,因为首先要执行 TitleSelector。然后,加载高亮图层。
移动棋子
然后添加 EnterState 方法:
public void EnterState(GameObject piece) { movingPiece = piece; this.enabled = true; }
当这个方法调用时,它会保存被移动的棋子,然后禁用组件自身。
在 MoveSelector 的 Update 方法中,添加代码:
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(ray, out hit)) { Vector3 point = hit.point; Vector2Int gridPoint = Geometry.GridFromPoint(point); tileHighlight.SetActive(true); tileHighlight.transform.position = Geometry.PointFromGrid(gridPoint); if (Input.GetMouseButtonDown(0)) { // Reference Point 2: check for valid move location if (GameManager.instance.PieceAtGrid(gridPoint) == null) { GameManager.instance.Move(movingPiece, gridPoint); } // Reference Point 3: capture enemy piece here later ExitState(); } } else { tileHighlight.SetActive(false); }
这个 Update 方法和 TileSelector 的 Update 方法类似,同样使用射线检查鼠标正在点击那个瓦片。当鼠标键被按下,调用 GameManager 移动棋子到新瓦片上。
最后是 ExitState 方法,做一些清理工作并为下次移动做好准备:
private void ExitState() { this.enabled = false; tileHighlight.SetActive(false); GameManager.instance.DeselectPiece(movingPiece); movingPiece = null; TileSelector selector = GetComponent<TileSelector>(); selector.EnterState(); }
这里禁用了组件,将高亮瓦片隐藏。因为棋子已经移动完成,你可以清空它,让 Gamemanager 取消棋子的高亮状态。然后,调用 TileSelector 的 EnterState,以便再次开始整个过程。
回到编辑器,选中 Board 对象,从 prefab 文件夹将 tile overlay 预制件拖到 MoveSelector 的这些地方:
- Move Location Prefab 的值应该是 Selection-Blue
- Tile Highlight Prefab 的值应该是 Selection-Yellow.
- Attack Location Prefab 的值应该是 Selection-Red
你可以通过修改材质来调整它们的颜色。
开启试玩模式,移动棋子试试。
你会发现,你可以将棋子移动到任何空格子上。这在象棋中完全是不合理的。接下来应该让棋子的移动符合游戏规则。
算出合法的移动
在国际象棋中,每个棋子能够做出的动作是不一样的。有的棋子能够往任意方向移动,有的棋子可以移动任意个空格,有的棋子只能在某个方向上进行移动。你如何记住这些规则?
一种方法是用一个抽象的基类来表示所有棋子,然后由子类来实现具体的方法,以计算移动的位置。
另一个问题是:这些动作要在哪里生成?
一个选择是在 MoveSelector 的 EnterStat 方法中。这是你将棋子可以移动的位置显示给用户的方法,因此这是一个合理的选择 。
计算有效目标的集合
常见的策略是选中一颗棋子,然后让 GameMananger 返回一个有效目标(比如移动)的集合。GameManager 使用该棋子的子类计算可能的目标集合。然后,过滤掉其中已经被占的或者已经离开棋盘的位置。
过滤后的集合传回给 MoveSelector,将合理的移动高亮显示,等待玩家做出选择。
小兵的移动最为简单,因此我们从它开始。
打开 Pieces 下面的 Pawn.cs,修改 MoveLocation 方法:
public override List MoveLocations(Vector2Int gridPoint) { var locations = new List<Vector2Int>(); int forwardDirection = GameManager.instance.currentPlayer.forward; Vector2Int forward = new Vector2Int(gridPoint.x, gridPoint.y + forwardDirection); if (GameManager.instance.PieceAtGrid(forward) == false) { locations.Add(forward); } Vector2Int forwardRight = new Vector2Int(gridPoint.x + 1, gridPoint.y + forwardDirection); if (GameManager.instance.PieceAtGrid(forwardRight)) { locations.Add(forwardRight); } Vector2Int forwardLeft = new Vector2Int(gridPoint.x - 1, gridPoint.y + forwardDirection); if (GameManager.instance.PieceAtGrid(forwardLeft)) { locations.Add(forwardLeft); } return locations; }
这个方法做了以下事情:
- 这段代码首先创建一个空的 list 用于保存位置。然后,创建了一个 location 用于表示前方的一个空格。
- 因为白子和黑子移动方向相反,玩家对象有一个值表示了小兵可以移动的方向。对于第一个玩家这个值是 +1,第二个玩家这个值是 -1。
- 小兵有一个特殊的移动方式和几点特殊规则。它虽然可以往前移动一步,但它却不能吃那个格子中的对方棋子,而是吃前方对角线上的棋子。在把前面这个格子标记为有效移动位置之前,首先要判断这个地方有没有被其它棋子占据。如果没有,你可以将这个瓦片放到 list 中。
- 对于吃子,你必须检查那个位置是否有棋子。如果有,才能吃子。
现在还不需要关心需要判断是自己的棋子还是对方的棋子——这个稍后再说。
在 GameManager.cs 中,在 Move 方法后添加一个方法:
public List MovesForPiece(GameObject pieceObject) { Piece piece = pieceObject.GetComponent(); Vector2Int gridPoint = GridForPiece(pieceObject); var locations = piece.MoveLocations(gridPoint); // filter out offboard locations locations.RemoveAll(tile => tile.x < 0 || tile.x > 7 || tile.y < 0 || tile.y > 7); // filter out locations with friendly piece locations.RemoveAll(tile => FriendlyPieceAt(tile)); return locations; }
这里,你从 GameOject 中获取一个 Piece 组件,以及它的当前位置。
然后,要求 GameManager 返回一个该棋子的 location 集合,并过滤掉其中无效的值。
RemoveAll 方法使用一个回调 lamda 表达式作为参数。这个方法遍历 list 中所有值,把它传给表达式中的 tile 变量。如果表达式返回 ture,这个值就会从 list 中移除。
第一个表达式移除所有 x、y 值超出棋盘以外的 location。第二个表达式移除所有己方棋子的位置。
在 MoveSelector.cs 中,添加一个实例变量:
private List<Vector2Int> moveLocations; private List<GameObject> locationHighlights;
第一个变量保存了一个移动位置的 GridPoint 数组;第二个变量保存了玩家是否可以移动到那个地方的 overlay 瓦片数组。
在 EnterState 方法最后添加:
moveLocations = GameManager.instance.MovesForPiece(movingPiece); locationHighlights = new List<GameObject>(); foreach (Vector2Int loc in moveLocations) { GameObject highlight; if (GameManager.instance.PieceAtGrid(loc)) { highlight = Instantiate(attackLocationPrefab, Geometry.PointFromGrid(loc), Quaternion.identity, gameObject.transform); } else { highlight = Instantiate(moveLocationPrefab, Geometry.PointFromGrid(loc), Quaternion.identity, gameObject.transform); } locationHighlights.Add(highlight); }
这段代码做了这几件事情:
首先,它从 GameManager 获取有效 location 数组,构造一个空数组用于保存 overlay 瓦片对象。然后对 lcoation 数组进行遍历,如果在某个位置已经有棋子,那么必定是对方棋子,因为己方棋子已经被过滤掉了。
对方棋子加上攻击蒙层,而其它棋子则添加移动蒙层。
执行动作
在 Reference Point 2 处添加代码,就在判断鼠标按钮的 if 语句中:
if (!moveLocations.Contains(gridPoint)) { return; }
如果玩家玩家点击到无效的瓦片,退出函数。
最后,在 MoveSelector.cs 的 ExitState 中添加代码:
foreach (GameObject highlight in locationHighlights) { Destroy(highlight); }
这时,玩家已经选择了一个落点,你可以移除 overlay 对象了。
哇!改了这么多代码,仅仅是让小兵动一步而已。现在你已经完成了最艰巨的工作,其它棋子的移动就简单了。
下一个玩家
只有一边可以动的游戏并不多见。该解决下这个问题了!
为了让两边都能玩,你必须知道如何切换玩家以及在哪里添加代码。
因为 GameManager 负责所有游戏规则,切换玩家的代码应该放在这里。
实际上,切换玩家十分简单。在 GameManager 中定义有当前玩家和其它玩家的变量,你只需要交换二者即可。
更麻烦一点的问题是:在哪里调用切换玩家的方法?
当玩家移动了一颗棋子后,这个玩家的回合就结束了。MoveSelector 的 ExitState 方法在棋子被移动之后都会调用,因此这里就是进行切换的好地方。
在 GameManager.cs 最后添加这个方法:
public void NextPlayer() { Player tempPlayer = currentPlayer; currentPlayer = otherPlayer; otherPlayer = tempPlayer; }
交换需要使用临时变量;否则在拷贝一个值之前会导致原来的值被覆盖。
回到 MoveSelector.cs,在 ExitState 方法中,调用 EnterState 之前添加代码:
GameManager.instance.NextPlayer();
这就可以了!ExitState 和 EnterState 会进行清理工作。
进入试玩模式,你可以移动双方棋子了。距离真正的游戏不远喽!
吃子
吃子是棋类游戏中的重要内容。俗话说得好,“在真正失去骑士之前,一切都不过是游戏”。
因为游戏规则由 GameManager 负责,所以打开它增加一个方法:
public void CapturePieceAt(Vector2Int gridPoint) { GameObject pieceToCapture = PieceAtGrid(gridPoint); currentPlayer.capturedPieces.Add(pieceToCapture); pieces[gridPoint.x, gridPoint.y] = null; Destroy(pieceToCapture); }
这里,GameManager 查找位于目标位置的棋子。这颗棋子被添加到当前玩家的“吃掉的棋子”数组。然后,从 GameManager 的棋盘贴片中删除该棋子的记录,然后销毁 GameObject,导致从场景中移除该棋子。
要吃掉一颗棋子,你需要移动到其位置并点击。因此应当在 MoveSelector.cs 中调用这个方法。
在 Update 方法中,找到注释 Reference Point 3 的地方,编写代码:
else { GameManager.instance.CapturePieceAt(gridPoint); GameManager.instance.Move(movingPiece, gridPoint); }
之前 if 语句是判断目标位置是否有棋子。因为之前的移动已经过滤掉了己方棋子,那么如果有棋子则肯定是敌方棋子。
将敌方棋子移除后,就可以将手持的棋子放进去了。
点击 play,移动小兵,吃掉一颗棋子。
结束游戏
当玩家杀死对方的王之后,游戏就结束了。在你吃子时,需要判断对方是否是王。如果是,游戏结束。
当怎样才能结束游戏呢?一种方法是删除棋盘上的 TileSelector 和 MoveSelector 脚本。
在 GameManager.cs 的 CapturePieceAt 中,在销毁被杀死的棋子之前,添加代码:
if (pieceToCapture.GetComponent<Piece>().type == PieceType.King) { Debug.Log(currentPlayer.name + " wins!"); Destroy(board.GetComponent<TileSelector>()); Destroy(board.GetComponent<MoveSelector>()); }
光是禁用这些组件还不够。因为下一次调用 ExitState 和 EnterState 时会重新 enable 它们,那样游戏又可以玩了。
Destroy 方法不仅仅可用于 GameObject 类,还可以删除这个对象上绑定的组件。
点击 play。移动小兵,吃掉对方的王。你会看到 Unity 控制台打印出“胜利”的字样。
你可以挑战一下自己,添加显示 Game Over 和跳转回主菜单画面的 UI。
接下来祭出我们的大杀器,移动威力更强的棋子!
特殊移动
Piece 及其子类是封装特殊移动的好地方。
你可以使用和小兵一样的方法为其它棋子添加特殊移动。能够向不同方向移动一个空格的棋子,比如国王和骑士,都可以用同样的方式创建。试试看你能不能实现这些移动规则。
如果需要帮助,请阅读最终的项目代码。
多格移动
能够向某一方向移动多格的棋子要难一点。比如象、车和王后。为了简便起见,我们以象为例。
打开 Bishop.cs, 将 MoveLocations 替换为:
public override List<Vector2Int> MoveLocations(Vector2Int gridPoint) { List<Vector2Int> locations = new List<Vector2Int>(); foreach (Vector2Int dir in BishopDirections) { for (int i = 1; i < 8; i++) { Vector2Int nextGridPoint = new Vector2Int(gridPoint.x + i * dir.x, gridPoint.y + i * dir.y); locations.Add(nextGridPoint); if (GameManager.instance.PieceAtGrid(nextGridPoint)) { break; } } } return locations; }
foreach 循环每个方向。对于每一个方向,再次对棋子可以移动的位置进行循环。因为棋盘以外的位置会被过滤,所以你只需保证格子足够多不会遗漏任何瓦片即可。
在每一步里,创建一个 GridPoint 网点并添加到 list 里。然后判断当前位置是否有棋子。如果有,中断内层循环进入下一个方向。
因为如果已经有棋子的话会阻断棋子的移动,因此必须 break。同时,在后面会过滤掉己方棋子的位置,所以在这里你不需要关心这个问题。
注:如果你需要区分前后的方向,或者左右方向,那么你需要考虑黑白棋子在移动方向上的区别。
对于国际象棋,只有小兵才需要考虑这个问题,但如果是其它游戏则也可能需要进行这种区别。
好了!点击 play 试玩一下。
移动王后
王后是最强大的棋子,因此把它放到最后。
王后的移动是象和车的结合;在基类中,有一个数组用来保存每个棋子的移动方向。你可以用这个数组将两者结合。
将 Queen.cs 的 MoveLocations 修改为:
public override List<Vector2Int> MoveLocations(Vector2Int gridPoint) { List<Vector2Int> locations = new List<Vector2Int>(); List<Vector2Int> directions = new List<Vector2Int>(BishopDirections); directions.AddRange(RookDirections); foreach (Vector2Int dir in directions) { for (int i = 1; i < 8; i++) { Vector2Int nextGridPoint = new Vector2Int(gridPoint.x + i * dir.x, gridPoint.y + i * dir.y); locations.Add(nextGridPoint); if (GameManager.instance.PieceAtGrid(nextGridPoint)) { break; } } } return locations; }
唯一不同的地方是,你把方向数组转变成 List。
List 的特点是可以将其他数组中的方向添加进来,把所有方向都添加到这个 List。该方法的其余部分和 Bishop 类相同。
点击 play,把小兵移开,检查一下效果是否实现。
接下来去哪里?
还有一些内容需要你完成,比如实现王、骑士和车的移动。如果做不错来,请参考下载下来的项目资源代码。
还有一些特殊规则有待实现,比如允许兵第一步可以移动两格而不是一格,王车易位等。
一般的模式是向 GameManager 添加变量和方法,以记录这些情况,并检查它们在移动时是否可用。如果可用,则在 MoveLocations 中添加相应的位置。
还可以在视觉方面进行改进。例如,棋子平滑移动到目标位置,或者可以旋转镜头以表示其它玩家在进行回合时的视图。
有任何问题和建议,或者想秀一下你的 3D 象棋游戏,请在下面留言。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:- 第一台计算机国际象棋大师的前世今生
- 象棋人工智能算法的C++实现(五)——人机博弈的高阶算法
- 基于顺丰同城接口编写sdk,java三方sdk编写思路
- 使用 Clojure 编写 OpenWhisk 操作,第 1 部分: 使用 Lisp 方言为 OpenWhisk 编写简明的代码
- 编写一个Locust文件
- 编写现代 JavaScript 代码
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
JSP 2.0技术手册
杜远君、林康司、林上杰 / 湖北教育出版社,电子工业出版社 / 2004-5-1 / 59.0
本书图文并茂,以丰富的实例为引导,全面介绍了主流的Java Web开发技术——JSP 2.0,重点介绍Java在展示层的两项重要技术:Java Servlet与JavaServer Pages。它们是最重要的Java核心技术。对这两项技术的深入了解,将有助于您未来对于JavaServer Faces(JSF)技术以及Java Web Services技术的学习。 本书分为三大部分,前......一起来看看 《JSP 2.0技术手册》 这本书的介绍吧!