close

【CSDN 編者按】過去一周,不少人被《羊了個羊》這款遊戲虐的不輕,有多少個「再玩一把」的念頭,就有多少次被打入深淵的淒涼,甚至還有人評價道:「什麼事都可以過去,除了《羊了個羊》第二關」。因此,有用戶抱怨是「程序員故意挖坑製作死關卡」。然而在本文作者老王一探究竟以後,才發現並非程序員挖坑,而是該遊戲的本身,就有很多「天然的坑」。

作者 | 開發遊戲的老王
責編 | 張紅月
出品 | CSDN(ID:CSDNnews)

昨天有朋友和我說:「最近有個叫《羊了個羊》的遊戲爆火,就是太難玩了,你能復刻一個不?」話說上次玩休閒遊戲還是在幾年前,但是朋友之託必須赴湯蹈火啊,二話不說,開整!然而,衝動是魔鬼,直到此時此刻,老王也沒能親手玩一局原版遊戲,不知道是遊戲入口設計得太隱蔽還是網絡加載太慢,無論手機端還是PC端,遊戲都停留在如下界面。

所以本次遊戲的復刻,完全是基於各視頻網站雲觀摩的結果,好在遊戲的玩法不是特別難理解。復刻使用的開發工具是Godot Engine(使用其它工具開發原理也是相似的),目前項目已經開源到了GitCode:Godot版《羊了個羊》https://gitcode.net/hello_tute/SheepASheep。

接下來我將通過臨摹遊戲的方式推測一下這個小遊戲的實現原理,本文主要面向對遊戲開發有興趣的朋友,歡迎大家多提寶貴意見。

CSDN特供版《羊了個羊》

先說說玩法

第一眼看到《羊了個羊》,老王首先想到當年的《連連看》,不過有網友爆料,該遊戲「借鑑」了《3tiles》。瞄了眼《3tiles》,是比較相似。說心裡話,這個遊戲的玩法並沒有什麼過於出眾的地方,算是個中規中矩的「低卡路里」休閒遊戲。

之所以成為話題作品,主要就是因為它的第2關極其低的通關率,一下子激起了眾多玩家的挑戰欲望。而時至今日這個「低通關率」也被網絡上的眾多玩家揭秘,第2關其實大概率上本身就是個死局。是程序員故意挖坑設了死局麼?先賣個關子,我們先聊聊遊戲的開發,然後您自己就會有答案了。

實現概要

遊戲的整體很簡單,但其中有幾個實現的重點需要注意:

牌堆數據結構的實現

如何檢測和更新可拾取的牌

先做個小定義,一個牌堆中可被拾取的牌以下將簡稱其為:「窗口牌」。

牌堆的結構及其數據結構

最初,我還真被這複雜的牌堆結構蒙住了,但仔細研究一番發現,無論多麼複雜的牌堆,其實都是由如下三種牌堆模式組合拼湊而成的。

藍圈圈出的牌堆模式A:上面1張牌只擋住下面1張牌;同時下面的牌僅被上面1張牌擋住。只要上面的1張牌被取走,下面的牌就成為窗口牌;

紅圈圈出的牌堆模式C:上面1張牌可以擋住下面4張牌;同時下面的牌可能被上面4張牌擋住,一張牌只有它上面的4張牌都被取走,它自己才成為窗口牌。

雖然上圖中體現不是很明顯,但不難猜想出,第三種牌堆模式B 的存在,那就是:

上面1張牌可以擋住下面2張牌;同時下面的牌可能被上面2張牌擋住,一張牌只有它上面的2張牌都被取走,它自己才成為窗口牌。

對於牌堆模式A,有些朋友會迫不及待地用「隊列」或「棧」實現它,這樣做有兩個缺點:

邏輯上牌堆模式A的窗口牌也可能是2維的,如果用隊列實現就限制了它的靈活性;

牌堆模式B和C都不好用隊列實現,所以想追求數據結構的統一,還要另求他法。

