空气曲棍球





5.00/5 (25投票s)
如何用一套代码为三个移动平台创建一个人对抗人工智能的空气曲棍球游戏?
目录
引言
你玩过空气曲棍球吗?空气曲棍球是一种在游戏厅和其他人们聚会的地方相当流行的游戏。并不是很多人有机会在操作系统上将其作为预装游戏来尝试。游戏的目标是用球拍将球击入对方的球门。球放在一个特殊的桌子上,该桌子通过微小的孔产生空气垫,目的是减少摩擦并提高游戏速度。最先得 7 分的玩家获胜。
在本文中,我们将使用 Moscrif SDK 为三个移动平台:iOS、Android 和 Bada 创建一个空气曲棍球游戏。
关于游戏
我们的目标是创建一个单人游戏,与人工智能对战。游戏开始时,游戏场地会立即显示出来。菜单将作为对话框窗口创建,只有三个按钮:新游戏、继续和退出(退出按钮在 iOS 上不可用)。
图形设计
让我们通过准备图形来开始开发过程。我们的游戏非常简单,所以图形也不需要太复杂。它们包括菜单、游戏场地背景、按钮、球拍和球。
图片:图形设计
当前的移动市场提供了许多分辨率各异的设备。为了确保在每台设备上的最佳外观,图形是为所有常用分辨率单独创建的。
物理引擎
游戏中的球遵循物理定律。它从球拍和挡板上弹开,并以微小的阻尼移动。为了模拟这种物理行为,我们使用了 Moscrif SDK 支持的box2d 物理引擎。这个引擎也存在于 Nintendo DS 或 Wii 等其他平台上。关于 box2d 世界和物体(bodies)的章节与我上一篇文章类似。如果您在上一篇文章中读过,可以跳到 开始 - 启动文件 & 资源 章节。
世界
世界为所有物体、关节或接触创建了一个背景。世界始终具有等同于现实世界 10 米宽度的宽度。所有对象和事物都会被缩放以确保这个宽度。缩放属性说明一米有多少像素。box2d 还使用自己的坐标系,该坐标系从左下角开始,以米为单位计数。
图片:box2d 坐标
幸运的是,Moscrif 的框架通常使用标准的像素坐标,并且会自动进行转换。
物理体
物体是 box2d 物理引擎的另一个重要部分。使用物体,我们创建了所有在世界中交互的对象。在我们的游戏中,球、球拍、挡板和球门都作为物体创建。Box2d 支持三种类型的物体,它们具有不同的行为。第一个区别是并非所有类型的物体都会相互碰撞。下表显示了哪些物体会相互碰撞。
不同类型的物体在模拟中的行为也不同。静态物体在模拟中不会移动,并具有无限质量。它们不会根据力或速度移动。在我们的游戏中,静态物体是挡板和球门。运动物体仅根据其速度移动,并且也具有无限质量。动态物体会得到完全模拟。在我们的游戏中,动态物体是球拍和球门。
其他重要的物体属性是密度、摩擦力和弹性。弹性属性会影响物体弹开后的速度。V 1.0 意味着物体以与它落下时相同的速度反弹;然而,有可能增加反弹率,球将以更大的速度反弹。
开始 – 启动文件 & 资源
启动文件
我们将在 Moscrif IDE 中创建游戏,在该 IDE 中,我们需要基于游戏的框架启动一个新项目。默认情况下,启动文件是main.ms。此文件包含一个 Game
框架类的实例,该类是 Moscrif 中所有游戏项目的一个基类。Moscrif 的 game
框架还提供 PhysicsScene
类,该类创建物理世界并组合所有其他物理和非物理对象。PhysicsScene
类也用于创建 GameScene
类,该类将游戏场地投入存在,并且其实例在 Game
类的 onStart
事件中创建,该事件在游戏开始时被调用。
示例:创建一个 GameScene
的新实例
game.onStart = function(sender)
{
if (res.supportedResolution) {
// push game scene to application
this.gameScene = new GameScene();
this.push(this.gameScene);
// restart game (start new)
this.gameScene.reset();
}
this._paint = new Paint();
this._paint.textSize = System.height / 30;
var (w, h) = this._paint.measureText("Unsupported resolution");
this._textWidth = w;
}
在main.ms中,用户事件(如指针或按键)也会被管理。
示例:管理用户事件
// quit game when user clicks on the back or home hardware button
game.onKeyPressed = function(sender, keyCode)
{
if (keyCode == #back || keyCode == #home)
game.quit();
}
game.onPointerPressed = function()
{
if (!res.supportedResolution)
game.quit();
} // quit game when user clicks on the back or home hardware button
game.onKeyPressed = function(sender, keyCode)
{
if (keyCode == #back || keyCode == #home)
game.quit();
}
game.onPointerPressed = function()
{
if (!res.supportedResolution)
game.quit();
}
资源
如前所述,图形是为多种分辨率单独制作的。然而,所有图像都由 Resource
类管理,这意味着我们在后续开发过程中无需担心设备分辨率。resource
类的实例在main.ms中作为第二个全局变量创建,这也确保了所有图像只加载一次,并保存在设备的内存和性能中。
示例:在类构造函数中加载资源
function this()
{
this._supportedResolution = true;
this._images = {
background : this._loadImage("backGame", "jpg");
playerHuman : this._loadImage("player2");
playerAI : this._loadImage("player1");
puck : this._loadImage("puck");
menuBg : this._loadImage("menuBack");
menuButton : this._loadImage("menuBtn");
menuButtonPressed : this._loadImage("menuBtnPress");
menuPart : this._loadImage("menuPart");
};
...
}
_loadImage
函数根据设备分辨率加载图像。
示例:根据设备分辨率加载图像
function _loadImage(filename, format = "png")
{
var file = "app://" + System.width + "_" + System.height + "/" + filename + "." + format;
var bitmap;
if (System.isFile(file)) {
bitmap = Bitmap.fromFile(file);
if (bitmap != null)
return bitmap;
}
// Kindle Fire 600x1002
if (System.width == 600) {
file = "app://" + System.width + "_1024" + "/" + filename + "." + format;
bitmap = Bitmap.fromFile(file);
if (bitmap != null)
return bitmap;
}
// Galaxy tab 800x1232 752x1280
if (System.width == 800) {
file = "app://" + System.width + "_1280" + "/" + filename + "." + format;
bitmap = Bitmap.fromFile(file);
if (bitmap != null)
return bitmap;
}
// SE xperia 480x854
if (System.width == 480) {
file = "app://" + System.width + "_800" + "/" + filename + "." + format;
bitmap = Bitmap.fromFile(file);
if (bitmap != null)
return bitmap;
}
this._supportedResolution = false;
return null;
}
游戏场景
现在,让我们创建我们游戏最重要的部分——游戏场景。游戏场景就是游戏场地:桌子、挡板、球门、球和球拍。游戏场景由 GameScene
类创建,该类继承自 PhysicsScene
,后者创建物理世界。当框架类被构造时,它们会调用 init
(还有 beforeInit
和 afterInit
)方法。在我们的游戏中,我们将为场景创建物理世界,设置 onBeginContact
和 onEndContact
事件(当两个物体在场景中碰撞时被调用),并在 init
函数中创建所有其他对象。其他游戏元素,如球、挡板、球门或球拍,由单独的函数创建,例如 PhysicsSprite
对象。
游戏场景还会绘制桌子图像和得分。图像会调整大小以适应全屏,以防 Resource
类找不到当前设备分辨率的图像。
示例:绘制背景图像和得分
function draw(canvas)
{
canvas.drawBitmapRect(res.images.background, 0, 0, res.images.background.width,
res.images.background.height, 0, 0, System.width, System.height);
// save current canvas settings
canvas.save();
// rotate canvas to 270° CW
canvas.rotate(270);
// draw score
canvas.drawText(this.playerAI.score.toString(),
System.height / - 2 - 2 * this._scoreW,
System.width / 16 + this._scoreH, res.paints.scoreBlue);
canvas.drawText(this.playerHuman.score.toString(),
System.height / - 2 + this._scoreW,
System.width / 16 + this._scoreH, res.paints.scoreGreen);
// restore canvas settings (revert rotation)
canvas.restore()
super.draw(canvas);
}
球
Puck
是作为 PhysicsSprite
类的实例创建的。它的类型设置为动态,这意味着它会与其他静态或动态物体碰撞。球的弹性属性为一,这意味着它会以一定的力从其他物体上弹开。为了实现真实的物理行为,我们需要为物体应用线性阻尼。在真实的空气曲棍球中,球在空气垫上移动,因此球和桌子之间的阻尼非常小,球移动很快。它的图像以及游戏中所有其他图像一样,都从资源中加载。
示例:创建球
function _createPuck()
{
const density = 1.0, friction = 0.2, bounce = 1.0;
// create physics body of the puck
var puck = this.addCircleBody(res.images.puck, #dynamic,
density, friction, bounce, res.images.puck.width / 2/*radius*/);
// place puck to center of the table
puck.setPosition(System.width / 2, System.height / 2);
puck.fixedRotation = true;
puck.bullet = true;
puck.setLinearDamping(0.3);
return puck;
}
挡板
挡板阻止球和球拍离开桌子。它们环绕整个桌子,除了球门。它们的类型设置为 static
,这意味着它们在模拟中不会移动,但会与其他动态物体(球和球拍)碰撞。除了游戏场地周围的挡板,我们还将在桌子角落创建四个不可见的方块。没有这些方块,球有时会卡在左侧或右侧挡板附近,并沿上下方向移动。然而,当球碰到角落里的一个方块时,它会从挡板上弹开。
图片:挡板和游戏场地周围的“不可见”方块
示例:创建挡板
function _createBarriers()
{
const density = 0.0, friction = 0.0, bounce = 0.0;
const width = System.width / 4, height = System.width / 32;
var topWallA = this.addPolygonBody
(null, #static, density, friction, bounce, width, height);
topWallA.setPosition(System.width / 8, 1);
var topWallB = this.addPolygonBody
(null, #static, density, friction, bounce, width, height);
topWallB.setPosition(System.width - System.width / 8, 1);
var bottomWallA = this.addPolygonBody
(null, #static, density, friction, bounce, width, height);
bottomWallA.setPosition(System.width/8, System.height);
var bottomWallB = this.addPolygonBody
(null, #static, density, friction, bounce, width, height);
bottomWallB.setPosition(System.width - System.width / 8, System.height);
var leftWall = this.addPolygonBody(null, #static, density, friction,
bounce, System.width / 32, System.height);
leftWall.setPosition(1, System.height / 2);
var rightWall = this.addPolygonBody(null, #static, density, friction,
bounce, System.width / 32, System.height);
rightWall.setPosition(System.width, System.height / 2);
// corners
var leftTop = this.addPolygonBody(null, #static, density, friction,
bounce, this.puckRadius, this.puckRadius);
leftTop.setPosition(this.puckRadius / 2, System.width / 60);
var rightTop = this.addPolygonBody(null, #static, density, friction,
bounce, this.puckRadius, this.puckRadius);
rightTop.setPosition(System.width - this.puckRadius / 2, System.width / 60);
var leftBottom = this.addPolygonBody(null, #static, density, friction,
bounce, this.puckRadius, this.puckRadius);
leftBottom.setPosition(this.puckRadius / 2, System.height - System.width / 60);
var rightBottom = this.addPolygonBody(null, #static, density, friction,
bounce, this.puckRadius, this.puckRadius);
rightBottom.setPosition(System.width - this.puckRadius / 2,
System.height - System.width / 60);
}
球门
球门位于顶部和底部挡板的中心。球门还由左侧、右侧和顶部(或底部)的三个挡板围住,但这些挡板在屏幕外,以允许球离开游戏场地。当球撞到球门内的某些挡板时,球会被移除,得分也会更新。
图片:球门
示例:创建球门
// creates goals
function _createGoals()
{
const density = 0.0, friction = 0.0, bounce = 0.0;
var goalA = this.addPolygonBody(null, #static, density, friction, bounce,
this.goalsWidth, System.width / 32);
goalA.beginContact = function(contact) { this super._checkGoal(contact, #playerAI); }
goalA.setPosition(System.width / 2, -2 * this.puckRadius + System.width / 32);
var goalALeft = this.addPolygonBody(null, #static, density, friction, bounce,
System.width / 32, 2 * this.puckRadius);
goalALeft.beginContact = function(contact) { this super._checkGoal(contact, #playerAI); }
goalALeft.setPosition(System.width / 2 - this.goalsWidth / 2 - this.puckRadius,
-1 * this.puckRadius);
var goalARight = this.addPolygonBody(null, #static, density, friction, bounce,
System.width / 32, 2 * this.puckRadius);
goalARight.beginContact = function(contact) { this super._checkGoal(contact, #playerAI); }
goalARight.setPosition(System.width / 2 + this.goalsWidth / 2 + this.puckRadius,
-1 * this.puckRadius);
...}
球拍
两个球拍都由相同的 _createPaddle
函数创建,该函数创建一个圆形物体,具有人类和 AI 球拍的不同图像。球拍的密度比球的密度大,这导致了更大的尺寸带来了比球更大的质量。当质量不同的两个物体碰撞时,质量较大的物体的运动受到的影响较小。
示例:创建球拍
// creates paddle (for AI or human player)
function _createPaddle(paddleType)
{
assert paddleType == #playerAI || paddleType == #playerHuman;
const density = 1.1, friction = 0.3, bounce = 0.0;
var paddle = (paddleType == #playerAI)
? this.addCircleBody(res.images.playerAI, #dynamic, density,
friction, bounce, res.images.playerAI.width / 2)
: this.addCircleBody(res.images.playerHuman, #dynamic, density,
friction, bounce, res.images.playerHuman.width / 2);
paddle.fixedRotation = true;
paddle.setLinearDamping(5.0);
return paddle;
}
碰撞
当两个物体在场景中碰撞时,会调用两个事件:onBeginContact
,在碰撞开始时调用,以及 onEndContact
,在碰撞结束时调用。我们在游戏场景的 init
函数中将这两个事件映射到类成员函数:_beginConcat
和 _endContact
。
示例:映射两个事件函数
function init()
{
// create physics world
this._world = new b2World(0.0, 0.0, true, true);
// world callback
this.onBeginContact = function(sender, contact) { this super._beginContact(contact); }
this.onEndContact = function(sender, contact) { this super._endContact(contact); }
...
}
有许多物体可能会相互接触(人类球拍与球、AI 球拍与球、人类球拍与 AI 球拍、球与挡板等)。通过任何 if
或 switch
条件检查正在碰撞的特定物体可能会过于复杂。为了简化碰撞管理,我们向所有应该在碰撞时触发事件的物体添加一个回调函数到 beginContact
或 endContact
变量。Moscrif 的 JavaScript 引擎允许在代码中的任何位置创建局部函数和成员函数。这意味着变量不必在类中定义,而可以像下一个示例一样简单地添加到任何单独的对象中。
示例
// create AI player
this.paddleAI = this._createPaddle(#playerAI);
this.paddleAI.endContact = function(body)
{
this super.playerAI.hit();
}
然后,当发生碰撞时,我们只为两个物体调用对象的 beginContact
或 endContact
方法,这会管理所有其他需要的操作。
示例:开始碰撞
// listener for begin of collision
function _beginContact(contact)
{
var bodyA = contact.getBodyA();
var bodyB = contact.getBodyB();
if(bodyA.beginContact)
bodyA.beginContact(bodyB, contact);
if(bodyB.beginContact)
bodyB.beginContact(bodyA, contact);
}
人类玩家
此时,一切都已准备就绪,但球拍还没有移动。为了做到这一点,创建了两个类:playerHuman
和 playerAI
。PlayerHuman
类移动由玩家控制的球拍。要移动球拍,我们使用鼠标关节。鼠标关节允许操纵物理物体到所需位置。它们也可以通过 setPosition
方法移动,但当物体通过此方法移动时,它不会与其他物理对象交互。
当用户点击屏幕时,场景会调用人类玩家的 handlePressed
方法。在 handlePressed
方法中,会创建一个鼠标关节来操纵由玩家控制的球拍。如果玩家点击了对手的场地一半,球拍会沿着中线移动。
示例:创建鼠标关节
// called by Table when touch down occurred
function handlePressed(x, y)
{
// check player's side
if (y < System.height / 2)
y = System.height / 2;
// just simple helper
const table = this.table;
// mouse joint definition
var mouseJointDef = {
maxForce : 10000,
frequencyHz : 1000,
dampingRatio : 0.0,
targetX : table.x2box2d(x), // specified in box2d coords
targetY : table.y2box2d(y) // specified in box2d coords
};
// move paddle to touched place
this.paddle.setTransform(x, y);
// create mouse joint
if (this.joint)
this.table.destroyJoint(this.joint);
this.joint = table.createMouseJoint(table.ground, this.paddle, mouseJointDef, true);
}
当用户在屏幕上移动手指时,场景会调用人类玩家类中的 handleDragged
方法。在此方法中,调用 mouseJoint
的 setTarget
方法。此方法将球拍移动到当前手指位置。
示例:将球拍移动到当前手指位置
// called by Table when touch drag occurred
function handleDragged(x, y)
{
// limit player's side
if (y < System.height / 2 + this.puckRadius)
y = System.height / 2 + this.puckRadius;
// affect mouse joint
if (this.joint != null)
this.joint.setTarget(this.table.x2box2d(x), this.table.y2box2d(y));
}
AI 玩家
对手的游戏由 playerAI
类控制。AI 玩家也像人类玩家一样通过鼠标关节移动,但 setTarget
方法的坐标是通过我们的算法计算出来的。AI 玩家只执行两个操作——防守或进攻。为了使玩家更逼真,该类实现了以下功能:
- 两次进攻之间的间隔至少为 700 毫秒
- AI 玩家不会在越过中线后立即响应,而是在越过中线后稍有延迟才响应
- AI 玩家不会在球在桌子角落时击球。
- 即使球在对手半场,AI 玩家也会移动到防守位置
- AI 玩家的球拍以逼真的速度移动
两次对手进攻之间的间隔至少为 700 毫秒。每 25 毫秒调用一次 handleProcess
方法,该方法会检查距离上次进攻是否已过至少 700 毫秒。如果已过,则对手再次进攻,否则,它会回到防守状态。
示例:检查上次进攻是否在至少 700 毫秒之前
// called by Table object (onProcess)
function handleProcess()
{
// get position of my paddle
var (x, y) = this.paddle.getPosition();
// get position of puck
var (px, py) = this.table.puck.getPosition();
// delay & defense after contact
if (System.tick - this.hitTime < 700) {
this._defense(x, y, px, py);
return;
}
// otherwise make a decision
this._makeDecision(x, y, px, py);
}
当距离上次进攻的时间至少为 700 毫秒时,玩家可以选择进攻,但不必一定进攻。要决定是否进攻,使用 _makeDecision
函数。如果球离桌子角落不远,并且它在玩家半场,则玩家会向球进攻。
示例:决定玩家是否应该进攻
function _makeDecision(x, y, px, py)
{
// attack when puck is in our corner
var puckInCorner = px < System.width / 5 || px > 4*System.width / 5;
if (puckInCorner && py < 2 * this.puckRadius ) {
return ;
}
// move to puck's position and hit puck to the second half of table
if (py < ( 9 * System.height / 20))
return this._moveTo(x, y, px, py - this.puckRadius / 4);
return this._defense(x, y, px, py);
}
defense
函数将球拍水平移动到球门前。然而,球拍不会从左挡板移动到右挡板。它只在中场区域根据当前球的位置移动。
示例:防守位置
// simple defense method
function _defense(x, y, px, py)
{
if (py < y && Math.abs(System.width / 2 - px) > System.width / 5)
return this._moveTo(x, y, px, py - this.puckRadius);
this._moveTo(x, y, System.width / 4 + System.width / 2 * (px / (1.0 * System.width)),
System.height / 6);
return true;
}
正如您在所有先前的方法中看到的,_moveTo
函数用于移动球拍。此函数根据所需速度逐渐移动球拍。此函数确保 AI 玩家的速度与人类速度相似,因为直接使用 setTarget
方法可能会导致 AI 玩家过快。
示例:移动 AI 玩家的球拍
// calculates movement for AI paddle
function _moveTo(ox, oy, px, py)
{
// be random
var speed = Integer.min(640, System.width) / (40.0 + rand(20));
// calculate deltas
const dx = px - ox;
const dy = py - oy;
// calculate distance between puck and paddle position (we use Pythagorean theorem)
const distance = Math.sqrt(dx * dx + dy * dy);
// if total distance is greater than the distance,
// of which we can move in one step calculate new x and y coordinates
// somewhere between current puck and paddle position.
if (distance > speed) {
// x = current paddle x position + equally part of speed on x axis
px = ox + speed / distance * dx;
py = oy + speed / distance * dy;
}
// move paddle to the new position
this.joint.setTarget(this.table.x2box2d(px), this.table.y2box2d(py));
return true;
}
AI 玩家游戏的整个逻辑显示在下图
图片:AI 玩家逻辑
总结
本文向您展示了如何创建一个空气曲棍球游戏。这个示例的某些部分可以轻松地重写成其他编程语言并在您的项目中使用。但是,为了以最少的工作量为尽可能多的设备创建此游戏的最佳方法,我强烈推荐使用 Moscrif SDK。
历史
- 2012 年 8 月 1 日:初始版本