整个项目都已经开源,项目地址:https://github.com/m8705/MAGIC-TOWER-JS
注:这是我高中时候的作品,BUG 很多,已经不再更新了。下载项目到本地就能玩。

 

前言

魔塔是一个非常经典的 2D 策略类 固定数值RPG游戏,你将扮演一个王国的勇士,前往远方的魔塔,解救里面的公主,探寻魔塔的秘密。以前我在学习机上玩的这款游戏,后来高中学了前端,用前端技术还原了这个经典的魔塔游戏,但是一直没有对这个游戏的制作的过程进行总结归纳。因此就有了现在这篇文章。

让我们从0开始,一起做一个完整的魔塔游戏吧。(剧透警告,建议先自己去玩一玩游戏)

 

需求分析和原型设计

需求分析:现在的魔塔游戏大多都是 Flash 版本的,没有用 HTML5 技术写的版本,可移植性差,而且我是写前端的,当然要用前端来写啦。因此我们的项目目标就是用 HTML5 canvas 技术实现一个运行在浏览器的经典魔塔游戏。

原型设计:魔塔在国内有很多个版本,从早期的胖老鼠21层版本到后来的50层、60层版本,再到后来的70层、100层版本,不同版本的魔塔在地图设计、故事情节、数值系统设计等方面都存在差异,我们到底要做哪一个版本的经典复刻呢?这里我选择的是以前在学习机上玩过的50层魔塔,这个版本的魔塔不仅情节内容比较丰富,玩法多样,略去了费时的战斗动画,而且具体实现起来也比较容易。

由于学习机里的魔塔虽然是50层魔塔,但是它并不是标准版的50层魔塔,游戏里的界面设计依照的是学习机的屏幕尺寸进行设计的,因此想要移植到浏览器端,就必须对界面进行重新的设计。这里我们参考《齐天大圣系列灵山圣战》的界面设计:

布局设计基本一模一样,最终的界面布局和效果如下图:

(这里因为 canvas 缩放没有处理好,因此文字看起来会比较模糊)

两边是状态栏和道具栏,中间是游戏的内容。
整个游戏以 32 × 32 px 的基本块作为创建游戏内容和布局的基本单位,
所有的角色、装备、宝物和地图都是由不同数量的基本块构成的,逻辑判断时也是以基本块为单位。
游戏地图的大小为 11 × 11 块,两边的状态栏和道具栏大小都为 4 × 11 块。

值得一提的是,
《灵山圣战》里一共有66层楼,
而我们要做的经典复刻版魔塔,只有50层楼,
而这50层楼的地图经过对比,基本和66层楼里的前50层楼的地图吻合,
因此我们魔塔的地图设计可以直接借鉴《灵山圣战》里的地图,
只需在《灵山圣战》无敌版中,利用楼层传送器,将《灵山圣战》中每层楼的地图都记录下来,
再人工转换成地图数据,即可简化开发时间。

在此还是要向《灵山圣战》的开发者致个歉,我在界面设计、数值系统、地图设计等很多地方借鉴了您的作品,
我只是将此作为一个个人的练手项目,并无以此谋私的意图,还请您谅解。

 

整体设计和实现思路

架构设计

这个游戏的架构主要分为两部分:逻辑部分和数据部分。
逻辑部分:绘图逻辑,规则逻辑和事件逻辑;
数据部分:地图数据,数值表数据,资源加载器

功能模块

绘图逻辑:负责绘制状态栏,道具栏、地图、角色、道具、交互信息
规则逻辑:负责碰撞检测和处理,创建游戏基本规则
事件逻辑:在规则逻辑的基础上,负责处理特定事件

地图数据:存储了魔塔的每一楼层的所有的道具位置,角色位置、建筑结构位置(墙,门,无法接触的地表)
数值表数据:存储了魔塔的所有怪物以及主角的属性信息
资源加载器:负责加载游戏必须的图片资源

实现思路

因为魔塔游戏的所有内容都是在地图上呈现的,因此要开始实现这个游戏就必须先研究地图的设计问题。

魔塔的每张地图都是 11 × 11 块的正方形,每次主角只能在横向或纵向移动一格,
因此很自然地就想到,可以用一个点 ( x , y ) 来表示主角在地图中的位置。
那么地图数据本身怎么存储呢?

