HTML5遊戲開發進階 5 :建立即時戰略遊戲世界

CopperDong發表於2018-02-12

     將定義自己的遊戲世界、建築、單位,以及一個故事主線,並建立一個動人的單人戰役。接著我們還要利用HTML5 WebSocket使遊戲支援多人實時對戰。

     這款遊戲的大部分素材有Daniel Cook(http://www.lostgarden.com)提供。

     開發該遊戲時,我們會盡可能保持程式碼的通用性和可定製性,這樣你就可以重新使用這些程式碼來實現自己的想法了。

5.1 基本HTML佈局

     先來定義幾個圖層:

  • 啟動畫面和主選單:遊戲開始時顯示,允許玩家選擇單人戰役模式或多人對戰模式。
  • 載入畫面:遊戲載入資源時顯示
  • 任務畫面:任務開始前顯示,帶有一段任務簡介
  • 遊戲介面:遊戲的主畫面,包括地圖區域和遊戲控制皮膚。

5.2 建立啟動畫面和主選單

       index.html

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-type" content="text/html; charset=utf-8">
    <title>Last Colony</title>
    <script src="js/common.js" type="text/javascript" charset="utf-8"></script>
    <script src="js/jquery.min.js" type="text/javascript" charset="utf-8"></script>
    <script src="js/game.js" type="text/javascript" charset="utf-8"></script>
    <script src="js/mouse.js" type="text/javascript" charset="utf-8"></script>
    <script src="js/singleplayer.js" type="text/javascript" charset="utf-8"></script>
    <script src="js/maps.js" type="text/javascript" charset="utf-8"></script>
    <link rel="stylesheet" href="style.css" type="text/css" media="screen" charset="utf-8">
  </head>
  <body>
    <div id="gamecontainer">
      <div id="gamestartscreen" class="gamelayer">
        <span id="singleplayer" onclick="singleplayer.start();">Campaign</span><br>
        <span id="multiplayer" onclick="multiplayer.start();">Multiplayer</span><br>
      </div>
      <div id="missionscreen" class="gamelayer">
        <input type="button" id="entermission" onclick="singleplayer.play();">
        <input type="button" id="exitmission" onclick="singleplayer.exit();">
        <div id="missionbriefing"></div>
      </div>
      <div id="gameinterfacescreen" class="gamelayer">
        <div id="gamemessages"></div>
        <div id="callerpicture"></div>
        <div id="cash"></div>
        <div id="sidebarbuttons"></div>
        <canvas id="gamebackgroundcanvas" height="400" width="480"></canvas>
        <canvas id="gameforegroundcanvas" height="400" width="480"></canvas>
      </div>
      <div id="loadingscreen" class="gamelayer">
        <div id="loadingmessage"></div>
      </div>
    </div>
  </body>
</html>

       style.css

/* 遊戲容器和圖層的初始樣式表 */
#gamecontainer {
	width: 640px;
	height: 480px;
	background: url(images/splashscreen.png);
	border: 1px solid black;
}


.gamelayer {
	width: 640px;
	height: 480px;
	position: absolute;
	display: none;
}


/* 啟動畫面與主選單 */
#gamestartscreen {
	padding-top: 320px;
	text-align: left;
	padding-left: 50px;
	width: 590px;
	height: 160px;
}


#gamestartscreen span {
	margin: 20px;
	font-family: 'Courier New', Courier, monospace;
	font-size: 48px;
	cursor: pointer;
	color: white;
	text-shadow: -2px 0 purple, 0 2px purple, 2px 0 purple, 0 -2px purple;
}


#gamestartscreen span:hover {
	color: yellow;
}


/* 載入畫面 */
#loadingscreen {
	background: rgba(100, 100, 100, 0.7);
	z-index: 10;
}


#loadingmessage {
	margin-top : 400px;
	text-align: center;
	height: 48px;
	color: white;
	background: url(images/loader.gif) no-repeat center;
	font: 12px Arial;
}
 
