终极塔防






4.94/5 (66投票s)
使用Web技术从零开始创建一个塔防游戏。
引言
塔防游戏是一种非常简单的游戏。维基百科说,塔防游戏的目标是“...通过建造陷阱来减缓敌人的速度,并建造塔楼在敌人经过时射击他们,从而阻止敌人穿过地图。”这些敌人以单位的形式出现,它们具有速度或生命值等属性。塔楼具有不同的能力,必须通过花费金钱购买。玩家通过击败传入的敌波来赚取金钱。
本文将构建一个基本的塔防游戏,该游戏易于扩展和修改。我们将使用HTML5、CSS3和JavaScript为现代Web构建此游戏。绘图将使用<canvas>
元素完成,但是,绘图是完全解耦的,可以很容易地通过一些CSS样式更改为<div>
元素。
项目本身不包含对jQuery、AngularJS或任何现有游戏引擎等第三方代码的依赖。相反,一切都是从头开始构建的。这比许多人想象的要容易得多,并给我们带来了一些额外的自由。另一个原因是避免了包含不必要功能的潜在开销。最后但同样重要的是,这为有用的教程提供了一个很好的可能性。
背景
我每年都会多次发表关于最新技术的讲座,例如“使用C#编程”或“使用HTML5、CSS3和JavaScript进行Web应用程序开发”。通常我对这些课程非常热情。原因之一是学习曲线相当陡峭,但是,有才华的学生能够或多或少轻松地处理所呈现的材料。另一个原因是最终会创建出令人惊叹的项目。每次我看到仅仅两周的工作就能完成的事情时,我都感到非常高兴。从零到英雄(足够接近)!
这个概念的妙处在于我可以给我的学生“项目创意”。我有很多想法,这对我个人来说有点问题,因为我永远找不到时间完成所有这些想法,至少不能以我想要的方式完成。尽管如此,对于最终项目,学生当然可以做一些捷径,并在任意但有用的地方停止。这样,我的学生不仅可以学到很多很酷的东西,我自己也可以学到一些有用的知识。至少,这是一个很好的交叉检查,可以验证我脑海中解决特定问题的想法是否真的有效(以及效果如何)。
大多数项目实际上是游戏。这不是一个要求,但如果我们仔细思考,这是有道理的。如果我们有一个特定的项目,那么这个应用程序可能实际上对我们有帮助,因此我们可能会选择它作为最终项目。然而,大多数人不需要在特定的时间点需要特定类型的应用程序。创建一个游戏的好处是拥有一些通常很有趣的东西。其他人也可能喜欢它,因为游戏从不解决问题,而是创造一个新问题(如果你愿意,可以将其视为一种任务或使命)。然后,这个问题只能通过应用程序(即游戏)本身来解决。
当然,大多数学生从未编写过游戏——或者至少是图形游戏。因此,他们需要了解游戏引擎是什么,因为他们可能需要它。所以我教他们如何编写一个简单的游戏引擎以及如何设计他们的游戏。有时我会给他们一些有用的算法或实现。另一个重要(但经常被忽视)的问题是去哪里获取所有这些资源,例如声音和图形。幸运的是,我的硬盘上存储着许多好的URL和资源。
这个塔防游戏最初是在C#课程中开发的。它使用SDL.NET进行渲染,使用DirectX播放声音。大多数精灵都是用画图工具绘制的,这让游戏具有一些复古像素风格。现在我决定通过将其移植到JavaScript来改进该项目。最后我认为这是一个很好的测试:我能多好(和多快)地将这种特殊的C#代码聚合到Web上?
游戏引擎要素
游戏引擎是一段负责绘制图形、播放声音和驱动逻辑的代码。这三个职责应该尽可能地分离。如果能完美地解耦它们,那么代码将非常灵活且易于维护。视频循环是与时间无关的,即它会尽可能频繁地执行其主体,而逻辑循环通常是与时间相关的,即以预设频率脉冲。这非常重要:有时游戏对某些计算机来说图形过于密集。虽然逻辑仍然以固定的每秒速率运行,但图形可能会出现卡顿。这是架构的一个影响:固定数量的逻辑步骤与可变数量的图形步骤。
在我们的游戏中,我们将主要逻辑例程放置在一个名为GameLogic
的类中。通过调用start()
方法,我们将触发逻辑。从那时起,JavaScript引擎将以constants.ticks
中定义的固定间隔调用tick()
方法。我们只会在没有逻辑循环运行时才启动逻辑循环。
var GameLogic = Base.extend({
/* ... */
start: function() {
/* ... */
if (!this.gameLoop) {
var me = this;
me.view.start();
me.gameLoop = setInterval(function() {
me.tick();
}, constants.ticks);
}
},
tick: function() {
/* ... */
},
pause: function() {
if (this.gameLoop) {
this.view.pause();
clearInterval(this.gameLoop);
this.gameLoop = undefined;
}
},
);
逻辑类已经知道存在视图。但是,它不知道具体的视图,也不知道除start()
和stop()
之外的任何方法。当逻辑循环开始时,视频循环也应该开始。此外,我们希望在逻辑暂停时结束连续的图形绘制。
与用户界面的交互将通过事件完成。此连接有两方面
- 由UI元素触发的事件,例如点击按钮
- 由游戏逻辑触发的事件,例如波次结束
游戏逻辑使用的事件系统是用JavaScript实现的。我们使用一个对象来跟踪已注册的事件和已分配的事件监听器。每个事件可以有任意数量的监听器。
var Base = Class.extend({
init: function() {
this.events = {};
},
registerEvent: function(event) {
if (!this.events[event])
this.events[event] = [];
},
unregisterEvent: function(event) {
if (this.events[event])
delete this.events[event];
},
triggerEvent: function(event, args) {
if (this.events[event]) {
var e = this.events[event];
for (var i = e.length; i--; )
e[i].apply(this, [args || {}]);
}
},
addEventListener: function(event, handler) {
if (this.events[event] && handler && typeof(handler) === 'function')
this.events[event].push(handler);
},
removeEventListener: function(event, handler) {
if (this.events[event]) {
if (handler && typeof(handler) === 'function') {
var index = this.events[event].indexOf(handler);
this.events[event].splice(index, 1);
} else
this.events[event].splice(0, this.events[event].length);
}
},
});
派生类通过使用registerEvent()
方法注册事件(通常在其init()
方法中)。triggerEvent()
方法用于触发事件。监听器可以使用addEventListener()
和removeEventListener()
方法进行注册或注销。这与在JavaScript中(取消)注册UI元素的事件处理程序非常相似。
最后,我们将编写如下代码
logic.addEventListener('moneyChanged', function(evt) {
moneyInfo.textContent = evt.money;
});
这将帮助我们将游戏逻辑和用户界面连接起来。
建造塔防游戏
塔防游戏并不难建造。这有几个原因
- 一个基本的塔防游戏可能是回合制的
- 它们非常适合粗略的网格
- 只需要非常基本的物理特性
- 规则实际上非常简单直接
任何塔防游戏的核心(像大多数策略游戏一样)是一个好的寻路算法。由于我们不会处理成千上万个单位,因此我们对一个非常快的算法不感兴趣。对于这个示例项目,我们可以直接使用著名的 **A* **算法。几乎所有编程语言都有多种实现。一个例子是我的实现,它或多或少是从C#移植过来的。如果你对它的工作原理感兴趣,那么只需阅读我网页上提供的文章 这里。文章还包含 一个使用单一(固定)策略的简短演示 的链接。
在这种情况下,用于存储不同迷宫策略的枚举看起来像以下对象
var MazeStrategy = {
manhattan : 1,
maxDXDY : 2,
diagonalShortCut : 3,
euclidean : 4,
euclideanNoSQR : 5,
custom : 6,
air : 7
};
通常单位以曼哈顿距离在迷宫中移动。曼哈顿距离是一种特殊的距离,不允许对角线捷径。在曼哈顿距离中,从(1,1)到(2,2)至少需要2步。作为比较,普通的欧几里得距离允许对角线移动。在这里,我们可以一步从(1,1)到(2,2)。
游戏中还使用了其他度量(例如没有平方距离开平方的欧几里得度量,在某些情况下会与普通欧几里得度量不同)。在这些度量中,空中策略最引人注目。它通过知道最短距离就是完全忽略任何障碍物,从而使任何适当路径的计算变得过时。此策略由一个只能被特殊防空塔攻击的单位使用。
塔通过继承Tower
类来实现。下面是代码草图。
var Tower = GameObject.extend({
init: function(speed, animationDelay, range, shotType) {
/* ... */
},
targetFilter: function(target) {
return target.strategy !== MazeStrategy.air;
},
update: function() {
this._super();
/* ... */
},
shoot: function() {
/* ... */
},
});
targetFilter()
方法用作目标的过滤器。所有塔,除了防空塔,都将使用标准过滤器,该过滤器仅排除空中单位。实现防空塔的代码将覆盖原始方法。
var Flak = Tower.extend({
init: function() {
this._super(Flak.speed, 200, Flak.range, Flak.shotType);
this.createVisual(Flak.sprite, [1, 1, 1, 1]);
},
targetFilter: function(target) {
return target.strategy === MazeStrategy.air;
},
});
构造函数,即init()
方法,只调用带有特定参数的基构造函数。此外,还会创建塔的视觉效果。视觉效果存储了完整的精灵信息,例如帧、基于方向的移动和精灵图像源。
每个塔定义一个射击类型,它只是一个适当的射击类的类型。用JavaScript术语来说:这是对可用于实例化适当射击对象的构造函数的引用。
所有射击类型的基类具有以下轮廓
var Shot = GameObject.extend({
init: function(speed, animationDelay, damage, impactRadius) {
/* ... */
},
update: function() {
/* ... */
},
});
在Flak
塔的情况下,我们将shotType
属性定义为对AirShot
的引用。此构造函数与以下代码一样简单
var AirShot = Shot.extend({
init: function() {
this._super(AirShot.speed, 10, AirShot.damage, AirShot.impactRadius);
this.createVisual(AirShot.sprite, [1, 1, 1, 1], 0.2);
this.playSound('flak');
},
});
没有目标定义。可能的目标列表将始终由实例化射击对象的塔设置。由于AirShot
将仅由Flak
塔使用,因此它将始终仅对空中单位有效。构造函数看起来非常相似。主要区别在于,射击对象在实例化后还会播放声音。
以下截图显示了游戏进行中的一些动作。
那么,塔可以被射击击中的目标是什么呢?嗯,这些目标以单位的形式出现。此时应该很明显,我们将遵循与之前相同的策略。我们将使用一个名为Unit
的基类作为每个派生对象的样板。
var Unit = GameObject.extend({
init: function(speed, animationDelay, mazeStrategy, hitpoints) {
/* ... */
},
playInitSound: function() {
/* ... */
},
playDeathSound: function() {
/* ... */
},
playVictorySound: function() {
/* ... */
},
update: function() {
/* ... */
},
hit: function(shot) {
/* ... */
},
});
这个游戏中有几个单位。平衡一切主要取决于创建一个好的波算法,它将尽力使游戏变得困难,但并非不可能。让我们看看各种单位类型
马里奥 - 一个非常容易对付的对手
绳索 - 只是稍微难一点(更多生命值)
火法师 - 速度很快,但生命值不多
空中之狼 - 游戏中唯一的空中单位
暗核桃 - 速度尚可,生命值高得多
飞毛腿 - 游戏中速度最快的单位,生命值也相当可观
阿莫斯 - 生命值最多,但也是最慢的单位
添加新单位非常容易(而且也很有趣!)。设计新单位的关键问题是:单位应该何时出现,以及应该考虑哪些属性(主要是速度、护甲)?
例如,我们考虑实现马里奥单位。以下代码将名为Mario的另一个单位添加到单位类型集合中。
var Mario = Unit.extend({
init: function() {
this._super(Mario.speed, 100, MazeStrategy.manhattan, Mario.hitpoints);
this.createVisual(Mario.sprite, [8,8,8,8]);
},
}, function(enemy) {
enemy.speed = 2.0;
enemy.hitpoints = 10;
enemy.description = 'You have to be careful with that plumber.';
enemy.nickName = 'Mario';
enemy.sprite = 'mario';
enemy.rating = enemy.speed * enemy.hitpoints;
types.units['Mario'] = enemy;
});
第一部分控制Mario
的实例,而第二部分仅设置适用于所有实例的静态属性。精灵将从createVisual()
方法中可用精灵列表中加载。
示例游戏
为了让提供的代码成为一个可运行的游戏,我们必须将所有内容捆绑在一起。我们从使用一个非常简单的HTML样板代码开始
<!doctype html>
<html>
<head>
<meta charset=utf-8 />
<title>Tower Defense Demo</title>
<link href="Content/style.css" rel="stylesheet" />
</head>
<body>
<div id="frame" class="hidden">
<div id="info">
<div id="money-info" title="Money left"></div>
<div id="tower-info" title="Towers built"></div>
<div id="health-info" title="Health left"></div>
</div>
<canvas id="game" width=900 height=450>
<p class="error">Your browser does not support the canvas element.</p>
</canvas>
<div id="towers"></div>
<div id="buttons">
<button id="startWave">Start Wave</button>
<button id="buyMedipack">Buy Medipack</button>
<button id="buyTowerbuild">Buy Towerbuild</button>
</div>
</div>
<script src="Scripts/manifest.js"></script>
<script src="Scripts/oop.js"></script>
<script src="Scripts/utilities.js"></script>
<script src="Scripts/path.js"></script>
<script src="Scripts/resources.js"></script>
<script src="Scripts/video.js"></script>
<script src="Scripts/sound.js"></script>
<script src="Scripts/main.js"></script>
<script src="Scripts/logic.js"></script>
<script src="Scripts/units.js"></script>
<script src="Scripts/shots.js"></script>
<script src="Scripts/towers.js"></script>
<script src="Scripts/app.js"></script>
</body>
</html>
好吧,这比一个最小的工作示例多一点,但是,它比一个利用游戏提供所有信息的非常复杂的示例少得多。
所有这些JavaScript文件都可以捆绑和最小化。像ASP.NET MVC这样的Web框架会自动执行此操作,或者我们编写一些make脚本,将其作为一次性构建任务执行。那么我们还有什么呢?最重要的元素是放置在由<div>
元素给出的框架中心的<canvas>
元素。
这三个按钮负责控制游戏。我们可以开始新一波(我们应该事先做好准备),购买一个医疗包,或者购买另一个塔建造权。可能的塔数量是有限的。购买建造另一个塔的权利是一笔费用,这笔费用会随着可能的塔数量的增加而增长。
我们如何建造塔呢?嗯,这从上面的代码中并不能直接看出来。我们将使用ID为towers
的<div>
元素。它将用作容器,其中填充已注册的塔类型。现有的JavaScript代码如下所示
var towerPanel = document.querySelector('#towers');
var towerButtons = [];
var addTower = function(tower) {
var div = document.createElement('div');
div.innerHTML = [
'<div class=title>', tower.nickName, '</div>',
'<div class=description>', tower.description, '</div>',
'<div class=rating>', ~~tower.rating, '</div>',
'<div class=speed>', tower.speed, '</div>',
'<div class=damage>', tower.shotType.damage, '</div>',
'<div class=range>', tower.range, '</div>',
'<div class=cost>', tower.cost, '</div>',
].join('');
towerButtons.push(div);
div.addEventListener(events.click, function() {
towerType = tower;
for (var i = towerButtons.length; i--; )
towerButtons[i].classList.remove('selected-tower');
this.classList.add('selected-tower');
});
towerPanel.appendChild(div);
};
var addTowers = function() {
for (var key in types.towers)
addTower(types.towers[key]);
};
因此,我们只需调用addTowers()
方法。这将遍历所有塔,为每个塔创建并添加一个新按钮。
查看CSS其实没什么意思。<canvas>
元素在没有任何样式的情况下也能很好地工作。因此,样式留给那些真正关心游戏专业外观的人。
类图
重写整个游戏的目的是为了以面向对象的方式描述所有内容。这将使编码更有趣,也更简单。最终的游戏也将包含更少的错误。以下类图是在创建游戏之前规划的
游戏严格遵循这个类图。扩展游戏实际上就像将游戏用作任意塔防游戏的基本样板一样简单。理论上,用其他类型(例如泥土、大门等)扩展战场也应该非常容易。这里的诀窍是使用其他图块,这些图块在建造时不会反映为0的权重。这已经包含在内,但尚未使用。
在下一节中,我们将看到如何使用现有代码发布我们自己的塔防游戏。
使用代码
给定的代码并不代表一个完整的游戏。相反,它代表一种塔防游戏的样板。提供的Web应用程序只是一个使用代码各个部分来表示示例游戏的示例。
资源加载器是一个相当有趣的类。它定义了专门资源加载器的核心功能。基本上,它只是接收一个资源列表,可选择设置用于进度、错误和完成指示的回调函数。
var ResourceLoader = Class.extend({
init: function(target) {
this.keys = target || {};
this.loaded = 0;
this.loading = 0;
this.errors = 0;
this.finished = false;
this.oncompleted = undefined;
this.onprogress = undefined;
this.onerror = undefined;
},
completed: function() {
this.finished = true;
if (this.oncompleted &&typeof(this.oncompleted) === 'function') {
this.oncompleted.apply(this, [{
loaded : this.loaded,
}]);
}
},
progress: function(name) {
this.loading--;
this.loaded++;
var total = this.loaded + this.loading + this.errors;
if (this.onprogress && typeof(this.onprogress) === 'function') {
this.onprogress.apply(this, [{
recent : name,
total : total,
progress: this.loaded / total,
}]);
}
if (this.loading === 0)
this.completed();
},
error: function(name) {
this.loading--;
this.errors++;
var total = this.loaded + this.loading + this.errors;
if (this.onerror && typeof(this.onerror) === 'function') {
this.onerror.apply(this, [{
error : name,
total : total,
progress: this.loaded / total,
}]);
}
},
load: function(keys, completed, progress, error) {
this.loading += keys.length;
if (completed && typeof(completed) === 'function')
this.oncompleted = completed;
if (progress && typeof(progress) === 'function')
this.onprogress = progress;
if (error && typeof(error) === 'function')
this.onerror = error;
for (var i = keys.length; i--; ) {
var key = keys[i];
this.loadResource(key.name, key.value);
}
},
loadResource: function(name, value) {
this.keys[name] = value;
},
});
此资源加载器有两个正在运行的实现。一个用于图像,另一个用于声音。两者加载资源的方式不同,因为图像可以通过以下代码轻松获取
var ImageLoader = ResourceLoader.extend({
init: function(target) {
this._super(target);
},
loadResource: function(name, value) {
var me = this;
var img = document.createElement('img');
img.addEventListener('error', function() {
me.error(name);
}, false);
img.addEventListener('load', function() {
me.progress(name);
}, false);
img.src = value;
this._super(name, img);
},
});
然而,对于音频元素来说,这并非易事。主要问题在于,不同的浏览器支持不同的音频格式。因此,需要以下代码。它将检查浏览器支持哪种格式(如果有),并选择检测到的格式。在这个例子中,格式选择固定为_MP3_和_OGG_。
var SoundLoader = ResourceLoader.extend({
init: function(target) {
this._super(target);
},
loadResource: function(name, value) {
var me = this;
var element = document.createElement('audio');
element.addEventListener('loadedmetadata', function() {
me.progress(name);
}, false);
element.addEventListener('error', function() {
me.error(name);
}, false);
if (element.canPlayType('audio/ogg').replace(/^no$/, ''))
element.src = value.ogg;
else if (element.canPlayType('audio/mpeg').replace(/^no$/, ''))
element.src = value.mp3;
else
return me.progress(name);
this._super(name, element);
},
});
通常,将此资源加载器扩展到任意数量的格式非常简单,但是,由于调整是微不足道的,因此灵活性在这里不是问题。
在此代码中,我们还引入了另一种不继承自ResourceLoader
的资源加载器。相反,它只是尝试捆绑其他ResourceLoader
实例。这样做的原因很简单:最终我们只需要指定用于某些资源集的资源加载器类型。整个加载过程将由这个加载器监督,它只按顺序触发给定的资源加载器。
那么,要推出我们自己的塔防游戏,实际上需要什么呢?
- 在manifest.js中定义您的资源并更改一些全局变量
- 通过替换/修改文件towers.js定义自定义塔
- 通过替换/修改文件units.js定义自定义单位
- 通过替换/修改文件shots.js定义自定义射击
- 您想要一个不同于画布的绘图工具吗?考虑扩展video.js
将所有内容组合在一个简单的启动脚本中,如下所示。我们可以将此脚本嵌入到文档中。如果我们将所有可用脚本最小化,您还应该将其包装在立即执行函数表达式(IIFE)中。这将使所有全局变量变为局部变量,这将是一个更好的选择。然而,这种方法有一个问题,即我们不能将启动脚本嵌入到文档中,因为嵌入的脚本无法看到包含在另一个脚本文件中的某个函数的局部变量。
一个非常简单的启动脚本
(function() {
"use strict";
var canvas = document.querySelector('#game');
var towerType = undefined;
var getMousePosition = function(evt) {
var rect = canvas.getBoundingClientRect();
return {
x: evt.clientX - rect.left,
y: evt.clientY - rect.top
};
};
var addHandlers = function() {
logic.addEventListener(events.playerDefeated, function() {
timeInfo.textContent = 'Game over ...';
});
startWaveButton.addEventListener(events.click, function() {
logic.beginWave();
});
canvas.addEventListener(events.click, function(evt) {
var mousePos = getMousePosition(evt);
var pos = logic.transformCoordinates(mousePos.x, mousePos.y);
evt.preventDefault();
if (towerType) logic.buildTower(pos, towerType);
else logic.destroyTower(pos);
});
};
var completed = function(e) {
addHandlers();
view.background = images.background;
logic.start();
};
var view = new CanvasView(canvas);
var logic = new GameLogic(view, 30, 15);
var loader = new Loader(completed);
loader.set('Images', ImageLoader, images, resources.images);
loader.set('Sounds', SoundLoader, sounds, resources.sounds);
loader.start();
})();
这除了确定应该建造哪个塔之外,完成了所有事情。提供的示例源中包含了一个更复杂的版本。
游戏平衡
演示游戏的初始版本相当容易。主要问题在于,对手的分布被选择为均匀的。这个选择的后果是,即使在高等级,也可能会出现非常容易的对手。更糟糕的是,出现一些强硬对手的几率与出现容易对手的几率相同。
通过选择更好的分布可以很容易地避免这种情况。在这种情况下,在可能的对手生成范围内工作的高斯分布似乎是最好的。唯一的问题是,我们想把这个高斯分布的峰值放在哪里。这个峰值将标记预期的对手类型。这个峰值可以根据等级移动。
在代码形式上,我们需要编写一个非常简单的算法来生成高斯随机值。这不是一项艰巨的任务,因为我们可以使用非常简单的Box-Muller变换。
var randu = function(max, min) {
min = min || 0;
return (Math.random() * (max - min) + min);
}
var randg = function(sigma, mu) {
var s, u, v;
sigma = sigma === undefined ? 1 : sigma;
mu = mu || 0;
do
{
u = randu(1.0, -1.0);
v = randu(1.0, -1.0);
s = u * u + v * v;
} while (s == 0.0 || s >= 1.0);
return mu + sigma * u * Math.sqrt(-2.0 * Math.log(s) / s);
}
在这种情况下,我们丢弃了另一个可能基于v
的数字。通常我们会将这个数字存储在缓冲区中,并在每次对randg
函数的请求中使用它。对于我们简单的游戏,我们只是省略了它,实际上是过度使用了随机数。
WaveList
已被修改,以在开始时生成简单的波次,在结束时生成困难的波次。首先,我们使用多项式来找到给定回合中的对手数量。这是通过使用一些魔法数字完成的,这些数字是通过将多项式拟合应用于所需值来计算的。现在,行为是前几回合对手数量较少,20级以后对手数量增加。在50级时,我们已经要面对超过150个对手。
var WaveList = Class.extend({
/* ... */
random: function() {
var wave = new Wave(this.index);
//The equation is a polynomfit (done with Sumerics) to yield the desired results
var n = ~~(1.580451 - 0.169830 * this.index + 0.071592 * this.index * this.index);
//This is the number of opponent unit types
var upper = this.index * 0.3 + 1;
var m = Math.min(this.unitNames.length, ~~upper);
var maxtime = 1000 * this.index;
wave.prizeMoney = n;
for (var i = 0; i < n; ++i) {
var j = Math.max(Math.min(m - 1, ~~randg(1.0, 0.5 * upper)), 0);
var name = this.unitNames[j];
var unit = new (types.units[name])();
wave.add(unit, i === 0 ? 0 : randd(maxtime));
}
return wave;
},
});
对手选择中的上限由upper
变量给出。最长时间仅仅是对手数量乘以每个对手一秒。高斯分布的峰值位于下限和上限之间。上限随着当前等级而移动。最终我们将达到最强的对手,并将高斯分布的中心放在那里。这是大多数对手将非常强大的点,伴随着一些较弱的对手,如果有的话,只有少数非常弱的对手。
上一张截图展示了演示游戏后期的新设计。为了减缓对手,创建了一个相当繁琐的迷宫。此外,还设置了许多地狱之门,这是阻止即使是重装甲对手的绝佳方法。最后,我们还必须处理大量堆叠的对手,这会给我们的塔楼带来很多麻烦。
这个小迭代的另一个特点是能够恢复现有的游戏。当前游戏将在波次结束后保存。如果浏览器在一开始就找到一个已保存的游戏,它会询问玩家是否应该恢复之前的游戏。这使得长期玩游戏无需担心。
有两个方法负责保存和加载游戏。第一个是saveState()
方法。它将当前的GameLogic
实例转换为一个可移植对象。这个对象没有任何需要解析的引用。它基本上是一个原子信息对象。
var GameLogic = Base.extend({
/* ... */
saveState: function() {
var towers = [];
for (var i = 0; i < this.towers.length; i++) {
var tower = this.towers[i];
towers.push({
point : { x : tower.mazeCoordinates.x , y : tower.mazeCoordinates.y },
type : tower.typeName,
});
}
return {
mediPackCost : this.mediPackCost,
mediPackFactor : this.mediPackFactor,
towerBuildCost : this.towerBuildCost,
towerBuildFactor : this.towerBuildFactor,
towerBuildNumber : this.maxTowerNumber,
hitpoints : this.player.hitpoints,
money : this.player.money,
points : this.player.points,
playerName : this.player.name,
towers : towers,
wave : this.waves.index,
state : this.state,
};
},
loadState: function(state) {
this.towers = [];
for (var i = 0; i < state.towers.length; i++) {
var type = types.towers[state.towers[i].type];
var tower = new type();
var point = state.towers[i].point;
var pt = new Point(point.x, point.y);
if (this.maze.tryBuild(pt, tower.mazeWeight)) {
tower.mazeCoordinates = pt;
tower.cost = type.cost;
this.addTower(tower);
}
}
this.mediPackFactor = state.mediPackFactor;
this.towerBuildFactor = state.towerBuildFactor;
this.player.points = state.points;
this.player.name = state.playerName;
this.setMediPackCost(state.mediPackCost);
this.setTowerBuildCost(state.towerBuildCost);
this.setMaxTowerNumber(state.towerBuildNumber);
this.player.setHitpoints(state.hitpoints);
this.player.setMoney(state.money);
this.waves.index = state.wave;
this.state = state.state;
},
/* ... */
});
上面代码中显示的第二个方法叫做loadState()
。给定一个原子信息对象,我们可以生成所有的塔(实例)并正确设置所有必需的属性。这样我们就可以对原子信息对象做任何我们想做的事情。一个自然的选择是将对象字符串化(或反过来解析)以将其存储在本地存储中。
另一个可能的用途将涉及异步访问,例如将其保存到某个服务器上的数据库或本地。我们也可以在cookie的上下文中传输它。
这场游戏中没有赢家。所以唯一剩下的问题是:你能走多远?在某个时候你会输掉。这也将删除保存的游戏。另一个选择是,一旦新一波开始,就删除保存的游戏。然而,在当前版本中,这允许游戏刷新浏览器以避免过早失败。
最后润色
当然,游戏和任何其他软件一样,永远不会完成。然而,由于这只是一个小小的移植项目,我希望以某种方式完成它。有很多东西缺失或者看起来不够专业,比如使用浏览器提供的消息框。然而,在我看来,至关重要的一点是,玩家能够看到下一回合可能要对付的对手类型。
实现这一点实际上是直截了当的。首先,我需要一种方法来读取下一关的对手。由于我使用的是随机波次,我不得不实现一种在实际创建下一波之前访问它的方法。
var WaveList = Class.extend({
/* as before */
nextOpponents: function() {
var upper = this.index * 0.3 + 1.3;
var m = Math.min(this.unitNames.length, ~~upper);
var units = [];
for (var i = 0; i < this.unitNames.length && i < m; i++)
units.push(this.unitNames[i]);
return units;
},
});
现在我们可以在我们的app.js文件中使用这个函数,它基本上是我们的逻辑和UI之间的粘合部分。我们可以在这里添加一个方法,如下所示
var updateNextWave = function() {
nextwave.innerHTML = '';
var names = logic.waves.nextOpponents();
for (var i = 0; i < names.length; i++) {
var name = names[i];
var unit = types.units[name];
var img = images[unit.sprite];
var div = document.createElement('div');
var icon = document.createElement('canvas');
var width = img.width / unit.frames;
icon.width = 32;
icon.height = 32;
var targetHeight = img.height > 32 ? 32 : img.height;
var targetWidth = width * targetHeight / img.height;
var ctx = icon.getContext('2d');
ctx.drawImage(img, 0, 0, width, img.height, 16 - targetWidth * 0.5, 16 - targetHeight * 0.5, targetWidth, targetHeight);
div.appendChild(icon);
var info = document.createElement('div');
info.innerHTML = [
'<div class=title>', unit.nickName, '</div>',
'<div class=description>', unit.description, '</div>',
'<div class=rating>', ~~unit.rating, '</div>',
'<div class=speed>', unit.speed, '</div>',
'<div class=damage>', unit.hitpoints, '</div>',
].join('');
info.classList.add('info');
div.appendChild(info);
nextwave.appendChild(div);
}
};
基本上,我们只是遍历所有给定的名称,获取类型(即带有一些属性的构造函数),并使用它来创建类似于塔楼的预览面板。大部分工作实际上是通过画布将给定的精灵制作成预览图像(包括重新缩放等)。这与塔楼按钮使用的预览图像不同,后者应用了一些CSS规则。
最后一点是图形上下文只绘制图像(即这些精灵的当前帧)的限制。如果我们要呈现更动态的信息,这并不是很有帮助。所以我决定用几行代码来扩展它
var CanvasView = View.extend({
/* ... */
drawVisual: function(element) {
/* as before */
element.draw(ctx, dx, dy, w, h);
},
});
现在,每个元素的draw()
方法都会被调用并带有一些参数。因此,每个元素都可以重写此方法以提供自定义覆盖。
这是GameObject
提供的标准实现。
var GameObject = Base.extend({
/* ... */
draw: function(ctx, x, y, width, height) {
},
});
以下是Unit
使用的自定义实现
var Unit = GameObject.extend({
/* ... */
draw: function(ctx, x, y, width, height) {
var maxLength = 12;
var barLength = maxLength * this.health / this.hitpoints;
x += (width - maxLength) * 0.5;
ctx.fillStyle = 'rgba(0, 0, 0, 0.4)';
ctx.fillRect(x, y - 6, maxLength, 3);
ctx.fillStyle = '#00ff00';
ctx.fillRect(x, y - 6, barLength, 3);
},
});
我们在这里做什么呢?嗯,读过代码的人可能会知道,我们实际上正在显示单位当前的生命值状态。首先我们绘制一个稍微不透明的矩形来表示总血量。然后我们在上面放置一个绿色矩形,表示当前的生命值状态。
现在我们知道还剩下多少生命值了!可惜我们无法使用这些信息,因为每个塔都会自动瞄准单位。这可能会在未来的版本中改变,但目前我认为这个项目已经结束了。
关注点
该代码采用与马里奥游戏(文章可在此处找到 CodeProject)相同的“OOP”JavaScript方法。JavaScript编程从一开始就是面向对象的,但是原型模式和以对象为中心的风格使得继承和以类为中心的架构比应有的更难。这就是oop.js文件派上用场的地方。它允许我们扩展现有定义并创建新定义。
此外,这里还可以实现某种形式的反射。我们可以将这些函数(我们称之为类型)添加到某些对象或数组中。这样我们就可以模拟类似于.NET Framework或Java中的反射。我们只需要显式执行此添加。然而,这有一个好处,就是不再需要过滤。而且我们非常灵活,可以轻松更改顺序、值等。
我们也不再需要属性了。属性只是注释某种类型,即为类放置一个静态值。在JavaScript中,我们可以为任意函数赋予属性。由于类只是一个类型,在JavaScript中由构造函数表示,因此我们可以根据需要为这个函数放置任意数量的属性。
回答问题:移植得如何以及多快?
官方答案是:4个晚上。然而,我花了一些时间才弄清楚原始代码的一些问题并正确理解原始作者的意图。此外,在JavaScript中调试代码确实比在C#中使用Visual Studio更难(我认为我对JavaScript相当有经验)。最大的问题甚至不是来自通用算法或实现。大多数时候,动态类型系统通过隐藏一些通常编译器会检测到的微不足道的类型错误而导致了bug。我承认,例如,在这方面使用TypeScript会很有益。TypeScript也会使OOP方法过时,因为它包含用于使用类的关键字,这些关键字将完美地转换为与现在生成的运行时代码相似的代码。但是TypeScript也有一个问题。我认为,对于给定大小的项目,使用TypeScript的开发时间会稍微长一些——即使包括调试时间。
我可以在线试玩吗?
当然,我在我的网页上放置了一个版本(代码与提供的演示应用程序略有不同)。你可以在 html5.florian-rappl.de/towerdefense 玩它。如果你有任何评论、建议或改进,欢迎在这里或通过发送任何类型的消息发布。
历史
- v1.0.0 | 首次发布 | 2014年3月1日
- v1.1.0 | 更新了资源加载器 | 2014年3月3日
- v1.1.1 | 纠正了一些拼写错误 | 2014年3月4日
- v1.2.0 | 平衡更新 | 2014年3月19日
- v1.2.1 | 修正了缺失的图片 | 2014年3月26日
- v1.3.0 | 添加了一些最后的话 | 2014年4月8日