仔细想想就可以想出来,每张地图可以表示为一个二维数组,按行或者按列储存,
地图数组里的每个元素储存着对应基础块的角色道具或建筑信息,绘制地图时就遍历整个数组,读取每个元素的信息进行绘制。
这种方法比较简单,但是还有很多值得探讨的地方:

第一,地图数组使用一维数组还是二维数组?

二维数组的遍历比一维数组要稍微复杂一些(表面复杂而已),在进行碰撞检测时,需要处理二维数组的 (跨行 / 跨列)问题。一维数组因为元素都是连续的,只需做边界判断 ( 左边界 / 右边界 ) 即可,
但是其因为只有1个索引,因此需要将索引值用公式转换为坐标 ( x , y ),而且坐标每更新一次都要计算一遍对应坐标。
我当年选择了使用一维数组来实现地图,现在想想用二维数组来实现其实会更简单一些。

第二,地图数组应该如何储存角色道具和建筑信息?

我们可以将地图数组里的每个元素都保存为对象(保存为数组效率太低了),
然后在绘制时遍历这个数组,读取每个对象的属性,逐个进行绘制。
但是在地图数据的写入阶段会比较麻烦,因为需要确保角色道具或建筑信息的数据属性都正确
( 数组中一共有 50 × 11 × 11 个对象,每个对象还有N个属性,这可咋办? )

我们也可以将道具位置,角色位置、建筑结构位置分别使用数组储存,
分别创建道具位置数组,角色位置数组,建筑位置数组,在数组中的特定位置存放道具类型,角色类型,建筑类型,
通过使用 Tiled Map Editor 或者类似的 2D 地图编辑器来写入地图数据,
绘制时按照 建筑 → 道具 → 角色 的顺序进行逐层绘制,

但是这种方法要求你要设置 3 个50层的数组,一旦改变某个数组,可能需要一并更改其他数组,
而且很容易出现稀疏数组,比如某一层只有1个角色,或者1个道具,需要考虑空值的处理。
如果地图更大一点可能会有影响,实际测试的时候其实性能还是可以的。

最后我还是选择了为位置,角色、建筑结构分别设置数组的方法,所有数据全部都是手输的
本来打算自己弄一个地图设计器,但是当时一直没解决图片分割和点选的问题,就没弄了


这个地图储存的实现感觉还是有改进的空间,
我不知道其他游戏怎么设计的,如果你们有知道的也可以告诉我???

第三,数值表数据应该怎样储存和使用?

数值表数据存储了游戏中所有怪物和主角的名字,血量、攻击力、防御力和击败后能获得的金币(主角:???)
这个数值表我是在网上随便找的,部分怪物的数值和名称经过和《灵山圣战》里的进行比对已经进行了修改。

数值设计是魔塔游戏的关键部分,它是游戏平衡和游玩策略的决定性因素,
它牵涉到地图,剧情等太多问题。对我而言数值设计实在太过困难,只好借用他人的劳动成果了。

数值表中的数据是以记录为单位储存的,
因此最简单的存储方法就是把每个怪物都设计为一个对象,然后将数值表记录的值写成对象的属性。


因为按怪物名称查找怪物对象不太合适,因此也需要给不同种类的怪物分配不同的标记号。

第四,为什么还不开始讲代码?

只要有了我们之前的地图设计作为基础,魔塔游戏的绘图逻辑,规则逻辑和事件逻辑都很容易设计出来了。
接下来我们就依次介绍一下这些逻辑的具体实现。

 

游戏具体实现及细节

绘图逻辑

首先我们引入两个全局状态变量:

var isPicLoaded = 0;
var picState = 0;

第一个变量 isPicLoaded 表示游戏的图片资源是否加载完成。它由游戏的资源加载器进行控制,如果资源加载器判断游戏的所有图片资源都已经加载完成了,则将该变量置为1。

/*loader.js 负责加载图片资源*/

var picture = [];
for(var n = 1; n <= 16; n++){
	picture[n-1] = new Image();
	picture[n-1].src = "image/" + n + ".png";
}

function pic(n){
	return picture[n-1];
}

pic(picture.length).onload = function(){//最后一张图片加载完成
	isPicLoaded  = 1;
}