實際上無論牌堆模式A、B還是C,都不過是3維數組結構,上圖中模式A看起來特殊,無非是它的x,y維度都為1罷了。而三種牌堆的區別也無非就是當一張窗口牌被取走,檢查牌堆是否出現新的窗口牌的方法罷了。

牌堆模式A

牌堆模式B

牌堆模式C

牌堆的數據結構

我將其定義為MContainerBase基類

#MContainerBaseextends Node2Dclass_name MContainerBasefunc _ready(): add_to_group(name) add_to_group("game") var Mask = FileReader.read(mask_file,null) box.resize(size_x) for i in range(size_x): box[i] = [] box[i].resize(size_y) for j in range(size_y): box[i][j] = [] box[i][j].resize(size_z) for k in range(size_z): if Mask == null or Mask[i][j] == 1: box[i][j][k] = add_tile(i,j,k,get_parent().distribute_face()) else: box[i][j][k] = null for x in range(size_x): for y in range(size_y): for z in range(size_z): check_is_on_top(x,y,z)

最基礎的牌堆就是一個 x*y*z的三維數組,我們可以使用一切方法構造想要的排隊形狀:柱形、條形、甚至金字塔形。這都不會影響後面程序的實現。

項目中為了增加這個「大方塊」的多樣性,我還給它設置了如下的「遮罩」,這就是遊戲中CSDN文字的由來。當然我們還可以通過「遮罩」來自由定義窗口牌,這部分就請大家自由發揮了。

# S形遮罩[ [0,0,0,0,0], [0,0,0,0,0], [1,1,1,0,1], [1,0,1,0,1], [1,0,1,1,1],]

如何檢測和更新可拾取的牌

三種牌堆模式分別派生自MContainerBase,並對應着如下三種檢測方式:

牌堆模式A

僅檢測自己正上方是否有牌

#1 Cover 1extends MContainerBasefunc check_is_on_top(x,y,z): if has_tile(x,y,z): if not has_tile(x,y,z + 1) : (box[x][y][z] as MTile).set_is_on_top(true)

牌堆模式B

檢測自己上方兩方位是否有牌

#1 Cover 2extends MContainerBasefunc check_is_on_top(x,y,z): if has_tile(x,y,z): if z%2 == 0: if not has_tile(x,y,z + 1) and not has_tile(x - 1 ,y,z + 1): (box[x][y][z] as MTile).set_is_on_top(true) else: if not has_tile(x,y,z + 1) and not has_tile(x + 1 ,y,z + 1): (box[x][y][z] as MTile).set_is_on_top(true)

牌堆模式C

檢測自己上方四方位是否有牌

#1 Cover 4extends MContainerBasefunc check_is_on_top(x,y,z): if has_tile(x,y,z): if z%2 == 0: if not has_tile(x,y,z + 1) and not has_tile(x - 1 ,y,z + 1) and not has_tile(x,y - 1 ,z + 1) and not has_tile(x - 1,y - 1,z + 1): (box[x][y][z] as MTile).set_is_on_top(true) else: if not has_tile(x,y,z + 1) and not has_tile(x + 1 ,y,z + 1) and not has_tile(x,y + 1 ,z + 1) and not has_tile(x + 1,y + 1,z + 1): (box[x][y][z] as MTile).set_is_on_top(true)

在Godot中,這三種牌堆模式還可以通過場景節點製作成預製體,這樣關卡設計師就可以輕鬆地製作出美觀的關卡了。

如何生成新關卡

簡單了解遊戲規則後,我們就不難推導出,每個關卡能被通過的一個必要條件就是每一種圖案的總數,必須能被3整除。實現方法如下:

var tiles = []export var initial_tiles = { 0:10, 1:10, 2:10, 3:10, 4:10, 5:10, 6:10, 7:10, 8:10, 9:10, 10:10, 11:10, 12:10, 13:10, 14:10, 15:10}func _init(): for key in initial_tiles: var num = initial_tiles[key]*3 for i in range(0,num): tiles.append(key) tiles.shuffle()

