家好,我是Echa。
前段時間有00后前端程序員粉絲私信小編問道:小時候我們玩什么小游戲,用Vue和React可以開發簡易的Web站點小游戲嗎?這一類的問題。立馬勾起了小編的小時候玩的那些經典小游戲愛的那么癡迷。
經典小游戲愛的那么癡迷
作為80后的小編,雖然禿頭程序員沒有頭發,但是童心還是一直都在的。對于童年時玩過的那些經典游戲,我們始終難以忘懷。回憶和懷念經典最好的方式就是重新體驗它們!GitHub作為程序員們的開源寶庫,有著很多非常好的項目。.其中有許多可以稱之為經典,像《俄羅斯方塊》、《記憶翻牌》、《掃雷》、《貪吃蛇》、《坦克大戰》等等陪伴我們度過了兒時快樂的時光。
小霸王學習機 一句“小霸王其樂無窮啊”能夠勾起多少八零九零的集體回憶,曾經靠一副手柄就能爽快打完魂斗羅、沙羅曼蛇、雪人兄弟、忍者神龜、超級瑪麗..。
小霸王
借此機會,小編給大家好物分享10個經典小游戲開源項目(Vue、React版),免費送一個。希望忙碌的粉絲們勞逸結合,可以玩玩,放松放松,有利于身心健康。不過千萬別成謎語游戲無法自拔的那種,玩也有一個度。
下面小編給一一介紹,看看哪款游戲是你們的最愛:你可以在試玩過程中自定義游戲關卡并學習源代碼。
Github:https://github.com/chvin/react-tetris
體驗:https://chvin.github.io/react-tetris/?lan=en
react-tetris - 經典俄羅斯方塊小游戲
復刻經典的俄羅斯方塊,該項目采用 React+Redux+Immutable 的技術棧。這款游戲的復刻程度堪稱像素級別,不僅體現在畫面上,還有流暢度、玩法、音效等方面都做到了極致。
俄羅斯方塊是一直各類程序語言熱衷實現的經典游戲,JavaScript的實現版本也有很多,用React 做好俄羅斯方塊則成了我一個目標。
Github:https://github.com/martindrapeau/backbone-game-engine
體驗:http://martindrapeau.github.io/backbone-game-engine/super-mario-bros/index.html
backbone-game-engine - 超級馬里奧
超級馬里奧是比較經典的GBA游戲了,這個紅帽子藍吊帶的大胡子工人陪伴著很多90后度過童年。這個游戲支持游戲自定義道具,充分回味童年的樂趣。
特性:
Github:https://github.com/shinima/battle-city
體驗:https://battle-city.js.org
坦克大戰 單人打
《坦克大戰》是由日本南夢宮Namco游戲公司開發的一款平面射擊游戲,于1985年發售。游戲以坦克戰斗及保衛基地為主題,屬于策略型聯機類。 這個項目在很大程度上還原了坦克大戰游戲。圖標、音效和界面等方面,各個細節的幾乎一模一樣。
坦克大戰 雙人打
該 GitHub 倉庫的版本是經典坦克大戰的復刻版本,基于原版素材,使用 React 將各類素材封裝為對應的組件。素材使用 SVG 進行渲染以展現游戲的像素風,可以先調整瀏覽器縮放再進行游戲,1080P 屏幕下使用 200% 縮放為最佳。此游戲使用網頁前端技術進行開發,主要使用 React 進行頁面展現,使用 Immutable.js 作為數據結構工具庫,使用 redux 管理游戲狀態,以及使用 redux-saga/little-saga 處理復雜的游戲邏輯。
Github:https://github.com/ekinkaradag/snake-vue3
經典貪吃蛇小游戲
snake-vue3 基于 Vue 3.3、Vite、Vuex 實現的經典貪吃蛇游戲。
Github:https://github.com/laoqiu233/minesweeper-react
Win98 的風格掃雷小游戲
一個掃雷游戲,作者嘗試使用老式字體和經典的 Win98 圖標,用 CSS 復制 Win98 的風格,使這個項目盡可能真實。該項目使用的技術棧包括:TypeScript、Webpack、React、Redux、React Router。
Github:https://github.com/RylanBot/threejs-tetris-react
3D 俄羅斯方塊游戲
基于 Three.js、React、TypeScript 實現的 3D 俄羅斯方塊游戲,可以拖動旋轉頁面進行觀察。
Github:https://github.com/HabitRPG/habitica
RPG 角色扮演游戲
這是一個培養習慣的開源應用,那它為什么會出現在游戲集合里呢?因為它會將你培養習慣的過程,當作一個 RPG 角色扮演游戲。
你需要根據設定的習慣,創建對應現實中需要完成的任務,當你完成一個任務時會獲得相應的經驗和金幣,這些東西可以用來提升虛擬人物的等級以及購買裝備。但當任務失敗時,對應的將失去血量作為懲罰。隨著你的等級提升,將會開啟更多的玩法,比如:孵化寵物、職業、專屬技能、組隊打副本等。
Github:https://github.com/Aklilu-Mandefro/game-application-using-react-and-typescript
簡單 2D 蛇游戲
使用 React 和 TypeScript 構建的簡單 2D 蛇游戲。可以使用 w、a、s 和 d 鍵來移動蛇。當吃掉水果時,得分和蛇的長度會動態增加,使用 canvas 元素構建。其用到的技術包括:React、Chakra-UI、Redux、Redux-saga。
Github:https://github.com/WeiChongDevelops/3072
體驗:https://3072.vercel.app/
3072 數字合并游戲
3072 是一款受流行游戲“2048”啟發的數字合并游戲,但游戲玩法與2048截然不同,使用的是 3 的倍數而不是 2,這真的是一種非常深刻和令人振奮的用戶體驗改變。這個項目使用 TypeScript、React 和 Tailwind CSS 構建,確保高性能的交互性和令人驚艷的響應式設計。
Github:https://github.com/Kirill2603/3d-chess-v2
經典國際象棋游戲
使用 React、Redux Toolkit、ThreeJS、React Three Fiber、ChessJS 和 ChakraUI 構建的經典國際象棋游戲。
Github:https://github.com/LAxBANDA/frontend-concentration-or-memory#concentration-or-memory-game
記憶翻牌游戲
使用 Vue3.3、Pinia、Webpack、TypeScript 開發的一款記憶翻牌游戲。
粉絲們,有沒有勾起你們兒童對回憶?喜歡哪款經典小游戲呢?
歡迎在評論區分享討論。
一臺電腦,一個鍵盤,盡情揮灑智慧的人生;
幾行數字,幾個字母,認真編寫生活的美好;
一 個靈感,一段程序,推動科技進步,促進社會發展。
創作不易,喜歡的老鐵們加個關注,點個贊,打個賞,后面會不定期更新干貨和技術相關的資訊,速速收藏,謝謝!你們的一個小小舉動就是對小編的認可,更是創作的動力。
創作文章的初心是:沉淀、分享和利他。既想寫給現在的你,也想貪心寫給 10 年、20 年后的工程師們,現在的你站在浪潮之巔,面對魔幻的互聯網世界,很容易把一條河流看成整片大海。未來的讀者已經知道了這段技術的發展歷史,但難免會忽略一些細節。如果未來的工程師們真的創造出了時間旅行機器,可以讓你回到現在。那么小編的創作就是你和當年工程師們的接頭暗號,你能感知到他們在這個時代的鍵盤上留下的余溫。
ameShell是一款由中國團隊打造的開源化掌機產品,早在2017年的11月份就在眾籌網站Kickstarter上開展眾籌,正式的項目名稱為“clockwork”。在設備上線僅13個小時之后,眾籌金額就已經達到了預定目標,最終得到了30萬美元左右的眾籌,并在今年一月中旬正式開賣。
這款GameShell最大的亮點就是它是一款模塊化掌機產品,每個部分都是獨立并且可以自由搭配,主板也是采用了樹莓派標準;除此之外還搭載了經定制的clockworkpi OS,可以實現主機編程,可以說是一款可玩性非常高的開源掌機。
首先要了解一下這款機器的參數,以便于了解這款主機的基本性能,下面是具體參數:
主板:經定制的 clockworkPi V3.1(樹莓派標準)
CPU:4核 ARM Cortex-A7 CPU,Mali-400 GPU
網絡: WI-FI & Bluetooth 模塊,
內存:1GB DDR3
接口: Micro HDMI 輸出,Micro SD卡槽,支持PMU電源管理。
屏幕:2.7 英寸 IPS RGB@60fps,分辨率 320*240
Keypad 鍵盤模塊/Arduino兼容開發板:支持經典的 D-Pad 物理按鈕和布局,支持 12 顆獨立 IO 的按鈕。可編程的鍵盤模塊開發板完全兼容 Arduino 生態。基于 ATmega168P MPU@20MIPS MPU,同時包括 USB 調試接口、兩個 15PIN GPIOs 擴展接口和一個 6PIN ISP 燒錄接口。一個7PIN 擴展口用于支持 Lightkey 模塊。
電池:1200mAh 鋰電池
系統:clockwork OS, 支持 Linux Kernel 4.2x 內核或更高內核版本。
從以上參數上來看,Cortex-A7架構的CPU曾經用于28nm制程的移動設備,大概在12年左右的中端智能機上應用,內存容量也將夠用。另外就是QVGA分辨率(320*240)的2.7英寸屏幕,由于主機顯示主要在這塊屏幕上,所以還是比較關鍵的,從屏幕分辨率的規格上可以看出這款主機的性能還是可以接受的。
GameShell的外觀設計更多體現在團隊對于掌機的理解,每個人心中都有一些經典機型的原型,而GameShell團隊中抽象出一些歷史上一些標志性的掌機特征,并經歷了至少500次修改之后才達到了現在的這個外觀。
幾乎每一個喜歡過掌機的人看過這臺掌機之后都會喜歡上它,經典的十字鍵與矩形排列的按鈕,橫向的橢圓形的按鍵以及橫版的屏幕全都是十分經典游戲機上的元素,再加上工程塑料一般質感的外殼以及兩端的旋鈕,都有著上個世紀九十年代最單純的游戲懷舊情懷實在是非常撩人。
當然這款GameShell并不只是掌機而已,這款掌機的樂趣也并不只是打游戲,還在于拼裝。提到拼裝,相比大家都會想到樂高或者高達,又或者去年十分火爆的Labo的紙盒子。相比其它拼裝,GameShell不需要使用復雜的工具,也沒讓人眼花繚亂的拼裝指南,大家打開盒子,只需簡單了解下各個組件,就可以輕松組裝。接下來就給大家詳細介紹一下。
首先是外包裝,外殼是淡黃色的紙盒右邊是GameShell的結構圖,線路的透視非常漂亮。右側則是主機的一些介紹與參數。
在套裝里分別有幾個部分,包括主板模塊、鍵盤模塊、聲音模塊、顯示模塊、電池模塊、Lightkey 模塊、1個前殼 & 2個后殼(透明簡潔后殼 + 兼容樂高積木插口的可拓展后殼) + 6個模塊外殼。 一張16GB的MicroSD Card內存卡(已內置 clockwork OS)、5根線纜(40PIN FPC、4PIN x 2、2PIN、7PIN)+ 14PIN 調試線纜。 以及 GameShell 安裝指引 & 貼紙與開放的外殼3D打印模型文件 & 電路原理圖。
主要的部件就是這些,電路板、屏幕以及電池都被封裝好,其他的部件是固定用的塑料盒子以及按鈕,是不是有種高達零件的感覺!
整體拼裝并不復雜,只是需要把細節做好,部件之間的水口要清理干凈,否則會有線纜鏈接的時候出現問題,只要按照拼裝指南一步一步拼好就行。
一共有六個部分,分別是屏幕部分,主板部分,按鍵部分,電池部分,音響部分以及Lightkey 模塊,這個模塊是提供模擬肩部按鍵功能的,可以選擇性的裝,如果要裝這個需要兼容樂高積木插口的可拓展后殼。
拼裝各部件還算簡單,但是把線纜連接起來并合在一處就相對復雜了一些,屏幕和主板在最上,鍵盤和電池在中間,音箱在最下方,Lightkey 模塊在后殼之外。期間需要注意走線,如果讓后背沒有飛線的話需要將線纜都夾在雙模塊之間,不過安裝的時候會有少許模塊的移動,不過拼完之后會更美觀一些。
在拼好之后,咱們就看到了這臺掌機的真容,從這臺掌機的按鈕上可以進行一些經典元素的結構,首先十字鍵,是任天堂第一代主機FC上手柄的方向鍵,十分之經典。而YXAB的排列則是索尼第一代主機PS1手柄上的排列方式,但YXAB這四個數字則是XBOX主機上的元素,上邊四個按MENU、與Select與Start鍵想不想最早FC手柄上的復位和暫停按鍵。
但是在2017年FC曾推出過一款復刻版手柄與現在的按鍵布局十分相似。
無論如何,看到都十分經典。
曾經任天堂推出過一款GAME BOY Color其中的透明塑料的原始質感是不是與今天介紹的GameShell十分相似啊。
左右兩個旋鈕并不是調節用,而是將掌機合住的卡扣,下邊的就是Lightkey模塊。
頂端則是3.5mm耳機插口、mini HDMI插口以及Micro USB插口與開關鍵,Lightkey模塊上共有五個按鍵,用戶可根據自己的需求進行自定義編程,一般用于模擬器中掌機部分無法涉及到的按鍵。
總結:
GameShell作為一款開源的模塊化掌機,在掌機的外觀上繼承了諸多經典元素,拼接完之后無論是工程塑料的感覺還是按鍵的設計以及透明背殼都十分有復古的懷舊氣息。而拼裝的DIY元素則是讓這款掌機除了游戲之外的另外一個樂趣。拼接過程雖然簡單但細節之處還是需要打磨,完全拼完大概需要一個小時左右的時間。
clockwork OS 基于 Debian 9 ARMhf 和 Linux 4.2x或更高版本的Linux 內核構建。支持包括 C、C++、Python、Lua、Golang、 JavaScript、LISP、JAVA 等各種主流語言及腳本,您可以輕易移植或創建各種屬于您的獨立游戲和應用程序。完美運行 PICO-8, TIC80, LOVE2D, PyGame, Phaser.io, Libretro 等各種游戲引擎。
當然這些都只是支持,咱們沒有辦法一一測試,所以就先來看一下這款掌機的系統部分。
進入主界面,可以看到最左到最右分別是設置、模擬器游戲、獨立游戲、一些系統自帶的游戲以及各種編程軟件,如PICO-8、TIC-80、Love2D、音樂、網絡傳輸與關機等按鍵。界面非常簡單,當然這個界面你也可以通過編程來自己定制,在設置之內有一個獨立的主題選擇。
在設置界面,有些類似手機中的布局,飛行模式、WIFI、音量、背景亮度、語言、藍牙、界面選擇,網關設置、主題管理等等,日常大家需要用到的功能都可以在這里找到。其中網絡部分由于主板空間的限制,wifi天線增益不是很高,所以在傳輸游戲或者音樂的時候需要在一個網絡較好的地方。
關于數據傳輸就要給大家做一個比較詳細的介紹。
連接分成兩種情況,分別為wifi連接與USB連接,由于上述的情況,比較建議大家進行USB連接,會更加穩定一些。
首先是wifi連接,需要與傳輸的設備在同一個網絡環境之下,然后進入掌機的Tiny Cloud中查看所在的網絡ip。
然后在文件管理器中輸入IP地址就能進入設備內部。
USB連接則更容易一些,MAC系統要在文件管理器中CMD鍵+K進入連接服務器,然后輸入USB-Ethernet中的地址,然后輸入ID與Key就能進入了。Win系統同理,在文件管理器地址上輸入IP地址,即可訪問機器中相應的文件夾,如上圖所示。
在目錄中有響應的文件夾,包括編程軟件的文件夾,與模擬器游戲的文件夾,放入對應格式的游戲即可。
小編使用了GB Studio制作了一段簡單劇情的GB游戲,然后放入對應文件夾,在掌機中親測可以使用。
玩自己制作的小游戲還是十分帶感的,同樣這款掌機完美運行 PICO-8, TIC80, LOVE2D, PyGame, Phaser.io, Libretro 等各種 游戲引擎。支持包括 C、C++、Python、Lua、Golang、 JavaScript、LISP、JAVA 等各種主流語言及腳本,如果你是個游戲制作人員的話可以非常輕易的移植獨立游戲與應用程序進入到掌機中。
下面是模擬器游戲,掌機支持包括 Atari、 GameBoy系列、NES、SNES、MAME街機系列、MD、PS1 等15種歷史上各種著名的游戲主機,無論是自己制作小游戲,還是以模擬器游戲進行游玩都不失為一個功能非常全面的掌機。
測試游戲為SFC版本《最終幻想6》
游戲在讀取的速度非常快,屏幕分辨率為320*240,復古的游戲分辨率大部分都被囊括,所以不用在意無法點對點模擬的問題。而且掌機中已經支持GPU驅動,在進行模擬游戲的時候非常順滑,只是在玩PS1等較大的3D游戲情況下較為吃力。對一般經典的模擬復古游戲來講一點問題都沒有。
在按鍵手感方面,由于是自行拼裝的,所以對按鍵拼裝的精準度有一些要求,如果對歪點的話,需要拆開重新在組裝一次,大部分是沒有問題的。按鍵反饋十分靈敏,背后的肩鍵模塊的十分牢固,在游戲過程中沒有出現松動的跡象。
掌機中有9檔屏幕亮度調節,與音量調節,可以應付大部分使用場景,Shift鍵與Select與Start可以進行組合調節音量大小,也是很方便的。
GameShell雖然是一個掌機的形態,但其實是一臺小型的電腦,所以在影音方面也是沒有問題的,只是系統與硬件上的限制無法瀏覽大型電影。GameShell機身自帶HDMI接口,也可以將掌機端的顯示內容投射到顯示器端口,讓這臺掌機化身為一代電腦。
音樂方面比較容易,只要連接到手機之后考入手機就可以進行音樂欣賞,GameShell支持播放各種音樂格式,包括各 種無損格式。機身自帶3.5mm耳機接口,所以可以直接當這臺掌機為一個前端。不過機身原生自帶的播放器不支持在音樂播放界面的操作,只能在列表中進行切換。
但是播放視頻就會有一些技術上的麻煩,GameShell本身由于硬件限制所以系統并沒有自帶播放器,但是我們可以通過GameShell的社區中尋找玩家的教程,在其中有Kodi播放器的安裝教程。
但是其硬件屏幕只能支持320*240分辨率,而且在系統層面上,GameShell的系統為clockwork OS 基于 Debian 9 ARMhf 和 Linux 4.2x 或更高版本的 Linux 內核構建。需要有一定的Linux系統使用基礎,并且需要了解一些代碼基礎才好操作,總體來說是需要一定的技術門檻。
在經過漫長的安裝過程之后終于把播放器裝上了,但是由于沒有低分辨率的UI所以在這個屏幕上看不清文字,在播放上面,超低分辨率的本地視頻是沒有問題的,但是超過QVGA分辨率的視頻就會出現卡頓的問題,在安裝流媒體插件之后可以觀看CNET等網絡電視,但是優于硬件性能的問題同樣會有卡頓的問題,不過聲音很流暢。
當然在影音部分“折騰”的樂趣遠比實際效果要重要很多,2.7英寸的屏幕用來看視頻本身也不夠舒適,但是完成安裝播放器并讓這款掌機播放視頻的樂趣卻無以言表,而這也是這款GameShell的真諦,它不僅是掌機,更是一款開源的口袋大小的全功能 Linux 個人電腦。
GameShell在本質上并不是一臺專業的游戲掌機,而是一個基于Linux的開源中端設備,而在這臺掌機上你可以獲得多種樂趣,可以拼裝,可以玩復古的模擬器游戲,可以將它作為一個編程的測試設備,而由于它又有著掌機的外形,對于動手能力強的玩家來說又可以通過學習簡單的編程自己也可以制作屬于自己的游戲。對于這款產品很難用簡單的一個掌機,或者一個游戲終端來概括,完全看他對你而言可以做到什么。
個月前,JS1k游戲制作節(JS1K game jam)傳出不再舉辦消息后,許多游戲迷開始哀嚎。
Frank Force 也是其中一位,但他還有另一層身份——一位德克薩斯州奧斯汀的獨立游戲設計師。Frank Force 在游戲行業工作了20年,參與過9款主流游戲、47個獨立游戲的設計。在聽到這個消息后,他馬上和其他開發朋友討論了這個問題,并決定做點什么為此紀念。
在此期間,他們受到三重因素的啟發。一是賽車游戲,包括懷舊向的80年代賽車游戲,他們在非常早期的硬件上推動實時 3D 圖形,所以作者沿用了相同的技術,用純 JavaScript 從頭開始實現做 3D 圖形和物理引擎;還有一些現代賽車游戲帶來了視覺設計的靈感,比如《Distance》和《Lonely Mountains: Downhill》;二是之前 Jake Gordon 用 JavaScript 創建一個虛擬3D賽車的項目,并分享了代碼;三是 Chris Glover 曾經做過一款小到只有 1KB 的 JS1k 賽車游戲《Moto1kross by Chris Glover》。
于是 Frank 和他的朋友們決定做一個壓縮后只有 2KB 的 3D 賽車游戲。2KB 到底有多小呢?提供一個參考,一個3.5英寸軟盤可以容納700多個這樣的游戲。
他給這個游戲取名 Hue Jumper。關于名字的由來,Frank 表示,游戲的核心操作是移動。當玩家通過一個關卡時,游戲世界就會換一個顏色色調。“在我想象中,每通過過一個關卡,玩家都會跳轉到另一個維度,有著完全不同的色調。”
做完這個游戲后,Frank 將包含了游戲的全部 JavaScript 代碼都發布在他的個人博客上,其中用到的軟件主要也是免費或開源軟件的。游戲代碼發布在CodePen,可以在 iframe 中試玩,有興趣的朋友可以去看看。
以下是原博內容,AI源創評論進行了不改變原意的編譯:
因為嚴格的大小限制,我需要非常仔細對待我的程序。我的總體策略是盡可能保持一切簡單,為最終目標服務。
為了幫助壓縮代碼,我使用了 Google Closure Compiler,它刪除了所有空格,將變量重命名為1個字母字符,并進行了一些輕量級優化。
用戶可以通過 Google Closure Compiler 官網在線跑代碼。不幸的是,Closure Compiler 做了一些沒有幫助的事情,比如替換模板字符串、默認參數和其他幫助節省空間的ES6特性。所以我需要手動撤銷其中一些事情,并執行一些更“危險”的壓縮技術來擠出最后一個字節空間。在壓縮方面,這不算很成功,大部分擠出的空間來自代碼本身的結構優化。
代碼需要壓縮到2KB。如果不是非要這么做不可,有一個類似的但功能沒那么強的工具叫做 RegPack 。
無論哪種方式,策略都是一樣的:盡最大可能重復代碼,然后用壓縮工具壓縮。最好的例子是 c.width,c.height和 Math。因此,在閱讀這段代碼時,請記住,你經常會看到我不斷重復一些東西,最終目的就是為了壓縮。
其實我的游戲很少使用 html ,因為它主要用到的是 JavaScript 。但這是創建全屏畫布 Canvas ,也能將畫布 Canvas 設為窗口內部大小的代碼最小方法。我不知道為什么在 CodePen 上有必要添加 overflow:hiddento the body,當直接打開時按理說也可以運行。
我將 JavaScript 封裝在一個 onload 調用,得到了一個更小的最終版本… 但是,在開發過程中,我不喜歡用這個壓縮設置,因為代碼存儲在一個字符串中,所以編輯器不能正確地高亮顯示語法。
有許多常量在各方面控制著游戲。當代碼被 Google Closure 這樣的工具縮小時,這些常量將被替換,就像 C++ 中的 #define 一樣,把它們放在第一位會加快游戲微調的過程。
// draw settings
const context = c.getContext`2d`; // canvas context
const drawDistance = 800; // how far ahead to draw
const cameraDepth = 1; // FOV of camera
const segmentLength = 100; // length of each road segment
const roadWidth = 500; // how wide is road
const curbWidth = 150; // with of warning track
const dashLineWidth = 9; // width of the dashed line
const maxPlayerX = 2e3; // limit player offset
const mountainCount = 30; // how many mountains are there
const timeDelta = 1/60; // inverse frame rate
const PI = Math.PI; // shorthand for Math.PI
// player settings
const height = 150; // high of player above ground
const maxSpeed = 300; // limit max player speed
const playerAccel = 1; // player forward acceleration
const playerBrake = -3; // player breaking acceleration
const turnControl = .2; // player turning rate
const jumpAccel = 25; // z speed added for jump
const springConstant = .01; // spring players pitch
const collisionSlow = .1; // slow down from collisions
const pitchLerp = .1; // rate camera pitch changes
const pitchSpringDamp = .9; // dampen the pitch spring
const elasticity = 1.2; // bounce elasticity
const centrifugal = .002; // how much turns pull player
const forwardDamp = .999; // dampen player z speed
const lateralDamp = .7; // dampen player x speed
const offRoadDamp = .98; // more damping when off road
const gravity = -1; // gravity to apply in y axis
const cameraTurnScale = 2; // how much to rotate camera
const worldRotateScale = .00005; // how much to rotate world
// level settings
const maxTime = 20; // time to start
const checkPointTime = 10; // add time at checkpoints
const checkPointDistance = 1e5; // how far between checkpoints
const maxDifficultySegment = 9e3; // how far until max difficulty
const roadEnd = 1e4; // how far until end of road
鼠標是唯一的輸入系統。通過這段代碼,我們可以跟蹤鼠標點擊和光標位置,位置顯示為-1到1之間的值。
雙擊是通過 mouseUpFrames 實現的。mousePressed 變量只在玩家第一次點擊開始游戲時使用這么一次。
mouseDown =
mousePressed =
mouseUpFrames =
mouseX = 0;
onmouseup =e=> mouseDown = 0;
onmousedown =e=> mousePressed ? mouseDown = 1 : mousePressed = 1;
onmousemove =e=> mouseX = e.x/window.innerWidth*2 - 1;
這個游戲使用了一些函數來簡化代碼和減少重復,一些標準的數學函數用于 Clamp 和 Lerp 值。 ClampAngle 是有用的,因為它在 -PI 和 PI 之間 wrap angles,在許多游戲中已經廣泛應用。
R函數就像個魔術師,因為它生成隨機數,通過取當前隨機數種子的正弦,乘以一個大數字,然后看分數部分來實現的。其實有很多方法可以做到,但這是最小的方法之一,而且對我們來說也是足夠隨機。
我們將使用這個隨機生成器來創建各種程序,且不需要保存任何數據。例如,山脈、巖石和樹木的變化不用存到內存。在這種情況下,目標不是減少內存,而是去除存儲和檢索數據所需的代碼。
因為這是一個“真正的3D”游戲,所以有一個 3D vector class 非常有用,它也能減少代碼量。這個 class 只包含這個游戲必需的基本元素,一個帶有加法和乘法函數的 constructor 可以接受標量或向量參數。為了確定標量是否被傳入,我們只需檢查它是否小于一個大數。更正確的方法是使用 isNan 或者檢查它的類型是否是 Vec3,但是這需要更多的存儲。
Clamp =(v, a, b) => Math.min(Math.max(v, a), b);
ClampAngle=(a) => (a+PI) % (2*PI) + (a+PILerp =(p, a, b) => a + Clamp(p, 0, 1) * (b-a);
R =(a=1, b=0) => Lerp((Math.sin(++randSeed)+1)*1e5%1,a,b);
class Vec3 // 3d vector class
{
constructor(x=0, y=0, z=0) {this.x = x; this.y = y; this.z = z;}
Add=(v)=>(
v = v new Vec3( this.x + v.x, this.y + v.y, this.z + v.z ));
Multiply=(v)=>(
v = v new Vec3( this.x * v.x, this.y * v.y, this.z * v.z ));
}
LSHA 通過模板字符串生成一組標準的 HSLA (色調、飽和度、亮度、alpha)顏色,并且剛剛被重新排序,所以更常用的 component 排在第一位。每過一關換一個整體色調也是通過這設置的。
DrawPoly 繪制一個梯形形狀,用于渲染場景中的一切。使用 |0 將 Ycomponent 轉換為整數,以確保每段多邊形道路都能無縫連接,不然路段之間就會有一條細線。
DrawText 則用于顯示時間、距離和游戲標題等文本渲染。
LSHA=(l,s=0,h=0,a=1)=>`hsl(${h+hueShift},${s}%,${l}%,${a})`;
// draw a trapazoid shaped poly
DrawPoly=(x1, y1, w1, x2, y2, w2, fillStyle)=>
{
context.beginPath(context.fillStyle = fillStyle);
context.lineTo(x1-w1, y1|0);
context.lineTo(x1+w1, y1|0);
context.lineTo(x2+w2, y2|0);
context.lineTo(x2-w2, y2|0);
context.fill;
}
// draw outlined hud text
DrawText=(text, posX)=>
{
context.font = '9em impact'; // set font size
context.fillStyle = LSHA(99,0,0,.5); // set font color
context.fillText(text, posX, 129); // fill text
context.lineWidth = 3; // line width
context.strokeText(text, posX, 129); // outline text
}
首先,我們必須生成完整的軌道,而且準備做到每次游戲軌道都是不同的。如何做呢?我們建立了一個道路段列表,存儲道路在軌道上每一關卡的位置和寬度。軌道生成器是非常基礎的操作,不同頻率、振幅和寬度的道路都會逐漸變窄,沿著跑道的距離決定這一段路有多難。
atan2 函數可以用來計算道路俯仰角,據此來設計物理運動和光線。
roadGenLengthMax = // end of section
roadGenLength = // distance left
roadGenTaper = // length of taper
roadGenFreqX = // X wave frequency
roadGenFreqY = // Y wave frequency
roadGenScaleX = // X wave amplitude
roadGenScaleY = 0; // Y wave amplitude
roadGenWidth = roadWidth; // starting road width
startRandSeed = randSeed = Date.now; // set random seed
road = ; // clear road
// generate the road
for( i = 0; i {
if (roadGenLength++ > roadGenLengthMax) // is end of section?
{
// calculate difficulty percent
d = Math.min(1, i/maxDifficultySegment);
// randomize road settings
roadGenWidth = roadWidth*R(1-d*.7,3-2*d); // road width
roadGenFreqX = R(Lerp(d,.01,.02)); // X curves
roadGenFreqY = R(Lerp(d,.01,.03)); // Y bumps
roadGenScaleX = i>roadEnd ? 0 : R(Lerp(d,.2,.6));// X scale
roadGenScaleY = R(Lerp(d,1e3,2e3)); // Y scale
// apply taper and move back
roadGenTaper = R(99, 1e3)|0; // random taper
roadGenLengthMax = roadGenTaper + R(99,1e3); // random length
roadGenLength = 0; // reset length
i -= roadGenTaper; // subtract taper
}
// make a wavy road
x = Math.sin(i*roadGenFreqX) * roadGenScaleX;
y = Math.sin(i*roadGenFreqY) * roadGenScaleY;
road[i] = road[i]? road[i] : {x:x, y:y, w:roadGenWidth};
// apply taper from last section and lerp values
p = Clamp(roadGenLength / roadGenTaper, 0, 1);
road[i].x = Lerp(p, road[i].x, x);
road[i].y = Lerp(p, road[i].y, y);
road[i].w = i > roadEnd ? 0 : Lerp(p, road[i].w, roadGenWidth);
// calculate road pitch angle
road[i].a = road[i-1] ?
Math.atan2(road[i-1].y-road[i].y, segmentLength) : 0;
}
現在跑道就緒,我們只需要預置一些變量就可以開始游戲了。
// reset everything
velocity = new Vec3
( pitchSpring = pitchSpringSpeed = pitchRoad = hueShift = 0 );
position = new Vec3(0, height); // set player start pos
nextCheckPoint = checkPointDistance; // init next checkpoint
time = maxTime; // set the start time
heading = randSeed; // random world heading
這是主要的更新功能,用來更新和渲染游戲中的一切!一般來說,如果你的代碼中有一個很大的函數,這不是好事,為了更簡潔易懂,我們會把它分幾個成子函數。
首先,我們需要得到一些玩家所在位置的道路信息。為了使物理和渲染感覺平滑,需要在當前和下一個路段之間插入一些數值。
玩家的位置和速度是 3D 向量,并受重力、dampening 和其他因素等影響更新。如果玩家跑在地面上時,會受到加速度影響;當他離開這段路時,攝像機還會抖動。另外,在對游戲測試后,我決定讓玩家在空中時仍然可以跑。
接下來要處理輸入指令,涉及加速、剎車、跳躍和轉彎等操作。雙擊通過 mouseUpFrames 測試。還有一些代碼是來跟蹤玩家在空中停留了多少幀,如果時間很短,游戲允許玩家還可以跳躍。
當玩家加速、剎車和跳躍時,我通過spring system展示相機的俯仰角以給玩家動態運動的感覺。此外,當玩家駕車翻越山丘或跳躍時,相機還會隨著道路傾斜而傾斜。
Update==>
{
// get player road segment
s = position.z / segmentLength | 0; // current road segment
p = position.z / segmentLength % 1; // percent along segment
// get lerped values between last and current road segment
roadX = Lerp(p, road[s].x, road[s+1].x);
roadY = Lerp(p, road[s].y, road[s+1].y) + height;
roadA = Lerp(p, road[s].a, road[s+1].a);
// update player velocity
lastVelocity = velocity.Add(0);
velocity.y += gravity;
velocity.x *= lateralDamp;
velocity.z = Math.max(0, time?forwardDamp*velocity.z:0);
// add velocity to position
position = position.Add(velocity);
// limit player x position (how far off road)
position.x = Clamp(position.x, -maxPlayerX, maxPlayerX);
// check if on ground
if (position.y {
position.y = roadY; // match y to ground plane
airFrame = 0; // reset air frames
// get the dot product of the ground normal and the velocity
dp = Math.cos(roadA)*velocity.y + Math.sin(roadA)*velocity.z;
// bounce velocity against ground normal
velocity = new Vec3(0, Math.cos(roadA), Math.sin(roadA))
.Multiply(-elasticity * dp).Add(velocity);
// apply player brake and accel
velocity.z +=
mouseDown? playerBrake :
Lerp(velocity.z/maxSpeed, mousePressed*playerAccel, 0);
// check if off road
if (Math.abs(position.x) > road[s].w)
{
velocity.z *= offRoadDamp; // slow down
pitchSpring += Math.sin(position.z/99)**4/99; // rumble
}
}
// update player turning and apply centrifugal force
turn = Lerp(velocity.z/maxSpeed, mouseX * turnControl, 0);
velocity.x +=
velocity.z * turn -
velocity.z ** 2 * centrifugal * roadX;
// update jump
if (airFrame++ && mouseDown && mouseUpFrames && mouseUpFrames{
velocity.y += jumpAccel; // apply jump velocity
airFrame = 9; // prevent jumping again
}
mouseUpFrames = mouseDown? 0 : mouseUpFrames+1;
// pitch down with vertical velocity when in air
airPercent = (position.y-roadY) / 99;
pitchSpringSpeed += Lerp(airPercent, 0, velocity.y/4e4);
// update player pitch spring
pitchSpringSpeed += (velocity.z - lastVelocity.z)/2e3;
pitchSpringSpeed -= pitchSpring * springConstant;
pitchSpringSpeed *= pitchSpringDamp;
pitchSpring += pitchSpringSpeed;
pitchRoad = Lerp(pitchLerp, pitchRoad, Lerp(airPercent,-roadA,0));
playerPitch = pitchSpring + pitchRoad;
// update heading
heading = ClampAngle(heading + velocity.z*roadX*worldRotateScale);
cameraHeading = turn * cameraTurnScale;
// was checkpoint crossed?
if (position.z > nextCheckPoint)
{
time += checkPointTime; // add more time
nextCheckPoint += checkPointDistance; // set next checkpoint
hueShift += 36; // shift hue
}
在渲染之前,canvas 每當高度或寬度被重設時,畫布內容就會被清空。這也適用于自適應窗口的畫布。
我們還計算了將世界點轉換到畫布的投影比例。cameraDepth 值代表攝像機的視場(FOV)。這個游戲是90度。計算結果是 1/Math.tan(fovRadians/2) ,FOV 是90度的時候,計算結果正好是1。另外為了保持屏幕長寬比,投影按 c.width 縮放。
// clear the screen and set size
c.width = window.innerWidth, c.height = window.innerHeight;
// calculate projection scale, flip y
projectScale = (new Vec3(1,-1,1)).Multiply(c.width/2/cameraDepth);
空氣背景是用全屏的 linear gradient (徑向漸變)繪制的,它還會根據太陽的位置改變顏色。
為了節省存儲空間,太陽和月亮在同一個循環中,使用了一個帶有透明度的全屏 radial gradient(線性漸變)。
線性和徑向漸變相結合,形成一個完全包圍場景的天空背景。
// get horizon, offset, and light amount
horizon = c.height/2 - Math.tan(playerPitch)*projectScale.y;
backgroundOffset = Math.sin(cameraHeading)/2;
light = Math.cos(heading);
// create linear gradient for sky
g = context.createLinearGradient(0,horizon-c.height/2,0,horizon);
g.addColorStop(0,LSHA(39+light*25,49+light*19,230-light*19));
g.addColorStop(1,LSHA(5,79,250-light*9));
// draw sky as full screen poly
DrawPoly(c.width/2,0,c.width/2,c.width/2,c.height,c.width/2,g);
// draw sun and moon (0=sun, 1=moon)
for( i = 2 ; i--; )
{
// create radial gradient
g = context.createRadialGradient(
x = c.width*(.5+Lerp(
(heading/PI/2+.5+i/2)%1,
4, -4)-backgroundOffset),
y = horizon - c.width/5,
c.width/25,
x, y, i?c.width/23:c.width);
g.addColorStop(0, LSHA(i?70:99));
g.addColorStop(1, LSHA(0,0,0,0));
// draw full screen poly
DrawPoly(c.width/2,0,c.width/2,c.width/2,c.height,c.width/2,g);
}
山脈是通過在地平線上畫50個三角形,然后根據程序自己生成的。
因為用了光線照明,山脈在面對太陽時會更暗,因為它們處于陰影中。此外,越近的山脈顏色越暗,我想以此來模擬霧氣。這里我有個訣竅,就是微調大小和顏色的隨機值。
背景的最后一部分是繪制地平線,再用純綠填充畫布的底部。
// set random seed for mountains
randSeed = startRandSeed;
// draw mountains
for( i = mountainCount; i--; )
{
angle = ClampAngle(heading+R(19));
light = Math.cos(angle-heading);
DrawPoly(
x = c.width*(.5+Lerp(angle/PI/2+.5,4,-4)-backgroundOffset),
y = horizon,
w = R(.2,.8)**2*c.width/2,
x + w*R(-.5,.5),
y - R(.5,.8)*w, 0,
LSHA(R(15,25)+i/3-light*9, i/2+R(19), R(220,230)));
}
// draw horizon
DrawPoly(
c.width/2, horizon, c.width/2, c.width/2, c.height, c.width/2,
LSHA(25, 30, 95));
在渲染道路之前,我們必須首先獲得投影的道路點。第一部分有點棘手,因為我們的道路的 x 值需要轉換成世界空間位置。為了使道路看起來蜿蜒曲折,我們把x值作為二階導數。這就是為什么有奇怪的代碼“x+=w+=”出現的原因。由于這種工作方式,路段沒有固定的世界空間位置,每一幀都是根據玩家的位置重新計算。
一旦我們有了世界空間位置,我們就可以從道路位置中知道玩家的位置,從而得到本地攝像機空間位置。代碼的其余部分,首先通過旋轉標題、俯仰角來應用變換,然后通過投影變換,做到近大遠小的效果,最后將其移動到畫布空間。
for( x = w = i = 0; i {
p = new Vec3(x+=w+=road[s+i].x, // sum local road offsets
road[s+i].y, (s+i)*segmentLength) // road y and z pos
.Add(position.Multiply(-1)); // get local camera space
// apply camera heading
p.x = p.x*Math.cos(cameraHeading) - p.z*Math.sin(cameraHeading);
// tilt camera pitch and invert z
z = 1/(p.z*Math.cos(playerPitch) - p.y*Math.sin(playerPitch));
p.y = p.y*Math.cos(playerPitch) - p.z*Math.sin(playerPitch);
p.z = z;
// project road segment to canvas space
road[s+i++].p = // projected road point
p.Multiply(new Vec3(z, z, 1)) // projection
.Multiply(projectScale) // scale
.Add(new Vec3(c.width/2,c.height/2)); // center on canvas
}
現在我們有了每個路段的畫布空間點,渲染就相當簡單了。我們需要從后向前畫出每一個路段,或者更具體地說,連接上一路段的梯形多邊形。
為了創建道路,這里有4層渲染:地面,條紋路邊緣,道路本身和白色虛線。每一個都是基于路段的俯仰角和方向來加陰影,并且根據該層的表現還有一些額外的邏輯。
有必要檢查路段是在近還是遠剪輯范圍,以防止渲染出現 bug 。此外,還有一個很好的優化方法是,當道路變得很窄時,可以通過 distance 來減小道路的分辨率。如此,不僅減少了 draw count 一半以上,而且沒有明顯的質量損失,這是一次性能勝利。
let segment2 = road[s+drawDistance]; // store the last segment
for( i = drawDistance; i--; ) // iterate in reverse
{
// get projected road points
segment1 = road[s+i];
p1 = segment1.p;
p2 = segment2.p;
// random seed and lighting
randSeed = startRandSeed + s + i;
light = Math.sin(segment1.a) * Math.cos(heading) * 99;
// check near and far clip
if (p1.z 0)
{
// fade in road resolution over distance
if (i % (Lerp(i/drawDistance,1,9)|0) == 0)
{
// ground
DrawPoly(c.width/2, p1.y, c.width/2,
c.width/2, p2.y, c.width/2,
LSHA(25 + light, 30, 95));
// curb if wide enough
if (segment1.w > 400)
DrawPoly(p1.x, p1.y, p1.z*(segment1.w+curbWidth),
p2.x, p2.y, p2.z*(segment2.w+curbWidth),
LSHA(((s+i)%19
// road and checkpoint marker
DrawPoly(p1.x, p1.y, p1.z*segment1.w,
p2.x, p2.y, p2.z*segment2.w,
LSHA(((s+i)*segmentLength%checkPointDistance 70 : 7) + light));
// dashed lines if wide and close enough
if ((segment1.w > 300) && (s+i)%9==0 && i DrawPoly(p1.x, p1.y, p1.z*dashLineWidth,
p2.x, p2.y, p2.z*dashLineWidth,
LSHA(70 + light));
// save this segment
segment2 = segment1;
}
游戲有兩種不同類型的物體:樹和石頭。首先,我們通過使用 R 函數來確定是否加一個對象。這是隨機數和隨機數種子特別有意思的地方。我們還將使用 R 為對象隨機添加不同的形狀和顏色。
最初我還想涉及其他車型,但為了達到 2KB 的要求,必須要進行特別多的削減,因此我最后放棄了這個想法,用風景作為障礙。這些位置是隨機的,也比較靠近道路,不然它們太稀疏,就很容易行駛。為了節省空間,對象高度還決定了對象的類型。
這是通過比較玩家和物體在 3D 空間中的位置來檢查它們之間的碰撞位置。當玩家撞到一個物體時,玩家減速,該物體被標記為“ hit ”,這樣它就可以安全通過。
為了防止對象突然出現在地平線上,透明度會隨著距離的接近而削弱。梯形繪圖函數定義物體的形狀和顏色,另外隨機函數會改變這兩個屬性。
if (R
{
// player object collision check
x = 2*roadWidth * R(10,-10) * R(9); // choose object pos
const objectHeight = (R(2)|0) * 400; // choose tree or rock
if (!segment1.h // dont hit same object
&& Math.abs(position.x-x) && Math.abs(position.z-(s+i)*segmentLength) && position.y-height {
// slow player and mark object as hit
velocity = velocity.Multiply(segment1.h = collisionSlow);
}
// draw road object
const alpha = Lerp(i/drawDistance, 4, 0); // fade in object
if (objectHeight)
{
// tree trunk
DrawPoly(x = p1.x+p1.z * x, p1.y, p1.z*29,
x, p1.y-99*p1.z, p1.z*29,
LSHA(5+R(9), 50+R(9), 29+R(9), alpha));
// tree leaves
DrawPoly(x, p1.y-R(50,99)*p1.z, p1.z*R(199,250),
x, p1.y-R(600,800)*p1.z, 0,
LSHA(25+R(9), 80+R(9), 9+R(29), alpha));
}
else
{
// rock
DrawPoly(x = p1.x+p1.z*x, p1.y, p1.z*R(200,250),
x+p1.z*(R(99,-99)), p1.y-R(200,250)*p1.z, p1.z*R(99),
LSHA(50+R(19), 25+R(19), 209+R(9), alpha));
}
}
}
}
游戲的標題、時間和距離是用一個非常基礎的字體渲染系統顯示出來的,就是之前設置的 DrawText 函數。在玩家點擊鼠標之前,它會在屏幕中央顯示標題。
按下鼠標后,游戲開始,然后 HUD 會顯示剩余時間和當前距離。時間也在這塊更新,玩過此類游戲的都知道,時間只在比賽開始后減少。
在這個 massive Update function 結束后,它調用 requestAnimationFrame (Update) 來觸發下一次更新。
if (mousePressed)
{
time = Clamp(time - timeDelta, 0, maxTime); // update time
DrawText(Math.ceil(time), 9); // show time
context.textAlign = 'right'; // right alignment
DrawText(0|position.z/1e3, c.width-9); // show distance
}
else
{
context.textAlign = 'center'; // center alignment
DrawText('HUE JUMPER', c.width/2); // draw title text
}
requestAnimationFrame(Update); // kick off next frame
} // end of update function
HTML 需要一個結束腳本標簽來讓所有的代碼能夠跑起來。
Update; // kick off update loop
這就是整個游戲啦!下方的一小段代碼就是壓縮后的最終結果,我用不同的顏色標注了不同的部分。完成所有這些工作后,你能感受到我在2KB內就做完了整個游戲是多么讓我滿意了嗎?而這還是在zip之前的工作,zip還可以進一步壓縮大小。
當然,還有很多其他 3D 渲染方法可以同時保證性能和視覺效果。如果我有更多的可用空間,我會更傾向于使用一個 WebGL API 比如 three.js ,我在去年制作的一個類似游戲“Bogus Roads”中用過這個框架。此外,因為它使用的是 requestAnimationFrame ,所以需要一些額外的代碼來確保幀速率不超過60 fps,增強版本中我會這么用,盡管我更喜歡使用 requestAnimationFrame 而不是 setInterval ,因為它是垂直同期的(VSyn,VerticalSynchronization),所以渲染更絲滑。這種代碼的一個主要好處是它非常兼容,可以在任何設備上運行,盡管在我舊 iPhone 上運行有點慢。
游戲代碼被我放到了 GitHub 上的 GPL-3.0 下(https://github.com/KilledByAPixel/HueJumper2k),所以你可以在自己的項目中自由使用它。該庫中還包含 2KB 版本的游戲,準確說是2031字節!歡迎你添加一些其他的功能,比如音樂和音效到“增強”版本中。
雷鋒網
*請認真填寫需求信息,我們會在24小時內與您取得聯系。