当页面结构加载完成后,图片资源可能还没加载完成,因此需要一直等待图片加载完成,才能进行下一步操作。我不太会写资源加载器,判断资源是否加载完成我是直接判断图片数组的最后一张图片有没有加载完成的,应该有 BUG,有什么更好的方法可以告诉我。

第二个变量 picState 表示角色动画播放到哪一帧。游戏里几乎所有角色都有动画效果,放几张素材图片:

可以看到,从左到右的每一帧都是角色的动作的一部分,所有这些帧都是在一张图片里。
只要间隔一定时间,按顺序循环绘制这些帧,你就会看到这些角色都动起来了,
这就是胶片电影放映的原理,利用了人眼的视觉残留。

这个 picState 正是用来记录整个游戏统一绘制到哪一帧了,
然后我们的绘图逻辑就绘制所有角色的第  picState + 1 帧(第一帧,第二帧...)
绘制到第四帧后,就需要回到第一帧重新开始绘制,可以用求余来计算 picState ,但我当时还不会

// main.js 初始化
function initialize(){
	
	showTitle();
	setEvent();
	draw();
	setInterval(function(){//启动动画
		if(picState === 3){
			picState = 1;
			draw();
		}
		else{
			picState++;
			draw();
		}
	},200);
}

 接下来就进入到最核心的绘图函数,draw 函数,这里的代码我觉得写得贼烂,我就只贴部分代码了

/* graph.js 绘制逻辑 */

//绘制界面边界,省略

var theItem = item[player.floor];
for(var a = 1; a <= 11; a++){//绘制物品
    for(var b = 1; b <= 11; b++){
		switch(theItem[(a-1) * 11 + b - 1]){//(a-1) * 11 + b - 1是当前的数字在数组中的位置
			case 1: //绘制红宝石
				c.drawImage(pic(2), 0, 0, 32, 32, 32 * b + 128, 32 * a, 32, 32);
				break;

                // 中间省略......

			case 32: //绘制楼层传送器
				c.drawImage(pic(16),32 * b + 128, 32 * a);
				break;
		}
	}
}
	
//绘制建筑和角色的代码基本差不多,省略

//绘制主角和物品信息,省略

//根据主角的事件状态,绘制不同的对话界面和道具界面,省略

这里面实现起来最困难的,一个就是阅读怪物书的界面,另一个就是绘制文字自动换行的对话界面。
怪物书是里面的一种道具,使用它可以查看该层怪物的属性信息,它大概长这样子:

怪物书的实现有以下几个步骤:
1.统计该层楼地图里所有的怪物种类(不能重复),遍历该层楼的角色数组,判断是否为怪,去重然后加入数组即可;
2.根据怪物的类型,读取怪物对象的属性信息,写个 switch - case 就行;
3.预测能不能打败特定怪物,以及打怪后会扣多少血,会奖励多少金币,这个比较难,需要专门讲一下

首先,预测能不能打败特定怪物,其实就是计算角色和怪物的血量,攻击、防御关系,判断满不满足某些条件,
如果经过计算,角色和怪物的这些属性满足特定条件,则角色能打败怪物,反之亦然。

我们这里先介绍另一个概念:试探(尝试往前?虚拟步伐?我也不知道怎么表达。。)
当我们的主角前面有一只怪,我们按下方向键,游戏里的主角马上往前走了一步,并成功地干掉了这只怪,整个过程非常自然
但是有个问题:按下方向键后,我们的主角应该什么时候向前?

照理来说我们按下方向键后,我们的主角应该立即向前,
如果周围有怪,那么他就不能马上向前,必须先打赢怪再向前,否则不能向前;
如果周围有怪的作用场,那么他也不能马上向前,必须先确定自身属性足以通过作用场后再向前;
如果周围有体型特别大的怪物(所有怪物根据其图像的中心点进行碰撞检测),那么他还是不能马上向前,也要打赢怪才能走
建筑物也是同理,前方是建筑物,如果主角条件和建筑物不匹配,也不能向前。
因此我们必须在按下按键后,主角真正往前走之前,先往前试探一步或多步,确认可以走之后,我们的主角再向前。