/* 任務畫面的CSS樣式 */
#missionscreen {
	background: url(images/missionscreen.png) no-repeat;
}
#missionscreen #entermission {
	position: absolute;
	top: 79px;
	left: 6px;
	width: 246px;
	height: 68px;
	border-width: 0px;
	background-image: url(images/buttons.png);
	background-position: 0px 0px;
}
#missionscreen #entermission:disabled, #missionscreen #entermission:active {
	background-image: url(images/buttons.png);
	background-position: -251px 0px;
}
#missionscreen #exitmission {
	position: absolute;
	top: 79px;
	left: 380px;
	width: 98px;
	height: 68px;
	border-width: 0px;
	background-image: url(images/buttons.png);
	background-position: 0px -76px;
}
#missionscreen #exitmission:disabled, #missionscreen #exitmission:active {
	background-image: url(images/buttons.png);
	background-position: -103px -76px;
}
#missionscreen #missionbriefing {
	position: absolute;
	padding: 10px;
	top: 160px;
	left: 20px;
	width: 410px;
	height: 300px;
	color: rgb(130, 150, 162);
	font-size: 13px;
	font-family: 'Courier New', Courier, monospace;
}
/* 遊戲介面 */
#gameinterfacescreen {
	background: url(images/maininterface.png) no-repeat;
}
#gameinterfacescreen #gamemessages {
	position: absolute;
	padding-left: 10px;
	top: 5px;
	left: 5px;
	width: 450px;
	height: 60px;
	color: rgb(130, 150, 162);
	overflow: hidden;
	font-size: 13px;
	font-family: 'Courier New', Courier, monospace;	
}
#gameinterfacescreen #gamemessages span {
	color: white;
}
#gameinterfacescreen #callerpicture {
	position: absolute;
	top: 154px;
	left: 498px;
	width: 126px;
	height: 88px;
	overflow: none;
}
#gameinterfacescreen #cash {
	position: absolute;
	top: 256px;
	left: 498px;
	width: 120px;
	height: 22px;
	color: rgb(130, 150, 162);
	overflow: hidden;
	font-size: 13px;
	font-family: 'Courier New', Courier, monospace;	
	text-align: right;
}
#gameinterfacescreen canvas {
	position: absolute;
	top: 79px;
	left: 0px;
}
#gameinterfacescreen #foregroundcanvas {
	z-index: 1;
}
#gameinterfacescreen #backgroundcanvas {
	z-index: 0;
}

       common.js

/* 設定requestAnimationFrame和影像載入器 */
(function () {
   var lastTime = 0;
   var vendors = ['ms', ';', 'webkit', 'o'];
   for (var x=0; x<vendors.length && !window.requestAnimationFrame; ++x){
   	   window.requestAnimationFrame = window[vendors[x] + 'RequestAnimationFrame'];
   	   window.cancelAnimationFrame = window[vendors[x] + 'CancelAnimationFrame'] 
   	     || window[vendors[x] + 'CancelRequestAnimationFrame'];
   }
   if (!window.requestAnimationFrame) {
   	   window.requestAnimationFrame = function(callback, element) {
   	   	  var currTime = new Date().getTime();
   	   	  var timeToCall = Math.max(0, 16 - (currTime - lastTime));
   	   	  var id = window.setTimeout(function() { callback(currTime + timeToCall);}, timeToCall);
   	   	  lastTime = currTime + timeToCall;
   	   	  return id;
   	   };
   }
   if (!window.cancelAnimationFrame) {
   	   window.cancelAnimationFrame = function(id) {
   	   	  clearTimeout(id);
   	   };
   }
}());

