根據(jù)微信官方對外公開的消息,微信小游戲的腳步越來越接近了。它的開發(fā)者資格門檻和使用者門檻都很低,以后必將引爆一波"全民開發(fā)小游戲"浪潮。
官方的開發(fā)工具創(chuàng)建項目即可獲取 打飛機
的源碼,這是一個很小但五臟俱全的2D游戲,相信大多數(shù)嗅覺靈敏的程序員小哥哥們都已經(jīng)體驗并且親手改造過啦。
但是如果你想借助微信的平臺,做一個交互性、可玩性很強的 聯(lián)網(wǎng)游戲
,就有一定的難度啦。不用怕,有 Bmob 的最新產(chǎn)品 游戲SDK 助力,第一波流量紅利你也能輕松抓住!這次教程我們就來討論 如何在完全不懂服務(wù)器開發(fā)的情況下做一個實時聯(lián)網(wǎng)對戰(zhàn)的微信小游戲 (聯(lián)網(wǎng)飛機大戰(zhàn))。
前言
為了能通讀這篇文章,你最好:
- 已經(jīng)掌握開發(fā)簡單的微信小游戲,能看懂官方
打飛機
源碼就行,甚至?xí)?Javascript
輸出HelloWorld也行 - 略懂Java,其實不懂也行,在JS的基礎(chǔ)上很容易引申,主要是要有 面向?qū)ο?/strong> 的思想
下文重點都是講如何快速上手開發(fā) 聯(lián)網(wǎng)的微信小游戲 , 但 如果你懂得一些U3D開發(fā),Bmob官方
也同時提供了 Unity3D版本的Demo+SDK
,兩者可以跨平臺互通一起玩,且接口規(guī)范高度一致,基本上覆蓋市面上所有的主流終端
PS:微信小游戲、Unity3D的SDK都是 開源 的,歡迎各位糾錯
最簡單的步驟
- 獲取 比目游戲云服務(wù) (下稱
官網(wǎng)
)的賬號,文章下方有獲得方式; - 在
官網(wǎng)
下載微信小游戲Demo+SDK
,導(dǎo)入到微信開發(fā)者工具
(下稱工具
),并修改AppKey
; - 在
官網(wǎng)
配置玩家同步屬性,并發(fā)布
下載的云端代碼
,然后在官網(wǎng)
選擇一個云服務(wù)器開啟(PS:云服務(wù)器是免費的); - 試運行
Demo
,如果console
沒有報錯的話,點擊工具
的預(yù)覽
,用微信掃描二維碼; - 現(xiàn)在,就可以在游戲內(nèi)
創(chuàng)建房間
,體驗電腦與手機聯(lián)網(wǎng)對戰(zhàn)啦;
接下來大概介紹一下微信小游戲項目開發(fā)的要點,云端代碼的詳解和U3D版本的教程將陸續(xù)推出
運行效果
左邊的是 微信小游戲-開發(fā)者工具
的游戲頁面,與右邊的 Unity3D-MacOS-Editor
跨平臺玩
超清/720P模式觀看體驗更好哦
不得不說程序員自己來做UI真的丑得可以,那個"房間"界面真的無力吐槽
目前的Demo跨平臺玩耍還有點小問題,例如玩家、怪物的移動速度不統(tǒng)一。但同平臺對戰(zhàn)是高度一致的。 這個問題與SDK沒有關(guān)系,都是Demo本地項目的參數(shù)設(shè)置,主要是因為Unity項目都用的是絕對值,微信小游戲項目都是相對值,后續(xù)Unity也采用相對值的方式,完善Demo。
如何從零開發(fā)
論游戲開發(fā)的經(jīng)驗,相信各位讀者中比我厲害的人多了去了。我這里就根據(jù)我個人的開發(fā)歷程,圍繞 聯(lián)網(wǎng)飛機大戰(zhàn)
這個項目,講一下從零開發(fā)游戲的步驟吧。(嫌麻煩的可以不用看這一篇)
- 確定游戲主題、玩法;
- 理清多個客戶端之間需要 同步的屬性、互相通知的事件;
- 分析客戶端與服務(wù)器需要 交互的事件;
- 制作/收集圖片、動畫、音效素材;
- 開發(fā)/照搬游戲世界的物理引擎,包括物體渲染、移動、碰撞檢測(以及內(nèi)存管理)等;
- 先開發(fā)服務(wù)端游戲邏輯(Java云端代碼),有利于理清整個游戲的邏輯;
- 后開發(fā)客戶端游戲邏輯、接入SDK;
- 測試、發(fā)布;
下面是展開來講 (獲取Demo、SDK完整源碼的方式見文章底部)
-
玩法:這個項目準(zhǔn)備做成可以容納超多人同時在線的飛機大戰(zhàn),所有設(shè)定基本上和微信小游戲官方Demo一樣,增加了幾個設(shè)定:
- 有四種造型、級別不同的Bot(有些人習(xí)慣稱為 '電腦',也可以稱為'飛機NPC') - 第3、4級的Bot可以開火,子彈(下稱Fire)飛行速度與玩家一致,4級Bot的開火頻率更高 - Bot有生命值(不再是一碰就死),分別是2、3、4、4,表示可以承受的Fire攻擊次數(shù) - Player(玩家)和Bot都分為兩個陣營,陣營內(nèi)無隊友傷害 - Player的陣營由服務(wù)器隨機劃分,也可以改成玩家自己決定 - 刷怪邏輯放在云端,指定新產(chǎn)生的Bot的陣營、位置、類型 - Player受到傷害即淘汰,F(xiàn)ire碰到任何物體都消失 - Player之間、Bot之間、Player與Bot 如果發(fā)生碰撞,會同歸于盡 - Player的開火暫時做成自動的,而不是按鍵開火 - Player的開火事件(開火坐標(biāo))是直接發(fā)送到其它客戶端,不經(jīng)過云端代碼 - Player的淘汰交由云端處理,由云端校驗后,再把該事件和勝負判定分發(fā)下去 - Bot的淘汰判定交由云端處理、分發(fā) - 當(dāng)某一方Player全部死亡時,另一方勝利;雙方各剩一人時同歸于盡則平局
-
客戶端間屬性同步、事件通知:玩家僅有兩個屬性需要自動同步、分發(fā),一個是
位置
,另一個是分數(shù)
;直接同步的事件僅有開火
- 位置:這是一個2D游戲,所以玩家位置可以用float[2]類型表達 但是為了保持一致性,Demo用了int[2],數(shù)值由0-65535,表達0%-100% (一致性,是指跨平臺或分辨率、屏幕大小不同時,坐標(biāo)需要達成一致最好用百分比) - 分數(shù):僅云端代碼有權(quán)限修改,根據(jù)Player、Bot的擊落事件加分 可以在游戲結(jié)束時,結(jié)算成經(jīng)驗值,保存到Bmob數(shù)據(jù)庫 - 開火:直接通知到其它客戶端,僅記錄Fire的起點坐標(biāo)即可,也就是[0-65535,0-65535] 表達成byte[]時,一個0-65535的int可以變成兩個0-255的數(shù)字組成 再加上需要標(biāo)記這次通知的事件類型(開火),這里定flag為50 也就是開火時向其它玩家發(fā)送 [50, 0-255, 0-255, 0-255, 0-255]
-
客戶端-云端交互事件:需要服務(wù)器做的事情有:保存房間信息;分配隊伍;正式通知游戲開始;刷怪邏輯;判定Bot淘汰;判定Player淘汰;添加Player分數(shù);判定勝負結(jié)果;戰(zhàn)績記錄
- 房間、戰(zhàn)績信息:通過云端代碼的Bmob數(shù)據(jù)庫操作API完成 - 分配隊伍:在客戶端Scene.OnLoad后通知服務(wù)器,服務(wù)器進行隊伍分配 將玩家隨機、均勻分成兩隊,然后下發(fā),客戶端處理完畢再通知服務(wù)器 - 正式開始:服務(wù)器確認所有客戶端處理了隊伍信息后,通知所有客戶端開始游戲 - 刷怪邏輯:隨機Bot的陣營、x軸位置、類型、名字,下發(fā)給客戶端處理 - Bot淘汰:任意客戶端上報'目睹'某Bot被擊毀,云端即采信、下發(fā)、記分 所謂'目睹',就是客戶端渲染時進行碰撞檢測,發(fā)現(xiàn)這個Bot的hp為0 - Player淘汰:n個客戶端'目睹'某Player被擊毀,在短時間內(nèi)n>=m,云端才采信、下發(fā)、記分 當(dāng)玩家僅有2、3人時,m為1,也就是上報即采信 當(dāng)玩家有4、5、6人時,m為2,不采信單個上報 當(dāng)玩家超過6人時,m為3,也就是起碼3人上報才采信 '短時間'目前是設(shè)為2000ms,也就是上報信息的有效期為2秒 - 判定勝負結(jié)果:兩隊最后一人同時淘汰時平局;某隊先于敵隊全員淘汰則敗
- 素材:來自美工/Unity Assets商店
-
物理引擎:來自微信官方Demo(Sprite.js)/腦洞+造輪子/第三方途徑下載
// 小改進后的矩形碰撞檢測: isCollideWith(sp) { if (this.visible && sp.visible) { let dis = sp.x - this.x; if (-sp.width < dis && dis < this.width) { dis = sp.y - this.y; if (-sp.height < dis && dis < this.height) return true; } } return false; }
- Java云端代碼:在上面第3點已經(jīng)有說明,這里放幾段代碼:
Room.java:
// public class Room extends RoomBase
// 保存到Bmob數(shù)據(jù)庫的id
public String mObjectId = null;
// 先分配隊伍,后開始游戲。分配隊伍這段時間,不是真正的游戲開始,不要刷怪
public boolean isNotReallyStart;
// 刷怪的時間間隔(毫秒),決定了刷怪的頻率,根據(jù)玩家人數(shù)來定。人越多,刷怪越快
private long botSpawnSpan;
// 上次刷怪的時間記錄
private long lastBotSpawnTime = 0;
// 怪物的個數(shù),也順便作為id
private long botCount = 0;
// 置信區(qū)間: 計算擊中的邏輯放到了客戶端的時候,擊中敵人/怪物的事件,不能完全聽信其中一個客戶端,防止ping差異擊殺、外掛
// 怪物還相對無關(guān)緊要,某一個客戶端上報了,就選擇相信他
// 但是玩家的淘汰影響到體驗,需要多個玩家同時認證的情況下判定
// 于是約定:如果房間有2、3人,可以一個人說了算(以免掉線玩家無敵)
// 如果有4個人玩游戲,需要2個人在短時間內(nèi)"看到"某個玩家的死亡,那么這個玩家才是真正的死亡了
// 更多人的情況下,最多只要3個人在短時間內(nèi)說某個玩家死亡,就可以作出判定
// 特殊的,如果某個玩家是匯報自己死亡,那么不用經(jīng)過置信區(qū)間檢測,直接判定死亡
public int confidenceInterval = 1;
private final Set<String> dieBotsNames = new HashSet<String>();
public static final byte//
NotifyType_AssignTeam = 1,//
NotifyType_BotSpawn = 2,//
NotifyType_ReallyStart = 3,//
NotifyType_PlayerCrash = 4,//
NotifyType_BotDie = 5,//
NotifyType_GameOver = 6//
;
@Override
public void onCreate() {
// 各1個玩家的時候,1秒2個怪;以此類推
// botSpawnSpan = (1000 / 2) / (playerCount / 2);
botSpawnSpan = (2000) / (playerCount / 2);
// 計算死亡判定的置信區(qū)間
if (playerCount > 3)
confidenceInterval = 2;
else if (playerCount > 5)
confidenceInterval = 3;
HttpResponse response = Bmob.getInstance().insert("Room", JSON.toJson(//
"roomId", roomId,//
"master", masterId,//
"masterKey", masterKey,//
"joinKey", joinKey,//
"playerCount", playerCount,//
"address", address,//
"tcpPort", tcpPort,//
"udpPort", udpPort,//
"websocketPort", websocketPort,//
"status", 0// 0: 開啟中,1: 游戲中,2:
// 房間關(guān)閉
));
mObjectId = response.jsonData.getString("objectId");
}
@Override
public void onGameStart() {
if (!Functions.isStrEmpty(mObjectId))
Bmob.getInstance().update("Room", mObjectId,
JSON.toJson("status", 1));
dieBotsNames.clear();
isNotReallyStart = true;
lastBotSpawnTime = 0;
botCount = 0;
}
@Override
public void onDestroy() {
if (!Functions.isStrEmpty(mObjectId))
Bmob.getInstance().update("Room", mObjectId,
JSON.toJson("status", 2));
}
@Override
@BmobGameSDKHook
public void onTick() {
if (isNotReallyStart)
return;
long curTime = getTime();
if (curTime > lastBotSpawnTime + botSpawnSpan) {
spawnBot();
lastBotSpawnTime = curTime;
}
}
// 分配隊伍
public void assignTeam() {
// 游戲開始,所有玩家就位了,將房間內(nèi)的玩家隨機、平均分到兩隊
// 服務(wù)器發(fā)送到客戶端的通知,就拿第一位當(dāng)作消息類型的區(qū)分吧(flag)
for (Player p : players)
p.teamId = 0;
// 如果[1]=1,表示players[0]是隊伍1; [2]=0表示players[1]是隊伍2
byte[] team = new byte[playerCount + 1];
// (flag)1表示分隊情況
team[0] = NotifyType_AssignTeam;
// 其中一個隊的人數(shù)
int team1Count = playerCount / 2;
while (team1Count != 0) {
int id = ((int) (Math.random() * 100000) % playerCount) + 1;
if (team[id] != 1) {
players[id - 1].teamId = 1;
team[id] = 1;
team1Count--;
}
}
sendToAll(team);
}
// 刷怪
private void spawnBot() {
botCount++;
// 游戲里面有4種難度不同的怪,將概率按1:2:3:4來劃分,越難打的怪出現(xiàn)幾率越低
// 位置(主要是x軸)隨機,按byte表示,0-255,表示最左邊到最右邊,128是在屏幕中鍵
// [0]表示flag,這個通知是一個刷怪事件
// [1]表示隊伍代號,這個怪是哪一邊的(和assignTeam的分配一致)
// [2]表示刷怪點x軸的位置
// [3]表示怪物種類
// [4-]表示怪物名(Bot[Type]_[Id])
byte botTeam = (byte) (((int) (Math.random() * 100)) % 2);
byte botPositionX = (byte) (((int) (Math.random() * 0xffff)) & 0xff);
byte botType = (byte) (Math.random() * 10); // 0-9
if (botType == 9) // 9
botType = 3;
else if (botType > 6) // 7、8
botType = 2;
else if (botType > 3) // 4、5、6
botType = 1;
else
botType = 0; // 0、1、2、3,默認都是怪物0
byte[] botName = ("Bot" + botType + "_" + Long.toHexString(botCount))
.getBytes();
byte[] botInfo = new byte[4 + botName.length];
// (flag)2表示分隊情況
botInfo[0] = NotifyType_BotSpawn;
botInfo[1] = botTeam;
botInfo[2] = botPositionX;
botInfo[3] = botType;
arraycopy(botName, 0, botInfo, 4, botName.length);
sendToAll(botInfo);
}
--
Player.java:
// public class Player extends PlayerBase
public int teamId = 0;
private boolean isDead = false;
private boolean isLoadOk = false, isTeamClear = false;
private long[] dieReports;
// 不重復(fù)下發(fā)怪物死亡事件
@BmobGameSDKHook
public native void setIsDead(boolean isDead);
@Override
public void onGameStart() {
dieReports = new long[room.playerCount];
isLoadOk = false;
isDead = false;
setIsDead(isDead);
syncToClient();
}
@BmobGameSDKHook
public strictfp void onAction_OnGameLoad(byte[] bs) {
// 加載好了游戲場景
this.isLoadOk = true;
// 檢查是否全部都準(zhǔn)備好了
for (Player p : roommates)
if (!p.isLoadOk)
return;
// 開始分配隊伍
room.assignTeam();
}
@BmobGameSDKHook
public strictfp void onAction_OnTeamInfoGet(byte[] bs) {
// 收到了隊伍安排
this.isTeamClear = true;
// 檢查是否全部都準(zhǔn)備好了
for (Player p : roommates)
if (!p.isTeamClear)
return;
// 讓房間真正運作起來
room.reallyPlaying();
}
// 有玩家上報,發(fā)現(xiàn)某一個玩家死亡
@BmobGameSDKHook
public strictfp void onAction_PlayerCrash(byte[] infos) {
if (room.isNotReallyStart || isDead)// 已經(jīng)死亡的玩家,匯報不予采信
return;
// 注意,如果是敵機碰到自己,會發(fā)送兩條,一條說自己被對方撞死,另一條是對方被自己撞死,這個時候都當(dāng)作是匯報自己死亡
// 0: 墜機對象的no,用byte表達的話,最多兼容256人大房間
// 1: 傷害者類型(0: 敵方玩家(直接碰撞); 1: 敵方炮彈; 2: 敵方Bot)
// 2: 如果是敵方玩家直接碰撞,那么對方的no是什么
int dieNo = (int) infos[0];
if (dieNo < 0 || dieNo > room.playerCount) {// 如果是128人以上的房間,dieNo可能是-127~-1,要考慮兼容
kick(); // 不合法的上報,踢出玩家
return;
}
int murdererNo = -1;
if (infos[1] == 0) {
murdererNo = (int) infos[2];
if (murdererNo < 0 || murdererNo > room.playerCount) {
kick(); // 不合法的上報,踢出玩家
return;
}
}
if (dieNo == no || murdererNo == no) {
// 給另外一個玩家添加一個死亡報告
if (dieNo == no) {
if (murdererNo != -1)
roommates[murdererNo].reportDie(this);
} else
roommates[dieNo].reportDie(this);
die();// 本玩家死亡
} else { // 觀察其它玩家的死亡
roommates[dieNo].reportDie(this);
}
}
void reportDie(Player reporter) {
if (room.isNotReallyStart || isDead) // 死豬不怕開水燙
return;
long curTime = getTime();
dieReports[reporter.no] = curTime;
int dieCount = 0;
long reportExpired = curTime - 2000;
for (long time : dieReports)
if (time > reportExpired)
dieCount++;
if (dieCount < room.confidenceInterval)
return;
die();
}
void die() {
isDead = true;
setIsDead(isDead);
syncToClient();
sendToAll(new byte[] { Room.NotifyType_PlayerCrash, (byte) no });
int[] teamAliveCounts = new int[] { 0, 0 };
String msg = String.format("Player[%d][%s] die\n", no, getUserId());
for (Player p : roommates) {
if (p.isDead) {
msg += p.no + " is dead, team " + p.teamId + "\n";
continue;
}
teamAliveCounts[p.teamId]++;
msg += p.no + " is alive, team " + p.teamId + "\n";
}
msg += String.format("team_0 has alive[%d] and team_1 is [%d]", no,
teamAliveCounts[0], teamAliveCounts[1]);
if (teamAliveCounts[0] == 0 || teamAliveCounts[1] == 0) { // 有一個隊沒人了
// 準(zhǔn)備發(fā)送GameOver, 0:平局,1:勝利,2:失敗
byte[] toTeam0 = new byte[] { Room.NotifyType_GameOver, 0 }, //
toTeam1 = new byte[] { Room.NotifyType_GameOver, 0 };
if (teamAliveCounts[0] == teamAliveCounts[1]) {// 都沒人了
} else if (teamAliveCounts[0] == 0) { // 隊伍1勝利
toTeam0[1] = 2;
toTeam1[1] = 1;
} else {
toTeam0[1] = 1;
toTeam1[1] = 2;
}
for (Player p : roommates)
p.send(p.teamId == 0 ? toTeam0 : toTeam1);
room.gameOver(); // 游戲結(jié)束
}
}
// 有玩家上報,怪物死亡
@BmobGameSDKHook
public strictfp void onAction_BotDie(byte[] infos) { // 暫時放怪物名
if (room.isNotReallyStart)
return;
// cn.bmob.gamesdk.server.Main.l("BotDie: (" +
// java.util.Arrays.toString(infos) + ") : " + infos.length);
if (room.isBotDieNow(new String(infos))) {// 不重復(fù)的
byte[] sendInfos = new byte[1 + infos.length];
sendInfos[0] = Room.NotifyType_BotDie;
arraycopy(infos, 0, sendInfos, 1, infos.length);
sendToAll(sendInfos);
}
}
// 游戲中掉線,當(dāng)作死亡
@Override
public void onOffline() {
if (room.isNotReallyStart)
return;
die();
}
// 游戲中離開房間,當(dāng)作死亡
@Override
public void onLeave() {
if (room.isNotReallyStart)
return;
die();
}
-
接入SDK:
// game.js // 根據(jù)屏幕大小來定玩家的大小, 我們定玩家如果需要穿過整個y軸最少需要2秒,怪物需要8秒 const PlayerMaxSpeed = screenHeight / 2000; // px per sec const BotSpeed = screenHeight / 8000; // px per sec const EnemyFireSpeed = screenHeight / 3000; // px per sec const FriendFireSpeed = -EnemyFireSpeed; // 其它玩家更新屬性 onOthersStatus(no, changedAttr, hisStatus) { if (changedAttr.position) { let y = hisStatus.position[1]; let gameObj = this.players[no].gameObject; if (gameObj.isTeammate) y = 65535 - y; gameObj.x = hisStatus.position[0] / WidthRatio - PlayerWidth / 2; gameObj.y = y / HeightRatio - PlayerHeight / 2; } } // 其它玩家發(fā)送事件 onTransfer(no, body) { switch (body.shift()) { case 50: console.log('Fire from: ', this.players[no]); let isTeammate = this.players[no].gameObject.isTeammate, x = (body[0] << 8) | body[1], y = (body[2] << 8) | body[3]; if (isTeammate) y = 65535 - y; let fire = new Sprite( isTeammate ? ImgSrc_Fire_Friend : ImgSrc_Fire_Enemy, FireWidth, FireHeight, x / WidthRatio, y / HeightRatio ); fire.objType = 3; // 0: sundries; 1: player; 2: bot; 3: fire fire.velocity = isTeammate ? FriendFireSpeed : EnemyFireSpeed; fire.teamId = isTeammate ? this.mTeamId : (1 - this.mTeamId); this.gameObjArr.push(fire); break; } } // 云端通知 onCloudNotify(notify) { switch (notify.shift()) { case NotifyType_AssignTeam: this.assignTeam(notify); break; case NotifyType_BotSpawn: this.botSpawn( notify[0] == this.mTeamId, (notify[1]) * screenWidth / 255, notify[2], model.bytesToString(notify, 3, notify.length) ); break; case NotifyType_ReallyStart: this.startGame(); break; case NotifyType_PlayerCrash: this.renderPlayerDie(notify[0]); break; case NotifyType_BotDie: this.botDie(model.bytesToString(notify, 0, notify.length)); break; case NotifyType_GameOver: this.isGameStart = false; switch (notify[0]) { case 0: this.gameDraw(); break; case 1: this.gameWin(); break; case 2: this.gameLose(); break; } break; } }
測試、發(fā)布:灰常好玩,下階段準(zhǔn)備做成四個陣營的玩法
開發(fā)體驗
在基本素材、組件(物理引擎)等預(yù)備充分的情況下,花了不到兩個小時就將一個單機游戲改造成了聯(lián)網(wǎng)對戰(zhàn)的游戲,而且邏輯也足夠健壯,效果還是很酷的。再加上SDK是開源的,有什么問題很容易定位。
總體來講,Bmob Game SDK真正拉低了網(wǎng)絡(luò)游戲開發(fā)的門檻,完全沒有了以前龐大、繁雜的后端開發(fā)和服務(wù)器運維工作,讓很多受限于資源、只能開發(fā)單機游戲的團隊和項目有了新的出路~
獲取Demo、SDK完整源碼的方式:
加官方客服,小小琪QQ:2967459363
官方Q群:726133616
其他教程
落地成盒?Bmob幫你開發(fā)自己的聯(lián)網(wǎng)"吃雞"游戲