[译]终极塔防——运用HTML5从头创建一个塔防游戏

翻译共享一篇CodeProject的高星力作,原文地址:http://www.codeproject.com/Articles/737238/Ultimate-Tower-Defense

article

 

介绍

塔防游戏是一种非常简单的游戏。维基百科对它的解释是,一个塔防游戏的目标在于“试图阻止敌人通过地图:通过各种陷阱来延缓它们的行进、同时搭建各种炮塔来射击消灭它们...”。敌对单元有各种基本属性(如速度、生命值)。防御塔则各自具有不同的能力,不过无例外地都需要消耗金钱来购买,金钱是由玩家通过击败入侵的敌人来取得的。

本文会带您构建一款基础级的塔防游戏,它易于扩展并且已经开源。我们将运用网间流行的HTML5、CSS3、和JavaScript来搭建。图形部分会用到<canvas>来实现,不过图形和其他部分之间其实是松耦合的、完全可以用其他技术(例如<div>加一些CSS)来替代实现。

本项目并不依赖于任何第三方代码例如jQuery、AngularJS、或任何游戏引擎。完全是从零基础建立起来。这其实要比很多人想象的容易得多,而且能给我们额外的自由度。另一个考量就是可以免去因包含进一些非必须的函数而带来的负担。最后但并非最不重要的是,这样做也使得编写出一篇简单易上手的指南成为了可能。

 

背景

我每年都会开几次培训课,讲授当下流行的技术,如C#开发、HTML5/CSS3/JavaScript网页应用开发。我往往会投入相当多的热情来对待这些课程。原因之一就是它们的学习曲线通常都是并不平坦的,尽管也的确有有天赋的学生能单单靠讲义就融会贯通的。另一个原因就在于结业项目会非常棒。每次我都惊喜于仅仅两周多时间所能创造出的成果,真的是从“新手”到“高手”!

在这方面一件我感觉很酷的事就是能把“项目构想”灌输给我的学生。要知道我是一个充满了想法的人,甚至我都感觉这成了一个麻烦了,因为我实在都找不到时间(至少是不能以我所能接受的高效方式)来实现它们!然而作为一个结业项目,学生们当然就可以走走捷径,并且把进度停在某个点上、只要这个点本身有价值。这样一来,不仅我的学生们能学到很多劲酷的内容,我自己也能收获到不少有价值的东西。这种方式至少能验证我脑中所想的究竟是否有助于解决问题、以及能解决到何种程度。

多数的项目,其实就是游戏~。这并非是源自客户需求,但却的确是有道理的。当我们构思某个课题来作结业项目时,它往往应该对我们能派得上用场才最好。可是,大多数人在某个时间点上并不会都特别需要某种应用。而打造一个游戏在这方面就很有优势,因为它能带给我们很多欢乐。而且别人也可能会喜欢它,游戏并不能解决真正的问题、但它能创造出新的问题(一种任务),这种任务也只有在这个游戏中才能获得解决。

当然,大多数学生并未曾写过游戏——至少是有画面的游戏。因此他们会面对“游戏引擎”的入门学习以便于去用到它。而我会教授他们如何写一个简单的游戏引擎、以及如何设计他们的游戏。有时我还能给他们一些有用的算法或实现。另一个关键但容易被忽略的地方是去哪里搜寻那些资源,例如声音、图像,所幸的是我的硬盘里存有大量的优质链接和资源文件。

这个塔防游戏最早是在C#培训中被开发的。用到了SDL.NET做渲染、以及DirectX来播放声音。多数的角色是用手工画出来的,这使得游戏有些"像素"怀旧风格(...)。后来我考虑把这个项目进一步修改成JavaScript版,最终我觉得这一定会是一次不错的实验:究竟我能多快多好地、把这些有趣的C#游戏代码转成网页版?(请拭目以待吧)

 

游戏引擎的要素