其中字典initial_tiles 的key對應着每一種圖案,後面的value對應着這一關該圖案出現的「對數」(此處1對等於3個)。按照value乘以3的數量存入數組tiles(下文稱之為:待發牌池),然後把待發牌池中的元素打亂順序,等待「發牌」。

關於遊戲中的坑

很多朋友抱怨:「程序員故意挖坑製作死關卡」。其實不然,他無須故意挖坑,因為這個遊戲本身就有很多「天然的坑」,如果不使勁填坑,它們自然而然就屬於你了。而這裡就隱藏了幾個可致命的坑:乍一看,待發牌池中所有的圖案都可以被3整除那麼一定可以通關?那可不一定:

只有桌面牌堆中牌的數量和待發牌池牌數一致,所有的牌才能「落地」,而遊戲中桌面牌堆到底有多少(層)本身就是個迷。並且如果沒猜錯的話,在每一局設計者先要確保牌堆形狀好看,然後再使堆牌數和待發池的牌數一致。二者哪怕差1個,也會造成死局。

上文說了,桌面牌數和待發牌池的牌數一致只是過關的必要而非充分條件。即使該條件滿足,如果相對於牌桌上的牌數以及圖案數量,窗口牌數太少,也會造成死局。比如下面這個極端的例子:假設遊戲共有 15種花色,而牌桌上只有這個模式A牌堆,它有90張牌。那麼玩家只要在連續7次拾牌時沒有遇到3個相同圖案的牌,就「必死無疑」了。

其實這個遊戲,一方面要控制關卡的難度,另一方面又要保證能通關本身就是一個相當困難的問題(至少老王沒有想出辦法)。而設計者反其道而行之,(可能)沒有花力氣去設計算法,把坑留給玩家,得到了極低的通關率,反而製造了話題並形成爆款。如此說來,這確實是個抖機靈的「設計」。但老王認為這種「設計」在遊戲策劃中是不宜被借鑑的,就像現在市面上泛濫的懸疑劇,開始埋坑無數,吊足觀眾胃口,最後爛尾不了了之一樣,長此以往觀眾(玩家)對於懸疑劇(遊戲)的信任感就被消費殆盡了。

洗牌道具的實現

洗牌的實現原理很簡單,把當前桌面的牌記錄在一個數組tiles中,當需要洗牌時,先打亂一下數組中牌的順序,然後讓桌面上每一張牌到tiles中重新取一個值。再來個眼花繚亂點的動畫,還真挺像那麼回事兒。

func shuffle_tiles(): tiles.shuffle() tiles_index = -1 func redistribute_face() -> int: tiles_index += 1 return tiles[tiles_index]

遮罩文件的讀取

這裡要夸一下Godot Engine,它的很多功能真是方便,比如下面這個str2var它可以簡單粗暴地直接把字符串轉換成對象類型。

class_name FileReaderstatic func read(path,default_data): var data = default_data var file = File.new() file.open(path,File.READ) var content :String = file.get_as_text() if not content.empty(): data = str2var(content) file.close() return data

對象間的通信

這個小遊戲中存在大量的對象間的通信需求:牌和牌之間、牌和牌堆之間、牌和關卡之間、牌堆和關卡之間。為了快速實現遊戲,我大量使用了Godot Engine的Group機制,不得不說Group是Godot Engine最贊的設計之一。

總結

小遊戲《羊了個羊》,從策劃和開發的角度來看並不困難,然而「瑕疵」竟然能夠成為「噱頭」,也讓人不得不感慨「遊戲世界真的一切皆有可能啊」。

作者簡介:

開發遊戲的老王,高校教師、技術專欄作者、獨立遊戲開發者

《新程序員001-004》全面上市,對話世界級大師,報道中國IT行業創新創造!
☞南開大學教授「段子手式」簡介,網友:笑着笑着突然「破防」了!
☞Python 竟然不是最賺錢的編程語言?!
☞除了數學,世界上所有知識都可以自學!
arrow
arrow
    全站熱搜
    創作者介紹
    創作者 鑽石舞台 的頭像
    鑽石舞台

    鑽石舞台

    鑽石舞台 發表在 痞客邦 留言(0) 人氣()