再回到我们的怪物书。
怪物书需要判断主角能不能打败特定怪物,以及打怪后会扣多少血,
照理来说我们应该和这个怪打一架,发生了战斗,才会知道能不能打得过,以及会扣多少血,
而很明显我们并没有真的和这个怪打一架,这就扯到了之前讲的试探的概念,
我们实际上是和这层楼的所有类型的怪都发生了一次试探,
如果试探成功了,就返回扣血量,如果试探失败,就判断无法打败,
我们只是假装打了一架,并没有发生真正的战斗,也没有扣血或者赚钱,仅此而已。

放一下关键代码:

function getFightInfo(enm){//查看怪物书时,用来获取与敌人对战的信息
	var d;//damage
	var c;//get coin
	
	if(player.att > enm.def){//玩家可以攻击敌人
	
		if(enm.id===24){//只要玩家攻击大于150,即可秒杀双手剑士
			if(player.att>=150){
				return {
					name:enm.name,
					hp:enm.hp,
					att:enm.att,
					def:enm.def,
					damage:0,
					coin:(player.item.indexOf(12)>=0)?110:55
				};
			}
		}
		
		if(player.hp > (enm.att-player.def)){//玩家可以抵抗攻击
			
			if(enm.id===8){
				if(player.item.indexOf(19)>=0){//有十字架,攻击力加倍
					d = (enm.att-player.def)<0?0:(enm.att-player.def);
				}
			}
			
			else if(enm.id===32){
				if(player.item.indexOf(21)>=0){//有屠龙剑,攻击力加倍
					d = (enm.att-player.def)<0?0:(enm.att-player.def);
				}
			}
			
			else{
				d = (enm.att-player.def)<0?0:(enm.att-player.def);
			}
				
				
			if(player.item.indexOf(12)>=0){//有幸运金币,获得的金币加倍
				c = (enm.coin*2);
			}
			else{
				c = enm.coin;
			}
			
			return {
				name:enm.name,
				hp:enm.hp,
				att:enm.att,
				def:enm.def,
				damage:d,
				coin:c
			};
		}
		else{//血量不够,无法攻击
			return {
				name:enm.name,
				hp:enm.hp,
				att:enm.att,
				def:enm.def
			};
		}
	}
	else{//攻击不够,无法攻击
		return {
			name:enm.name,
			hp:enm.hp,
			att:enm.att,
			def:enm.def
		};
	}
}

接下来是另一个问题,如何绘制文字自动换行的对话界面。
界面其实很简单,就一个方框嘛,但难点在于如何让文字自动换行。

为什么要弄文字自动换行绘制呢?
因为canvas 自带的 drawText 方法每次只能绘制一行文字,
如果要用 canvas 绘制一大段话,而且要求换行, 就必须手动调用一次 drawText 方法,手动绘制每行字,效率太低。

因此当年我冥思苦想,想出了那个 HTML5 canvas 绘制自动换行文字的方法,写了第一篇博客,
链接在这:https://blog.csdn.net/m8705/article/details/52995099

规则逻辑

规则逻辑非常简单,就是碰撞检测和处理。这里用到了之前讲的试探方法,代码量巨大,试探怪物的部分就一千五百多行。。。
随便贴点代码上来吧

/* 省略N行 */

if( (p===15 || r[(player.y-2)*11+player.x-1]===15 || r[(player.y)*11+player.x-1]===15) && (p*player.x !==15) ){//初级巫师
	if(player.shield !== 5){
		if(player.hp > 100){
			player.hp -= 100;
		}
		else{
			player.x++;
		}
	}
}

/* 省略N行 */

这部分简直是噩梦,整个项目最难最辛苦的地方就在这。

因为之前讲到的,选了一维数组,因此每次都要计算一次玩家的 ( x , y ) 坐标,
而且因为玩家前进方向不同,上下左右,要打不同的怪,要试探不同的怪物数组坐标位置,而且要考虑边界问题,
再加上怪物还有作用场,体型还不一样,我自己都不敢相信这能写得出来。

但我还是写出来了,虽然可能还会有些小 BUG,但是实际测试的时候没发现什么大问题,也就算啦。

事件逻辑