游戏引擎是一段用来负责游戏的图形绘画、声音播放、以及逻辑演绎的代码。这三项职责应该尽可能地被分隔开。如果我们能够精确地解耦它们,那么代码就会是真正具有可扩展性、并且易维护的。尽管视频循环是“时间无关”的(例如以尽可能高的频率来执行画面更新,只要硬件允许),但逻辑循环却是“时间相关”的(例如每隔一个预设的时间间隔做一次处理)。记住这一点非常重要:一个游戏有时对某台设备来说可能是画面负荷过重的。而此时逻辑循环仍旧是试图以它的“节奏”来运行,表现在画面上就会变“卡”。此类结果源自于这样一种架构:一套步数固定的逻辑步骤,要匹配到一套(视硬件配置而定)步数可变的绘画步骤上去。

在游戏中我们把主要的逻辑处理放在了一个叫做GameLogic的类中。通过调用start()方法能触发该逻辑处理。从那一时点上JavaScript引擎就能开始以固定间隔调用tick()函数(这一间隔被定义在了contants.ticks中)。只有当前一个逻辑循环已经不再运行时新的逻辑循环才能被触发。

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;    

        }

    },

);

逻辑类预先就知道会有一个View存在。但它并不知道具体的View类、也不知道start(),stop()之外的任何方法。当逻辑循环开始时,视频循环也该被同时开始。此外当逻辑循环暂停时我们也将停止绘画操作。

UI之间的交互通过事件来完成,包括两个方向上的事件:

  • 来自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()被用于触发一个事件。侦听者能通过addEventListenerremoveEventListener来分别注册、注销到一个事件。其它就和通常在JavaScript中注册/注销到某个UI控件的事件处理器一样了。

最后我们可以这样来写:

logic.addEventListener('moneyChanged', function(evt) {

    moneyInfo.textContent = evt.money;

});

这样就能把游戏逻辑和UI联系到了一起。

 

构建一个塔防游戏

塔防游戏并不很难搭建。因为有这么几个原因:

  • 一个基础级的塔防游戏往往是回合制的
  • 很适合用粗糙的网格就能表现出来
  • 只需用到很基本的物理原理
  • 规则非常简单直接

任何塔防游戏的核心(就和很多策略游戏中一样)就是路径搜寻算法。我们不必去应对成千上万个游戏单元,因此也无需去寻求一个快速算法。在这个范例项目中我们可以就采用著名的A*算法,它在各种语言中几乎都有多种版本的实现。其中之一就是我的实现~,移植自它的C#版本。如果你关心它是如何实现的,可以阅读我的相关文章。文中也包括了使用单一(固定)策略的一段简短演示的链接

在此,用于存储不同迷宫策略的枚举型对象将是这个样子的:

var MazeStrategy = {

    manhattan        : 1,

    maxDXDY         : 2,

    diagonalShortCut : 3,

    euclidean        : 4,

    euclideanNoSQR : 5,

    custom         : 6,

    air             : 7

};

通常游戏单元会以Manhattan计量方式来走过迷宫。Manhattan是一种较为特殊的计量方式,它不允许走对角线捷径。在Manhattan方式中,从(1,1)走到(2,2)被算作至少需要2步。相对而言在更为普通的Euclidean计量方式中,从(1,1)走到(2,2)会被只算作1步。

还有其他的计算方式会被用在游戏中(比如不对平方距离计算平方根的Euclidean算法的变体,在某些情况下它的结果是不同于标准Euclidean算法的)。当然,在所有计算方式中,air策略堪称是最“了不起”的:它会令一切优秀的路径算法黯然失色,因为它熟知忽略掉一切障碍物直取目标的方式才是“最短路径”;这种策略只能被用在一种游戏单元上,而这种游戏单元也只能由一种塔防单元来击落——那就是、防空塔。

一个塔是通过继承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(),只需带着一些特定参数调用基类的构造函数即可。此外就是创建塔的视觉效果。一个视觉效果类中包含了完整的动画对象的信息,例如所有的帧、带方向的移动、以及动画对象的图像源。

每个塔都定义了一种发射类型,也就是特定的shot类的类别。用JavaScript的语言来说,就是一个指向能用来实例化特定shot对象的构造函数的引用。