var loader = {
	loaded: true,
	loadedCount: 0,   //目前已被載入的資源數
	totalCount: 0,    //需要載入的資源總數
	init: function() {
		//檢查聲音格式支援
		var mp3Support, oggSupport;
		var audio = document.createElement('audio');
		if (audio.canPlayType) {
			// 目前canPlayType()方法返回:"", "maybe"或"probably"
			mp3Support = "" != audio.canPlayType('audio/mpeg');
			oggSupport = "" != audio.canPlayType('audio/ogg; codecs="vorbis"');
		} else {
			//瀏覽器不支援audio標籤
			mp3Support = false;
			oggSupport = false;
		}
		// 檢查是否支援ogg, mp3格式,若都不支援,設定soundFileExtn為undefined
		loader.soundFileExtn = oggSupport?".ogg":mp3Support?".mp3":undefined;
	},
	loadImage: function(url) {
		this.totalCount++;
		this.loaded = false;
		$('#loadingscreen').show();
		var image = new Image();
		image.src = url;
		image.onload = loader.itemLoaded;
		return image;
	},
	soundFileExtn: ".ogg",
	loadSound: function(url) {
		this.totalCount++;
		$('#loadingscreen').show();
		var audio = new Audio();
		audio.src = url+loader.soundFileExtn;
		audio.addEventListener("canplaythrough", loader.itemLoaded, false);
		return audio;
	},
	itemLoaded: function() {
		loader.loadedCount++;
		$('#loadingmessage').html('Loaded ' + loader.loadedCount + ' of ' + loader.totalCount);
		if (loader.loadedCount === loader.totalCount) {
			loader.loaded = true;
			$('#loadingscreen').hide();
			if (loader.onload) {
				loader.onload();
				loader.onload = undefined;
			}
		}
	},
}

       game.js

$(window).load(function() {
	game.init();
});

var game = {
	// 開始預載入資源
	init: function() {
		loader.init();
		mouse.init();

		$('.gamelayer').hide();
		$('#gamestartscreen').show();

		game.backgroundCanvas = document.getElementById('gamebackgroundcanvas');
		game.backgroundContext = game.backgroundCanvas.getContext('2d');

		game.foregroundCanvas = document.getElementById('gameforegroundcanvas');
		game.foregroundContext = game.foregroundCanvas.getContext('2d');

		game.canvasWidth = game.backgroundCanvas.width;
		game.canvasHeight = game.backgroundCanvas.height;
	},
	start: function() {
		$('.gamelayer').hide();
		$('#gameinterfacescreen').show();
		game.running = true;
		game.refreshBackground = true;
		game.drawingLoop();
	},
	// 地圖被分割成20畫素x20畫素的方形網格
	gridSize: 20,
	// 記錄背景是否移動了,是否需要被重繪
	backgroundChanged: true,
	// 控制迴圈,執行固定的時間
	animationTimeout: 100,  // 100ms
	offsetX: 0, //地圖平移偏移量,X和Y
	offsetY: 0,
	panningThreshold: 60, //與canvas邊緣的距離,在此距離範圍內拖拽滑鼠進行地圖平移
	panningSpeed: 10,     //每個繪畫迴圈平移的畫素數
	handlePanning: function() {
		//如果滑鼠離開canvas,地圖不再平移
		if (!mouse.insideCanvas) {
			return;
		}
		if (mouse.x <= game.panningThreshold) { //滑鼠在最左邊
			if (game.offsetX >= game.panningSpeed) {
				game.refreshBackground = true;
				game.offsetX -= game.panningSpeed;
			}
		} else if (mouse.x >= game.canvasWidth - game.panningThreshold) {//滑鼠在最右邊
			if (game.offsetX + game.canvasWidth + game.panningSpeed <= game.currentMapImage.width) {
				game.refreshBackground = true;
				game.offsetX += game.panningSpeed;
			}
		}
		if (mouse.y<=game.panningThreshold) {
			//滑鼠在最上邊
			if (game.offsetY >= game.panningSpeed) {
				game.refreshBackground = true;
				game.offsetY -= game.panningSpeed;
			}
		} else if (mouse.y>=game.canvasHeight - game.panningThreshold) {
			//滑鼠在最下邊
			if (game.offsetY + game.canvasHeight + game.panningSpeed <= game.currentMapImage.height) {
				game.refreshBackground = true;
				game.offsetY += game.panningSpeed;
			}
		}
		if (game.refreshBackground) {
			//基於平移偏移量,更新滑鼠座標
			mouse.calculateGameCoordinates();
		}
	},
	animationLoop: function() {
		// 執行遊戲中每個物體的動畫迴圈
	},
	drawingLoop: function() {
		// 處理地圖平移
		game.handlePanning();

		// 繪製背景地圖是一項龐大的工作,我們僅僅在地圖改變()時重繪
		if (game.refreshBackground) {
			game.backgroundContext.drawImage(game.currentMapImage, game.offsetX,
				game.offsetY, game.canvasWidth, game.canvasHeight, 0, 0, game.canvasWidth,
				game.canvasHeight);
			game.refreshBackground = false;
		}
		//清空前景canvas
		game.foregroundContext.clearRect(0,0,game.canvasWidth,game.canvasHeight);
		//繪製前景上的物體

		//繪製滑鼠
		mouse.draw();

		// 下一次繪圖迴圈
		if (game.running) {
			requestAnimationFrame(game.drawingLoop);
		}
	},
}

    主選單目前提供了兩個選項:戰役項,基於故事線的單玩家模式;多人對戰選項,玩家對玩家模式。

