拼图游戏
教程面向已经能简单使用pixi.js的开发者,通过创建一个拼图游戏,来演示怎么完整的开发一款pixi
游戏并最终发布。
此教程也被pixi.js官方收录为教程 How to make jigsaw game此项目中你可以学会怎么使用
ES6+
开发,怎么划分模块
,怎么提前加载资源
,怎么进行屏幕自适应
,怎么播放音频
和视频
,怎么分层
,怎么通过继承pixi类
来扩展功能,怎么实现多国语言
,怎么用webpack
进行开发期的调试
以及最终怎么构建发布
游戏(webpack详细教程可参考之前的文章《使用webpack搭建pixi.js开发环境》)。
欢迎关注专栏 pixijs游戏开发
前言
- 下面将完整讲解所有流程,详细讲解每一个类,请结合源代码一起开始吧。
我们开始吧
-
配置环境
- 安装
nodejs
。 - 选一个编辑器,推荐
vscode
。 - chrome浏览器。
- 图集制作工具
texturepacker
,免费版本即可。
- 安装
- 把项目 pixi-jigsaw clone下来。
- 运行
npm install
安装依赖库。 - 运行
npm start
启动项目,会自动打开chrome浏览器并启动游戏,玩一把然后跟着下面的讲解开始学习吧。
目录及文件说明
-
res
: 存放不需要放到游戏里面的源工程文件,例如texturepacker
图集项目,字体等等。 -
src
: 所有的游戏代码和资源。 -
dist
: 此目录为构建脚本动态生成,存放构建完成的项目文件,每次构建都会重新生成这个目录。 -
webpack.common.js
文件: webpack公共脚本。 -
webpack.dev.js
文件: webpack开发配置脚本。 -
webpack.prod.js
文件: webpack发布配置脚本。 -
package.json
文件:node
项目的配置文件。
源码说明
-
游戏主页
src/index.html
<!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; } .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" class="autofit"></canvas> <!--这里不需要引入js,webpack会自动帮我们引入--> </body> </html>
-
配置文件
src/js/config.js
,用于配置游戏资源和常量。//游戏基本信息,游戏名字,版本,宽,高等。 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
,能让程序根据浏览器语言自动调整程序界面语言。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
库实现功能。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
模块功能。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 //让canvas显示在中心,高铺满的话,两边留黑边,宽铺满的话,上下留黑边 let x = viewRect.x + (viewRect.width - width) / 2 let y = viewRect.y + (viewRect.height - height) / 2 //自适应 let autofitItems = document.querySelectorAll('.autofit') autofitItems.forEach(item => { //设置canvas(autofit)的宽高,注意这里千万不要直接设置canvas.width和height。 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
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
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
文件并混淆和去除无用引用,优化图片资源。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。