所有发射类型的基类都如下:

var Shot = GameObject.extend({

    init: function(speed, animationDelay, damage, impactRadius) {

        /* ... */

    },

    update: function() {

        /* ... */

    },

});

Flak塔(防空塔)中我们定义的发射类型指向的就是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塔(防空塔)用到,它也就只能把空军单位作为目标。(发射类的)构造函数看上去都很近似,主要区别也就在于被实例化之后的那一声“炮响”(会用到不同的音效)。

下图展示了在经过了若干行动之后的游戏画面:

action

那么什么能被防御塔作为发射目标呢?很好,这样的目标就来自于“游戏单元”。显然的,在此我们可以遵循之前的策略,我们将使用一个Unit类来作为所有相关派生对象的基类。

var Unit = GameObject.extend({

     init: function(speed, animationDelay, mazeStrategy, hitpoints) {

         /* ... */

     },

     playInitSound: function() {

         /* ... */

     },

     playDeathSound: function() {

         /* ... */

     },

     playVictorySound: function() {

         /* ... */

     },

     update: function() {

         /* ... */

     },

     hit: function(shot) {

         /* ... */

     },

});

游戏里有几种单位。游戏的平衡性主要就依赖于创建一些好的攻击波算法,从而使得游戏有难度,但又并非不可能完成。让我们看看各种单元类型:

  • mario马里奥(Mario) - 一种非常好对付的小怪
  • rope草蛇(Rope) - 只增加了一点点难度(更多的生命值)
  • wizzrobe火法师(Fire Wizzrobe) - 非常快速,但没有多少生命值
  • airwolf空中战狼(Air Wolf) - 游戏中唯一的飞行单位
  • darknut黑骑士(DarkNut) - 速度还可以,但是生命值很高
  • speedy极速精灵(Speedy) - 游戏中最快速的单位,而且很有些生命值
  • armos重装者(Armos) - 最高生命值单位,但也是速度最慢的

添加一种新的单元非常简单(而且实际上也很有趣!)。设计一个新游戏单元的关键问题在于:这一单元应该在社么时候出现,以及具有什么属性(主要是速度、装甲)。

作为一个例子,我们看一下马里奥(Mario)单元的实现。如下代码将把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会自动做这些,或者我们可以写一些脚本来把这作为构建任务来执行。那么除此之外我们还有什么? 最重要的元素就是<canvas>,它被放在一个由<div>来标记的frame框的正中。

有3个按钮被用来控制游戏。我们能让新的一波攻击开始(在此之前请布置好防御)、购买一个医疗包、或是购买一个额外的塔防建造权。可建造的塔防的数量是受限制的。构建额外的塔防所需的开销是会随着已允许建造的塔防的数量而递增的。

我们能怎么建造塔防?好吧,这个无法直接从上面的代码看出。我们会用到一个带标识符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>控件也并不需要用到任何风格。所以风格的改善就留待希望拥有更专业游戏外观的的开发者来做吧。

 

类图

重写整个游戏的另一个目的,是源自于想用面向对象的方式把所有东西重新描述一番。这会使得编程更为有趣和简单。而且最终的游戏也会更少有Bug。下面这张类图就是在创建这个游戏之初所做的筹划:

clsdiagram-preview

游戏严格遵循着这份类图。扩展这个游戏实际上简单到只需把它作为一个模板、就基本上能扩展到任何塔防游戏。理论上也很容易把战场扩展成其他类型,例如泥沼(译者:还是MUD游戏?)、传送门等等。这里的一个技巧就是改用其他的、在构建时不会反射出0权重的方格(译者:此处不甚理解)。这已经被包括在代码内了,但是还没有被正式用。

下一节我们将看到怎样运用现有的代码来发行我们自己的塔防游戏。

 

运用代码

我所给出的代码并不代表一个游戏的完成态。相反,它代表的是一系列塔防游戏的模板。我所提供的网页应用,只是运用到了代码的各个不同部分来合成一个简单游戏的范例。