5.3 地圖與關卡

    有很多可行的方法為遊戲定義地圖和關卡。其中一個方法是,將地圖的資訊作為後設資料儲存起來,遊戲執行時,瀏覽器根據這些後設資料動態地生成並繪製出地圖。

     另一種略簡單一些的方法是,利用自己的關卡設計工具軟體,將地圖儲存為一張較大的圖片。只需要儲存地圖圖片的路徑和另外一些後設資料,如遊戲中的物體、關卡任務的目標等。使用Tiled(www.mapeditor.org)---一款通用的地圖編輯軟體。

      定義maps物件,js/maps.js,

      地圖被分割成20px寬和20px高的格網。目前,我們使用“除錯”模式在地圖上繪製一層格網,這樣在除錯遊戲的時候,就很容易確定遊戲中物體的位置。

      初始位置座標是基於地圖格網座標系統的,它用來決定在遊戲開始時,視野位於地圖上的哪一塊區域。

/* 定義基本的關卡後設資料 */
var maps = {
	"singleplayer": [
		{
			"name": "Introduction",
			"briefing": "In this level you will learn how to pan across the map.\n\nDon't worry! We will be implementing more features soon.",
			/* 地圖細節 */
			"mapImage": "images/maps/level-one-debug-grid.png",
			"startX": 4,
			"startY": 4,
		},
	],
};

5.4 載入任務簡介畫面

     在index.html中加入任務畫面

     singleplayer.js

/* 實現基本的Singleplaer物件 */
var singleplayer = {
	// 開始單人戰役
	start: function() {
		// 隱藏開始選單圖層
		$('.gamelayer').hide();
		// 從第一關開始
		singleplayer.currentLevel = 0;
		game.type = "singleplayer";
		game.team = "blue";
		// 最後,開始關卡
		singleplayer.startCurrentLevel();
	},
	exit: function() {
		//顯示開始選單
		$('.gamelayer').hide();
		$('#gamestartscreen').show();
	},
	currentLevel: 0,
	startCurrentLevel: function() {
		//獲取用來構建關卡的資料
		var level = maps.singleplayer[singleplayer.currentLevel];
		// 載入資源完成之前,禁用”開始任務“按鈕
		$("#entermission").attr("disabled", true);
		//載入用來建立關卡的資源
		game.currentMapImage = loader.loadImage(level.mapImage);
		game.currentLevel = level;
		// 設定地圖偏移量
		game.offsetX = level.startX * game.gridSize;
		game.offsetY = level.startY * game.gridSize;
		// 載入資源完成後,啟用”開始任務“按鈕
		if (loader.loaded) {
			$("#entermission").removeAttr("disabled");
		} else {
			loader.onload = function() {
				$("#entermission").removeAttr("disabled");
			}
		}
		// 載入任務簡介畫面
		$('#missionbriefing').html(level.briefing.replace(/\n/g, '<br><br>'));
		$('#missionscreen').show();
	},
	play: function() {
		game.animationLoop();
		game.animationInterval = setInterval(game.animationLoop, game.animationTimeout);
		game.start();
	},
}

5.5 製作遊戲介面

     在index.html中加入遊戲介面 gameinterfacescreen

     遊戲介面層包含以下幾個區域:

  • 遊戲區域:玩家在該區域檢視地圖,與建築、單位及遊戲中的其他物體進行互動。該區域有兩個canvas元素組成。
  • 訊息區域:玩家可以在該區域看到系統提示與故事驅動訊息
  • 影像區域:玩家在該區域可以看到故事驅動資訊傳送者的影像
  • 資金欄:玩家可以在此區域檢視資金餘額。
  • 側邊欄按鈕:此區域包含了玩家用來建立單位和建築的按鈕。