规则逻辑只是一套框架,而要设计魔塔的剧情和事件,就必须在规则逻辑的基础上进行。
这部分说起来其实也挺简单,
在规则逻辑判断前,加入一个事件判断函数,对当前玩家勇士的状态(楼层,具体位置)和地图情况进行判断,
如果碰到 / 干掉了某个角色,触发了陷阱,使用了道具,就进行事件处理。
如果没有任何特殊情况发生,则回到逻辑判断的阶段。

贴一点代码:

var eventHappened = [ 0, 0, ... ] //存放事件是否发生的标志

function checkEvent(){

    var f = floor;
    var r = role;
    var i = item;
	
    if(!eventHappened[0]){
    	if(r[2][16]===0 && r[2][18]===0){//2楼中级卫兵被打败,开牢门
    		floor[2][48]=floor[2][81]=floor[2][114]=0;
    		floor[2][52]=floor[2][85]=floor[2][118]=0;
    		eventHappened[0] = 1;
    }
    

    /* 省略... */

    if(!eventHappened[6]){
		if(eventHappened[5] && (role[10][37]+role[10][38]+role[10][39]+role[10][48]+role[10][50]+role[10][59]+role[10][60]+role[10][61]) === 0){//10楼打败陷阱怪
			player.hearing = "不可能,你无法打败我!来吧!让我们一决高下!";
			player.isTalking = 1;
			floor[10][27]=0;
			eventHappened[6] = 1;
		}
    }

    /* 省略... */

}

其实也非常好理解。
但是事件逻辑设计起来就是比较繁琐,尤其是在设置陷阱或者负责事件的时候,简直是要了命。
这个魔塔的事件处理我就只写到10层骷髅队长就懒得写了,剩下40层,大家自由发挥吧(逃)

 

存档和读档

作为一款策略类游戏,魔塔非常需要存档和读档的机制,因为允许存档鼓励玩家进行探索,提高了玩家探索的容错率。
存档和读档实现起来非常简单,使用 localStorage 即可:

/*storage.js 负责游戏的存档管理*/

function replay(){//bug
	if(confirm("重玩不仅会让让游戏回到原点,而且会清除存储的档案,确定继续吗?")){
		localStorage.clear();
		location.reload(true);
	}
}

function saveData(){
	localStorage.player = JSON.stringify(player);
	localStorage.floor = JSON.stringify(floor);
	localStorage.role = JSON.stringify(role);
	localStorage.item = JSON.stringify(item);
	alertify.success("存档完毕!");
}

function loadData(){
	if(!localStorage.player){
		alertify.error("未发现存档!");
	}
	else{
		player = JSON.parse(localStorage.player);
		floor = JSON.parse(localStorage.floor);
		role = JSON.parse(localStorage.role);
		item = JSON.parse(localStorage.item);
		alertify.success("读档完毕!");
	}
}

游戏重玩的那个功能我没有设计好,其实也不算是 BUG,
因为游戏里的角色数据和地图数据都是在加载页面时自动加载的,
游戏里的角色数据和地图数据只有一份,只要一改变就没法还原,
如果要恢复成游戏刚开始的样子,除了另外存一份副本,就是删档刷新,
因为存一份副本过于浪费资源,因此采用了删档刷新的做法。

一些想说的话

恭喜还能看到这里的朋友,你们坚持下来了,不过我还是要浇一盆冷水,
就算能看到现在,也很少有人会去找原版的魔塔玩一玩,
更少有人会照着这个项目,做个自己的版本。

魔塔这个游戏现在已经很少人玩了,现在很多人都喜欢那种快又爽的开放世界游戏,或者史诗冒险类游戏,
其实我也喜欢,毕竟场景宏大,特效堆叠,真好看,谁不喜欢呢
但是我还是很怀念以前在学习机上玩的这个魔塔,
那种费了半天走错路,卡关废档的挫败感,和打完小头目,发现隐藏道具的喜悦感,
到最后通关,打败魔王,甚至到达 -1 层的自豪感,真的令人难以忘怀。

现在费劲心思,肯下功夫钻研某件东西的人少了,越来越多人想走捷径,
试探一下,几次不行就放弃了,没人愿意做蠢事了。
但我仍决定走下去。


也许我还会无数次跌倒,也许我无法打败那个魔王,也许我救不出那个公主,
但我仍会无数次站起来,走下去。

Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