资源加载器(resource loader)是一个颇为有趣的类。它定义了一个特定的资源加载器所需的核心功能。基本上它只是接收一个资源列表,而加载任务的进度、错误、完成事件、则可通过设置回调函数来取得。

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中修改相关的全局变量
  • 定制防御塔,替换/修改tower.js
  • 定制游戏单元,替换/修改units.js
  • 定制发射类,替换/修改shots.js
  • 你想用不同于<canvas>的东西来做绘图么?可以考虑扩展video.js

用后面的一个简单的启动脚本来组装所有东西。我们能把这个启动脚本嵌入到通常的文档(html)中。如果我们想要最小化所有的可执行脚本,你就还需要把它封装在一个IIFE(Immediately-Invoked-Function-Expression)表达式中。它会使得所有的全局变量变成局部可用,这是个很棒的选择。不过这个方法有个问题,就是我们就不能把启动脚本嵌入到文档中了,因为被嵌入的脚本、将无法从其他脚本文件的一些方法中看到局部变量。

一个非常简单的启动脚本:

(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变量标识的。maxtime只是对每种怪物都把怪物数量乘以1秒钟(?)。高斯的峰值被放在怪物选择的最高值与最低值的正中位置。最高值会随着当前关卡而迁移,最终我们会到达最强怪物、并把我们的高斯分布的峰值(中心值)放在那里。这时大多数的怪物都真心很强力,伴随着一些弱一些的怪,而真正的弱怪将少到几乎不可能出现。

chaos

这幅截图展现了重新设计之后的游戏直到非常后面的关卡中的盛况。一个颇为繁琐的迷宫被创建来拖慢怪物们。同时造了很多地狱门,它们能使得即使最强装甲的怪物也停下脚步。最后我们还要对付很多成群出现的怪物,否则它们会对我们的塔群造成问题。

在这一开发回合中实现的另一个特性就是游戏的保存和加载。每当一轮攻击波结束当前的游戏进度会被自动保存。当浏览器发现有已被保存的游戏进度时会提示玩家是否恢复该进度。这使得游戏能被玩得再久都没关系。

有两个方法被用来实现游戏的保存和加载。第一个是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中。

lost

这个游戏并不会有“赢家”。所剩唯一的问题就在于:你能走得多远?在某些时间点上你可能会输、这会导致进度被删除重来。而有一个方法就是,在一波新的攻击开始后选择删除进度。在当前版本中,刷新浏览器可以删除当前进度、作为挽回你败局的一种另类手段。

 

有趣之处

(TODO)

回答那个问题: 这样的移植能完成得多快多好?

官方回答是:4个晚上。不过,我也花了一些时间解决原有代码的一些问题、以及正确理解原作者的意图。在JavaScript中做调试要比在C#中难得多(尽管我自认非常熟悉JavaScript了)。最大的问题并不在于一般的算法或实现。主要的时间消耗源自于动态类型系统(dynamic type system)使得那些琐碎的原本能被浏览器自动定位到的类型错误全都被隐藏了起来(那个难找啊..)。我承认,如果使用TypeScript的话原本会有助于解决这个问题。TypeScript也会使得现在的OOP方式不再需要,因为它自己包含了使用类的关键词、能产生出和现在的运行时代码同样优美的代码。不过TypeScript也有自己的问题——在我感觉中、对同等项目开发而言、如果用TypeScript来做开发周期会被拖得更长。

这游戏能在线玩么?

当然,我把它放在了我的网页上(版本与我的范例有少许不同)。你能在html5.florian-rappl.de/towerdefense访问到。如果你有评价、建议、改进,你可以在下方(CodeProject文章原址上)以任何形式发给我(原作者)反馈。

 

编辑履历

(请参照原文)

 

版权

本文及所有相关代码,遵循CPOL(The Code Project Open License)版权规定

 

关于作者

(请参照原文)

 

本作品由楚天译作,采用知识共享署名 3.0 中国大陆许可协议进行许可。欢迎转载、演绎、或用于商业目的,但是必须保留本文的署名(包含链接),或联系作者协商授权。

你可能感兴趣的:(html5)