5.6 實現地圖平移

     建立mouse物件mouse.js

     init()方法中,設定了所有必要的事件響應函式:

var mouse = {
	// 滑鼠相對於canvas左上角的x、y座標
	x: 0,
	y: 0,
	// 滑鼠相對於遊戲地圖左上角的座標
	gameX: 0,
	gameY: 0,
	// 滑鼠在遊戲網格中的座標
	gridX: 0,
	gridY: 0,
	// 滑鼠左鍵當前是否被按下
	buttonPressed: false,
	// 是否按下滑鼠左鍵並進行拖拽
	dragSelect: false,
	// 滑鼠是否在canvas區域內
	insideCanvas: false,
	//
	click: function(ev, rightClick) {
		// 在canvas內單擊滑鼠
	},
	//
	draw: function() {
		//是否拖拽
		if (this.dragSelect) {
			var x = Math.min(this.gameX, this.dragX);
			var y = Math.min(this.gameY, this.dragY);
			var width = Math.abs(this.gameX - this.dragX);
			var height = Math.abs(this.gameY - this.dragY);
			game.foregroundContext.strokeStyle = 'white';
			game.foregroundContext.strokeRect(x-game.offsetX, y-game.offsetY, width, height);
		}
	},
	// 將滑鼠的座標轉換為遊戲座標
	calculateGameCoordinates: function() {
		mouse.gameX = mouse.x + game.offsetX;
		mouse.gameY = mouse.y + game.offsetY;
		mouse.gridX = Math.floor((mouse.gameX)/game.gridSize);
		mouse.gridY = Math.floor((mouse.gameY)/game.gridSize); 
	},
	//
	init: function() {
		var $mouseCanvas = $("#gameforegroundcanvas");
		//滑鼠移動時,計算滑鼠的位置座標並儲存起來。
		//檢查滑鼠按鍵是否被按下,以及按下按鍵的滑鼠是否被拖拽超過4畫素,
		//如果是,則將dragSelect置為true。
		//4畫素的閥值用來阻止遊戲將每一次單擊操作都轉化為拖拽操作
		$mouseCanvas.mousemove(function(ev) {
			var offset = $mouseCanvas.offset();
			mouse.x = ev.pageX - offset.left;
			mouse.y = ev.pageY - offset.top;
			mouse.calculateGameCoordinates();
			if (mouse.buttonPressed) {
				if ((Math.abs(mouse.dragX - mouse.gameX)>4 || 
					 Math.abs(mouse.dragY - mouse.gameY)>4)) {
					mouse.dragSelect = true;
				}
			} else {
				mouse.dragSelect = false;
			}
		});
		//單擊操作完成後
		$mouseCanvas.click( function(ev) {
			mouse.click(ev, false);
			mouse.dragSelect = false;
			return false;
	    });
	    //
	    $mouseCanvas.mousedown(function(ev) {
	    	//滑鼠左鍵被按下時
	    	if (ev.which == 1) {
	    		mouse.buttonPressed = true;
	    		mouse.dragX = mouse.gameX;
	    		mouse.dragY = mouse.gameY;
	    		//阻止瀏覽器預設的單擊行為
	    		ev.preventDefault();
	    	}
	    	return false;
	    });
	    //右鍵彈出瀏覽器上下文選單
	    $mouseCanvas.bind('contextmenu', function(ev){
	    	mouse.click(ev, true);
	    	return false;
	    });
	    $mouseCanvas.mouseup(function(ev) {
	    	var shiftPressed = ev.shiftKey;
	    	//左鍵釋放時
	    	if (ev.which == 1) {
	    		// Left key was released
	    		mouse.buttonPressed = false;
	    		mouse.dragSelect = false;
	    	}
	    	return false;
	    });
	    //滑鼠離開canvas區域
	    $mouseCanvas.mouseleave(function(ev) {
	    	mouse.insideCanvas = false;
	    });
	    //滑鼠進入canvas區域
	    $mouseCanvas.mouseenter(function(ev) {
	    	mouse.buttonPressed = false;
	    	mouse.insideCanvas = true;
	    });
	},
}


相關文章