【React Native】Matching Game 配对游戏
基于React Native 的 跨三端(Android、iOS、Web)配对游戏
目录
第一章 需求分析和功能
我们知道现在有很多小游戏,但是这些小游戏要么只能Android运行,要么只能iOS运行,要么只能Web端运行。所以我想到了,要是能一次性编码解决跨端游戏开发就好了。
于是我就用React -Native来实现此次跨端小游戏的开发。
跨端小游戏的功能很简单,我主要实现游戏卡片的配对并计数。
刘海屏和全面屏底部的兼容
首先是头部的title,由于不能影响到有的刘海屏手机,所以我们要进行兼容考虑。
主页面中的图片反转业务逻辑
如果两次翻转的不一样,就会使两个图片还原,并统计次数。
如果全部翻转完了,进行关卡升级,增加图片个数。
第二章 框架架构和编码实现
框架架构
本项目是基于React-Native,所以首先需要进行React-Native环境配置(nodejs和react-native)
然后我们需要初始化一个React-Native项目
npx react-native init MatchingGame
- App.js 进行项目主要数据、逻辑、组件、样式的引入和实现
- Card.js 代表翻转的卡片组件,以备之后调用
之后我们在React-Native框架上进行开发就好啦
React-Native 简略讲解
React-Native是基于React.JS的移动开发框架,使用React.JS能够实现通过Javascript一次开发多端运行。
1. React-Native数据流基于MVVM、框架基于React
- MVVM
Model层代表数据模型,View代表UI组件,ViewModel是View和Model层的桥梁,数据会绑定到viewModel层并自动将数据渲染到页面中,视图变化的时候会通知viewModel层更新数据。 - React
React是Javascript的构建页面的框架
2. React实现方式
编程语言是JSX
JSX是综合HTML和JS的编程方式,也就是能够在JS语言中使用HTML,JSX在编译时会自动转成JS
React开发组件
父组件可以调用子组件
例如本项目就在header组件下面、footer上面也就是main组件中间插入Card卡片组件
也就是:
<View style={ styles.main }>
<View style={ styles.gameBoard }>
{
this.state.cardSymbolsInRand.map((symbol, index) => {
return (
<Card
key={ index }
onPress={ () => this.cardPressHandler(index) }
style={ styles.button }
fontSize={ 30 }
title={ symbol }
cover="❓"
isShow={ this.state.isOpen[index] }
/>
)
})
}
</View>
</View>
而这里Card组件创建如下
class Card extends Component {
render () {
return (
<TouchableOpacity onPress={ this.props.onPress } style={{ ...this.props.style }}>
{/* TouchableOpacity:点击时,有半透明的回馈 */}
<Text
style={{ fontSize: this.props.fontSize || 32 }}
>{ this.props.isShow ? this.props.title : this.props.cover }</Text>
</TouchableOpacity>
)
}
}
3.React数据流
类似事件捕获,数据流是自上而下单向地从父节点传递到子节点,所以组件是简单且容易把握的,他们只需要从父节点提供的props中获取数据并渲染即可。如果顶层组件的某个prop改变了,React会递归地向下遍历整棵组件数,重新渲染所有使用这个属性的组件。
改变组件样式的props属性
props类似于css的属性,它是组件里面的属性,能够从父组件中进行修改
例如card里面的Text组件里面就有之前我们引入的TouchableOpacity组件的属性
<TouchableOpacity onPress={ this.props.onPress } style={{ ...this.props.style }}>
{/* TouchableOpacity:点击时,有半透明的回馈 */}
<Text
style={{ fontSize: this.props.fontSize || 32 }}
>{ this.props.isShow ? this.props.title : this.props.cover }</Text>
</TouchableOpacity>
能够进行组件事件响应从而改变数据的state事件(view-model层)
例如本项目的图标点击事件
从view视图层绑定的监听事件触发过后能够传给view-model层,cardPressHandler函数就是这个view-model层,进行通过this.setState({ })
进行数据修改。
// 每一次点击图标的事件
cardPressHandler = (index) => {
let isOpen = [...this.state.isOpen]
// 如果点击的是已被翻开的图标,不执行后续代码
if (isOpen[index]) {
return
}
isOpen[index] = true
if (this.state.firstPickedIndex == null && this.state.secondPickedIndex == null) {
this.setState({
isOpen,
firstPickedIndex: index
})
} else if (this.state.firstPickedIndex != null && this.state.secondPickedIndex == null) {
this.setState({
isOpen,
secondPickedIndex: index
})
}
this.setState({
steps: this.state.steps + 1
})
}
事件触发后进行DOM更新
这是一个React组件实现组件可交互所需的流程,
- render()输出虚拟DOM,
- 虚拟DOM转为DOM,
- 再在DOM上注册事件,
- 事件触发setState()修改数据,
- 在每次调用setState方法时,React会自动执行render方法来更新虚拟DOM,如果组件已经被渲染,那么还会更新到DOM中去。
在本项目中:我们首先render输出了集有各种组件的虚拟DOM,然后进行虚拟DOM到DOM的转换,然后绑定按钮监听事件cardPressHandler
,事件触发后通过setState修改数据,然后进行更新
4.组件的生命周期
- 本项目涉及生命周期的地方有:
- 初次载入界面渲染过后对卡片顺序进行打乱也就是通过
componentDidMount ()
进行卡片顺序打乱
initGame = () => {
let newCardsSymbols = [...this.state.cardSymbols, ...this.state.cardSymbols]
// 将 16 个 emoji 的顺序打乱
let cardSymbolsInRand = this.shuffleArray(newCardsSymbols)
// 存储打开状态
let isOpen = []
for (let i = 0; i < newCardsSymbols.length; i++) {
isOpen.push(false)
}
this.setState({
cardSymbolsInRand,
isOpen
})
}
componentDidMount () {
this.initGame()
}
- 在用户点击两个卡片,触发更新监听事件之后,需要对两个卡片进行统计,如果一样的话就会进行次数统计以及决定在屏幕上保不保留
componentDidUpdate (prevProps, prevState) {
if (prevState.secondPickedIndex != this.state.secondPickedIndex) {
this.calculateGameResult()
}
}
编码实现
逻辑实现
1. 导入react-native库里面已经封装好的组件
状态栏 刘海屏适配 创建css属性 文字 点击事件
import React, { Component, Fragment } from 'react'
import {
StatusBar,//状态栏
SafeAreaView,//刘海屏适配
View,
Text,
StyleSheet,//属性都写在这里
Dimensions,//获取当前屏幕宽高
TouchableOpacity//点击事件
} from 'react-native'
2. model层:state里面的数据定义
包括反转状态、图标顺序、反转次数、是否结束
state = {
cardSymbols: [
'❤', '🤩', '💩', '👍', '😂', '🥶', '😈', '😕'
],
// 打乱顺序的 16 个图标
cardSymbolsInRand: [],
// 记录被图标反转的状态
isOpen: [],
// 第一次点击的图标
firstPickedIndex: null,
// 第二次点击的图标
secondPickedIndex: null,
// 记录所有图标被反转的次数
steps: 0,
// 判断游戏是否已结束
isEnded: false
}
3. 游戏初始化
打乱16个emoji表情顺序、将初始化状态传到state数据里
initGame = () => {
let newCardsSymbols = [...this.state.cardSymbols, ...this.state.cardSymbols]
// 将 16 个 emoji 的顺序打乱
let cardSymbolsInRand = this.shuffleArray(newCardsSymbols)
// 存储打开状态
let isOpen = []
for (let i = 0; i < newCardsSymbols.length; i++) {
isOpen.push(false)
}
this.setState({
cardSymbolsInRand,
isOpen
})
}
componentDidMount () {
this.initGame()
}
shuffleArray = (arr) => {
const newArr = arr.slice()
for (let i = newArr.length - 1; i > 0; i--) {
const rand = Math.floor(Math.random() * (i + 1));
[newArr[i], newArr[rand]] = [newArr[rand], newArr[i]]
}
return newArr
}
4. 绑定点击事件
进行了点击之前已翻开图片的冗余操作、如果翻开的两个的属性一样就进行state数据修改
// 每一次点击图标的事件
cardPressHandler = (index) => {
let isOpen = [...this.state.isOpen]
// 如果点击的是已被翻开的图标,不执行后续代码
if (isOpen[index]) {
return
}
isOpen[index] = true
if (this.state.firstPickedIndex == null && this.state.secondPickedIndex == null) {
this.setState({
isOpen,
firstPickedIndex: index
})
} else if (this.state.firstPickedIndex != null && this.state.secondPickedIndex == null) {
this.setState({
isOpen,
secondPickedIndex: index
})
}
this.setState({
steps: this.state.steps + 1
})
}
5. 每次渲染更新之后进行判断所有emoji表情是否都被打开
若打开那么就重置游戏
calculateGameResult = () => {
if (this.state.firstPickedIndex != null && this.state.secondPickedIndex != null) {
// 判断是否所有图标都被打开(结束游戏)
if (this.state.cardSymbolsInRand.length > 0) {
let totalOpens = this.state.isOpen.filter((isOpen) => isOpen)
if (totalOpens.length === this.state.cardSymbolsInRand.length) {
this.setState({
isEnded: true
})
return
}
}
let firstSymbol = this.state.cardSymbolsInRand[this.state.firstPickedIndex]
let secondSymbol = this.state.cardSymbolsInRand[this.state.secondPickedIndex]
if (firstSymbol != secondSymbol) {
// Incorrect
setTimeout(() => {
let isOpen = [...this.state.isOpen]
isOpen[this.state.firstPickedIndex] = false
isOpen[this.state.secondPickedIndex] = false
this.setState({
firstPickedIndex: null,
secondPickedIndex: null,
isOpen
})
}, 1000)
} else {
// Correct
this.setState({
firstPickedIndex: null,
secondPickedIndex: null
})
}
}
}
componentDidUpdate (prevProps, prevState) {
if (prevState.secondPickedIndex != this.state.secondPickedIndex) {
this.calculateGameResult()
}
}
resetGame = () => {
this.initGame()
this.setState({
firstPickedIndex: null,
secondPickedIndex: null,
steps: 0,
isEnded: false
})
}
视图实现
render () {
return (
<Fragment>
<StatusBar />
<SafeAreaView style={ styles.container }>
<View style={ styles.header }>
<Text style={ styles.heading }>Matching Game</Text>
</View>
<View style={ styles.main }>
<View style={ styles.gameBoard }>
{
this.state.cardSymbolsInRand.map((symbol, index) => {
return (
<Card
key={ index }
onPress={ () => this.cardPressHandler(index) }
style={ styles.button }
fontSize={ 30 }
title={ symbol }
cover="❓"
isShow={ this.state.isOpen[index] }
/>
)
})
}
</View>
</View>
<View style={ styles.footer }>
<Text style={ styles.footerText }>{
this.state.isEnded
? `Congrats! You have completed in ${ this.state.steps } setps.`
: `You have tried ${ this.state.steps } time(s).`
}</Text>
{
this.state.isEnded ?
<TouchableOpacity onPress={ this.resetGame } style={ styles.tryAgainButton }>
<Text style={ styles.tryAgainButtonText }>Try Again</Text>
</TouchableOpacity>
: null
}
</View>
</SafeAreaView>
</Fragment>
)
}
card卡片组件的复用
card组件绑定点击事件,如果点击就翻牌,两个不一样就会翻回去
import React, { Component } from 'react'
import {
TouchableOpacity,
Text
} from 'react-native'
class Card extends Component {
render () {
return (
<TouchableOpacity onPress={ this.props.onPress } style={{ ...this.props.style }}>
{/* TouchableOpacity:点击时,有半透明的回馈 */}
<Text
style={{ fontSize: this.props.fontSize || 32 }}
>{ this.props.isShow ? this.props.title : this.props.cover }</Text>
</TouchableOpacity>
)
}
}
export default Card
全部代码
import React, { Component, Fragment } from 'react'
import {
StatusBar,
SafeAreaView,
View,
Text,
StyleSheet,
Dimensions,
TouchableOpacity
} from 'react-native'
import Card from './src/components/Card'
/*
制作配对游戏:
1. 建构界面
2. 组件与事件
3. 游戏逻辑
*/
export default class App extends Component {
state = {
cardSymbols: [
'❤', '🤩', '💩', '👍', '😂', '🥶', '😈', '😕'
],
// 打乱顺序的 16 个图标
cardSymbolsInRand: [],
// 记录被图标反转的状态
isOpen: [],
// 第一次点击的图标
firstPickedIndex: null,
// 第二次点击的图标
secondPickedIndex: null,
// 记录所有图标被反转的次数
steps: 0,
// 判断游戏是否已结束
isEnded: false
}
initGame = () => {
let newCardsSymbols = [...this.state.cardSymbols, ...this.state.cardSymbols]
// 将 16 个 emoji 的顺序打乱
let cardSymbolsInRand = this.shuffleArray(newCardsSymbols)
// 存储打开状态
let isOpen = []
for (let i = 0; i < newCardsSymbols.length; i++) {
isOpen.push(false)
}
this.setState({
cardSymbolsInRand,
isOpen
})
}
componentDidMount () {
this.initGame()
}
shuffleArray = (arr) => {
const newArr = arr.slice()
for (let i = newArr.length - 1; i > 0; i--) {
const rand = Math.floor(Math.random() * (i + 1));
[newArr[i], newArr[rand]] = [newArr[rand], newArr[i]]
}
return newArr
}
// 每一次点击图标的事件
cardPressHandler = (index) => {
let isOpen = [...this.state.isOpen]
// 如果点击的是已被翻开的图标,不执行后续代码
if (isOpen[index]) {
return
}
isOpen[index] = true
if (this.state.firstPickedIndex == null && this.state.secondPickedIndex == null) {
this.setState({
isOpen,
firstPickedIndex: index
})
} else if (this.state.firstPickedIndex != null && this.state.secondPickedIndex == null) {
this.setState({
isOpen,
secondPickedIndex: index
})
}
this.setState({
steps: this.state.steps + 1
})
}
calculateGameResult = () => {
if (this.state.firstPickedIndex != null && this.state.secondPickedIndex != null) {
// 判断是否所有图标都被打开(结束游戏)
if (this.state.cardSymbolsInRand.length > 0) {
let totalOpens = this.state.isOpen.filter((isOpen) => isOpen)
if (totalOpens.length === this.state.cardSymbolsInRand.length) {
this.setState({
isEnded: true
})
return
}
}
let firstSymbol = this.state.cardSymbolsInRand[this.state.firstPickedIndex]
let secondSymbol = this.state.cardSymbolsInRand[this.state.secondPickedIndex]
if (firstSymbol != secondSymbol) {
// Incorrect
setTimeout(() => {
let isOpen = [...this.state.isOpen]
isOpen[this.state.firstPickedIndex] = false
isOpen[this.state.secondPickedIndex] = false
this.setState({
firstPickedIndex: null,
secondPickedIndex: null,
isOpen
})
}, 1000)
} else {
// Correct
this.setState({
firstPickedIndex: null,
secondPickedIndex: null
})
}
}
}
componentDidUpdate (prevProps, prevState) {
if (prevState.secondPickedIndex != this.state.secondPickedIndex) {
this.calculateGameResult()
}
}
resetGame = () => {
this.initGame()
this.setState({
firstPickedIndex: null,
secondPickedIndex: null,
steps: 0,
isEnded: false
})
}
render () {
return (
<Fragment>
<StatusBar />
<SafeAreaView style={ styles.container }>
<View style={ styles.header }>
<Text style={ styles.heading }>Matching Game</Text>
</View>
<View style={ styles.main }>
<View style={ styles.gameBoard }>
{
this.state.cardSymbolsInRand.map((symbol, index) => {
return (
<Card
key={ index }
onPress={ () => this.cardPressHandler(index) }
style={ styles.button }
fontSize={ 30 }
title={ symbol }
cover="❓"
isShow={ this.state.isOpen[index] }
/>
)
})
}
</View>
</View>
<View style={ styles.footer }>
<Text style={ styles.footerText }>{
this.state.isEnded
? `Congrats! You have completed in ${ this.state.steps } setps.`
: `You have tried ${ this.state.steps } time(s).`
}</Text>
{
this.state.isEnded ?
<TouchableOpacity onPress={ this.resetGame } style={ styles.tryAgainButton }>
<Text style={ styles.tryAgainButtonText }>Try Again</Text>
</TouchableOpacity>
: null
}
</View>
</SafeAreaView>
</Fragment>
)
}
}
const styles = StyleSheet.create({
container: {
flex: 1
},
header: {
flex: 1,
backgroundColor: '#eee',
justifyContent: 'center',
alignItems: 'center'
},
heading: {
fontSize: 32,
fontWeight: 'bold',
textAlign: 'center'
},
main: {
flex: 3,
backgroundColor: '#fff'
},
footer: {
flex: 1,
backgroundColor: '#eee',
justifyContent: 'center',
alignItems: 'center'
},
footerText: {
fontSize: 20,
textAlign: 'center'
},
gameBoard: {
flex: 1,
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
alignItems: 'center',
alignContent: 'center'
},
button: {
backgroundColor: '#ccc',
borderRadius: 8,
width: 48,
height: 48,
justifyContent: 'center',
alignItems: 'center',
margin: (Dimensions.get('window').width - (48 * 4)) / (5 * 2)
},
buttonText: {
fontSize: 30
},
tryAgainButton: {
backgroundColor: '#ab956d',
padding: 8,
borderRadius: 8,
marginTop: 20
},
tryAgainButtonText: {
fontSize: 18,
paddingHorizontal: 10,
fontWeight: 'bold',
color: '#fff'
}
})
第三章 后续扩展
- 进行排名的统计,会涉及到在设备上的数据缓存。
- 通过react-router实现多路由,比如可以选择关卡等。