這個教程面向已經能簡單使用pixi.js的開發者,經過建立一個拼圖遊戲,來演示怎麼完整的開發一款pixi
遊戲並最終發佈。在此項目中你能夠學會怎麼使用
ES6+
開發,怎麼劃分模塊
,怎麼提早加載資源
,怎麼進行屏幕自適應
,怎麼播放音頻
和視頻
,怎麼分層
,怎麼經過繼承pixi類
來擴展功能,怎麼實現多國語言
,怎麼用webpack
進行開發期的調試
以及最終怎麼構建發佈
遊戲(webpack詳細教程可參考以前的文章《使用webpack搭建pixi.js開發環境》)。javascript
在線體驗 html
配置環境java
nodejs
。vscode
。texturepacker
,免費版本便可。npm install
安裝依賴庫。npm start
啓動項目,會自動打開chrome瀏覽器並啓動遊戲,玩一把而後跟着下面的講解開始學習吧。res
: 存放不須要放到遊戲裏面的源工程文件,例如texturepacker
圖集項目,字體等等。src
: 全部的遊戲代碼和資源。dist
: 此目錄爲構建腳本動態生成,存放構建完成的項目文件,每次構建都會從新生成這個目錄。webpack.common.js
文件: webpack公共腳本。webpack.dev.js
文件: webpack開發配置腳本。webpack.prod.js
文件: webpack發佈配置腳本。gulpfile.js
文件:gulp
構建腳本,用於發佈項目時候時候構建和優化整個項目。package.json
文件: node
項目的配置文件。遊戲主頁 src/index.html
node
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>jigsaw puzzle</title> <style> html, body { width: 100%; height: 100%; padding: 0; margin: 0; overflow: hidden; background: transparent; } canvas { margin: 0; padding: 0; overflow: hidden; position: absolute; } .autofit { margin: 0; padding: 0; overflow: hidden; position: absolute; object-fit: cover; } .fullscreen { display: block; padding: 0; margin: auto; width: 100%; height: 100%; object-fit: cover; left: 0; top: 0; right: 0; bottom: 0; position: absolute; } </style> </head> <body> <canvas id="scene"></canvas> <script type="text/javascript" src="game.min.js" charset="utf-8"></script> </body> </html>
配置文件 src/js/config.js
,用於配置遊戲資源和常量。webpack
//遊戲基本信息,遊戲名字,版本,寬,高等。 export const meta = { name: 'jigsaw puzzle', version: '1.0.0', width: 796, height: 1280 } //多國語言,根據瀏覽器語言自動加載相應的語言包資源。 export const i18n = { 'en': 'assets/i18n/en.json', 'zh-cn': 'assets/i18n/zh-cn.json' } //遊戲視口顯示區域,不寫的話全屏顯示。 export const viewRect = null //資源列表 export const resources = [ { name: 'main', url: 'assets/image/main.json' }, { name: 'sound_bg', url: 'assets/audio/bg.mp3' }, { name: 'sound_win', url: 'assets/audio/win.mp3' }, { name: 'sound_fail', url: 'assets/audio/fail.mp3' }, //若是圖片或者音頻視頻涉及多國語言,在這裏配置多國語言資源,程序會按需加載特定語言相關資源。 { name: 'bg', i18n: { 'en': 'assets/image/bg_en.png', 'zh-cn': 'assets/image/bg_zh-cn.png', } }]
多國語言模塊src/js/i18n.js
,能讓程序根據瀏覽器語言自動調整程序界面語言。git
import { parseQueryString } from './util' import mustache from 'mustache' export default class I18N { //i18n_config就是config.js裏面的i18n配置節 constructor(i18n_config) { this.config = i18n_config this.words = {} } //維護一個鍵值對列表,i18n判斷完語言後,經過key查詢value。 add(words) { Object.assign(this.words, words) } //判斷用戶語言若是querystring加 ?lang=zh-cn之類的,則按照這個現實,不然判斷瀏覽器語言。 get language() { let lang = parseQueryString().lang let languages = Object.keys(this.config) if (lang && languages.indexOf(lang) !== -1) { return lang } lang = window.navigator.userLanguage || window.navigator.language if (lang && languages.indexOf(lang) !== -1) { return lang } return 'en' } //獲取當前語言的配置文件路徑,參考config.js i18n配置節 get file() { let uri = this.config[this.language] return uri } //根據key獲取value //注意這裏用到了mustache模板 //例如 get('hello {{ user }}', {user:'jack'}),返回'hello jack'。 get(key, options) { let text = this.words[key] if (text) { if (options) { return mustache.render(text, options) } return text } else { console.warn('can not find key:' + key) return '' } } }
音頻模塊src/js/sound.js
,音頻模塊須要依賴pixi-sound
庫實現功能。github
import sound from 'pixi-sound' export default class Sound { //設置音量大小 volumn 0≤volume≤1 setVolume(volume) { sound.volumeAll = Math.max(0, Math.min(1, parseFloat(volume)) ) } //播放音樂,name是音樂名字,config.js文件resources裏面音頻的name,loop是否循環播放。 play(name, loop) { if (typeof loop !== 'boolean') { loop = false } let sound = app.res[name].sound sound.loop = loop return sound.play() } //中止播放name stop(name) { app.res[name].sound.stop() } //開啓關閉靜音 toggleMuteAll() { sound.toggleMuteAll() } }
Application模塊src/js/app.js
,此類繼承PIXI.Application
,擴展了本身須要的功能,實現了自適應,資源加載,集成i18n
、sound
模塊功能。web
import * as PIXI from 'pixi.js' import Sound from './sound' import I18N from './i18n' import * as config from './config' import { throttle } from 'throttle-debounce' export default class Application extends PIXI.Application { // @param {jsonobject} options 和 PIXI.Application 構造函數須要的參數是同樣的 constructor(options) { //禁用 PIXI ResizePlugin功能,防止pixi自動自適應. //pixi的自適應會修改canvas.width和canvas.height致使顯示錯誤,無法鋪滿寬或者高。 options.resizeTo = undefined super(options) PIXI.utils.EventEmitter.call(this) //canvas顯示區域,若是設置了viewRect就顯示在viewRect矩形內,沒設置的話全屏顯示。 this.viewRect = config.viewRect //防止調用過快發生抖動,throttle一下 window.addEventListener('resize', throttle(300, () => { this.autoResize(this.viewRect) })) window.addEventListener('orientationchange', throttle(300, () => { this.autoResize(this.viewRect) })) //自適應 this.autoResize(this.viewRect) //掛載模塊 this.sound = new Sound() this.i18n = new I18N(config.i18n) } //自適應cavas大小和位置,按比例鋪滿寬或者高。 autoResize() { let viewRect = Object.assign({ x: 0, y: 0, width: window.innerWidth, height: window.innerHeight }, this.viewRect) //遊戲寬高比 const defaultRatio = this.view.width / this.view.height //視口寬高比 const windowRatio = viewRect.width / viewRect.height let width let height //這裏判斷根據寬適配仍是高適配 if (windowRatio < defaultRatio) { width = viewRect.width height = viewRect.width / defaultRatio } else { height = viewRect.height width = viewRect.height * defaultRatio } //居中顯示canvas let x = viewRect.x + (viewRect.width - width) / 2 let y = viewRect.y + (viewRect.height - height) / 2 //讓canvas顯示在中心,高鋪滿的話,兩邊留黑邊,寬鋪滿的話,上下留黑邊 this.view.style.left = `${x}px` this.view.style.top = `${y}px` //設置canvas的寬高,注意這裏千萬不要直接設置canvas.width和height。 this.view.style.width = `${width}px` this.view.style.height = `${height}px` //若是有其餘須要自適應的dom一塊兒根據canvas自適應 let autofitItems = document.querySelectorAll('.autofit') autofitItems.forEach(item => { item.style.left = `${x}px` item.style.top = `${y}px` item.style.width = `${width}px` item.style.height = `${height}px` }) } //加載全部的資源 load(baseUrl) { let loader = new PIXI.Loader(baseUrl) //爲了解決cdn緩存不更新問題,這裏獲取資源時候加個版本bust loader.defaultQueryString = `v=${config.meta.version}` //加載當前語言的配置文件 loader.add(this.i18n.file) //加載全部遊戲資源 config.resources.forEach(res => { if (res.i18n) { loader.add({ name: res.name, url: res.i18n[this.i18n.language] }) } else { loader.add(res) } }) loader .on('start', () => { console.log('loader:start') this.emit('loader:start') }) .on('progress', (loader, res) => { this.emit('loader:progress', parseInt(loader.progress)) }) .on('load', (loader, res) => { console.log(`loader:load ${res.url}`) // this.emit('load:res', res.url) }) .on('error', (err, loader, res) => { console.warn(err) this.emit('loader:error', res.url) }) .load((loader, res) => { console.log('loader:completed') app.res = res this.i18n.add(res[this.i18n.file].data) delete res[this.i18n.file] this.emit('loader:complete', res) }) return loader } } //mixinEventEmitter Object.assign(Application.prototype,PIXI.utils.EventEmitter.prototype)
loading頁面src/js/loading.js
chrome
import { TextStyle, Container, Text, Graphics } from 'pixi.js' import { meta } from './config' //這是加載等待界面,菊花轉。可用於加載,網絡延遲時候顯示加載中。 export default class Loading extends Container { //@param {object} options //@param {boolean} options.progress 是否顯示加載進度文本 constructor(options) { super() this.options = Object.assign({ progress: true }, options) //一段弧的弧度 let arcAngle = Math.PI * 0.2 //弧之間的間距弧度 let gapAngle = Math.PI * 0.05 //pixi.js 裏面 graphics 從3點鐘方向開始爲0°,這裏爲了好看往回移動半個弧的距離。 let offsetAngle = -arcAngle * 0.5 //菊花的半徑 let radius = 80 //背景遮罩,一層灰色的遮罩,阻擋底層ui和操做 let bg = new Graphics() bg.moveTo(0, 0) bg.beginFill(0x000000, 0.8) bg.drawRect(-meta.width / 2, -meta.height / 2, meta.width, meta.height) bg.interactive = true this.addChild(bg) //建立8個弧 for (let i = 0; i < 8; i++) { let arc = new Graphics() arc.lineStyle(16, 0xffffff, 1, 0.5) let startAngle = offsetAngle + gapAngle * i + arcAngle * i let endAngle = startAngle + arcAngle arc.arc(0, 0, radius, startAngle, endAngle) this.addChild(arc) } //建立旋轉的弧,加載時候,有個弧會一直轉圈,順序的蓋在八個弧之上。 let mask = new Graphics() this.addChild(mask) if (this.options.progress) { this.indicatorText = new Text('0%', new TextStyle({ fontFamily: 'Arial', fontSize: 20, fill: '#ffffff', })) this.indicatorText.anchor.set(0.5) this.addChild(this.indicatorText) } //旋轉的弧當前轉到哪一個位置了,一共八個位置。 let maskIndex = 0 //啓動timer讓loading轉起來 this.timer = setInterval(() => { mask.clear() mask.lineStyle(16, 0x000000, 0.5, 0.5) let startAngle = offsetAngle + gapAngle * maskIndex + arcAngle * maskIndex let endAngle = startAngle + arcAngle mask.arc(0, 0, radius, startAngle, endAngle) maskIndex = (maskIndex + 1) % 8 }, 100) } //設置進度 set progress(newValue) { if (this.options.progress) { this.indicatorText.text = `${newValue}%` } } destroy() { clearInterval(this.timer) super.destroy(true) } }
piece模塊,一張大拼圖中得一塊圖,src/js/piece.js
npm
import { Sprite, utils } from 'pixi.js' //一張大拼圖中得一塊圖,可拖拽。 export default class Piece extends Sprite { // @param {texture} 塊顯示的圖片 // @param {currentIndex} 塊當前的索引 // @param {targetIndex} 塊的正確位置 // 當塊的 targetIndex == currentIndex 說明塊在正確的位置了 // piece 的索引(以3*3爲例) // 0 1 2 // 3 4 5 // 6 7 8 constructor(texture, currentIndex, targetIndex) { super(texture) //mixin EventEmitter utils.EventEmitter.call(this) this.currentIndex = currentIndex this.targetIndex = targetIndex //讓塊相應觸摸事件 this.interactive = true //監聽拖拽事件 this .on('pointerdown', this._onDragStart) .on('pointermove', this._onDragMove) .on('pointerup', this._onDragEnd) .on('pointerupoutside', this._onDragEnd) } //開始拖拽 _onDragStart(event) { this.dragging = true this.data = event.data //拖拽中得快設置成半透明 this.alpha = 0.5 //當前鼠標位置(相對於父節點的位置) let pointer_pos = this.data.getLocalPosition(this.parent) //鼠標點擊位置和piece位置的偏移量,用於移動計算,防止鼠標點擊後塊中心點瞬間偏移到鼠標位置。 this.offset_x = pointer_pos.x - this.x this.offset_y = pointer_pos.y - this.y //塊原來的位置,用於交換兩個塊時候位置設置 this.origin_x = this.x this.origin_y = this.y //發射拖拽開始事件 this.emit('dragstart', this) } //拖拽移動中 _onDragMove() { if (this.dragging) { const pos = this.data.getLocalPosition(this.parent) //根據鼠標位置,計算塊當前位置。 this.x = pos.x - this.offset_x this.y = pos.y - this.offset_y this.emit('dragmove', this) } } //拖拽完成,鬆開鼠標或擡起手指 _onDragEnd() { if (this.dragging) { this.dragging = false //恢復透明度 this.alpha = 1 this.data = null this.emit('dragend', this) } } //塊的中心點 get center() { return { x: this.x + this.width / 2, y: this.y + this.height / 2 } } } //mixin EventEmitter Object.assign(Piece.prototype, utils.EventEmitter.prototype)
拼圖類src/js/jigsaw.js
,控制拼圖邏輯。
import { Texture, Container, Rectangle } from 'pixi.js' import Piece from './piece' //piece之間的空隙 const GAP_SIZE = 2 //拼圖類,控制拼圖邏輯,計算塊位置,檢查遊戲是否結束。 export default class Jigsaw extends Container { //level難度,好比level=3,則拼圖切分紅3*3=9塊,可嘗試換成更大的值調高難度。 //texture 拼圖用的大圖 constructor(level, texture) { super() this.level = level this.texture = texture //移動步數 this.moveCount = 0 //全部塊所在的container(層級) this.$pieces = new Container() this.$pieces.y = 208 this.$pieces.x = -4 this.addChild(this.$pieces) //前景層,將拖拽中得塊置於此層,顯示在最前面 this.$select = new Container() this.$select.y = 208 this.$select.x = -4 this.addChild(this.$select) this._createPieces() } //洗牌生成一個長度爲level*level的數組,裏面的數字是[0,level*leve)隨機值 //例如level=3,返回[0,3,2,5,4,1,8,7,6] _shuffle() { let index = -1 let length = this.level * this.level const lastIndex = length - 1 const result = Array.from({ length }, (v, i) => i) while (++index < length) { const rand = index + Math.floor(Math.random() * (lastIndex - index + 1)) const value = result[rand] result[rand] = result[index] result[index] = value } return result } // 建立拼圖用的全部的塊(piece) _createPieces() { //每一個piece的寬和高 this.piece_width = this.texture.orig.width / this.level this.piece_height = this.texture.orig.height / this.level //塊位置的偏移量,由於是以屏幕中心點計算的,全部全部塊向左偏移半張大圖的位置。 let offset_x = this.texture.orig.width / 2 let offset_y = this.texture.orig.height / 2 let shuffled_index = this._shuffle() for (let ii = 0; ii < shuffled_index.length; ii++) { //從大圖中選一張小圖生成塊(piece),以level=3爲例,將大圖切成3*3=9塊圖 // 0 1 2 // 3 4 5 // 6 7 8 //而後根據shuffled_index從大圖上的位置取一個圖 let row = parseInt(shuffled_index[ii] / this.level) let col = shuffled_index[ii] % this.level let frame = new Rectangle(col * this.piece_width, row * this.piece_height, this.piece_width, this.piece_height) //注意,這裏currentIndex=ii,targetIndex=shuffled_index[ii] let piece = new Piece(new Texture(this.texture, frame), ii, shuffled_index[ii]) //將塊放在currentIndex所指示的位置位置 let current_row = parseInt(ii / this.level) let current_col = ii % this.level piece.x = current_col * this.piece_width - offset_x + GAP_SIZE * current_col piece.y = current_row * this.piece_height - offset_y + GAP_SIZE * current_row piece .on('dragstart', (picked) => { //當前拖拽的塊顯示在最前 this.$pieces.removeChild(picked) this.$select.addChild(picked) }) .on('dragmove', (picked) => { //檢查當前拖拽的塊是否位於其餘塊之上 this._checkHover(picked) }) .on('dragend', (picked) => { //拖拽完畢時候恢復塊層級 this.$select.removeChild(picked) this.$pieces.addChild(picked) //檢查是否有能夠交換的塊 let target = this._checkHover(picked) if (target) { //有的話增長步數,交換兩個塊 this.moveCount++ this._swap(picked, target) target.tint = 0xFFFFFF } else { //沒有的話,迴歸原位 picked.x = picked.origin_x picked.y = picked.origin_y } }) this.$pieces.addChild(piece) } } // 交換兩個塊的位置 // @param {*} 當前拖拽的塊 // @param {*} 要交換的塊 _swap(picked, target) { //互換指示當前位置的currentIndex和位置 let pickedIndex = picked.currentIndex picked.x = target.x picked.y = target.y picked.currentIndex = target.currentIndex target.x = picked.origin_x target.y = picked.origin_y target.currentIndex = pickedIndex } //遊戲是否成功 get success() { //全部的piece都在正確的位置 let success = this.$pieces.children.every(piece => piece.currentIndex == piece.targetIndex) if (success) { console.log('success', this.moveCount) } return success } //當前的拖拽的塊是否懸浮在其餘塊之上 _checkHover(picked) { let overlap = this.$pieces.children.find(piece => { //拖拽的塊中心點是否在其它塊矩形邊界內部 let rect = new Rectangle(piece.x, piece.y, piece.width, piece.height) return rect.contains(picked.center.x, picked.center.y) }) this.$pieces.children.forEach(piece => piece.tint = 0xFFFFFF) //改變底下塊的顏色,顯示塊可被交換 if (overlap) { overlap.tint = 0x00ffff } return overlap } }
結果頁src/js/result.js
,這個頁面平淡無奇,惟一值得注意的是裏面用到了i18n
用於根據當前語言調整ui顯示的語言,具體查看代碼app.i18n.get
處。
import { TextStyle, Container, Sprite, Text, Graphics } from 'pixi.js' import { meta } from './config' export default class Result extends Container { constructor() { super() this.visible = false let bg = new Graphics() bg.moveTo(0, 0) bg.beginFill(0x000000, 0.8) bg.drawRect(-meta.width / 2, -meta.height / 2, meta.width, meta.height) bg.interactive = true this.addChild(bg) //成功時候顯示 this.$win = new Container() let win_icon = new Sprite(app.res.main.textures.win) win_icon.anchor.set(0.5) win_icon.y = -160 this.$win.addChild(win_icon) let win_text = new Text(app.i18n.get('result.win', { prize: app.i18n.get('prize.win') }), new TextStyle({ fontFamily: 'Arial', fontSize: 40, fontWeight: 'bold', fill: '#ffffff', })) win_text.anchor.set(0.5) this.$win.addChild(win_text) let win_button = new Sprite(app.res.main.textures.button_get) win_button.anchor.set(0.5) win_button.y = 80 win_button.interactive = true win_button.buttonMode = true win_button.on('pointertap', () => { console.log('win') location.href = location.href.replace(/mobile(\d)/, 'mobile0') }) this.$win.addChild(win_button) this.$fail = new Container() let fail_icon = new Sprite(app.res.main.textures.fail) fail_icon.y = -200 fail_icon.anchor.set(0.5) fail_icon.interactive = true fail_icon.buttonMode = true fail_icon.on('pointertap', () => { console.log('fail') location.href = location.href.replace(/mobile(\d)/, 'mobile0') }) this.$fail.addChild(fail_icon) //失敗時候顯示 let fail_text = new Text(app.i18n.get('result.fail', { prize: app.i18n.get('prize.fail') }), new TextStyle({ fontFamily: 'Arial', fontSize: 40, fontWeight: 'bold', fill: '#ffffff', })) fail_text.anchor.set(0.5) this.$fail.addChild(fail_text) this.addChild(this.$fail) this.addChild(this.$win) } //顯示成功 win() { this.visible = true this.$win.visible = true this.$fail.visible = false } //顯示失敗 fail() { this.visible = true this.$win.visible = false this.$fail.visible = true } }
遊戲場景src/js/scene.js
,這個類負責整個遊戲世界的顯示,控制遊戲的開始和結束。
import {TextStyle,Container,Sprite,Text} from 'pixi.js' import Jigsaw from './jigsaw' import Result from './result' const STYLE_WHITE = new TextStyle({ fontFamily: 'Arial', fontSize: 46, fontWeight: 'bold', fill: '#ffffff', }) //遊戲時間顯示,30秒內沒完成,則遊戲失敗 const TOTAL_TIME = 30 //second //倒計時 let _countdown = TOTAL_TIME export default class Scene extends Container { constructor() { super() let bg = new Sprite(app.res.bg.texture) bg.anchor.set(0.5) this.addChild(bg) //提示圖 let idol = new Sprite(app.res.main.textures.puzzle) idol.y = -198 idol.x = -165 idol.anchor.set(0.5) idol.scale.set(0.37) this.addChild(idol) //倒計時顯示 this.$time = new Text(_countdown + '″', STYLE_WHITE) this.$time.anchor.set(0.5) this.$time.x = 170 this.$time.y = -156 this.addChild(this.$time) //拼圖模塊 this.$jigsaw = new Jigsaw(3, app.res.main.textures.puzzle) this.addChild(this.$jigsaw) } //開始遊戲 start() { //建立結果頁面 let result = new Result() this.addChild(result) //播放背景音樂 app.sound.play('sound_bg', true) //啓動倒計時timer,判斷遊戲成功仍是失敗。 let timer = setInterval(() => { if (this.$jigsaw.success) { //成功後中止timer,中止背景音樂,播放勝利音樂,顯示勝利頁面。 clearInterval(timer) app.sound.stop('sound_bg') app.sound.play('sound_win') result.win() } else { _countdown-- this.$time.text = _countdown + '″' if (_countdown == 0) { //失敗後中止timer,中止背景音樂,播放失敗音樂,顯示失敗頁面。 clearInterval(timer) app.sound.stop('sound_bg') app.sound.play('sound_fail') result.fail() } } }, 1000) } }
遊戲入口類src/js/main.js
import { Container } from 'pixi.js' import * as config from './config' import Application from './app' import Loading from './loading' import VideoAd from './ad' import Scene from './scene' import swal from 'sweetalert' //遊戲分層 const layers = { back: new Container(), scene: new Container(), ui: new Container() } //啓動項目 async function boot() { document.title = config.meta.name window.app = new Application({ width: config.meta.width, height: config.meta.height, view: document.querySelector('#scene'), transparent: true }) //把層加入場景內,並將層位置設置爲屏幕中心點. for (const key in layers) { let layer = layers[key] app.stage.addChild(layer) layer.x = config.meta.width / 2 layer.y = config.meta.height / 2 } } //預加載遊戲資源 function loadRes() { let promise = new Promise((resolve, reject) => { //顯示loading進度頁面 let loading = new Loading() layers.ui.addChild(loading) //根據application事件更新狀態 app.on('loader:progress', progress => loading.progress = progress) app.on('loader:error', error => reject(error)) app.on('loader:complete', () => { resolve() loading.destroy() }) app.load() }) return promise } //建立遊戲場景 function setup() { let scene = new Scene() layers.scene.addChild(scene) //這裏註釋掉了播放視頻模塊,你能夠打開這部分,遊戲開始前將播放一個視頻,視頻播放完畢後纔會顯示遊戲。 // let ad = new VideoAd() // layers.ui.addChild(ad) // ad.on('over', () => { scene.start() // }) } window.onload = async () => { //啓動application boot() //加載資源,出錯的話就顯示錯誤提示 try { await loadRes() } catch (error) { let reload = await swal({ title: 'load resource failed', text: error, icon: 'error', button: 'reload' }) if (reload) { location.reload(true) } return } //加載成功後顯示遊戲界面 setup() }
npm run build
可發佈項目,最終全部文件會拷貝到dist
目錄下,會合並全部的js
文件並混淆和去除無用引用,優化圖片資源。