React 全栈项目第二版(五)
原文:
zh.annas-archive.org/md5/35c59f78351aeb34721c43c78c53c92a译者:飞龙
第十六章:自定义媒体播放器并改进 SEO
用户访问媒体流应用主要是为了播放媒体和探索其他相关媒体。这使得媒体播放器——以及渲染相关媒体详情的视图——对于流应用至关重要。
在本章中,我们将专注于开发我们在上一章(第十一章,构建媒体流应用)开始构建的 MERN Mediastream 应用程序的播放媒体页面。我们将解决以下问题,以增强媒体播放功能,并帮助提升媒体内容在网上的影响力,使其触及更多用户:
-
在
ReactPlayer上自定义播放器控件 -
从相关视频列表中播放下一个视频
-
自动播放相关媒体列表
-
服务器端渲染(SSR)的
PlayMedia视图,以数据改进搜索引擎优化(SEO)
完成这些主题后,您将更擅长设计前端用户界面中 React 组件之间的复杂交互,并提高您的全栈 React 应用程序的 SEO。
将自定义媒体播放器添加到 MERN Mediastream
上一章中开发的 MERN Mediastream 应用程序实现了一个简单的媒体播放器,带有默认浏览器控件,一次播放一个视频。在本章中,我们将使用定制的ReactPlayer和相关媒体列表更新播放媒体视图,该列表可以在当前视频结束时自动播放。带有自定义播放器和相关播放列表的更新视图将类似于以下截图:
完整的 MERN Mediastream 应用程序代码可在 GitHub 上找到,网址为github.com/PacktPublishing/Full-Stack-React-Projects-Second-Edition/tree/master/Chapter11%20and%2012/mern-mediastream。您可以将此代码克隆并运行,以便在阅读本章剩余部分的代码解释时运行应用程序。
下面的组件树图显示了构成 MERN Mediastream 前端的所有自定义组件,包括本章中将改进或添加的组件:
本章中修改的组件和新添加的组件包括PlayMedia组件,它包含所有媒体播放器功能;MediaPlayer组件,它添加了一个带有自定义控件的ReactPlayer;以及RelatedMedia组件,它包含一个相关视频列表。在下一节中,我们将讨论播放媒体页面的结构,以及它将如何容纳本章中将在 MERN Mediastream 应用程序中扩展的所有媒体观看和交互功能。
播放媒体页面
当访客想在 MERN Mediastream 上查看特定媒体时,他们将被带到播放媒体页面,该页面将包含媒体详情、用于流式传输视频的媒体播放器以及可以播放的相关媒体列表。我们将使用名为 PlayMedia 的 React 组件实现此 PlayMedia 视图。在下一节中,我们将讨论如何构建此组件以实现这些功能。
组件结构
我们将在播放媒体页面中构建组件结构,以便将媒体数据从父组件逐级传递到内部组件。在这种情况下,PlayMedia 组件将是父组件,包含 RelatedMedia 组件,还包含 Media 组件,该组件将包含嵌套的 MediaPlayer 组件,如下面的截图所示:
当在应用程序的前端访问单个媒体链接时,PlayMedia 组件将从服务器检索并加载相应的媒体数据和相关媒体列表。然后,相关细节将通过 props 传递给 Media 和 RelatedMedia 子组件。
RelatedMedia 组件将列出并链接其他相关媒体,点击列表中的任何媒体将重新渲染 PlayMedia 组件及其内部组件,并使用新数据。
我们将更新第十一章 Building a Media-Streaming Application 中开发的 Media 组件,以添加一个自定义媒体播放器作为子组件。这个定制的 MediaPlayer 组件也将利用从 PlayMedia 传递过来的数据来流式传输当前视频并链接到相关媒体列表中的下一个视频。
在 PlayMedia 组件中,我们将添加一个自动播放切换功能,允许用户选择是否自动播放相关媒体列表中的视频,一个接一个。自动播放状态将由 PlayMedia 组件管理,但此功能需要在 MediaPlayer 嵌套子组件中视频结束时重新渲染父组件状态中的数据,以确保下一视频能够自动播放,同时跟踪相关列表。
为了实现这一点,PlayMedia 组件需要提供一个状态更新方法作为 prop,该 prop 将在 MediaPlayer 组件中使用,以更新这些组件之间的共享和相互依赖的状态值。
考虑到这个组件结构,我们将扩展并更新 MERN Mediastream 应用程序,以实现一个功能齐全的播放媒体页面。在下一节中,我们将首先添加一个功能,在 PlayMedia 视图中向用户提供相关媒体列表。
列出相关媒体
当用户在应用程序中查看单个媒体时,他们将在同一页面上看到相关媒体列表。相关媒体列表将包括属于与给定视频相同类型的其他媒体记录,并按观看次数最高的顺序排序。为了实现此功能,我们需要集成一个全栈切片,从后端的媒体集合中检索相关列表并在前端渲染它。在接下来的几节中,我们将在后端添加一个相关媒体列表 API,以及在前端获取此 API 的方法和一个 React 组件,用于渲染通过此 API 检索到的媒体列表。
相关媒体列表 API
我们将在后端实现一个 API 端点来从数据库中检索相关媒体的列表。该 API 将在'/api/media/related/:mediaId'接收GET请求,并且该路由将与其他媒体路由一起声明,如下所示:
mern-mediastream/server/routes/media.routes.js
router.route('/api/media/related/:mediaId')
.get(mediaCtrl.listRelated)
路径中的:mediaId参数将由第十一章中“视频 API”部分实现的mediaByID方法处理,该部分是构建媒体流应用。它从数据库中检索与该 ID 对应的媒体,并将其附加到request对象中,以便在下一种方法中访问。listRelated控制器方法是调用此 API 路由的GET请求的下一个方法。此方法将查询媒体集合以找到与提供的媒体具有相同类型的记录,并排除返回结果中的给定媒体记录。listRelated控制器方法定义如下所示:
mern-mediastream/server/controllers/media.controller.js
const listRelated = async (req, res) => {
try {
let media = await Media.find({ "_id": { "$ne": req.media },
"genre": req.media.genre})
.limit(4)
.sort('-views')
.populate('postedBy', '_id name')
.exec()
res.json(media)
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
查询返回的结果将按观看次数最高的顺序排序,并限制为前四个媒体记录。返回结果中的每个media对象也将包含发布媒体的用户的名称和 ID,如populate方法中指定。
在客户端,我们将设置一个相应的fetch方法,该方法将在PlayMedia组件中使用,以使用此 API 检索相关媒体列表。此方法定义如下:
mern-mediastream/client/media/api-media.js
const listRelated = async (params, signal) => {
try {
let response = await fetch('/api/media/related/'+ params.mediaId, {
method: 'GET',
signal: signal,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
})
return await response.json()
} catch(err) {
console.log(err)
}
}
这个listRelated获取方法将接受一个媒体 ID,并向后端的相关媒体列表 API 发起一个GET请求。我们将在PlayMedia组件中使用此方法来检索与当前媒体播放器中加载的媒体相关的媒体列表。然后,这个列表将在RelatedMedia组件中显示。在下一节中,我们将探讨这个RelatedMedia组件的实现。
相关媒体组件
在播放媒体页面上,除了在播放器中加载的媒体外,我们将在RelatedMedia组件中加载相关媒体列表。RelatedMedia组件将从PlayMedia组件接收相关媒体列表作为 prop,并渲染列表中每个视频的详细信息以及视频快照,如图所示:
在RelatedMedia视图的实现中,我们使用map函数遍历从 props 接收到的媒体数组,并按以下代码结构渲染每个媒体的详细信息以及视频快照:
mern-mediastream/client/media/RelatedMedia.js
{props.media.map((item, i) => {
return
<span key={i}>... video snapshot ... | ... media details ...</span>
})
}
在此结构中,为了渲染每个媒体项目的视频快照,我们将使用不带控件的基本ReactPlayer,如下所示:
mern-mediastream/client/media/RelatedMedia.js
<Link to={"/media/"+item._id}>
<ReactPlayer url={'/api/media/video/'+item._id}
width='160px'
height='140px'/>
</Link>
我们将ReactPlayer包装在一个链接中,以访问此媒体的单独视图。因此,点击给定的视频快照将重新渲染PlayMedia视图以加载链接媒体的详细信息。在快照旁边,我们将显示每个视频的详细信息,包括标题、类型、创建日期和观看次数,以下代码所示:
mern-mediastream/client/media/RelatedMedia.js
<Typography type="title" color="primary">{item.title}</Typography>
<Typography type="subheading"> {item.genre} </Typography>
<Typography component="p">
{(new Date(item.created)).toDateString()}
</Typography>
<Typography type="subheading">{item.views} views</Typography>
这将为接收到的 props 中每个相关媒体列表中的媒体渲染视频快照旁边的详细信息。
要在播放媒体页面上渲染此RelatedMedia组件,我们必须将其添加到PlayMedia组件中。PlayMedia组件将使用本节中较早实现的关联媒体列表 API 从后端检索相关媒体,并将其作为 props 传递给RelatedMedia组件。在下一节中,我们将讨论此PlayMedia组件的实现。
PlayMedia组件
PlayMedia组件将渲染播放媒体页面。此组件由Media和RelatedMedia子组件以及自动播放切换组成,并在加载到视图中时为这些组件提供数据。
当用户访问单个媒体链接时,我们将向MainRouter中添加一个Route,并在'/media/:mediaId'处挂载PlayMedia,如下所示:
mern-mediastream/client/MainRouter.js
<Route path="/media/:mediaId" component={PlayMedia}/>
当PlayMedia组件挂载时,它将根据路由链接中的mediaId参数使用useEffect钩子从服务器获取媒体数据和相关媒体列表。
在一个useEffect钩子中,它将获取要在媒体播放器中加载的媒体,如下所示:
mern-mediastream/client/media/PlayMedia.js
useEffect(() => {
const abortController = new AbortController()
const signal = abortController.signal
read({mediaId: props.match.params.mediaId}, signal).then((data) => {
if (data && data.error) {
console.log(data.error)
} else {
setMedia(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [props.match.params.mediaId])
从 React Router 组件接收到的props.match中访问路由路径中的媒体 ID。它在调用read API 获取方法时用于从服务器检索媒体详细信息。接收到的media对象被设置在状态中,以便可以在Media组件中渲染。
在另一个 useEffect 钩子中,我们使用相同的媒体 ID 调用 listRelated API 获取方法,如下面的代码所示。
mern-mediastream/client/media/PlayMedia.js
useEffect(() => {
const abortController = new AbortController()
const signal = abortController.signal
listRelated({
mediaId: props.match.params.mediaId}, signal).then((data) => {
if (data.error) {
console.log(data.error)
} else {
setRelatedMedia(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [props.match.params.mediaId])
listRelated API 获取方法从服务器检索相关媒体列表,并将值设置到状态中,以便在 RelatedMedia 组件中渲染。
存储在状态中的媒体和相关媒体列表值用于将这些属性传递给在视图中添加的子组件。例如,在以下代码中,只有当相关媒体列表包含任何媒体时,RelatedMedia 组件才会被渲染,并将列表作为属性传递给它:
mern-mediastream/client/media/PlayMedia.js
{relatedMedia.length > 0 &&
(<RelatedMedia media={relatedMedia}/>)}
在本章的后面部分,在 自动播放相关媒体 部分中,我们将在相关媒体列表长度大于 0 的情况下,仅在 RelatedMedia 组件上方添加 Autoplay 切换组件。我们还将讨论将作为属性传递给 Media 组件的 handleAutoPlay 方法的实现。它还将接收 media 详细对象,以及相关媒体列表中第一项的视频 URL,这将被视为下一个要播放的 URL。Media 组件被添加到 PlayMedia 中,并带有这些属性,如下面的代码所示:
mern-mediastream/client/media/PlayMedia.js
const nextUrl = relatedMedia.length > 0
? `/media/${relatedMedia[0]._id}` : ''
<Media media={media}
nextUrl={nextUrl}
handleAutoplay={handleAutoplay}/>
此 Media 组件在播放媒体页面上渲染媒体详情,以及一个定制的媒体播放器,允许观众控制视频的流。在下一节中,我们将讨论此定制媒体播放器的实现,并完成播放媒体页面的核心功能。
定制媒体播放器
在 MERN Mediastream 中,我们希望为用户提供一个比默认浏览器选项更多的控件,并且外观与应用程序的其他部分相匹配。我们将定制 ReactPlayer 上的播放器控件,用自定义的外观和功能替换这些默认控件,如下面的截图所示:
控件将被添加到视频下方,包括进度搜索栏;播放、暂停、下一曲、音量、循环和全屏选项;还将显示视频的总时长和已播放的量。在以下章节中,我们首先更新上一章中讨论的 Media 组件,第十一章,构建媒体流应用,以适应新的播放器功能。然后,在实现此播放器中自定义媒体控件的功能之前,我们将初始化一个包含新播放器的 MediaPlayer 组件。
更新媒体组件
现有的Media组件包含一个基本的ReactPlayer,它具有默认的浏览器控件,用于播放指定的视频。我们将用一个新的MediaPlayer组件替换这个ReactPlayer,我们将在下一节开始实现它。MediaPlayer组件将包含一个定制的ReactPlayer,并且它将被添加到Media组件代码中,如下所示:
mern-mediastream/client/media/Media.js
const mediaUrl = props.media._id
? `/api/media/video/${props.media._id}`
: null
...
<MediaPlayer srcUrl={mediaUrl}
nextUrl={props.nextUrl}
handleAutoplay={props.handleAutoplay}/>
当将此MediaPlayer组件添加到Media组件时,它将传递当前视频的源 URL、下一视频的源 URL 以及handleAutoPlay方法,这些作为props在Media组件中从PlayMedia组件接收。这些 URL 值和自动播放处理方法将在MediaPlayer组件中用于添加各种视频播放选项。在下一节中,我们将开始实现这个MediaPlayer组件,通过初始化添加到自定义媒体播放器中所需的不同值来添加功能控制。
初始化媒体播放器
我们将在MediaPlayer组件中实现定制的媒体播放器。这个播放器将渲染从后端流出的视频,并为用户提供不同的控制选项。我们将使用ReactPlayer组件将此媒体播放功能以及自定义控制选项集成到MediaPlayer中。正如前一章所讨论的,ReactPlayer组件提供了一系列的自定义选项,我们将利用这些选项来实现本应用程序中要添加的媒体播放器功能。
在定义MediaPlayer组件时,我们将在添加自定义功能及其对应的用户操作处理代码之前,首先初始化ReactPlayer组件的控制起始值。
我们自定义的控制值将对应于ReactPlayer组件中允许的属性。要查看可用属性列表及其解释,请访问 github.com/CookPete/re…。
首先,我们需要在组件的状态中设置初始的控制值。我们将从以下对应于以下控制值的控制值开始:
-
媒体的播放状态
-
音频的音量
-
静音状态
-
视频的时长
-
搜索状态
-
视频的播放速率
-
循环值
-
全屏值
-
视频错误
-
正在流媒体的视频的播放、加载和结束状态
初始化这些值的代码将添加如下:
mern-mediastream/client/media/MediaPlayer.js
const [playing, setPlaying] = useState(false)
const [volume, setVolume] = useState(0.8)
const [muted, setMuted] = useState(false)
const [duration, setDuration] = useState(0)
const [seeking, setSeeking] = useState(false)
const [playbackRate, setPlaybackRate] = useState(1.0)
const [loop, setLoop] = useState(false)
const [fullscreen, setFullscreen] = useState(false)
const [videoError, setVideoError] = useState(false)
const [values, setValues] = useState({
played: 0, loaded: 0, ended: false
})
在状态中设置的这些值将允许我们自定义ReactPlayer组件中相应控制的功能,我们将在下一节中详细讨论。
在MediaPlayer组件的视图代码中,我们将添加这个ReactPlayer,并使用从Media组件发送的 prop,使用这些控制值和源 URL,如下面的代码所示:
mern-mediastream/client/media/MediaPlayer.js
<ReactPlayer
ref={ref}
width={fullscreen ? '100%':'inherit'}
height={fullscreen ? '100%':'inherit'}
style={fullscreen ? {position:'relative'} : {maxHeight: '500px'}}
config={{ attributes: { style: { height: '100%', width: '100%'} } }}
url={props.srcUrl}
playing={playing}
loop={loop}
playbackRate={playbackRate}
volume={volume}
muted={muted}
onEnded={onEnded}
onError={showVideoError}
onProgress={onProgress}
onDuration={onDuration}/>
除了设置控件值外,我们还将根据播放器是否处于全屏模式添加样式。我们还需要获取浏览器中渲染的此播放器元素的引用,以便可以在自定义控件的代码中使用它。我们将使用useRef React 钩子将引用初始化为null,然后使用ref方法将其设置为相应的播放器元素,如下面的代码所示:
mern-mediastream/client/media/MediaPlayer.js
let playerRef = useRef(null)
const ref = player => {
playerRef = player
}
playerRef中的值将提供对浏览器中渲染的播放器元素的访问权限。我们将使用此引用按需操作播放器,以使自定义控件功能化。
作为初始化媒体播放器的最后一步,我们将添加处理播放器抛出的错误代码,如果由于任何原因指定的视频源无法加载。我们将定义一个showVideoError方法,当发生视频错误时将被调用。showVideoError方法将定义如下:
mern-mediastream/client/media/MediaPlayer.js
const showVideoError = e => {
console.log(e)
setVideoError(true)
}
此方法将在媒体播放器上方的视图中渲染错误消息。我们可以通过在ReactPlayer上方的视图中添加以下代码有条件地显示此错误消息:
mern-mediastream/client/media/MediaPlayer.js
{videoError && <p className={classes.videoError}>Video Error. Try again later.</p>}
当发生错误时,这将渲染视频错误消息。由于我们将允许用户从相关媒体列表中在播放器中播放另一个视频,因此如果加载了新视频,我们将重置错误消息。我们可以通过确保useEffect仅在视频源 URL 更改时运行,使用useEffect钩子来隐藏新视频加载时的错误消息,如下面的代码所示:
mern-mediastream/client/media/MediaPlayer.js
useEffect(() => {
setVideoError(false)
}, [props.srcUrl])
这将确保在新的视频加载并正确流式传输时不会显示错误消息。
在设置了这些初始控件值并将ReactPlayer添加到组件后,在下一节中,我们可以开始自定义这些控件在我们应用程序中的外观和功能。
自定义媒体控件
我们将在MediaPlayer组件中渲染的视频下方添加自定义播放器控件元素,并使用ReactPlayer库提供的选项和事件操作其功能。在以下章节中,我们将实现播放、暂停和重放控件;播放下一个控件;循环功能;音量控制选项;进度控制选项;全屏选项,并显示视频的总时长和已播放的量。
播放、暂停和重放
用户将能够播放、暂停和重放当前视频。我们将使用绑定到ReactPlayer属性和事件的Material-UI组件实现这三个选项。播放、暂停和重放选项将渲染如下截图所示:
为了实现播放、暂停和重播功能,我们将根据视频是否正在播放、是否已暂停或已结束,有条件地添加播放、暂停或重播图标按钮,如下面的代码所示:
mern-mediastream/client/media/MediaPlayer.js
<IconButton color="primary" onClick={playPause}>
<Icon>{playing ? 'pause': (ended ? 'replay' : 'play_arrow')}</Icon>
</IconButton>
根据三元运算符的结果,在此 IconButton 中渲染播放、暂停或重播图标。
当用户点击按钮时,我们将更新状态中的 playing 值,以便更新 ReactPlayer。我们通过在按钮点击时调用 playPause 方法来实现这一点。playPause 方法如下定义:
mern-mediastream/client/media/MediaPlayer.js
const playPause = () => {
setPlaying(!playing)
}
状态中 playing 的更新值将根据 ReactPlayer 组件相应地播放或暂停视频。在下一节中,我们将看到如何添加一个控制选项,允许我们从相关媒体列表中播放下一个视频。
下一个播放
用户可以使用下一个播放按钮播放相关媒体列表中的下一个视频,该按钮将根据下一个视频是否可用而渲染。此下一个播放按钮的两个版本如下截图所示:
如果相关列表不包含任何媒体,则下一个播放按钮将被禁用。下一个播放图标将基本链接到从 PlayMedia 传递的作为属性的下一个 URL 值。此下一个播放按钮将被添加到 MediaPlayer 视图中,如下所示:
mern-mediastream/client/media/MediaPlayer.js
<IconButton disabled={!props.nextUrl} color="primary">
<Link to={props.nextUrl}>
<Icon>skip_next</Icon>
</Link>
</IconButton>
点击此下一个播放按钮将重新加载带有新媒体详情的 PlayMedia 组件并开始播放视频。在下一节中,我们将添加一个控制选项,允许当前视频循环播放。
视频结束时循环
用户可以使用循环按钮设置当前视频循环播放。循环按钮将以两种状态渲染,即设置和取消设置,如下面的截图所示:
此循环图标按钮将以不同的颜色显示,以指示它是否已被用户设置或取消设置。渲染此循环按钮的代码将被添加到 MediaPlayer 中,如下所示:
mern-mediastream/client/media/MediaPlayer.js
<IconButton color={loop ? 'primary' : 'default'}
onClick={onLoop}>
<Icon>loop</Icon>
</IconButton>
循环图标颜色将根据状态中 loop 的值而改变。当点击此循环图标按钮时,我们将通过调用以下定义的 onLoop 方法来更新状态中的 loop 值:
mern-mediastream/client/media/MediaPlayer.js
const onLoop = () => {
setLoop(!loop)
}
当此 loop 值设置为 true 时,视频将循环播放。我们需要捕获 onEnded 事件,以检查 loop 是否已设置为 true,以便相应地更新 playing 值。当视频到达结束时,将调用 onEnded 方法。此 onEnded 方法如下定义:
mern-mediastream/client/media/MediaPlayer.js
const onEnded = () => {
if(loop){
setPlaying(true)
} else{
setValues({...values, ended: true})
setPlaying(false)
}
}
因此,如果将loop值设置为true,当视频结束时,它将再次开始播放;否则,它将停止播放并渲染重播按钮。在下一节中,我们将添加设置视频音量的控件。
音量控制
为了控制正在播放的视频的音量,用户可以选择增加或降低音量,以及静音或取消静音。渲染的音量控件将根据用户操作和当前音量值进行更新。音量控件的不同状态如下:
- 如果音量提高,将渲染一个音量增加图标,如下所示截图:
- 如果用户将音量降低到零,将渲染一个音量关闭图标,如下所示:
- 如果用户点击图标以静音音量,将显示一个音量静音图标按钮,如下所示:
为了实现这一点,我们将根据volume、muted、volume_up和volume_off值有条件地渲染IconButton中的不同图标,如下所示代码:
<IconButton color="primary" onClick={toggleMuted}>
<Icon> {volume > 0 && !muted && 'volume_up' ||
muted && 'volume_off' ||
volume==0 && 'volume_mute'} </Icon>
</IconButton>
当点击此 IconButton 时,将通过调用toggleMuted方法来静音或取消静音音量,该方法定义如下:
mern-mediastream/client/media/MediaPlayer.js
const toggleMuted = () => {
setMuted(!muted)
}
根据状态中muted的当前值,音量将被静音或取消静音。为了允许用户增加或降低音量,我们将添加一个类型为range的输入元素,允许用户设置介于0和1之间的音量值。此输入元素将添加到代码中,如下所示:
mern-mediastream/client/media/MediaPlayer.js
<input type="range"
min={0}
max={1}
step='any'
value={muted? 0 : volume}
onChange={changeVolume}/>
在输入范围上更改value将通过调用changeVolume方法相应地设置状态中的volume值。此changeVolume方法定义如下:
mern-mediastream/client/media/MediaPlayer.js
const changeVolume = e => {
setVolume(parseFloat(e.target.value))
}
状态中volume值的变化将应用于ReactPlayer,这将设置当前播放媒体的音量。在下一节中,我们将添加控制正在播放的视频进度的选项。
进度控制
在媒体播放器中,用户将看到视频已加载和播放的部分,并在进度条中显示。为了实现此功能,我们将使用 Material-UI 的LinearProgress组件来指示视频已缓冲的部分以及已播放的部分。然后,我们将结合此组件与类型为range的输入元素,使用户能够将时间滑块移动到视频的不同部分并从那里播放。
这次时间滑块和进度条将渲染如下截图所示:
LinearProgress组件将使用状态中的played和loaded值来渲染这些条形。它将使用played和loaded值来显示不同的颜色,如下面的代码所示:
mern-mediastream/client/media/MediaPlayer.js
<LinearProgress color="primary" variant="buffer"
value={values.played*100} valueBuffer={values.loaded*100}
style={{width: '100%'}}
classes={{
colorPrimary: classes.primaryColor,
dashedColorPrimary : classes.primaryDashed,
dashed: classes.dashed
}}
/>
每个进度条的外观和颜色将由您为primaryColor、dashedColorPrimary和dashed类定义的样式决定。
为了在视频播放或加载时更新LinearProgress组件,我们将使用onProgress事件监听器来设置played和loaded的当前值。onProgress方法将定义如下所示:
mern-mediastream/client/media/MediaPlayer.js
const onProgress = progress => {
if (!seeking) {
setValues({...values, played: progress.played, loaded: progress.loaded})
}
}
我们只想在当前未搜索时更新时间滑块,因此我们首先在设置played和loaded值之前检查状态中的seeking值。
对于时间滑动控制,我们将添加范围输入元素并定义样式,如下面的代码所示,将其放置在LinearProgress组件之上。范围值将随着played值的变化而更新,因此范围值看起来会随着视频的进度而移动。这个代表时间滑块的输入元素将被添加到媒体播放器中,如下面的代码所示:
mern-mediastream/client/media/MediaPlayer.js
<input type="range" min={0} max={1}
value={values.played} step='any'
onMouseDown={onSeekMouseDown}
onChange={onSeekChange}
onMouseUp={onSeekMouseUp}
style={{ position: 'absolute',
width: '100%',
top: '-7px',
zIndex: '999',
'-webkit-appearance': 'none',
backgroundColor: 'rgba(0,0,0,0)' }}
/>
在用户自己拖动并设置范围选择器的情况下,我们将添加代码来处理onMouseDown、onMouseUp和onChange事件,以便从所需位置开始播放视频。
当用户按下鼠标开始拖动时,我们将seeking设置为true,这样就不会在played和loaded中设置进度值。这将通过定义如下所示的onSeekMouseDown方法来实现:
mern-mediastream/client/media/MediaPlayer.js
const onSeekMouseDown = e => {
setSeeking(true)
}
当范围值发生变化时,我们将调用onSeekChange方法来设置played值,并在检查用户是否将时间滑块拖动到视频末尾后设置ended值。此onSeekChange方法将定义如下:
mern-mediastream/client/media/MediaPlayer.js
const onSeekChange = e => {
setValues({...values, played:parseFloat(e.target.value),
ended: parseFloat(e.target.value) >= 1})
}
当用户完成拖动并抬起鼠标点击时,我们将seeking设置为false,并将媒体播放器的seekTo值设置为输入范围中当前设置的值。当用户完成搜索后,将执行onSeekMouseUp方法,其定义如下:
mern-mediastream/client/media/MediaPlayer.js
const onSeekMouseUp = e => {
setSeeking(false)
playerRef.seekTo(parseFloat(e.target.value))
}
这样,用户将能够选择视频的任何部分进行播放,并获得正在流式传输的视频的时间进度视觉信息。在下一节中,我们将添加一个控件,允许用户以全屏模式查看视频。
全屏
用户可以通过点击控制栏中的全屏按钮以全屏模式查看视频。播放器的全屏按钮将渲染如下截图所示:
为了为视频实现全屏选项,我们将使用screenfull节点模块来跟踪视图是否处于全屏状态,并使用react-dom中的findDOMNode来指定哪个文档对象模型(DOM)元素将通过screenfull实现全屏。
为了设置fullscreen代码,我们首先通过在命令行中运行以下命令来安装screenfull:
yarn add screenfull
然后,我们将导入screenfull和findDOMNode到MediaPlayer组件中,如下面的代码所示:
mern-mediastream/client/media/MediaPlayer.js
import screenfull from 'screenfull'
import { findDOMNode } from 'react-dom'
当MediaPlayer组件挂载时,我们将使用useEffect钩子添加一个screenfull更改事件监听器,该监听器将更新状态中的fullscreen值以指示屏幕是否处于全屏状态。useEffect钩子将添加如下,带有screenfull更改监听器代码:
mern-mediastream/client/media/MediaPlayer.js
useEffect(() => {
if (screenfull.enabled) {
screenfull.on('change', () => {
let fullscreen = screenfull.isFullscreen ? true : false
setFullscreen(fullscreen)
})
}
}, [])
在状态中设置的此fullscreen值将在用户与按钮交互以全屏模式渲染视频时更新。在视图中,我们将添加一个icon按钮用于fullscreen,与其他控制按钮一起,如下面的代码所示:
mern-mediastream/client/media/MediaPlayer.js
<IconButton color="primary" onClick={onClickFullscreen}>
<Icon>fullscreen</Icon>
</IconButton>
当用户点击此按钮时,我们将使用screenfull和findDOMNode通过调用定义如下所示的onClickFullscreen方法来使视频播放器全屏:
mern-mediastream/client/media/MediaPlayer.js
const onClickFullscreen = () => {
screenfull.request(findDOMNode(playerRef))
}
我们通过使用findDOMNode中的playerRef引用来访问浏览器中渲染媒体播放器的元素,并使用screenfull.request使其全屏。用户可以全屏观看视频,在任何时候按Esc键退出全屏并返回到PlayMedia视图。在下一节中,我们将实现媒体播放器控制中的最终定制,以显示视频的总长度以及已经播放的部分。
播放时长
在媒体播放器的自定义媒体控制部分,我们希望在可读的时间格式中显示已经过去的时间和视频的总时长,如下面的截图所示:
为了显示时间,我们可以利用 HTML 的time元素,它接受一个datetime值,并将其添加到MediaPlayer的视图代码中,如下所示:
mern-mediastream/client/media/MediaPlayer.js
<time dateTime={`P${Math.round(duration * played)}S`}>
{format(duration * played)}
</time> /
<time dateTime={`P${Math.round(duration)}S`}>
{format(duration)}
</time>
在这些time元素的dateTime属性中,我们提供了表示播放时长或视频总时长的总舍入秒数。我们将通过使用onDuration事件获取视频的总duration值,并将其设置到状态中,以便在time元素中渲染。onDuration方法定义如下:
mern-mediastream/client/media/MediaPlayer.js
const onDuration = (duration) => {
setDuration(duration)
}
为了使时长和已播放时间值可读,我们将使用以下 format 函数:
mern-mediastream/client/media/MediaPlayer.js
const format = (seconds) => {
const date = new Date(seconds * 1000)
const hh = date.getUTCHours()
let mm = date.getUTCMinutes()
const ss = ('0' + date.getUTCSeconds()).slice(-2)
if (hh) {
mm = ('0' + date.getUTCMinutes()).slice(-2)
return `${hh}:${mm}:${ss}`
}
return `${mm}:${ss}`
}
这个 format 函数将秒数转换为 hh/mm/ss 格式,使用 JavaScript 日期 API 中的方法。
添加到这个自定义媒体播放器的控件大多基于 ReactPlayer 模块中提供的一些可用功能及其官方文档中的示例。在为这个应用程序实现自定义媒体播放器时,我们更新并添加了相关的播放控件、循环选项、音量控件、进度搜索控件、全屏观看选项以及视频时长的显示。ReactPlayer 中还有更多选项可用于进一步的定制和扩展,具体取决于特定的功能需求。在实现了自定义媒体播放器的不同功能后,在下一节中,我们可以开始讨论如何从可用的媒体列表中实现播放器的自动播放视频。
自动播放相关媒体
在播放媒体页面,用户将可以选择从相关媒体列表中自动播放一个视频接一个视频。为了实现这一功能,PlayMedia 组件将管理自动播放状态,这将决定在当前视频在播放器中流式传输结束后,MediaPlayer 和 RelatedMedia 组件将如何渲染数据和数据。在接下来的章节中,我们将通过在 PlayMedia 组件中添加一个切换按钮并实现 handleAutoplay 方法来完成这个自动播放功能,该方法需要在 MediaPlayer 组件中视频结束时被调用。
切换自动播放
在播放媒体页面上,我们将在相关媒体列表上方添加一个自动播放切换选项。除了让用户设置自动播放外,切换按钮还将指示它是否当前已设置,如下面的截图所示:
为了添加自动播放切换选项,我们将使用 Material-UI 的 Switch 组件和 FormControlLabel,并将其添加到 PlayMedia 组件上方的 RelatedMedia 组件中。它只会在相关媒体列表中有媒体时渲染。我们将添加如下代码所示的表示自动播放切换的 Switch 组件:
mern-mediastream/client/media/PlayMedia.js
<FormControlLabel
control={
<Switch
checked={autoPlay}
onChange={handleChange}
color="primary"
/>
}
label={autoPlay ? 'Autoplay ON':'Autoplay OFF'}
/>
自动播放切换标签将根据状态中 autoPlay 的当前值进行渲染。为了处理用户与之交互时的切换变化,并在状态 autoPlay 值中反映这种变化,我们将使用以下 onChange 处理函数:
mern-mediastream/client/media/PlayMedia.js
const handleChange = (event) => {
setAutoPlay(event.target.checked)
}
这个autoPlay值表示用户是否选择了自动播放所有媒体,这将决定当前视频流结束时会发生什么。在下一节中,我们将讨论自动播放行为将如何根据用户设置的autoPlay切换值与PlayMedia中的子组件集成。
在组件间处理自动播放
当用户选择将自动播放切换设置为开启时,这里期望的功能是当视频结束时,如果autoPlay设置为true并且当前相关媒体列表不为空,PlayMedia应加载相关列表中第一个视频的媒体详情。
相应地,Media和MediaPlayer组件应该更新为新媒体详情,开始播放新视频,并适当地在播放器上渲染控件。RelatedMedia组件中的列表也应该更新,移除列表中的当前媒体,以便只显示剩余的播放列表项目。
为了处理PlayMedia组件及其子组件之间的自动播放行为,PlayMedia将一个handleAutoPlay方法作为属性传递给Media组件,以便在视频结束时由MediaPlayer组件使用。handleAutoPlay方法定义如下代码所示:
mern-mediastream/client/media/PlayMedia.js
const handleAutoplay = (updateMediaControls) => {
let playList = relatedMedia
let playMedia = playList[0]
if(!autoPlay || playList.length == 0 )
return updateMediaControls()
if(playList.length > 1){
playList.shift()
setMedia(playMedia)
setRelatedMedia(playList)
}else{
listRelated({
mediaId: playMedia._id}).then((data) => {
if (data.error) {
console.log(data.error)
} else {
setMedia(playMedia)
setRelatedMedia(data)
}
})
}
}
当MediaPlayer组件中的视频结束时,此handleAutoplay方法会处理以下情况:
-
它接收来自
MediaPlayer组件中onEnded事件监听器的回调函数。如果未设置自动播放或相关媒体列表为空,则此回调函数将被执行,以便在MediaPlayer上渲染控件以显示视频已结束。 -
如果设置了自动播放并且列表中有多个相关媒体,那么:
-
将相关媒体列表中的第一个项目设置为状态中的当前
media对象,以便可以渲染。 -
通过移除这个第一个项目来更新相关媒体列表,这个项目现在将在视图中开始播放。
-
-
如果设置了自动播放并且相关媒体列表中只有一个项目,则此最后一个项目被设置为
media以便开始播放,并且调用listRelated获取方法以重新填充RelatedMedia视图,包含此最后一个项目的相关媒体。
在handleAutoplay方法中完成这些步骤后,如果将自动播放设置为true,则可以在视频结束时相应地更新播放媒体页面的所有方面。在下一节中,我们将看到MediaPlayer组件如何在当前视频结束时利用此handleAutoplay方法,以便使自动播放功能生效。
在 MediaPlayer 中更新视频结束时状态
MediaPlayer组件从PlayMedia接收handleAutoplay方法作为属性。当当前视频在播放器中播放完毕时,将使用此方法。因此,我们将更新onEnded事件的监听器代码,仅在当前视频的loop设置为false时执行此方法。我们不希望播放下一个视频,如果用户决定循环当前视频。MediaPlayer中的onEnded方法将更新为以下代码块中显示的突出代码:
mern-mediastream/client/media/MediaPlayer.js
const onEnded = () => {
if(loop){
setPlaying(true)
} else{
props.handleAutoplay(()=>{
setValues({...values, ended: true})
setPlaying(false)
})
}
}
在此代码中,一个回调函数被传递给handleAutoplay方法,以便在PlayMedia确定自动播放未设置或相关媒体列表为空后,将playing值设置为false并渲染重播图标按钮,而不是播放或暂停图标按钮。
此实现将自动播放相关视频,一个接一个。这个实现演示了在组件之间值相互依赖时更新状态的另一种方式。
通过实现这个自动播放功能,我们拥有了一个完整的播放媒体页面,其中包括一个定制的媒体播放器和用户可以选择自动播放的媒体列表,就像播放列表一样。在下一节中,我们将通过在后端填充媒体数据来使用 SSR(服务器端渲染)使这个页面 SEO(搜索引擎优化)友好。
带数据的服务器端渲染
对于任何向用户交付内容并希望使内容易于查找的 Web 应用程序来说,SEO(搜索引擎优化)非常重要。通常,如果网页上的内容易于搜索引擎阅读,那么内容获得更多观众的机会会更大。当一个搜索引擎机器人访问一个 Web URL 时,它将获取 SSR 输出。因此,为了使内容可被发现,内容应该是 SSR 输出的一部分。
在 MERN Mediastream 中,我们将使用使媒体详情在搜索引擎结果中流行的案例,来演示如何在基于 MERN 的应用程序中将数据注入到 SSR 视图中。我们将专注于实现为在'/media/:mediaId'路径返回的PlayMedia组件注入数据的 SSR。这里概述的一般实现步骤可以用来实现其他视图的 SSR 和数据注入。
在接下来的几节中,我们将扩展在第四章中讨论的 SSR 实现,即添加 React 前端以完成 MERN。我们首先定义一个静态路由配置文件,并使用它来更新后端现有的 SSR 代码,以从数据库中注入必要的媒体数据。然后,我们将更新前端代码以在视图中渲染这些服务器注入的数据,最后检查这个 SSR 实现是否按预期工作。
添加路由配置文件
为了在服务器上渲染这些 React 视图时加载数据,我们需要在路由配置文件中列出前端路由。然后,这个文件可以与react-router-config模块一起使用,该模块为 React Router 提供静态路由配置助手。
我们将首先通过在命令行运行以下命令来安装该模块:
yarn add react-router-config
接下来,我们将创建一个路由配置文件,该文件将列出前端 React Router 路由。此配置将在服务器上用于将这些路由与传入的请求 URL 匹配,以检查在服务器返回针对此请求渲染的标记之前是否需要注入数据。
对于 MERN Mediastream 的路由配置,我们只列出渲染PlayMedia组件的路由,并演示如何使用从后端注入的数据来服务器端渲染特定组件。路由配置将定义如下:
mern-mediastream/client/routeConfig.js
import PlayMedia from './media/PlayMedia'
import { read } from './media/api-media.js'
const routes = [
{
path: '/media/:mediaId',
component: PlayMedia,
loadData: (params) => read(params)
}
]
export default routes
对于这个前端路由和PlayMedia组件,我们指定api-media.js中的read获取方法作为loadData方法。然后,可以使用它来检索并将数据注入到PlayMedia视图,当服务器生成此组件的标记后,在收到/media/:mediaId的请求时。在下文中,我们将使用此路由配置来更新后端现有的 SSR 代码。
更新 Express 服务器的 SSR 代码
我们将更新server/express.js中现有的基本 SSR 代码,以添加为将在服务器端渲染的 React 视图添加数据加载功能。在接下来的章节中,我们将首先了解如何使用路由配置来加载服务器渲染 React 组件时需要注入的数据。然后,我们将集成isomorphic-fetch,以便服务器能够使用来自前端相同的 API 获取代码进行read获取调用以检索必要的数据。最后,我们将将这些检索到的数据注入到服务器生成的标记中。
使用路由配置加载数据
当服务器收到任何请求时,我们将使用路由配置文件中定义的路由来查找匹配的路由。如果找到匹配项,我们将使用配置中为该路由声明的相应loadData方法来检索必要的数据,在将其注入到代表 React 前端的服务器端渲染的标记之前。我们将在名为loadBranchData的方法中执行这些路由匹配和数据加载操作,该方法定义如下:
mern-mediastream/server/express.js
import { matchRoutes } from 'react-router-config'
import routes from './../client/routeConfig'
const loadBranchData = (location) => {
const branch = matchRoutes(routes, location)
const promises = branch.map(({ route, match }) => {
return route.loadData
? route.loadData(branch[0].match.params)
: Promise.resolve(null)
})
return Promise.all(promises)
}
此方法使用matchRoutes从react-router-config,以及路由配置文件中定义的路由,来查找与传入请求 URL 匹配的路由,该 URL 作为location参数传递。如果找到匹配的路由,则将执行任何相关的loadData方法以返回包含获取数据的Promise,如果没有loadData方法,则返回null。这里定义的loadBranchData需要在服务器收到请求时调用,因此如果找到任何匹配的路由,我们可以在服务器端渲染时获取相关数据并将其注入到 React 组件中。在下文中,我们将确保前端代码中定义的 fetch 方法在服务器端也能正常工作,因此这些相同的方法也会从服务器端加载对应的数据。
Isomorphic-fetch
我们将通过使用isomorphic-fetch Node 模块来确保我们为客户端代码定义的任何 fetch 方法也可以在服务器上使用。我们首先通过从命令行运行以下命令来安装模块:
yarn add isomorphic-fetch
然后,我们只需在express.js中简单地导入isomorphic-fetch,如下所示,以确保 fetch 方法现在在客户端和服务器端都可以同构工作:
mern-mediastream/server/express.js
import 'isomorphic-fetch'
此isomorphic-fetch集成将确保read fetch 方法,或我们为客户端定义的任何其他 fetch 方法,现在也可以在服务器上使用。在集成成为功能之前,我们需要确保 fetch 方法使用绝对 URL,如下一节所述。
绝对 URL
使用isomorphic-fetch的一个问题是它目前需要 fetch URL 是绝对路径。因此,我们需要更新在api-media.js中定义的read fetch 方法中使用的 URL,将其更新为绝对路径。
在代码中而不是硬编码服务器地址,我们将在config.js中设置一个config变量,如下所示:
mern-mediastream/config/config.js
serverUrl: process.env.serverUrl || 'http://localhost:3000'
这将允许我们为开发环境和生产环境中的 API 路由定义和使用不同的绝对 URL。
然后,我们将更新api-media.js中的read方法,以确保它使用绝对 URL 调用服务器上的read API,如下所示:
mern-mediastream/client/media/api-media.js
import config from '../../config/config'
const read = (params) => {
return fetch(config.serverUrl +'/api/media/' + params.mediaId, {
method: 'GET'
}).then((response) => { ... })
这将使read fetch 调用与isomorphic-fetch兼容,因此可以在服务器端无问题地使用它来检索媒体数据,同时使用数据服务器端渲染PlayMedia组件。在下文中,我们将讨论如何将检索到的数据注入到表示已渲染 React 前端的服务器生成的标记中。
将数据注入到 React 应用中
在后端现有的 SSR 代码中,我们使用ReactDOMServer将 React 应用转换为标记。我们将更新express.js中的此代码,以将检索到的数据注入到MainRouter中,如下所示:
mern-mediastream/server/express.js
...
loadBranchData(req.url).then(data => {
const markup = ReactDOMServer.renderToString(
sheets.collect(
<StaticRouter location={req.url} context={context}>
<ThemeProvider theme={theme}>
<MainRouter data={data}/>
</ThemeProvider>
</StaticRouter>
)
)
...
}).catch(err => {
res.status(500).send({"error": "Could not load React view with data"})
})
...
我们使用loadBranchData方法检索请求视图的相关数据,然后将这些数据作为属性传递给MainRouter组件。为了在服务器生成标记时正确地将这些数据添加到渲染的PlayMedia组件中,我们需要更新客户端代码以考虑此服务器注入的数据,如下一节所述。
将服务器注入的数据应用于客户端代码
我们将更新前端中的 React 代码,以添加对可能从服务器注入的数据的考虑,如果视图正在服务器端渲染。对于这个 MERN Mediastream 应用程序,在客户端,我们将访问从服务器传递的媒体数据,并在服务器接收到直接请求以渲染此组件时将其添加到PlayMedia视图中。在接下来的章节中,我们将看到如何将MainRouter接收到的数据传递给PlayMedia组件,并相应地渲染它。
从 MainRouter 传递数据属性到 PlayMedia
在使用ReactDOMServer.renderToString生成标记时,我们将预加载数据作为属性传递给MainRouter。我们可以在MainRouter组件定义中访问此数据属性,如下所示:
mern-mediastream/client/MainRouter.js
const MainRouter = ({data}) => { ... }
为了让PlayMedia能够访问从MainRouter的这些数据,我们将更改最初添加的Route组件来声明PlayMedia的路由,并将此数据作为属性传递,如下所示:
mern-mediastream/client/MainRouter.js
<Route path="/media/:mediaId"
render={(props) => (
<PlayMedia {...props} data={data} />
)}
/>
发送到PlayMedia的数据属性需要在视图中渲染,如下一节所述。
在 PlayMedia 中渲染接收到的数据
在PlayMedia组件中,我们将检查从服务器传递的数据,并将值设置到状态中,以便在服务器生成相应的标记时,在视图中渲染媒体详情。我们将像以下代码所示进行此检查和分配:
mern-mediastream/client/media/PlayMedia.js
if (props.data && props.data[0] != null) {
media = props.data[0]
relatedMedia = []
}
如果从服务器接收到的媒体数据包含在 props 中,我们将它分配给状态中的media值。我们还将relatedMedia值设置为空数组,因为我们不打算在服务器生成的版本中渲染相关媒体列表。这种实现将在服务器直接接收到相应的前端路由请求时,在PlayMedia视图中注入媒体数据生成服务器生成的标记。在下一节中,我们将看到如何确保此实现实际上正在工作并且成功渲染了包含数据的服务器生成的标记。
检查使用数据的 SSR 实现
对于 MERN Mediastream,任何渲染 PlayMedia 的链接现在都应该在服务器端生成带有预加载媒体详情的标记。我们可以通过在浏览器中打开应用程序 URL 并关闭 JavaScript 来验证数据 SSR 实现是否正常工作。在接下来的章节中,我们将探讨如何在 Chrome 浏览器中实现此检查,以及最终视图应该向用户和搜索引擎展示什么内容。
Chrome 中的测试
在 Chrome 中测试此实现只需要更新 Chrome 设置并在标签页中加载应用程序,同时阻止 JavaScript。在接下来的章节中,我们将介绍检查 PlayMedia 视图是否仅使用服务器生成的标记来渲染数据的步骤。
启用 JavaScript 加载页面
首先,在 Chrome 中打开 MERN Mediastream 应用程序,然后浏览到任何媒体链接,并允许它在启用 JavaScript 的情况下正常渲染。这应该显示具有功能媒体播放器和相关媒体列表的已实现的 PlayMedia 视图。在执行下一步以禁用 Chrome 中的 JavaScript 之前,请保持此标签页打开。
从设置中禁用 JavaScript
要测试服务器生成的标记在视图中的渲染方式,我们需要在 Chrome 中禁用 JavaScript。为此,您可以前往 chrome://settings/content/javascript 的高级设置,并使用切换按钮来阻止 JavaScript,如图所示:
现在,在 MERN Mediastream 标签页中刷新媒体链接,地址旁边应该有一个图标,如图所示,表示 JavaScript 确实已被禁用:
在此阶段将在浏览器中显示的视图将仅渲染从后端接收到的服务器生成的标记。在下一节中,我们将讨论当浏览器中阻止 JavaScript 时预期的视图是什么。
阻止 JavaScript 的 PlayMedia 视图
当浏览器中阻止 JavaScript 时,PlayMedia 视图应仅渲染填充了媒体详情。但由于 JavaScript 被阻止,用户界面不再交互式,只有默认浏览器控件是可操作的,如图所示:
这是搜索引擎机器人将读取的媒体内容标记,也是当浏览器中没有加载 JavaScript 时用户将看到的内容。如果此数据 SSR 实现未添加到应用程序中,那么在此场景中此视图将不会渲染与相关媒体详情关联的视图,因此媒体信息将不会被搜索引擎读取和索引。
MERN Mediastream 现在拥有完全可操作的媒体播放工具,将使用户能够轻松浏览和播放视频。此外,由于 SSR 预加载数据,显示单个媒体内容项的媒体视图现在已针对搜索引擎优化。
摘要
在本章中,我们对 MERN Mediastream 的播放媒体页面进行了全面升级。我们首先添加了自定义媒体播放器控件,利用了ReactPlayer组件中可用的选项。然后,在从数据库中检索相关媒体后,我们为相关媒体播放列表集成了自动播放功能。最后,通过在服务器上渲染视图时从服务器注入数据,我们使媒体详细信息可由搜索引擎读取。
您可以将本章中探索的技术应用于构建播放媒体页面,使用相互依赖的 React 组件构建和组合您自己的复杂用户界面,以及为需要 SEO 友好的视图添加 SSR(服务器端渲染)和数据。
我们现在已经探索了 MERN 堆栈技术的先进功能,如流式传输和 SEO。在接下来的两个章节中,我们将通过将虚拟现实(VR)元素集成到使用 React 360 的全栈 Web 应用程序中,进一步测试这个堆栈的潜力。
第十七章:开发基于 Web 的 VR 游戏
虚拟现实(VR)和增强现实(AR)技术的出现正在改变用户与软件以及他们周围世界的互动方式。VR 和 AR 的可能应用数不胜数,尽管游戏行业是早期采用者,但这些快速发展的技术有潜力在多个学科和行业中改变范式。
为了展示 MERN 堆栈与 React 360 相结合如何轻松地为任何 Web 应用程序添加 VR 功能,我们将在本章和下一章中讨论和开发一个动态的、基于 Web 的 VR 游戏。在本章中,我们将专注于定义 VR 游戏的特点。此外,在开发使用 React 360 的游戏视图之前,我们将回顾与实现此 VR 游戏相关的关键 3D VR 概念。
在本章中,我们将通过以下主题构建 VR 游戏,使用 React 360:
-
介绍 MERN VR 游戏
-
开始使用 React 360
-
开发 3D VR 应用程序的关键概念
-
定义游戏详情
-
在 React 360 中构建游戏视图
-
将 React 360 代码打包以集成到 MERN 框架中
在了解这些主题之后,您将能够应用 3D VR 概念并使用 React 360 开始构建自己的基于 VR 的应用程序。
介绍 MERN VR 游戏
MERN VR 游戏 Web 应用程序将通过扩展 MERN 框架并使用 React 360 集成 VR 功能来开发。它将是一个动态的、基于 Web 的 VR 游戏应用程序,其中注册用户可以创建自己的游戏,任何访问该应用程序的访客都可以玩这些游戏。该应用程序的主页将列出平台上的游戏,如下面的截图所示:
使用 React 360 实现 VR 游戏功能的代码可在 GitHub 上找到:github.com/PacktPublishing/Full-Stack-React-Projects-Second-Edition/tree/master/Chapter13/MERNVR。您可以在阅读本章剩余部分的代码解释时克隆此代码并运行应用程序。
游戏的特点将足够简单,足以展示将 VR 引入基于 MERN 的应用程序的能力,而不会深入探讨可能用于实现更复杂 VR 功能的 React 360 的高级概念。在下一节中,我们将简要定义该应用程序中游戏的特点。
游戏特点
MERN VR 游戏应用程序中的每个游戏本质上都是一个不同的 VR 世界,用户可以与放置在 360 度全景世界中不同位置的 3D 对象进行交互。
游戏玩法将与寻宝游戏相似,为了完成每个游戏,用户必须找到并收集与每个游戏提示或描述相关的 3D 对象。这意味着游戏世界将包含一些可以被玩家收集的 VR 对象,以及一些不能被收集但可能被游戏制作者作为道具或提示放置的 VR 对象。最后,当所有相关的 3D 对象都被用户收集后,游戏即告胜利。
在本章中,我们将使用 React 360 构建这些游戏功能,主要关注与实现这里定义的功能相关的 VR 和 React 360 概念。一旦游戏功能准备就绪,我们将讨论如何将 React 360 代码打包并准备与第十四章(17bbfed7-9867-4c8b-99fd-42581044a906.xhtml)中开发的 MERN 应用程序代码集成。在深入实现游戏功能之前,我们首先将在下一节中查看设置和开始使用 React 360。
开始使用 React 360
React 360 使得使用与 React 中相同的声明式和组件化方法来构建 VR 体验成为可能。React 360 的底层技术利用了 Three.js JavaScript 3D 引擎,在任意兼容的 Web 浏览器中使用 WebGL 渲染 3D 图形,并且通过 Web VR API 提供了对 VR 头显的访问。
虽然 React 360 是基于 React 构建的,并且应用在浏览器中运行,但 React 360 与 React Native 有很多共同之处,这使得 React 360 应用成为跨平台应用。这也意味着 React Native 的一些概念也适用于 React 360。涵盖所有 React 360 概念超出了本书的范围;因此,我们将专注于构建游戏和与 MERN 栈 Web 应用程序集成的所需概念。在下一节中,我们将首先设置一个 React 360 项目,然后在章节的后续部分扩展以构建游戏功能。
设置 React 360 项目
React 360 提供了开发者工具,使得开始开发新的 React 360 项目变得简单。启动步骤在官方 React 360 文档中有详细说明,因此我们在这里只总结步骤,并指出与游戏开发相关的文件。
由于我们已为 MERN 应用程序安装了 Node 和 Yarn,我们可以通过在命令行中运行以下命令来开始安装 React 360 CLI 工具:
yarn global add react-360-cli
然后,使用这个 React 360 CLI 工具创建一个新的应用程序,并从命令行运行以下命令来安装所需的依赖项:
react-360 init MERNVR
这将在当前目录中创建一个名为 MERNVR 的文件夹,并将所有必要的文件添加到该文件夹中。最后,我们可以在命令行中进入这个文件夹,并使用以下命令运行应用程序:
yarn start
此start命令将初始化本地开发服务器,默认的 React 360 应用程序可以在浏览器中的http://localhost:8081/index.html查看。
要更新这个启动应用程序并实现我们的游戏功能,我们将主要修改index.js文件中的代码,并在client.js文件中进行一些小的更新,这些文件可以在MERNVR项目文件夹中找到。
由 React 360 生成的启动应用程序的默认index.js代码如下。请注意,它在一个 360 度的世界中渲染了“欢迎使用 React 360”文本:
import React from 'react'
import { AppRegistry, StyleSheet, Text, View } from 'react-360'
export default class MERNVR extends React.Component {
render() {
return (
<View style={styles.panel}>
<View style={styles.greetingBox}>
<Text style={styles.greeting}>
Welcome to React 360
</Text>
</View>
</View>
)
}
}
const styles = StyleSheet.create({
panel: {
// Fill the entire surface
width: 1000,
height: 600,
backgroundColor: 'rgba(255, 255, 255, 0.4)',
justifyContent: 'center',
alignItems: 'center',
},
greetingBox: {
padding: 20,
backgroundColor: '#000000',
borderColor: '#639dda',
borderWidth: 2,
},
greeting: {
fontSize: 30,
}
})
AppRegistry.registerComponent('MERNVR', () => MERNVR)
这个index.js文件包含了应用程序的内容和主要代码,包括视图和样式代码。client.js中的代码包含了将浏览器连接到index.js中 React 应用程序的样板代码。启动项目文件夹中的默认client.js文件应该看起来像这样:
import {ReactInstance} from 'react-360-web'
function init(bundle, parent, options = {}) {
const r360 = new ReactInstance(bundle, parent, {
// Add custom options here
fullScreen: true,
...options,
})
// Render your app content to the default cylinder surface
r360.renderToSurface(
r360.createRoot('MERNVR', { /* initial props */ }),
r360.getDefaultSurface()
)
// Load the initial environment
r360.compositor.setBackground(r360.getAssetURL('360_world.jpg'))
}
window.React360 = {init}
此代码执行index.js中定义的 React 代码,本质上创建了一个新的 React 360 实例,并通过将其附加到 DOM 来加载 React 代码。
这样,默认的 React 360 启动项目就设置好了,并准备好扩展。在修改此代码以实现游戏之前,在下一节中,我们将首先查看一些与开发 3D VR 体验相关的关键概念,以及这些概念如何与 React 360 结合应用。
开发 VR 游戏的关键概念
在创建游戏中的 VR 内容和交互式 360 度体验之前,我们将突出显示虚拟世界的相关方面,以及如何使用 React 360 与这些 VR 概念协同工作。鉴于 VR 空间中的广泛可能性以及 React 360 提供的各种选项,我们需要确定并探索特定的概念,这些概念将使我们能够实现为游戏定义的交互式 VR 功能。在接下来的章节中,我们将讨论构成游戏 360 度世界的图像,3D 定位系统,以及将用于实现游戏的 React 360 组件、API 和输入事件。
等经纬全景图像
游戏的 VR 世界将由一个全景图像组成,该图像作为背景图像添加到 React 360 环境中。
全景图像通常是 360 度图像或球形全景,这些图像被投影到一个完全围绕观众的球体上。360 度全景图像的一种常见且流行的格式是等经纬格式。React 360 目前支持等经纬图像的单色和立体格式。
要了解更多关于 React 360 中 360 度图像和视频支持的信息,请参阅 React 360 文档facebook.github.io/react-360/d…。
这里展示的照片是一个等经纬,360 度全景图像的例子。在 MERN VR 游戏中设置世界背景时,我们将使用这种图像:
等经线全景图像由一个宽高比为 2:1 的单一图像组成,其中宽度是高度的两倍。这些图像使用特殊的 360 度相机创建。Flickr 是等经线图像的一个优秀来源;你只需搜索 equirectangular 标签。
通过在 React 360 环境中使用等经线图像设置背景场景来创建游戏世界,将使 VR 体验更加沉浸式,并将用户带到虚拟位置。为了有效地在这个 VR 世界中添加 3D 对象并增强这种体验,我们需要了解与 3D 空间相关的布局和坐标系,这将在下文中讨论。
3D 位置 - 坐标和变换
为了在 VR 世界空间中放置 3D 对象并使 VR 体验更加真实,我们需要了解定位和方向。在接下来的章节中,我们将回顾 3D 坐标系,以帮助我们确定虚拟对象在 3D 空间中的位置,以及 React 360 中的变换功能,这将允许我们按要求定位、定向和缩放对象。
3D 坐标系
对于 3D 空间的映射,React 360 使用一个类似于 OpenGL® 3D 坐标系的基于米的三维坐标系统。这允许单个组件相对于其父组件的布局在 3D 中进行变换、移动或旋转。
React 360 中使用的 3D 坐标系是一个右手坐标系。这意味着正 x 轴在右侧,正 y 轴向上,正 z 轴向后。这提供了与世界空间中资产和 3D 世界建模的常见坐标系统更好的映射。
如果我们尝试可视化 3D 空间,用户将开始于以下图中 x-y-z 轴的中心:
z 轴指向用户前方,用户朝向 -z 轴方向望去。y 轴垂直上下,而 x 轴则从一侧到另一侧。图中弯曲的箭头显示了正旋转值的方向。
在决定在 360 度世界中放置 3D 对象的位置和方式时,我们必须根据这个 3D 坐标系设置值。在下一节中,我们将通过设置变换属性来演示如何使用 React 360 放置 3D 对象。
转换 3D 对象
3D 对象的位置和方向将由其变换属性确定,这些属性将具有与 3D 坐标系相对应的值。在以下屏幕截图中,通过更改渲染 3D 对象的 React 360 Entity 组件样式属性中的 transform 属性,将相同的 3D 书籍对象放置在两个不同的位置和方向:
此变换功能基于 React 中使用的变换样式,React 360 将其扩展为完全 3D,考虑到x-y-z轴。transform属性以键值对数组的形式添加到Entity组件的style属性中:
style={{ ...
transform: [
{TRANSFORM_COMMAND: TRANSFORM_VALUE},
...
]
... }}
与我们要放置在游戏中的 3D 对象相关的变换命令和值是translate [x, y, z],单位为米;rotate [x, y, z],单位为度;以及scale,用于确定对象在所有轴上的大小。我们还可以利用matrix命令,它接受一个包含 16 个数字的数组,代表平移、旋转和缩放值。
要了解更多关于 React 360 3D 坐标和变换的信息,请参阅 React 360 文档,网址为facebook.github.io/react-360/d…。
我们将利用这些变换属性来根据 3D 坐标系定位和定向 3D 对象,同时在用 React 360 构建的游戏世界中放置对象。在下一节中,我们将介绍 React 360 组件,这些组件将允许我们构建游戏世界。
React 360 组件
React 360 提供了一系列可以直接用于创建游戏 VR 用户界面的组件。这个范围包括从 React Native 可用的基本组件,以及允许你在 VR 游戏中集成交互式 3D 对象的 VR 特定组件。在接下来的几节中,我们将总结用于构建游戏视图和功能的特定组件,包括核心组件,如View和Text,以及 VR 特定组件,如Entity和VrButton。
核心组件
React 360 的核心组件包括 React Native 的两个内置组件——Text和View组件。在游戏中,我们将使用这两个组件向游戏世界添加内容。在接下来的几节中,我们将讨论这两个核心组件。
View
View组件是构建 React Native 用户界面的最基本组件,它直接映射到 React Native 在运行的平台上的原生视图等效物。在我们的案例中,由于应用程序将在浏览器中渲染,它将映射到浏览器的<div>元素。View组件可以添加如下:
<View>
<Text>Hello</Text>
</View>
View组件通常用作其他组件的容器;它可以嵌套在其他视图中,并且可以有零到多个任何类型的子组件。
我们将使用View组件来包含游戏世界视图,并添加 3D 对象实体和文本到游戏中。接下来,我们将查看Text组件,它将允许我们在视图中添加文本。
文本
Text组件是一个用于显示文本的 React Native 组件,我们将通过将Text组件放置在View组件内部来在 3D 空间中渲染字符串,如下面的代码所示:
<View>
<Text>Welcome to the MERN VR Game</Text>
</View>
我们将使用这两个 React Native 组件以及其他 React 360 组件来组合游戏世界,并将 VR 功能集成到游戏中。在下一节中,我们将介绍 React 360 组件,这些组件将允许我们在游戏世界中添加交互式 VR 对象。
3D VR 体验组件
React 360 提供了一套自己的组件来创建 VR 体验。具体来说,我们将使用Entity组件添加 3D 对象,并使用VrButton组件来捕获用户的点击。我们将在以下章节中讨论Entity和VrButton组件。
实体
为了将 3D 对象添加到游戏世界,我们将使用Entity组件,它允许我们在 React 360 中渲染 3D 对象。以下是在视图中添加Entity组件的方法:
<Entity
source={{
obj: {uri: "http://linktoOBJfile.obj "},
mtl: {uri: "http://linktoMTLfile.obj "}
}}
/>
包含特定 3D 对象信息的文件通过source属性添加到Entity组件中。source属性接受一个键值对对象,将资源文件类型映射到其位置。React 360 支持 Wavefront OBJ 文件格式,这是 3D 模型的常见表示。因此,在source属性中,Entity组件支持以下键:
-
obj:OBJ 格式模型的存储位置 -
mtl:MTL 格式材料(OBJ 的配套文件)的位置
obj和mtl属性的值指向这些文件的位置,可以是静态字符串、asset()调用、require()语句或 URI 字符串。
OBJ(或 .OBJ)是一种几何定义文件格式,最初由 Wavefront Technologies 开发。它是一种简单的数据格式,将 3D 几何形状表示为顶点和纹理顶点的列表。OBJ 坐标没有单位,但 OBJ 文件可以在可读的注释行中包含缩放信息。您可以在paulbourke.net/dataformats…了解更多关于此格式的信息。
MTL(或 .MTL)是包含一个或多个材料定义的材料库文件,每个定义都包括单个材料的颜色、纹理和反射图。这些应用于对象的表面和顶点。您可以在paulbourke.net/dataformats…了解更多关于此格式的信息。
Entity组件还接受style属性中的transform属性值,因此可以将对象放置在 3D 世界空间中期望的位置和方向。
在我们的 MERN VR 游戏应用中,制作者将为每个游戏中的Entity对象添加指向 VR 对象文件(.obj和.mtl)的 URL,并指定transform属性值,以指示 3D 对象在游戏世界中的放置位置和方式。
一个好的 3D 对象来源是clara.io/,提供多种文件格式可供下载和使用。
Entity 组件将在 3D 世界空间中渲染 3D 对象。为了使这些对象具有交互性,我们需要使用 VrButton 组件,这在下一节中将会讨论。
VrButton
React 360 中的 VrButton 组件将帮助我们为要添加到游戏中的对象和 Text 按钮实现简单的、按钮风格的 onClick 行为。默认情况下,VrButton 组件在视图中是不可见的,它仅作为事件捕获的包装器,但可以像 View 组件一样进行样式化,如下面的代码所示:
<VrButton onClick={this.clickHandler}>
<View>
<Text>Click me to make something happen!</Text>
</View>
</VrButton>
此组件是管理用户在不同输入设备上进行的点击类型交互的辅助工具。将触发点击事件的输入事件包括键盘上的空格键按下、鼠标的左键点击以及屏幕上的触摸。
React 360 的 Entity 和 VrButton 组件将使我们能够在游戏世界中渲染交互式 3D 对象。为了在游戏世界中集成其他 VR 功能,如设置背景场景和播放音频,我们将在下一节中探索 React 360 API 中的相关选项。
React 360 API
除了上一节中讨论的 React 360 组件外,我们还将利用 React 360 提供的 API 实现设置背景场景、播放音频、处理外部链接、添加样式、捕获用户当前视图方向以及使用静态资产文件等功能。在接下来的章节中,我们将探讨 Environment API、Audio 和 Location 原生模块、StyleSheet API、VrHeadModel 模块以及资产指定选项。
环境
在游戏中,我们将使用等角全景图像设置世界或背景场景。我们将使用 React 360 的 Environment API,通过其 setBackgroundImage 方法,在 React 代码中动态地更改此背景场景。此方法可以使用如下方式:
Environment.setBackgroundImage( {uri: 'http://linktopanoramaimage.jpg' } )
此方法使用指定 URL 的资源设置当前背景图像。当我们将 React 360 游戏代码与包含游戏应用后端的 MERN 栈集成时,我们可以使用此方法通过用户提供的图像链接动态设置游戏世界图像。在下一节中,我们将探讨允许我们在浏览器中播放此渲染场景中的音频以及提供浏览器位置访问权限的原生模块。
原生模块
React 360 中的原生模块为我们提供了访问仅在主浏览器环境中可用的功能的能力。在游戏中,我们将使用 NativeModules 中的 AudioModule 来响应用户活动播放声音,以及 Location 模块,以在浏览器中访问 window.location 来处理外部链接。这些模块可以在 index.js 中按如下方式访问:
import {
...
NativeModules
} from 'react-360'
const { AudioModule, Location } = NativeModules
我们可以在代码中使用这些导入的模块来操作浏览器中的音频和位置 URL。在接下来的章节中,我们将探讨这些模块如何被用来实现游戏的功能。
音频模块
当用户与游戏中的 3D 对象交互时,我们将根据对象是否可收集以及游戏是否已完成来播放声音。NativeModules中的AudioModule允许我们在 VR 世界中添加声音,作为背景环境音频、一次性音效和空间音频。在我们的游戏中,我们将使用环境音频和一次性音效:
- 环境音频:为了在游戏成功完成后循环播放音频并设置氛围,我们将使用
playEnvironmental方法,该方法将音频文件路径作为source属性,并将loop选项作为playback参数,如下面的代码所示:
AudioModule.playEnvironmental({
source: asset('happy-bot.mp3'),
loop: true
})
- 音效:当用户点击 3D 对象时,我们将使用
playOneShot方法播放单个声音,该方法将音频文件路径作为source属性,如下面的代码所示:
AudioModule.playOneShot({
source: asset('clog-up.mp3'),
})
传递给playEnvironmental和playOneShot的选项中的source属性接受一个资源文件位置来加载音频。它可以是asset()语句或形式为{uri: 'PATH'}的资源 URL 声明。
我们将在游戏实现代码中调用这些AudioModule方法,根据需要播放指定的音频文件。在下一节中,我们将探讨如何使用Location模块,这是 React 360 中的另一个原生模块。
位置
在我们将包含游戏的 React 360 代码与包含游戏应用程序后端的 MERN 堆栈集成后,VR 游戏将从声明路由中的 MERN 服务器启动,该路由包含特定游戏的 ID。然后,一旦用户完成游戏,他们也将有选择离开 VR 空间并前往包含其他游戏列表的 URL。为了在 React 360 代码中处理这些传入和传出的应用链接,我们将利用NativeModules中的Location模块。
Location模块实际上是浏览器中只读window.location属性返回的Location对象。我们将使用Location对象中的replace方法和search属性来实现与外部链接相关的功能。我们将如下处理传入和传出链接:
- 处理传出链接:当我们想要将用户从 VR 应用程序导向另一个链接时,我们可以使用
Location中的replace方法,如下面的代码所示:
Location.replace(url)
- 处理传入链接:当 React 360 应用从外部 URL 启动并在已注册的组件挂载后,我们可以通过
Location中的search属性访问 URL 并检索其查询字符串部分,如下面的代码所示。
componentDidMount = () => {
let queryString = Location.search
let gameId = queryString.split('?id=')[1]
}
为了将此 React 360 组件与 MERN VR 游戏(MERN VR Game)集成,并动态加载游戏详情,我们将捕获此初始 URL,从查询参数中解析游戏 ID,然后使用它向 MERN 应用程序服务器发起读取 API 调用。这种实现方式在 第十一章,使用 MERN 使 VR 游戏动态化 中有进一步的阐述。
除了使用 React 360 API 中的这些原生模块外,我们还将使用 StyleSheet API 为渲染的游戏视图添加样式。我们将在下一节中演示如何使用 StyleSheet API。
StyleSheet
React Native 的 StyleSheet API 也可以在 React 360 中使用,以便在单个位置定义多个样式,而不是向单个组件添加样式。样式可以使用 StyleSheet 定义,如下面的代码所示:
const styles = StyleSheet.create({
subView: {
width: 10,
borderColor: '#d6d7da',
},
text: {
fontSize: '1em',
fontWeight: 'bold',
}
})
使用 StyleSheet.create 定义的这些样式对象可以根据需要添加到组件中,如下面的代码所示:
<View style={styles.subView}>
<Text style={styles.text}>hello</Text>
</View>
这将根据需要将 CSS 样式应用于 View 和 Text 组件。
在 React 360 中将 CSS 属性(如宽度、高度)映射到 3D 空间时,默认的距离单位是米,而 React Native 中的 2D 接口的默认距离单位是像素。
我们将使用 StyleSheet 以这种方式为将构成游戏视图的组件定义样式。在下一节中,我们将讨论 React 360 中的 VrHeadModel 模块,它将使我们能够确定用户当前正在看哪里。
VrHeadModel
VrHeadModel 是 React 360 中的一个实用模块,它简化了获取头戴式设备当前方向的过程。由于用户在 VR 空间中移动,当需要将对象或文本放置在用户当前方向之前或相对于用户当前方向时,了解用户当前注视的确切位置变得至关重要。
在 MERN VR 游戏(MERN VR Game)中,我们将使用此功能向用户展示游戏完成的消息,无论他们从初始位置转向何处。例如,当用户在收集最终对象时可能向上或向下看,完成消息应出现在用户注视的任何位置。
为了实现这一点,我们将使用 VrHeadModel 中的 getHeadMatrix() 从数组中检索当前头矩阵,并将其设置为包含游戏完成消息的 View 组件样式属性中的 transform 属性的值。
这将在用户当前注视的位置渲染消息。我们将在本章的 在 React 360 中构建游戏视图 部分中看到 getHeadMatrix() 函数的使用。在下一节中,我们将讨论如何在 React 360 中加载静态资源。
加载资源
为了在代码中加载任何静态资产文件,例如图像或音频文件,我们可以利用 React 360 中的 asset 方法。React 360 中的 asset() 功能使我们能够检索外部资源文件,包括音频和图像文件。
例如,我们将游戏的声音音频文件放置在 static_assets 文件夹中,使用 asset() 来检索添加到游戏中的每个音频,如下面的代码所示:
AudioModule.playOneShot({
source: asset('collect.mp3'),
})
这将在调用 playOneShot 时加载要播放的音频文件。
在 React 360 中,我们有这些不同的 API 和模块可用,我们将集成不同的功能以用于 VR 游戏,例如设置背景场景、播放音频、添加样式、加载静态文件和检索用户方向。在下一节中,我们将查看 React 360 中的一些可用输入事件,这将使我们能够使游戏具有交互性。
React 360 输入事件
为了使游戏界面具有交互性,我们将利用 React 360 中公开的一些输入事件处理器。输入事件来自鼠标、键盘、触摸和游戏手柄交互,以及 VR 头盔上的 gaze 按钮点击。
我们将工作的特定输入事件是 onEnter、onExit 和 onClick 事件,如下表所述:
-
onEnter:每当平台光标开始与组件相交时,都会触发此事件。我们将捕获这个事件用于游戏中的 VR 对象,以便当平台光标进入特定对象时,对象可以开始围绕 y 轴旋转。 -
onExit:每当平台光标停止与组件相交时,都会触发此事件。它具有与onEnter事件相同的属性,我们将使用它来停止旋转刚刚退出的 VR 对象。 -
onClick:onClick事件与VrButton组件一起使用,当与VrButton进行点击交互时触发。我们将使用它来设置 VR 对象上的点击事件处理器,以及游戏完成消息,以便将用户从 VR 应用程序重定向到包含游戏列表的链接。
这些事件将允许我们在游戏中添加动作,即当用户进行某些操作时发生的动作。
在实现 VR 游戏时,我们将应用 3D 世界概念来确定如何使用等距圆盘全景图像设置游戏世界,并根据 3D 坐标系在这个世界中定位 VR 物体。我们将使用 React 360 组件,如 View、Text、Entity 和 VrButton 来渲染 VR 游戏视图。我们还可以使用可用的 React 360 API 在浏览器环境中加载音频和外部 URL,用于 VR 游戏。最后,我们可以利用可用的 React 360 事件来捕获用户交互,使 VR 游戏具有交互性。在本节中,我们讨论了与 VR 相关的概念、React 360 组件、API、模块和事件,我们准备在开始使用这些概念实现完整的 VR 游戏之前定义具体的游戏数据详情。在下一节中,我们将介绍游戏数据结构和详情。
定义游戏详情
在 MERN VR 游戏中,每一款游戏都将定义在一个通用的数据结构中,React 360 应用在渲染单个游戏详情时也将遵循此数据结构。在接下来的章节中,我们将讨论捕获游戏详情的数据结构,并突出使用静态游戏数据和动态加载游戏数据之间的区别。
游戏数据结构
游戏数据将包括游戏名称、指向游戏世界等距圆盘图像位置的 URL,以及包含要添加到游戏世界中的每个 VR 物体详情的两个数组。以下列表指出了对应游戏数据属性的字段:
-
name: 一个表示游戏名称的字符串 -
world: 一个字符串,包含指向等距圆盘图像的 URL,这些图像可以托管在云存储、CDNs 上,或者存储在 MongoDB 中 -
answerObjects: 一个包含玩家可收集的 VR 物体详情的 JavaScript 对象数组 -
wrongObjects: 一个包含其他 VR 物体详情的 JavaScript 对象数组,这些物体将被放置在 VR 世界中,但玩家无法收集
这些详情将定义 MERN VR 游戏应用程序中的每个游戏。包含 VR 物体详情的数组将存储要添加到游戏 3D 世界中的每个对象的属性。在以下章节中,我们将介绍表示这些数组中 VR 物体的详情。
VR 物体的详情
游戏数据结构中的两个数组将存储要添加到游戏世界中的 VR 物体的详情。answerObjects 数组将包含可收集的 3D 物体的详情,而 wrongObjects 数组将包含无法收集的 3D 物体的详情。每个对象将包含指向 3D 数据资源文件的链接和 transform 样式属性值。在以下列表中,我们将介绍每个对象需要存储的这些具体详情:
-
OBJ 和 MTL 链接: VR 物体的 3D 数据信息资源将被添加到
objUrl和mtlUrl属性中。这些属性将包含以下值:-
objUrl: 3D 对象的.obj文件的链接 -
mtlUrl: 到配套.mtl文件的链接
-
objUrl和mtlUrl链接可能指向存储在云存储、CDNs 或 MongoDB 上的文件。对于 MERN VR 游戏,我们将假设制作者将添加他们自己托管 OBJ、MTL 和等角图像文件的 URL。
-
Translation values: VR 对象在 3D 空间中的位置将通过以下属性中的
translate值来定义:-
translateX: 沿着x轴的对象平移值 -
translateY: 沿着y轴的对象平移值 -
translateZ: 沿着z轴的对象平移值
-
所有平移值都是米为单位的数字。
-
Rotation values: 3D 对象的方向将通过以下键中的
rotate值来定义:-
rotateX: 围绕x轴旋转对象的值;换句话说,向上或向下转动对象 -
rotateY: 围绕y轴旋转对象的值,这将使对象向左或向右转动 -
rotateZ: 围绕z轴旋转对象的值,使对象向前或向后倾斜
-
所有旋转值都是数字或数字的字符串表示形式。
-
Scale value:
scale值将定义 3D 对象在 3D 环境中的相对大小和外观:scale: 一个数值,用于定义所有轴上的统一缩放比例
-
颜色: 如果 3D 对象的材质纹理在 MTL 文件中没有提供,可以在
color属性中定义一个颜色值来设置对象的默认颜色:color: 表示 CSS 中允许的颜色值的字符串
这些属性将定义要添加到游戏中的每个 VR 对象的详细信息。
使用这种能够存储游戏及其 VR 对象详细信息的游戏数据结构,我们可以根据示例数据值相应地在 React 360 中实现游戏。在下一节中,我们将查看示例游戏数据,并区分静态设置游戏数据与为不同游戏动态加载数据。
静态数据与动态数据
在下一章中,当将使用 React 360 开发的游戏与基于 MERN 的应用程序集成时,我们将更新 React 360 代码以从后端数据库动态获取游戏数据。这将渲染存储在数据库中的不同游戏的 React 360 游戏视图。目前,我们将在这里使用设置在组件状态中的虚拟游戏数据来开发游戏功能。示例游戏数据将按以下方式设置,使用定义的游戏数据结构:
game: {
name: 'Space Exploration',
world: 'https://s3.amazonaws.com/mernbook/vrGame/milkyway.jpg',
answerObjects: [
{
objUrl: 'https://s3.amazonaws.com/mernbook/vrGame/planet.obj',
mtlUrl: 'https://s3.amazonaws.com/mernbook/vrGame/planet.mtl',
translateX: -50,
translateY: 0,
translateZ: 30,
rotateX: 0,
rotateY: 0,
rotateZ: 0,
scale: 7,
color: 'white'
}
],
wrongObjects: [
{
objUrl: 'https://s3.amazonaws.com/mernbook/vrGame/tardis.obj',
mtlUrl: 'https://s3.amazonaws.com/mernbook/vrGame/tardis.mtl',
translateX: 0,
translateY: 0,
translateZ: 90,
rotateX: 0,
rotateY: 20,
rotateZ: 0,
scale: 1,
color: 'white'
}
]
}
此游戏对象包含一个示例游戏的详细信息,包括名称、到 360 世界图像的链接以及包含每个数组中一个详细 3D 对象的两个对象数组。出于初始开发目的,此示例游戏数据可以设置在状态中,以便在游戏视图中渲染。使用此游戏结构和数据,在下一节中,我们将实现 React 360 中的游戏功能。
在 React 360 中构建游戏视图
我们将应用 React 360 的概念,并使用游戏数据结构来实现 MERN VR 游戏应用中每个游戏的特性。对于这些实现,我们将更新在初始化的 React 360 项目中生成的index.js和client.js文件中的默认启动代码。
对于一个可工作的游戏版本,我们将从使用上一节中的示例游戏数据初始化的MERNVR组件的状态开始。
MERNVR组件在index.js中定义,代码将使用上一节中的示例游戏数据初始化的状态进行更新,如下面的代码所示:
/MERNVR/index.js
export default class MERNVR extends React.Component {
constructor() {
super()
this.state = {
game: sampleGameData
...
}
}
...
}
这将使示例游戏的详细信息可用于构建其余的游戏功能。在接下来的几节中,我们将更新index.js和client.js文件中的代码,首先挂载游戏世界,定义 CSS 样式,并为游戏加载 360 度环境。然后,我们将向游戏中添加 3D VR 对象,使这些对象具有交互性,并实现表示游戏完成的操作。
更新 client.js 并将其挂载到位置
client.js中的默认代码将挂载在index.js中声明的挂载点到 React 360 应用中的默认Surface上,其中Surface是一个用于放置 2D 用户界面的圆柱层。为了在 3D 空间中进行布局,我们需要挂载到一个Location对象上而不是Surface。因此,我们将更新client.js以将renderToSurface替换为renderToLocation,如下面的代码所示:
/MERNVR/client.js
r360.renderToLocation(
r360.createRoot('MERNVR', { /* initial props */ }),
r360.getDefaultLocation()
)
这将使我们的游戏视图挂载到 React 360 的Location上。
您还可以通过更新client.js中的r360.compositor.setBackground(**r360.getAssetURL('360_world.jpg')**)代码来自定义初始背景场景,以使用您希望使用的图像。
在client.js中添加此更新后,我们可以继续更新index.js中的代码,其中将包含我们的游戏功能。在下一节中,我们将首先定义游戏视图中要渲染的元素的 CSS 样式。
使用 StyleSheet 定义样式
在index.js中,我们将更新初始 React 360 项目中生成的默认样式,以添加我们自己的 CSS 规则。在StyleSheet.create调用中,我们将定义用于游戏组件的样式对象,如下面的代码所示:
/MERNVR/index.js
const styles = StyleSheet.create({
completeMessage: {
margin: 0.1,
height: 1.5,
backgroundColor: 'green',
transform: [ {translate: [0, 0, -5] } ]
},
congratsText: {
fontSize: 0.5,
textAlign: 'center',
marginTop: 0.2
},
collectedText: {
fontSize: 0.2,
textAlign: 'center'
},
button: {
margin: 0.1,
height: 0.5,
backgroundColor: 'blue',
transform: [ { translate: [0, 0, -5] } ]
},
buttonText: {
fontSize: 0.3,
textAlign: 'center'
}
})
对于本书中实现的游戏功能,我们使用 CSS 仅对显示在游戏完成时的文本和按钮进行简单样式化。在下一节中,我们将探讨如何加载代表每个游戏 3D 世界的 360 度全景图像。
世界背景
为了设置游戏的 360 度世界背景,我们将使用Environment API 中的setBackgroundImage方法更新当前的背景场景。我们将在index.js中定义的MERNVR组件的componentDidMount内部调用此方法,如下面的代码所示:
/MERNVR/index.js
componentDidMount = () => {
Environment.setBackgroundImage(
{uri: this.state.game.world}
)
}
这将用我们从云存储中获取的示例游戏世界图像替换入门级 React 360 项目中的默认 360 度背景。如果你正在编辑默认的 React 360 应用程序并且正在运行它,在浏览器中刷新http://localhost:8081/index.html链接应该会显示外太空背景,你可以使用鼠标进行平移:
为了生成前面的截图,默认代码中的View和Text组件也被更新为自定义 CSS 规则,以在屏幕上显示此文本。
这样,我们将拥有一个用户可以探索的 360 度游戏世界。在下一节中,我们将探讨如何在这个世界中放置 3D 对象。
添加 3D VR 对象
我们将使用 React 360 的Entity组件将 3D 对象添加到游戏世界中,以及为游戏定义的answerObjects和wrongObjects数组中的示例对象详情。
首先,我们将在componentDidMount中将answerObjects和wrongObjects数组连接起来,形成一个包含所有 VR 对象的单一数组,如下面的代码所示:
/MERNVR/index.js
componentDidMount = () => {
let vrObjects = this.state.game.answerObjects.concat(this.state.game.wrongObjects)
this.setState({vrObjects: vrObjects})
...
}
这将给我们一个包含游戏所有 VR 对象的单一数组。然后,在主视图中,我们将遍历这个合并的vrObjects数组来渲染具有每个对象详情的Entity组件。迭代代码将通过map添加,如下面的代码所示:
/MERNVR/index.js
{this.state.vrObjects.map((vrObject, i) => {
return (
<Entity key={i} style={this.setModelStyles(vrObject, i)}
source={{
obj: {uri: vrObject.objUrl},
mtl: {uri: vrObject.mtlUrl}
}}
/>
)
})
}
obj和mtl文件链接被添加到Entity的source属性中,transform样式的详细信息通过调用setModelStyles应用于Entity组件的样式。setModelStyles方法使用在 VR 对象详情中定义的值构建要渲染的特定 VR 对象的样式。
setModelStyles方法实现如下:
/MERNVR/index.js
setModelStyles = (vrObject, index) => {
return {
display: this.state.collectedList[index] ? 'none' : 'flex',
color: vrObject.color,
transform: [
{
translateX: vrObject.translateX
}, {
translateY: vrObject.translateY
}, {
translateZ: vrObject.translateZ
}, {
scale: vrObject.scale
}, {
rotateY: vrObject.rotateY
}, {
rotateX: vrObject.rotateX
}, {
rotateZ: vrObject.rotateZ
}
]
}
}
display属性将允许我们根据对象是否已经被玩家收集来显示或隐藏对象。translate和rotate值将在 VR 世界中渲染 3D 对象到期望的位置和方向。接下来,我们将进一步更新Entity代码以启用用户与这些 3D 对象的交互。
与 VR 对象交互
为了使 VR 游戏对象具有交互性,我们将使用 React 360 事件处理程序,如与Entity一起使用的onEnter和onExit,以及与VrButton一起使用的onClick,以添加旋转动画和游戏行为。在接下来的章节中,我们将添加在用户聚焦于 VR 对象时旋转 VR 对象的实现,以及为对象添加点击行为,以便用户在游戏中收集正确的对象。
旋转 VR 对象
我们希望添加一个功能,即当玩家聚焦于 3D 对象时,开始围绕其y轴旋转 3D 对象,即当平台光标开始与渲染特定 3D 对象的Entity组件相交时。
我们将更新上一节中的Entity组件,添加onEnter和onExit处理程序,如下面的代码所示:
/MERNVR/index.js
<Entity
...
onEnter={this.rotate(i)}
onExit={this.stopRotate}
/>
使用此Entity组件渲染的对象将在光标进入或聚焦于对象时开始旋转,并在平台光标退出对象且不再在玩家焦点中时停止。在下一节中,我们将讨论此旋转动画的实现。
使用requestAnimationFrame进行动画
每个 3D 对象的旋转行为是在添加到渲染 3D 对象的Entity组件的事件处理程序中实现的。具体来说,在onEnter和onExit事件发生时调用的rotate(index)和stopRotate()处理方法中,我们将使用requestAnimationFrame实现旋转动画行为,以在浏览器中实现平滑动画。
window.requestAnimationFrame()方法要求浏览器在下次重绘之前调用指定的回调函数来更新动画。使用requestAnimationFrame,浏览器优化动画以使其更平滑且更高效。
使用rotate方法,我们将使用requestAnimationFrame在设定的时间间隔内以恒定速率更新给定对象的rotateY变换值,如下面的代码所示:
/MERNVR/index.js
this.lastUpdate = Date.now()
rotate = index => event => {
const now = Date.now()
const diff = now - this.lastUpdate
const vrObjects = this.state.vrObjects
vrObjects[index].rotateY = vrObjects[index].rotateY + diff / 200
this.lastUpdate = now
this.setState({vrObjects: vrObjects})
this.requestID = requestAnimationFrame(this.rotate(index))
}
requestAnimationFrame方法将rotate方法作为递归回调函数,然后执行它以使用新值重绘旋转动画的每一帧,并相应地更新屏幕上的动画。
requestAnimationFrame方法返回一个requestID,我们将使用它在stopRotate调用中,以便在stopRotate方法中取消动画。此stopRotate方法定义如下:
/MERNVR/index.js
stopRotate = () => {
if (this.requestID) {
cancelAnimationFrame(this.requestID)
this.requestID = null
}
}
这将实现仅在 3D 对象处于观看者焦点时才对其动画化的功能。如图所示,当 3D 魔方处于焦点时,它围绕其y轴顺时针旋转:
虽然这里没有涉及,但探索 React 360 Animated 库也是值得的,它可以用来组合不同类型的动画。核心组件可以使用这个库进行原生动画,并且可以使用createAnimatedComponent()方法来动画化其他组件。这个库最初是从 React Native 实现的;要了解更多信息,您可以参考 React Native 文档。
现在玩游戏的用户将观察到,当他们聚焦于游戏世界中放置的任何 VR 对象时,会有运动效果。在下一节中,我们将添加捕捉用户点击这些对象的功能。
点击 3D 对象
为了在游戏中添加的每个 3D 对象上注册点击行为,我们需要将Entity组件包裹在一个可以调用onClick处理器的VrButton组件中。
我们将更新vrObjects数组迭代代码中添加的Entity组件,将其包裹在VrButton组件中,如下面的代码所示:
/MERNVR/index.js
<VrButton onClick={this.collectItem(vrObject)} key={i}>
<Entity … />
</VrButton>
当点击VrButton组件时,它将调用collectItem方法,并传递当前对象的详细信息。
当用户点击一个 3D 对象时,我们需要collectItem方法根据游戏功能执行以下操作:
-
检查点击的对象是
answerObject还是wrongObject。 -
根据对象类型,播放相关的声音。
-
如果对象是
answerObject,则应将其收集并从视图中移除,然后添加到收集对象列表中。 -
检查是否通过这次点击成功收集了所有
answerObject实例:- 如果是,向玩家显示游戏完成消息并播放游戏完成的声音。
我们将在collectItem方法中使用以下结构和步骤来实现这些动作:
collectItem = vrObject => event => {
if (vrObject is an answerObject) {
... update collected list ...
... play sound for correct object collected ...
if (all answer objects collected) {
... show game completed message in front of user ...
... play sound for game completed ...
}
} else {
... play sound for wrong object clicked ...
}
}
任何时间用户点击 VR 对象,在这个方法中,我们首先检查对象的类型,然后再采取相关的行动。我们将在下一节详细讨论这些步骤和动作的实现。
在点击时收集正确的对象
当用户点击一个 3D 对象时,我们首先需要检查点击的对象是否是答案对象。如果是,这个对象将被收集并从视图中隐藏,同时更新收集对象列表以及总数以跟踪用户在游戏中的进度。
为了检查点击的 VR 对象是否是answerObject,我们将使用indexOf方法在answerObjects数组中找到一个匹配项,如下面的代码所示:
let match = this.state.game.answerObjects.indexOf(vrObject)
如果vrObject是answerObject,indexOf将返回匹配对象的数组索引;如果没有找到匹配项,它将返回-1。
为了跟踪游戏中的收集对象,我们还将维护一个布尔值数组collectedList在相应的索引处,以及到目前为止收集的对象总数collectedNum,如下面的代码所示:
let updateCollectedList = this.state.collectedList
let updateCollectedNum = this.state.collectedNum + 1
updateCollectedList[match] = true
this.setState({collectedList: updateCollectedList,
collectedNum: updateCollectedNum})
使用 collectedList 数组,我们还将确定哪个 Entity 组件应该从视图中隐藏,因为相关的对象已被收集。相关的 Entity 组件的 display 样式属性将根据 collectedList 数组中相应索引的布尔值设置。我们使用前面在 添加 3D VR 对象 部分中提到的 setModelStyles 方法设置此样式。此显示样式值使用以下代码条件性地设置:
display: this.state.collectedList[index] ? 'none' : 'flex'
根据渲染的 VR 对象的数组索引是否在对象的收集列表中设置为 true,我们将隐藏或显示视图中的 Entity 组件。
例如,在下面的屏幕截图中,宝箱可以被点击并收集,因为它是一个 answerObject,而花盆不能被收集,因为它是一个 wrongObject:
当点击宝箱时,由于 collectedList 被更新,它将从视图中消失,并且我们还会使用 AudioModule.playOneShot 以以下代码播放收集音效:
AudioModule.playOneShot({
source: asset('collect.mp3'),
})
然而,当点击花盆并且它被识别为错误对象时,我们将播放另一个音效,表明它不能被收集,如下面的代码所示:
AudioModule.playOneShot({
source: asset('clog-up.mp3'),
})
由于花盆被识别为错误对象,collectedList 没有被更新,它仍然在屏幕上,而宝箱已经消失,如下面的屏幕截图所示:
当对象被点击时,执行所有这些步骤的 collectItem 方法中的完整代码如下。
/MERNVR/index.js:
collectItem = vrObject => event => {
let match = this.state.game.answerObjects.indexOf(vrObject)
if (match != -1) {
let updateCollectedList = this.state.collectedList
let updateCollectedNum = this.state.collectedNum + 1
updateCollectedList[match] = true
this.checkGameCompleteStatus(updateCollectedNum)
AudioModule.playOneShot({
source: asset('collect.mp3'),
})
this.setState({collectedList: updateCollectedList,
collectedNum: updateCollectedNum})
} else {
AudioModule.playOneShot({
source: asset('clog-up.mp3'),
})
}
}
使用此方法收集到点击的对象后,我们还将通过调用 checkGameCompleteStatus 方法来检查是否所有 answerObjects 都已被收集,以及游戏是否完成。我们将在下一节中查看此方法的实现和游戏完成功能。
游戏完成状态
每次收集到 answerObject 时,我们将检查收集到的项目总数是否等于 answerObjects 数组中的对象总数,以确定游戏是否完成。我们将通过调用 checkGameCompleteStatus 方法来实现这一点,如下面的代码所示:
/MERNVR/index.js
checkGameCompleteStatus = (collectedTotal) => {
if (collectedTotal == this.state.game.answerObjects.length) {
AudioModule.playEnvironmental({
source: asset('happy-bot.mp3'),
loop: true
})
this.setState({hide: 'flex', hmMatrix: VrHeadModel.getHeadMatrix()})
}
}
在此方法中,我们首先确认游戏确实已完成,然后执行以下操作:
-
使用
AudioModule.playEnvironmental播放游戏完成的音频。 -
使用
VrHeadModel获取当前的headMatrix值,以便将其设置为包含游戏完成信息的View组件的变换矩阵值。 -
将
View消息的display样式属性设置为flex,以便消息渲染给观众。
将包含向玩家表示祝贺完成游戏的View组件添加到父View组件中,如下所示:
/MERNVR/index.js
<View style={this.setGameCompletedStyle}>
<View style={this.styles.completeMessage}>
<Text style={this.styles.congratsText}>Congratulations!</Text>
<Text style={this.styles.collectedText}>
You have collected all items in {this.state.game.name}
</Text>
</View>
<VrButton onClick={this.exitGame}>
<View style={this.styles.button}>
<Text style={this.styles.buttonText}>Play another game</Text>
</View>
</VrButton>
</View>
对setGameCompletedStyle()方法的调用将设置View消息的样式,包括更新的display值和transform矩阵值。setGameCompletedStyle方法定义如下:
/MERNVR/index.js
setGameCompletedStyle = () => {
return {
position: 'absolute',
display: this.state.hide,
layoutOrigin: [0.5, 0.5],
width: 6,
transform: [{translate: [0, 0, 0]}, {matrix: this.state.hmMatrix}]
}
}
这些样式值将使View组件在用户当前视图的中心显示完成消息,无论他们是在 360 度 VR 世界的向上、向下、向后还是向前看,如下面的截图所示:
View消息中的最终文本将充当按钮,因为我们把这个View包裹在一个调用exitGame方法的VrButton组件中。exitGame方法定义如下:
/MERNVR/index.js
exitGame = () => {
Location.replace('/')
}
exitGame方法将使用Location.replace方法将用户重定向到可能包含游戏列表的外部 URL。
replace方法可以传递任何有效的 URL,一旦这个 React 360 游戏代码与第十四章中基于 MERN 的 VR 游戏应用程序集成,replace('/')将用户带到整个应用程序的主页。
VR 游戏功能已经通过这些对 React 360 项目的更新而完整。现在可以设置 360 度全景背景作为游戏世界,并向这个世界添加交互式 VR 对象。如果游戏规则允许,这些 3D 对象将原地旋转,并且可以根据用户交互进行收集。在下一节中,我们将演示如何打包这个 React 360 代码,以便游戏可以与基于 MERN 的 Web 应用程序集成。
生产打包和与 MERN 集成
现在我们已经实现了 VR 游戏的功能,并且与示例游戏数据一起是功能性的,我们可以为生产做准备,并将其添加到我们的 MERN 应用程序中,以查看如何将 VR 添加到现有的 Web 应用程序中。在接下来的几节中,我们将探讨如何打包 React 360 代码,将其与 MERN 应用程序集成,并通过从应用程序中运行游戏来测试集成。
打包 React 360 文件
React 360 工具提供了一个脚本来将所有 React 360 应用程序代码打包成几个文件,我们可以将这些文件直接放置在 MERN Web 服务器上,并在指定的路由上作为内容提供。要创建打包文件,我们可以在 React 360 项目目录中运行以下命令:
yarn bundle
这将在名为 build 的文件夹中生成 React 360 应用程序文件的编译版本。编译后的打包文件是 client.bundle.js 和 index.bundle.js。这两个文件,加上 index.html 文件和 static-assets/ 文件夹,构成了开发的整个 React 360 应用程序的生产版本。最终的生产代码将在以下文件夹和文件中:
-- static_assets/
-- index.html
-- index.bundle.js
-- client.bundle.js
我们必须将这些文件夹和文件移动到 MERN 项目目录中,以便将游戏与 MERN 应用程序集成,如下一节所述。
与 MERN 应用程序集成
为了将用 React 360 开发的游戏与基于 MERN 的 Web 应用程序集成,我们首先将上一节中讨论的 React 360 生产文件引入我们的 MERN 应用程序项目中。然后,我们将更新生成的 index.html 代码中的打包文件引用,使其指向打包文件的新位置,在 Express 应用程序中指定路由加载 index.html 代码之前。
添加 React 360 生产文件
考虑到现有 MERN 骨架应用程序的文件夹结构,我们将添加 static_assets 文件夹并将 React 360 生产文件中的打包文件添加到 dist/ 文件夹中。这将使我们的 MERN 代码保持有序,所有打包文件都在同一位置。index.html 文件将被放置在 server 文件夹下的一个新文件夹中,命名为 vr,如下面的文件夹结构所示:
-- ...
-- client/
-- dist/
--- static_assets/
--- ...
--- client.bundle.js
--- index.bundle.js
-- ...
-- server/
--- ...
--- vr/
---- index.html
-- ...
这将把 React 360 代码带到 MERN 应用程序中。然而,为了使其功能正常,我们需要更新 index.html 代码中的文件引用,如下一节所述。
更新 index.html 中的引用
在打包 React 360 项目后生成的 index.html 文件引用了打包文件,期望这些文件在同一个文件夹中,如下面的代码所示:
<html>
<head>
<title>MERNVR</title>
<style>body { margin: 0 }</style>
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
</head>
<body>
<!-- Attachment point for your app -->
<div id="container"></div>
<script src="img/client.bundle.js"></script>
<script>
// Initialize the React 360 application
React360.init(
'index.bundle.js',
document.getElementById('container'),
{
assetRoot: 'static_assets/',
}
)
</script>
</body>
</html>
我们需要更新这个 index.html 代码,使其引用 client.bundle.js、index.bundle.js 和 static_assets 文件夹的正确位置。
首先,按照以下方式更新对 client.bundle.js 的引用,以指向我们放置在 dist 文件夹中的文件:
<script src="img/strong>" type="text/javascript"></script>
然后,更新 React360.init 调用,使用正确的引用 index.bundle.js,并将 assetRoot 设置为 static_assets 文件夹的正确位置,如下面的代码所示:
React360.init(
'./../dist/index.bundle.js',
document.getElementById('container'),
{ assetRoot: '/dist/static_assets/' }
)
assetRoot 指定了在用 asset() 在 React 360 组件中设置资源时查找资源文件的位置。
使用 React 360 实现的游戏视图现在可在 MERN 应用程序中使用。在下一节中,我们将通过设置一个路由从 Web 应用程序加载游戏来尝试这种集成。
尝试集成
如果我们在 MERN 应用程序中设置一个 Express 路由以在响应中返回 index.html 文件,那么在浏览器中访问该路由将渲染 React 360 游戏。为了测试这种集成,我们可以设置一个示例路由,如下所示:
router.route('/game/play')
.get((req, res) => {
res.sendFile(process.cwd()+'/server/vr/index.html')
})
这在'/game/play'路径中声明了一个GET路由,它将简单地返回我们放置在vr文件夹中的与服务器代码一起的index.html文件,作为对请求客户端的响应。
然后,我们可以运行 MERN 服务器,并在浏览器中的localhost:3000/game/play打开此路由。这应该会在基于 MERN 的 Web 应用内部渲染本章实现的完整的 React 360 游戏。
摘要
在本章中,我们使用 React 360 开发了一个可以轻松集成到 MERN 应用的基于 Web 的 VR 游戏。
我们首先为游戏玩法定义了简单的 VR 功能。然后,我们为开发设置了 React 360,并研究了关键的 VR 概念,例如等角全景图像、3D 位置和 360 度 VR 世界的坐标系。我们探索了实现游戏功能所需的 React 360 组件和 API,包括View、Text、Entity和VrButton组件,以及Environment、VrHeadModel和NativeModules API。
最后,我们更新了入门级 React 360 项目的代码,以使用示例游戏数据实现游戏,然后打包了代码文件,并讨论了如何将这些编译后的文件添加到现有的 MERN 应用中。
完成这些步骤后,你现在将能够使用 React 360 构建自己的 VR 界面,这些界面可以轻松地集成到任何基于 MERN 的 Web 应用中。
在下一章中,我们将开发一个 MERN VR 游戏应用,包括游戏数据库和后端 API。这样我们就可以通过从存储在 MongoDB 中的游戏收藏中获取数据,使本章开发的游戏变得动态。
第十八章:使用 MERN 使 VR 游戏动态化
在本章中,我们将扩展 MongoDB、Express.js、React.js、和 Node.js(MERN)骨架应用程序以构建 MERN VR Game 应用程序,并使用它将上一章开发的静态 React 360 游戏转换为动态游戏。我们将通过用从数据库直接获取的游戏详情替换示例游戏数据来实现这一点。我们将使用 MERN 栈技术在后端实现游戏模型和 创建、读取、更新和删除(CRUD)应用程序编程接口(APIs),这将允许存储和检索游戏,以及前端视图,这将允许用户在浏览器中创建自己的游戏,同时还可以玩平台上任何游戏。我们将更新并将使用 React 360 开发的游戏集成到使用 MERN 技术开发的游戏平台中。完成这些实现和集成后,您将能够设计和构建具有动态 VR 功能的全栈 Web 应用程序。
为了使 MERN VR Game 成为完整且动态的游戏应用程序,我们将实现以下功能:
-
用于在 MongoDB 中存储游戏详情的游戏模型模式
-
游戏 CRUD 操作的 API
-
用于创建、编辑、列出和删除游戏的 React 视图
-
使用 API 更新 React 360 游戏以获取数据
-
使用动态游戏数据加载 VR 游戏
介绍动态 MERN VR Game 应用程序
在本章中,我们将使用 MERN-stack 技术开发 MERN VR Game 应用程序。在这个平台上,注册用户可以通过提供游戏世界的等距图像以及放置在游戏世界中的每个对象的变换属性值来创建和修改自己的游戏。任何访问应用程序的访客都可以浏览所有由制作者添加的游戏,并玩任何游戏,以找到和收集与每个游戏线索或描述相关的游戏世界中的 3D 对象。当注册用户登录应用程序时,他们将看到一个包含所有游戏列表的主页,以及创建自己游戏的选项,如下面的截图所示:
完整的 MERN VR Game 应用程序的代码可在 GitHub 上找到,网址为 github.com/PacktPublishing/Full-Stack-React-Projects-Second-Edition/tree/master/Chapter14/mern-vrgame。您可以在阅读本章其余部分的代码解释时克隆此代码并运行应用程序。
MERN VR Game 应用程序所需的视图将通过扩展和修改 MERN 骨架应用程序中现有的 React 组件来开发。以下截图所示的组件树显示了本章开发的 MERN VR Game 前端的所有自定义 React 组件:
我们将添加与创建、编辑和列出 VR 游戏相关的新的 React 组件,并且随着我们在本章的其余部分构建 MERN VR 游戏应用程序的功能,我们还将修改现有的组件,如Profile、Menu和Home组件。在这个游戏平台的核心功能依赖于存储每个游戏特定细节的能力。在下一节中,我们将通过定义存储每个游戏详细信息的游戏模型来开始实现 MERN VR 游戏应用程序。
定义游戏模型
为了在平台上存储每场比赛的详细信息,我们将实现一个 Mongoose 模型来定义一个游戏模型,其实现方式将与之前章节中介绍的其他 Mongoose 模型实现类似,例如在第六章,构建基于 Web 的课堂应用程序中定义的课程模型。在第十三章,开发基于 Web 的 VR 游戏中,游戏数据结构部分详细说明了实现游戏玩法中定义的寻宝功能所需的每个游戏的详细信息。
我们将根据这些关于游戏、其 VR 物体以及游戏制作者的具体细节来设计游戏模式。在以下章节中,我们将讨论游戏模式的细节,存储游戏中将作为一部分的单独 VR 物体的子模式,以及确保在游戏中放置的最小 VR 物体数量的验证检查。
探索游戏模式
游戏模式,它定义了游戏数据结构,将指定存储每个游戏详细信息的字段。这些详细信息将包括游戏名称;游戏世界图像文件、文本描述或线索的链接;包含游戏中 3D 物体详细信息的数组;表示游戏创建或更新的时间戳;以及创建游戏的用户的引用。游戏模型的模式将在server/models/game.model.js中定义,以下列表中给出了定义这些游戏字段的代码,并附有说明:
- 游戏名称:
name字段将存储游戏的标题。它被声明为String类型,并且是一个必填字段:
name: {
type: String,
trim: true,
required: 'Name is required'
},
- 世界图像 URL:
world字段将包含指向构成游戏 3D 世界的等经圆图像的 URL。它被声明为String类型,并且是一个必填字段:
world: {
type: String, trim: true,
required: 'World image is required'
},
- 线索文本:
clue字段将存储String类型的文本,以描述游戏或提供完成游戏的线索:
clue: {
type: String,
trim: true
},
- 可收集和其他 VR 对象:
answerObjects字段将是一个包含要添加到游戏中的可收集 VR 对象详细信息的数组,而wrongObjects字段将是一个包含游戏中不能收集的 VR 对象的数组。这些数组中的对象将在下一节中讨论的单独 VR 对象模式中定义:
answerObjects: [VRObjectSchema],
wrongObjects: [VRObjectSchema],
- 创建时间和更新时间:
created和updated字段为Date类型,created在添加新游戏时生成,updated在修改任何游戏详细信息时更改:
updated: Date,
created: {
type: Date,
default: Date.now
},
- 游戏制作者:
maker字段将是创建游戏的用户的引用:
maker: {type: mongoose.Schema.ObjectId, ref: 'User'}
在游戏模式定义中添加的这些字段将捕获平台上每个游戏的详细信息,并允许我们在 MERN VR 游戏应用程序中实现游戏相关功能。在游戏模式中的 answerObjects 和 wrongObjects 数组中存储的 VR 对象将包含放置在游戏世界中的每个 VR 对象的详细信息。在下一节中,我们将探讨定义每个 VR 对象存储详细信息的模式。
指定 VR 对象模式
游戏模式中已定义的 answerObjects 和 wrongObjects 字段都将包含 VR 对象文档的数组。这些文档将代表游戏中的 VR 对象。我们将为这些文档单独定义 VR 对象 Mongoose 模式,其中包含用于存储 对象(OBJ)文件和 材质模板库(MTL)文件 URL 的字段,以及 React 360 变换值、每个 VR 对象的缩放值和颜色值。
VR 对象的模式也将定义在 server/models/game.model.js 中,定义这些字段的代码如下列表所示,并附有说明:
- OBJ 和 MTL 文件 URL:
objUrl和mtlUrl字段将存储表示 3D 对象数据的 OBJ 和 MTL 文件的链接。这些字段为String类型,是存储 VR 对象的必需字段:
objUrl: {
type: String, trim: true,
required: 'OBJ file is required'
},
mtlUrl: {
type: String, trim: true,
required: 'MTL file is required'
},
- 平移变换值:
translateX、translateY和translateZ字段将包含 VR 对象在 3D 空间中的位置值。这些字段为Number类型,每个字段的默认值均为0:
translateX: {type: Number, default: 0},
translateY: {type: Number, default: 0},
translateZ: {type: Number, default: 0},
- 旋转变换值:
rotateX、rotateY和rotateZ字段将包含 VR 对象在 3D 空间中的方向值。这些字段为Number类型,每个字段的默认值均为0:
rotateX: {type: Number, default: 0},
rotateY: {type: Number, default: 0},
rotateZ: {type: Number, default: 0},
- 缩放:
scale字段将表示 VR 对象的相对大小外观。此字段为Number类型,默认值为1:
scale: {type: Number, default: 1},
- 颜色:如果 MTL 文件中没有提供,
color字段将指定对象的默认颜色。此字段为String类型,默认值为white:
color: {type: String, default: 'white'}
VR 对象模式中的这些字段代表要添加到游戏世界中的 VR 对象。当新的游戏文档保存到数据库时,answerObjects和wrongObjects数组将填充符合此模式定义的VRObject文档。当用户使用定义的游戏和 VR 对象模式创建新游戏时,我们希望确保用户至少在每个游戏数据数组中添加一个 VR 对象。在下一节中,我们将探讨如何将此验证检查添加到游戏模型中。
在游戏模式中验证数组长度
在定义游戏模型的游戏模式中,我们有两个数组用于向游戏中添加 VR 对象。当游戏在游戏集合中保存时,这些在游戏文档中的answerObjects和wrongObjects数组必须每个数组至少包含一个 VR 对象。为了将最小数组长度验证添加到游戏模式中,我们将向使用 Mongoose 定义的GameSchema中的answerObjects和wrongObjects路径添加以下自定义验证检查。
我们将使用validate为answerObjects字段添加数组长度验证,如下面的代码所示:
mern-vrgame/server/models/game.model.js:
GameSchema.path('answerObjects').validate(function(v) {
if (v.length == 0) {
this.invalidate('answerObjects',
'Must add alteast one VR object to collect')
}
}, null)
在此验证检查中,如果发现数组长度为0,我们将抛出一个验证错误消息,指出在将游戏文档保存到数据库之前,至少必须向数组中添加一个对象。
相同的验证代码也添加到wrongObjects字段,如下面的代码所示:
mern-vrgame/server/models/game.model.js:
GameSchema.path('wrongObjects').validate(function(v) {
if (v.length == 0) {
this.invalidate('wrongObjects',
'Must add alteast one other VR object')
}
}, null)
这些检查会在每次要将游戏保存到数据库时运行,并有助于确保游戏至少包含两个 VR 对象,包括一个可收集的对象和一个不可收集的对象。用于定义游戏模型的这些模式定义和验证将允许维护应用程序的游戏数据库。为了允许用户访问游戏集合,无论是制作自己的游戏还是检索他人的游戏,我们需要在后端实现相应的 CRUD API。在下一节中,我们将实现这些 CRUD API,这将使用户能够从应用程序中创建、读取、列出、更新和删除游戏。
实现游戏 CRUD API
为了构建一个允许制作、管理和访问 VR 游戏的游戏平台,我们需要扩展后端以接受允许在数据库中操作游戏数据的请求。为了使这些功能成为可能,MERN VR Game 应用程序的后端将公开一组 CRUD API,用于在数据库中创建、编辑、读取、列出和删除游戏,这些 API 可以在应用程序的前端使用 fetch 调用中使用,包括在 React 360 游戏实现中。在接下来的章节中,我们将在后端实现这些 CRUD API 端点,以及将在前端部署以使用这些 API 的相应 fetch 方法。
创建一个新的游戏
已登录应用程序的用户将能够通过创建游戏 API 端点在数据库中创建新游戏。对于在后端实现此 API,我们首先在 /api/games/by/:userId 上声明一个 POST 路由,如下所示:
mern-vrgame/server/routes/game.routes.js:
router.route('/api/games/by/:userId')
.post(authCtrl.requireSignin, gameCtrl.create)
向此路由发送 POST 请求将处理 :userId 参数,验证当前用户是否已登录,然后使用请求中传递的游戏数据创建一个新游戏。
包含此路由声明的 game.routes.js 文件将与 user.routes 文件非常相似,为了在 Express 应用中加载这些新路由,我们需要在 express.js 中挂载游戏路由,就像我们为认证和用户路由所做的那样。可以通过添加以下代码行将游戏路由挂载到 Express 应用中:
mern-vrgame/server/express.js
app.use('/', gameRoutes)
这将使声明的游戏路由在服务器运行时能够接收请求。
在接收到创建游戏 API 的请求后,为了处理 :userId 参数并从数据库检索相关用户,我们将利用用户控制器中的 userByID 方法。我们还将添加以下代码到游戏路由中,以便用户在 request 对象中可用:
mern-vrgame/server/routes/game.routes.js:
router.param('userId', userCtrl.userByID)
在接收到包含游戏数据的 POST 请求并验证用户身份验证后,将调用 create 控制器方法,将新游戏添加到数据库中。此 create 控制器方法定义如下所示:
mern-vrgame/server/controllers/game.controller.js
const create = async (req, res, next) => {
const game = new Game(req.body)
game.maker = req.profile
try{
let result = await game.save()
res.status(200).json(result)
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
在此 create 方法中,根据游戏模型和客户端请求体中传递的数据创建一个新的游戏文档。然后,在用户引用设置为游戏制作者后,将此文档保存到游戏集合中。
在前端,我们将在 api-game.js 中添加一个相应的 fetch 方法,通过传递从已登录用户收集的表单数据向创建游戏 API 发送 POST 请求。此 fetch 方法定义如下所示:
mern-vrgame/client/game/api-game.js
const create = async (params, credentials, game) => {
try {
let response = await fetch('/api/games/by/'+ params.userId, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: JSON.stringify(game)
})
return await response.json()
} catch(err) {
console.log(err)
}
}
这个 fetch 方法将在前端使用,并提供创建游戏 API 所需的用户凭证以进行 POST 请求。fetch 方法的响应将告诉用户游戏是否成功创建。
这个创建游戏 API 端点是准备好的,可以在一个表单视图中使用,它可以收集用户的新游戏详情,因此可以将新游戏添加到数据库中。在下一节中,我们将实现一个 API 端点,该端点将检索已添加到数据库中的游戏。
列出所有游戏
在 MERN VR Game 应用程序中,将可以使用后端中的列表游戏 API 从数据库中检索 Game 集合中的所有游戏列表。我们将通过向游戏路由添加一个 GET 路由来实现这个 API 端点,如下面的代码所示:
mern-vrgame/server/routes/game.routes.js:
router.route('/api/games')
.get(gameCtrl.list)
对 /api/games 的 GET 请求将执行 list 控制器方法,该方法将查询数据库中的 Game 集合,以在客户端响应中返回所有游戏。
这个 list 控制器方法将定义如下:
mern-vrgame/server/controllers/game.controller.js:
const list = async (req, res) => {
try {
let games = await Game.find({}).populate('maker', '_id name').sort('-created').exec()
res.json(games)
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
在此方法中,查询 Game 集合检索到的结果将按创建日期排序,最新游戏排在前面。列表中的每个游戏也将填充创建它的用户的名字和 ID。排序后的游戏列表将作为响应返回给请求客户端。
在前端,要使用此列表 API 获取游戏,我们将在 api-game.js 中设置相应的 fetch 方法,如下面的代码所示:
mern-vrgame/client/game/api-game.js:
const list = async (signal) => {
try {
let response = await fetch('/api/games', {
method: 'GET',
signal: signal
})
return await response.json()
} catch(err) {
console.log(err)
}
}
这个 fetch 方法可以在任何前端界面中使用,以调用列表游戏 API。fetch 将向 API 发出 GET 请求,并在响应中接收游戏列表,这可以在界面中渲染。在下一节中,我们将实现另一个仅返回特定用户制作的游戏的列表 API。
按制作者列出游戏
在 MERN VR Game 应用程序中,还可以检索由特定用户制作的游戏列表。为了实现这一点,我们将在后端添加另一个 API 端点,该端点接受在 /api/games/by/:userId 路由上的 GET 请求。此路由将与其他游戏路由一起声明,如下面的代码所示:
mern-vrgame/server/routes/game.routes.js:
router.route('/api/games/by/:userId')
.get(gameCtrl.listByMaker)
在此路由收到的 GET 请求将调用 listByMaker 控制器方法,该方法将查询数据库中的 Game 集合以获取匹配的游戏。listByMaker 控制器方法将定义如下:
mern-vrgame/server/controllers/game.controller.js:
const listByMaker = async (req, res) => {
try {
let games = await Game.find({maker:
req.profile._id}).populate('maker', '_id name')
res.json(games)
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
在此方法中对游戏集合的查询中,我们找到所有 maker 字段与 userId 路由参数中指定的用户匹配的游戏。检索到的游戏将包含制造商的名称和 ID,并将作为响应返回给请求客户端。
在前端,为了通过制造商 API 使用此列表获取特定用户的游戏,我们将在 api-game.js 中添加相应的 fetch 方法,如下所示:
mern-vrgame/client/game/api-game.js:
const listByMaker = async (params, signal) => {
try {
let response = await fetch('/api/games/by/'+params.userId, {
method: 'GET',
signal: signal,
})
return await response.json()
} catch(err) {
console.log(err)
}
}
此 fetch 方法可以在前端界面中使用用户 ID 调用由制造商 API 列出的游戏。fetch 方法将对 API 发出 GET 请求并接收由 URL 中指定的用户创建的游戏列表。在下一节中,我们将实现一个类似的 GET API 来检索单个游戏的详细信息。
加载游戏
在 MERN VR Game 应用程序的后端,我们将公开一个 API,该 API 将检索游戏集合中指定 ID 的单个游戏的详细信息。为了实现这一点,我们可以添加一个 GET API,该 API 查询 Game 集合并返回响应中的相应游戏文档。我们将开始实现此 API 以通过声明接受在 '/api/game/:gameId' 处的 GET 请求的路由来获取单个游戏,如下所示:
mern-vrgame/server/routes/game.routes.js:
router.route('/api/game/:gameId')
.get(gameCtrl.read)
当接收到此路由的请求时,路由 URL 中的 :gameId 参数将首先被处理以从数据库中检索单个游戏。因此,我们还将以下内容添加到游戏路由中:
router.param('gameId', gameCtrl.gameByID)
路由中存在 :gameId 参数将触发 gameByID 控制器方法,该方法与 userByID 控制器方法类似。它将从数据库中检索游戏并将其附加到 request 对象中,以便在 next 方法中使用。此 gameByID 控制器方法定义如下所示:
mern-vrgame/server/controllers/game.controller.js:
const gameByID = async (req, res, next, id) => {
try {
let game = await Game.findById(id).populate('maker', '_id name').exec()
if (!game)
return res.status('400').json({
error: "Game not found"
})
req.game = game
next()
} catch (err) {
return res.status('400').json({
error: "Could not retrieve game"
})
}
}
从数据库查询到的游戏还将包含制造商的名称和 ID 详细信息,如 populate() 方法中指定的。在此情况下,next 方法——即 read 控制器方法——简单地返回检索到的游戏作为对客户端的响应。此 read 控制器方法定义如下:
mern-vrgame/server/controllers/game.controller.js:
const read = (req, res) => {
return res.json(req.game)
}
此读取单个游戏详细信息的 API 将用于在 React 360 游戏世界的实现中加载游戏。我们可以在前端代码中使用 fetch 方法调用此 API,根据其 ID 检索单个游戏的详细信息。可以定义一个相应的 fetch 方法来调用此游戏 API,如下所示:
mern-vrgame/client/game/api-game.js:
const read = async (params) => {
try {
let response = await fetch('/api/game/' + params.gameId, {
method: 'GET'
})
return await response.json()
} catch(err) {
console.log(err)
}
}
此 read 方法将获取 params 中的游戏 ID 并使用 fetch 方法向 API 发出 GET 请求。
此用于加载单个游戏的 API 将被用于 React 视图获取游戏详情以及 React 360 游戏视图,该视图将在 MERN VR 游戏应用中渲染游戏界面。在下节中,我们将实现允许制作者更新他们在平台上已创建的游戏的 API。
编辑游戏
已登录的授权用户以及特定游戏的制作者将能够编辑该游戏在数据库中的详细信息。为了启用此功能,我们将在后端实现一个编辑游戏的 API。我们将添加一个PUT路由,允许授权用户编辑他们自己的其中一个游戏。该路由声明如下:
mern-vrgame/server/routes/game.routes.js:
router.route('/api/games/:gameId')
.put(authCtrl.requireSignin, gameCtrl.isMaker, gameCtrl.update)
对'/api/games/:gameId'的PUT请求将首先执行gameByID控制器方法以检索特定游戏的详细信息。requireSignin认证控制器方法也将被调用以确保当前用户已登录。然后,isMaker控制器方法将确定当前用户是否是此特定游戏的制作者,最后运行游戏update控制器方法以在数据库中修改游戏。
isMaker控制器方法确保已登录的用户实际上是正在编辑的游戏的制作者,并且它定义如下所示:
mern-vrgame/server/controllers/game.controller.js:
const isMaker = (req, res, next) => {
let isMaker = req.game && req.auth && req.game.maker._id == req.auth._id
if(!isMaker){
return res.status('403').json({
error: "User is not authorized"
})
}
next()
}
如果isMaker条件不满足,这意味着当前登录的用户不是正在编辑的游戏的制作者,并且响应中返回授权错误。但如果条件满足,则调用next方法。在这种情况下,update控制器方法是next方法,它将更改保存到数据库中的游戏。此更新方法定义如下所示:
mern-vrgame/server/controllers/game.controller.js:
const update = async (req, res) => {
try {
let game = req.game
game = extend(game, req.body)
game.updated = Date.now()
await game.save()
res.json(game)
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
此update方法将接受现有的游戏详情和请求体中接收到的表单数据以合并更改,并将更新后的游戏保存到数据库中的 Game 集合。
此编辑游戏 API 可以通过前端视图使用一个fetch方法调用,该方法将更改作为表单数据发送到后端,并随请求一起发送用户凭据。相应的fetch方法定义如下所示:
mern-vrgame/client/game/api-game.js:
const update = async (params, credentials, game) => {
try {
let response = await fetch('/api/games/' + params.gameId, {
method: 'PUT',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: JSON.stringify(game)
})
return await response.json()
} catch(err) {
console.log(err)
}
}
此方法向编辑游戏 API 发出PUT请求,在请求体中提供游戏的更改,在请求头中提供当前用户的凭据,并在路由 URL 中提供要编辑的游戏的 ID。此方法可以在前端使用,它渲染一个表单,允许用户更新游戏详情。在下节中,我们将在后端实现另一个 API,允许授权用户删除他们在平台上创建的游戏。
删除游戏
经过认证和授权的用户将能够删除他们在应用程序中创建的任何游戏。为了启用此功能,我们将在后端实现一个删除游戏的 API。我们将首先添加一个DELETE路由,允许授权的制作者删除他们自己的游戏,如下面的代码所示:
mern-vrgame/server/routes/game.routes.js:
router.route('/api/games/:gameId')
.delete(authCtrl.requireSignin, gameCtrl.isMaker, gameCtrl.remove)
在服务器上接收到api/games/:gameId的DELETE请求后,控制器方法执行的流程将与编辑游戏 API 类似,最终调用的是remove控制器方法而不是update。
当接收到/api/games/:gameId的DELETE请求,并且已经验证当前用户是给定游戏的原始制作者时,remove控制器方法会从数据库中删除指定的游戏。remove控制器方法定义如下所示:
mern-vrgame/server/controllers/game.controller.js:
const remove = async (req, res) => {
try {
let game = req.game
let deletedGame = await game.remove()
res.json(deletedGame)
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
这个remove方法会永久地从数据库中的游戏集合中删除指定的游戏。
为了从前端使用此 API,我们将在api-game.js中添加相应的remove方法,以向删除游戏 API 发送 fetch 请求。此fetch方法定义如下:
mern-vrgame/client/game/api-game.js:
const remove = async (params, credentials) => {
try {
let response = await fetch('/api/games/' + params.gameId, {
method: 'DELETE',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
}
})
return await response.json()
} catch(err) {
console.log(err)
}
}
此方法使用fetch向删除游戏 API 发送DELETE请求。它接受params中的游戏 ID 以及后端 API 端点需要的用户凭据,以检查当前用户是否是指定游戏的授权制作者。如果请求成功并且相应的游戏已从数据库中删除,则响应中返回成功消息。
在后端这些游戏 CRUD API 功能实现后,我们准备实现前端,该前端将使用这些 API 允许用户创建新游戏、列出游戏、修改现有游戏以及在 React 360 游戏视图中加载单个游戏。我们可以在下一节开始构建这个前端,从创建和编辑应用程序中游戏的 React 视图开始。
添加创建和编辑游戏的表单
在 MERN VR 游戏应用程序上注册的用户将能够创建新游戏并从应用程序的视图中修改这些游戏。为了实现这些视图,我们将添加 React 组件,允许用户为每个游戏组合和修改游戏详情以及 VR 对象详情。由于创建新游戏和编辑现有游戏的表单将具有类似的表单字段来组合游戏详情和 VR 对象详情,我们将创建可重用的组件,这些组件可以用于创建和编辑目的。在以下章节中,我们将讨论创建新游戏和编辑现有游戏的表单视图,以及在这些视图中实现通用表单组件。
创建新游戏
当任何用户登录到应用程序时,他们将被提供创建自己的 VR 游戏的选择。他们将在菜单中看到一个“创建游戏”链接,该链接将引导他们到一个表单,他们可以在其中填写游戏详情以在平台上创建新游戏。在接下来的部分中,我们将更新前端代码以在菜单上添加此链接并实现NewGame组件,该组件将包含创建新游戏的表单。
更新菜单
我们将更新应用程序中的导航菜单以添加“创建游戏”按钮,该按钮将根据用户是否已登录而条件显示,并将用户重定向到包含创建新游戏表单的视图。创建游戏按钮将显示在菜单中,如下面的截图所示:
要将此按钮添加到Menu组件,我们将使用一个Link组件,其中包含指向NewGame组件的表单的路由。为了使其条件渲染,我们将将其放置在先前的截图所示的“我的个人资料”链接之前,在仅当用户已认证时渲染的章节中。按钮代码将按以下代码所示添加:
mern-vrgame/client/core/Menu.js:
<Link to="/game/new">
<Button style={isActive(history, "/game/new")}>
<AddBoxIcon color="secondary"/> Make Game
</Button>
</Link>
这将向已登录用户显示“创建游戏”选项,他们可以点击它以重定向到包含在平台上创建新游戏表单视图的/game/new路由。在下一节中,我们将查看将渲染此表单的组件。
新游戏组件
我们将在NewGameReact 组件中实现创建新游戏的表单视图。此表单视图将允许用户填写单个游戏的字段。NewGame组件将渲染与游戏详情相对应的这些表单元素,包括 VR 对象详情,如下面的截图所示:
NewGame组件将使用GameForm组件,该组件将包含所有渲染的表单字段,以组成这个新游戏表单。GameForm组件将是一个可重用组件,我们将在创建和编辑表单中使用它。
当添加到NewGame组件时,它将一个onSubmit方法作为属性,以及任何服务器返回的错误消息,如下面的代码所示:
mern-vrgame/client/game/NewGame.js:
<GameForm onSubmit={clickSubmit} errorMsg={error}/>
传递给onSubmit属性的函数将在用户提交表单时执行。在这种情况下传递的clickSubmit方法是在NewGame组件中定义的。它使用api-game.js中的创建游戏fetch方法向创建游戏 API 发送带有游戏表单数据和用户详情的POST请求。
此clickSubmit方法定义如下所示:
mern-vrgame/client/game/NewGame.js:
const clickSubmit = game => event => {
const jwt = auth.isAuthenticated()
create({
userId: jwt.user._id
}, {
t: jwt.token
}, game).then((data) => {
if (data.error) {
setError(data.error)
} else {
setError('')
setRedirect(true)
}
})
}
如果用户在表单中输入游戏详情时出错,当在表单提交时调用此clickSubmit方法时,后端会发送错误消息。如果没有错误并且游戏在数据库中成功创建,用户将被重定向到另一个视图。
为了在指定的 URL 加载此NewGame组件并且仅对认证用户,我们将在MainRouter中添加一个PrivateRoute,如下面的代码所示:
mern-vrgame/client/MainRouter.js:
<PrivateRoute path="/game/new" component={NewGame}/>
这将使得当认证用户访问时,NewGame组件将在浏览器的/game/new路径上加载。在下一节中,我们将看到类似的实现,用于从数据库中编辑现有游戏并渲染相同的表单。
编辑游戏
用户将能够使用与创建新游戏表单类似的表单编辑他们在平台上创建的游戏。我们将在EditGame组件中实现此编辑游戏视图,该组件将渲染预填充现有游戏详情的游戏表单字段。我们将在下一节中查看此EditGame组件的实现。
The EditGame component
正如NewGame组件中一样,EditGame组件也将使用GameForm组件来渲染表单元素。但在这个表单中,字段将加载要编辑的游戏的当前值,并且用户将能够更新这些值,如下面的截图所示:
在此EditGame组件的情况下,GameForm将接受给定的游戏 ID 作为属性,以便它可以获取游戏详情,除了onSubmit方法和可能的服务器生成的错误消息。GameForm组件将带有这些属性添加到EditGame组件中,如下所示:
mern-vrgame/client/game/EditGame.js:
<GameForm gameId={params.gameId} onSubmit={clickSubmit} errorMsg={error}/>
编辑表单的clickSubmit方法将使用api-game.js中的更新游戏fetch方法向编辑游戏 API 发送带有表单数据和用户详情的PUT请求。此编辑表单提交的clickSubmit方法将定义如下面的代码所示:
mern-vrgame/client/game/EditGame.js:
const clickSubmit = game => event => {
const jwt = auth.isAuthenticated()
update({
gameId: match.params.gameId
}, {
t: jwt.token
}, game).then((data) => {
if (data.error) {
setError(data.error)
} else {
setError('')
setRedirect(true)
}
})
}
如果用户在表单中修改游戏详情时出错,当在表单提交时调用此clickSubmit方法时,后端会发送错误消息。如果没有错误并且游戏在数据库中成功更新,用户将被重定向到另一个视图。
为了在指定的 URL 加载此EditGame组件并且仅对认证用户,我们将在MainRouter中添加一个PrivateRoute,如下面的代码所示:
mern-vrgame/client/MainRouter.js:
<PrivateRoute path="/game/edit/:gameId" component={EditGame}/>
当认证用户访问时,EditGame 组件将在浏览器中的 /game/edit/:gameId 路径上加载。这个 EditGame 组件和 NewGame 组件都使用 GameForm 组件来渲染允许用户添加游戏详情的表单元素。在下一节中,我们将讨论这个可重用的 GameForm 组件的实现。
实现 GameForm 组件
GameForm 组件在 NewGame 和 EditGame 组件中都被使用,它包含了允许用户输入单个游戏的游戏详情和 VR 对象详情的元素。它可能从一个空白的游戏对象开始,或者加载一个现有的游戏。为了开始实现这个组件,我们首先在组件状态中初始化一个空白的游戏对象,如下面的代码所示:
mern-vrgame/client/game/GameForm.js:
const [game, setGame] = useState({ name: '',
clue: '',
world: '',
answerObjects: [],
wrongObjects: []
})
如果 GameForm 组件从父组件(例如从 EditGame 组件)接收到 gameId 属性——那么它将使用加载游戏 API 来检索游戏的详情并将其设置到状态中,以便在表单视图中渲染。我们将在下面的代码中调用这个 API,如下所示:
mern-vrgame/client/game/GameForm.js:
useEffect(() => {
if(props.gameId){
const abortController = new AbortController()
const signal = abortController.signal
read({gameId: props.gameId}, signal).then((data) => {
if (data.error) {
setReadError(data.error)
} else {
setGame(data)
}
})
return function cleanup(){
abortController.abort()
}
}
}, [])
在 useEffect 钩子中,我们首先检查从父组件接收到的属性中是否包含 gameId 属性,然后使用该值来调用加载游戏 API。如果 API 调用返回错误,我们将错误设置到状态中;否则,我们将检索到的游戏设置到状态中。通过这段代码,我们将根据初始值初始化游戏详情,以便在表单视图中使用。
GameForm 组件中的表单视图部分基本上有两个部分:一部分用于输入简单的游戏详情(如名称、世界图像链接和线索文本),另一部分允许用户将可变数量的 VR 对象添加到答案对象数组或错误对象数组中。在接下来的几节中,我们将查看这两个部分的实现,它们将构成游戏详情表单视图。
输入简单的游戏详情
在创建或编辑游戏时,用户首先会看到游戏简单详情的表单元素,例如名称、世界图像 URL 和线索文本。这个包含简单游戏详情的表单部分将主要是使用 Material-UI TextField 组件添加的文本输入元素,并通过 onChange 处理器传递一个更改处理方法。我们将在 GameForm 组件中构建这个部分,该组件在 mern-vrgame/client/game/GameForm.js 中实现,如下所示的相关代码:
- 表单标题:表单标题将根据是否将现有的游戏 ID 作为属性从父组件传递给
GameForm来决定,如下面的代码所示:
<Typography type="headline" component="h2">
{props.gameId? 'Edit': 'New'} Game
</Typography>
- 游戏世界图像输入:我们将在表单最顶部的
img元素中渲染背景图像 URL,以向用户显示他们添加的游戏世界图像 URL。图像 URL 输入将在渲染的图像下方的TextField组件中获取,如下面的代码所示:
<img src={game.world}/>
<TextField id="world" label="Game World Equirectangular Image (URL)"
value={game.world} onChange={handleChange('world')}/>
- 游戏名称:游戏名称将被添加到默认的
text类型的单个TextField中,如下面的代码所示:
<TextField id="name" label="Name" value={game.name} onChange={handleChange('name')}/>
- 线索文本:线索文本将被添加到一个多行
TextField组件中,如下面的代码所示:
<TextField id="multiline-flexible" label="Clue Text" multiline rows="2"
value={game.clue} onChange={handleChange('clue')}/>
在添加到GameForm组件的这些表单元素中,输入字段也接受一个onChange处理函数,该函数被定义为handleChange。这个handleChange方法会在用户更改输入元素中的值时更新状态中的游戏值。handleChange方法定义如下:
mern-vrgame/client/game/GameForm.js:
const handleChange = name => event => {
const newGame = {...game}
newGame[name] = event.target.value
setGame(newGame)
}
在这个方法中,根据被更改的具体字段值,我们更新状态中游戏对象的相应属性。这捕捉了用户输入的值作为他们 VR 游戏的简单细节。表单还将提供定义将作为游戏一部分的 VR 对象数组的选项。在下一节中,我们将查看允许用户操作 VR 对象数组的表单实现。
修改 VR 对象数组
用户将能够为每个游戏定义两个不同数组中的动态数量的 VR 对象。为了允许用户修改他们希望添加到 VR 游戏中的answerObjects和wrongObjects数组,GameForm将遍历每个数组,并为每个对象渲染一个 VR 对象表单组件。这样,它将使从GameForm组件中添加、删除和修改 VR 对象成为可能,如下面的截图所示:
在以下几节中,我们将在GameForm组件中添加这些数组操作功能。我们首先将渲染 VR 对象数组中的每个项目,并包含添加新项目或从数组中删除现有项目的选项。然后,由于数组中的每个项目本质上都是一个输入 VR 对象细节的表单,我们还将讨论如何处理在GameForm组件中每个项目内进行的输入更改。
迭代和渲染对象详情表单
我们将添加上一节中看到的表单界面,使用 Material-UI ExpansionPanel组件来创建给定游戏中每种 VR 对象数组类型的可修改的 VR 对象列表。
在嵌套的ExpansionPanelDetails组件内部,我们将遍历answerObjects数组或wrongObjects数组,为每个 VR 对象渲染一个VRObjectForm组件,如下面的代码所示:
mern-vrgame/client/game/GameForm.js:
<ExpansionPanel>
<ExpansionPanelSummary expandIcon={<ExpandMoreIcon />}>
<Typography>VR Objects to collect</Typography>
</ExpansionPanelSummary>
<ExpansionPanelDetails> {
game.answerObjects.map((item, i) => {
return <div key={i}>
<VRObjectForm index={i} type={'answerObjects'}
handleUpdate={handleObjectChange}
vrObject={item}
removeObject={removeObject}/>
</div>
})
}
...
</ExpansionPanelDetails>
</ExpansionPanel>
为了渲染数组中的每个对象,我们使用VRObjectForm组件。我们将在本章的后面部分查看VRObjectForm组件的具体实现。在添加VRObjectForm到这段代码时,我们传递单个vrObject项目作为属性,以及数组中的当前index、数组的类型,以及两个方法,用于在GameForm中通过更改详细信息或从VRObjectForm组件内部删除对象来修改数组详细信息时更新状态。这将渲染一个表单,用于在GameForm组件中与游戏关联的数组中的每个 VR 对象。在下一节中,我们将看到包括将这些数组中添加新对象选项的实现。
向数组中添加新对象
对于游戏表单中渲染的每个数组,我们将添加一个按钮,允许用户将新的 VR 对象推送到给定的数组。添加对象的此按钮将渲染一个新的VRObjectForm组件以获取新 VR 对象的详细信息。我们将在迭代代码之后将此按钮添加到ExpansionPanelDetails组件中,如下所示:
mern-vrgame/client/game/GameForm.js:
<ExpansionPanelDetails>
...
<Button color="primary" variant="contained"
onClick={addObject('answerObjects')}>
<AddBoxIcon color="secondary"/>
Add Object
</Button>
</ExpansionPanelDetails>
此添加对象按钮将渲染在每个 VR 对象表单列表的末尾。当点击时,它将通过调用addObject方法添加一个新的空白 VR 对象表单。此addObject方法将如下定义:
mern-vrgame/client/game/GameForm.js:
const addObject = name => event => {
const newGame = {...game}
newGame[name].push({})
setGame(newGame)
}
addObject方法传递了数组类型,因此我们知道用户想要将新对象添加到哪个数组。在此方法中,我们只需将一个空对象添加到正在迭代的数组中,这样就会在它的位置渲染一个空表单,用户可以填写以输入新对象的详细信息。在下一节中,我们将看到如何让用户从 VR 对象表单列表中删除这些项目之一。
从数组中删除对象
VR 对象表单列表中渲染的每个项目也可以由用户删除。显示项目的VRObjectForm组件将包含一个删除选项,这将从给定的数组中删除对象。
为了实现此删除按钮的删除项功能,我们将一个removeObject方法作为属性传递给从父组件GameForm组件的VRObjectForm组件。此方法将允许在用户在特定的VRObjectForm上点击 DELETE 时,更新父组件的状态中的数组。此removeObject方法将按照以下代码所示定义:
mern-vrgame/client/game/GameForm.js:
const removeObject = (type, index) => event => {
const newGame = {...game}
newGame[type].splice(index, 1)
setGame(newGame)
}
在此方法中,将根据指定的 index 从具有指定 type 的数组中切片,移除对应于点击项的 VR 对象。当在状态中设置时,此更新后的对象数组将在游戏中反映出来,删除的 VR 对象将从表单视图中移除。在下一节中,我们将探讨当用户在 VR 对象表单中更新值时如何处理 VR 对象详情的更改,该表单是根据 VR 对象数组中的项渲染的。
处理对象详情更改
当用户在相应的 VR 对象表单中的任何字段更改输入值时,游戏中的任何 VR 对象的详情都将被更新。为了注册此更新,包含 VR 对象表单的 GameForm 将 handleObjectChange 方法传递给 VRObjectForm 组件,该组件将渲染 VR 对象表单。此 handleObjectChange 方法将定义如下:
mern-vrgame/client/game/GameForm.js:
const handleObjectChange = (index, type, name, val) => {
var newGame = {...game}
newGame[type][index][name] = val
setGame(newGame)
}
这个 handleObjectChange 方法将在 VRObjectFrom 组件中使用,以捕获更改后的输入值并更新给定 type 的数组中指定 index 的 VR 对象的相应字段,因此它反映在 GameForm 中的游戏对象状态中。
GameForm 组件用于渲染修改游戏详情的表单元素,包括 VR 对象列表。使用此表单,用户可以在列表中添加、修改和删除 VR 对象。列表以 VR 对象表单的形式渲染每个项,用户可以使用它来组合对象的详情。在下一节中,我们将实现渲染游戏中每个 VR 对象的 VR 对象表单的 React 组件。
VRObjectForm 组件
我们将实现 VRObjectForm 组件以渲染用于修改单个 VR 对象详情的输入字段,这些字段被添加到 GameForm 组件中的 answerObjects 和 wrongObjects 数组中。VRObjectForm 组件将渲染一个表单,如下面的截图所示:
要开始实现包含 VR 对象表单的 VRObjectForm 组件,我们将在组件的状态中使用 useState 钩子初始化 VR 对象的空白详情,如下面的代码所示:
mern-vrgame/client/game/VRObjectForm.js:
const [values, setValues] = useState({
objUrl: '',
mtlUrl: '',
translateX: 0,
translateY: 0,
translateZ: 0,
rotateX: 0,
rotateY: 0,
rotateZ: 0,
scale: 1,
color:'white'
})
这些详情对应于存储 VR 对象定义的架构。当 VRObjectForm 组件被添加到 GameForm 组件时,它可能接收一个空的 VR 对象或一个填充了详情的 VR 对象,具体取决于是否正在渲染空表单或具有现有对象详情的表单。如果传递了现有的 VR 对象作为 prop,我们将使用 useEffect 钩子将此对象的详情设置在组件状态中,如下面的代码所示:
mern-vrgame/client/game/VRObjectForm.js:
useEffect(() => {
if(props.vrObject && Object.keys(props.vrObject).length != 0){
const vrObject = props.vrObject
setValues({...values,
objUrl: vrObject.objUrl,
mtlUrl: vrObject.mtlUrl,
translateX: Number(vrObject.translateX),
translateY: Number(vrObject.translateY),
translateZ: Number(vrObject.translateZ),
rotateX: Number(vrObject.rotateX),
rotateY: Number(vrObject.rotateY),
rotateZ: Number(vrObject.rotateZ),
scale: Number(vrObject.scale),
color:vrObject.color
})
}
}, [])
在此useEffect钩子中,如果通过 prop 传入的vrObject值不是一个空对象,我们将设置接收到的 VR 对象的详细信息到状态中。这些值将用于组成 VR 对象表单的输入字段。我们将使用 Material-UI TextField组件在VRObjectForm视图的视图中添加与 VR 对象详情对应的输入字段,如下面的代码和以下列表所示:
- 3D 对象文件输入:每个 VR 对象的 OBJ 和 MTL 文件链接将通过
TextField组件以文本输入的形式收集,如下面的代码所示:
<TextField label=".obj url" value={values.objUrl}
onChange={handleChange('objUrl')} />
<TextField label=".mtl url" value={values.mtlUrl}
onChange={handleChange('mtlUrl')} />
- 翻译值输入:VR 对象在 x、y 和 z 轴上的翻译值将通过
number类型的TextField组件输入,如下面的代码所示:
<TextField type="number" value={values.translateX}
label="TranslateX" onChange={handleChange('translateX')} />
<TextField type="number" value={values.translateY}
label="TranslateY" onChange={handleChange( 'translateY')} />
<TextField type="number" value={values.translateZ}
label="TranslateZ" onChange={handleChange('translateZ')} />
- 旋转值输入:VR 对象围绕 x、y 和 z 轴的旋转值将通过
number类型的TextField组件输入,如下面的代码所示:
<TextField type="number" value={values.rotateX}
label="RotateX" onChange={handleChange('rotateX')} />
<TextField type="number" value={values.rotateY}
label="RotateY" onChange={handleChange('rotateY')} />
<TextField type="number" value={values.rotateZ}
label="RotateZ" onChange={handleChange('rotateZ')} />
- 缩放值输入:VR 对象的缩放值将通过
TextField组件的number类型输入,如下面的代码所示:
<TextField type="number" value={values.scale}
label="Scale" onChange={handleChange('scale')} />
- 对象颜色输入:VR 对象的颜色值将通过
text类型的TextField组件输入,如下面的代码所示:
<TextField value={values.color} label="Color"
onChange={handleChange('color')} />
这些输入字段将允许用户在游戏中设置 VR 对象的详细信息。当用户在这些输入字段中更改任何 VR 对象详细信息时,将调用handleChange方法。此handleChange方法将定义如下所示,代码如下:
mern-vrgame/client/game/VRObjectForm.js:
const handleChange = name => event => {
setValues({...values, [name]: event.target.value})
props.handleUpdate(props.index, props.type, name, event.target.value)
}
此handleChange方法将更新VRObjectForm组件状态中的相应值,并使用从GameForm作为 prop 传递的handleUpdate方法来更新GameForm状态中的 VR 对象,以特定对象详情的更改值。
VRObjectForm还将包含一个 DELETE 按钮,该按钮将执行在GameForm中作为 prop 接收的removeObject方法,这将允许从游戏列表中移除指定的对象。此删除按钮将按以下代码添加到视图中:
mern-vrgame/client/game/VRObjectForm.js:
<Button onClick={props.removeObject(props.type, props.index)}>
<Icon style={{marginRight: '5px'}}>cancel</Icon> Delete
</Button>
removeObject方法将接受对象数组类型和数组索引位置值,以从GameForm组件的状态中的相关 VR 对象数组中移除指定的对象。
通过这些实现,创建和编辑游戏的表单已经就绪,包括不同大小的 VR 对象输入表单。我们使用了可重用组件来组合创建和编辑游戏所需的形式元素,并添加了修改游戏中 VR 对象数组的 capability。任何注册用户都可以使用这些表单在 MERN VR 游戏应用程序中添加和编辑游戏详情。在下文中,我们将讨论实现视图,该视图将在平台上渲染不同的游戏列表。
添加游戏列表视图
访问 MERN VR 游戏的访客将从主页和单个用户个人资料中渲染的列表访问应用程序中的游戏。主页将列出应用程序上的所有游戏,特定制作者的游戏将列在其用户个人资料页面上。这些列表视图将通过使用后端 API 列出游戏来迭代游戏数据,并在可重用的 React 组件中渲染每个游戏的详细信息。
在以下章节中,我们将讨论使用可重用组件渲染列表中所有游戏以及仅由特定制作者的游戏的实现。
渲染游戏列表
我们将在应用程序的主页上渲染平台上可用的所有游戏。为了实现此功能,Home 组件将首先使用列表游戏 API 从数据库中的游戏集合中获取所有游戏的列表。我们将在 Home 组件中的 useEffect 钩子中实现这一点,如下所示:
mern-vrgame/client/core/Home.js:
useEffect(() => {
const abortController = new AbortController()
const signal = abortController.signal
list(signal).then((data) => {
if (data.error) {
console.log(data.error)
} else {
setGames(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [])
在此 useEffect 钩子中从服务器检索到的游戏列表将设置到状态中,并遍历以渲染列表中的每个游戏的 GameDetail 组件,如下所示:
mern-vrgame/client/core/Home.js:
{games.map((game, i) => {
return <GameDetail key={i} game={game} updateGames={updateGames}/>
})}
GameDetail 组件将被实现为一个可重用的组件,用于渲染单个游戏的详细信息。它将传递游戏细节和 updateGames 方法。updateGames 方法将允许在列表上的任何游戏被制作者删除时更新 Home 组件中的游戏列表。updateGames 方法定义如下所示:
mern-vrgame/client/core/Home.js:
const updateGames = (game) => {
const updatedGames = [...games]
const index = updatedGames.indexOf(game)
updatedGames.splice(index, 1)
setGames(updatedGames)
}
updateGames 方法将通过从游戏数组中切割指定的游戏来更新 Home 组件中渲染的游戏列表。当用户使用 GameDetail 组件中条件渲染的 EDIT 和 DELETE 选项删除他们的游戏时,将调用此方法,如下图中所示的应用程序主页上的游戏列表截图:
我们可以在用户个人资料页面上渲染类似的列表视图,仅显示相应用户制作的游戏,如下图中所示:
与 Home 组件中的实现步骤类似,在这个 Profile 组件中,我们可以通过调用相关的制作者列表游戏 API,在 useEffect 钩子中获取给定用户的游戏列表。在状态中设置检索到的游戏列表后,我们可以遍历它,在 GameDetail 组件中渲染每个游戏,正如之前讨论的那样,用于在主页上渲染所有游戏。在下文中,我们将讨论此 GameDetail 组件的实现,该组件将渲染单个游戏的详细信息。
游戏详情组件
我们将在应用程序中的任何游戏列表视图中实现GameDetail组件以渲染单个游戏。这个GameDetail组件接受游戏对象作为属性,并渲染游戏的详细信息,包括一个链接到 VR 游戏视图的“玩游戏”按钮,如下面的截图所示:
如果当前用户是游戏的制作者,此组件将条件性地渲染“编辑”和“删除”按钮。
在GameDetail组件的视图代码中,我们首先添加游戏详情——如名称、世界图像、线索文本和制作者名称——以使用户对游戏有一个概述。我们将使用 Material-UI 组件将这些详情组合成界面,如下面的代码所示:
mern-vrgame/client/game/GameDetail.js:
<Typography type="headline" component="h2">
{props.game.name}
</Typography>
<CardMedia image={props.game.world}
title={props.game.name}/>
<Typography type="subheading" component="h4">
<em>by</em>
{props.game.maker.name}
</Typography>
<CardContent>
<Typography type="body1" component="p">
{props.game.clue}
</Typography>
</CardContent>
此代码将渲染传入属性的游戏世界图像、游戏名称、制作者名称和线索文本。
在GameDetail组件中渲染的“玩游戏”按钮将简单地是一个包裹在 HTML 链接元素中的按钮,该链接指向打开 React 360 生成的index.html文件的路径(服务器上此路径的实现将在玩 VR 游戏部分讨论)。这个“玩游戏”链接添加到GameDetail组件中,如下所示:
mern-vrgame/client/game/GameDetail.js:
<a href={"/game/play?id=" + props.game._id} target='_self'>
<Button variant="contained" color="secondary"
className={classes.button}>
Play Game
</Button>
</a>
游戏视图的路径使用游戏 ID 作为查询参数。我们在链接上设置了target='_self',这样 React Router 就会跳过转到下一个状态,让浏览器处理这个链接。这样做将允许浏览器在点击链接时直接向服务器发送请求,并渲染服务器对此请求发送的index.html文件,使用户能够立即开始玩渲染的 VR 游戏。
在GameDetail组件的最后部分,我们将条件性地显示“编辑”和“删除”选项,仅当当前登录的用户也是渲染的游戏的制作者时。我们将使用以下代码添加这些选项:
mern-vrgame/client/game/GameDetail.js:
{auth.isAuthenticated().user
&& auth.isAuthenticated().user._id == props.game.maker._id &&
(<div>
<Link to={"/game/edit/" + props.game._id}>
<Button variant="raised" color="primary"
className={classes.editbutton}>
Edit
</Button>
</Link>
<DeleteGame game={props.game}
removeGame={props.updateGames}/>
</div>)}
在确保当前用户确实认证后,我们检查已登录用户的用户 ID 是否与游戏中的制作者 ID 匹配。然后,相应地,我们渲染链接到编辑表单视图的“编辑”按钮,以及带有DeleteGame组件的“删除”选项。
这个DeleteGame组件的实现与第七章中讨论的DeleteShop组件类似,使用在线市场锻炼 MERN 技能。不同于商店,DeleteGame组件将接受要删除的游戏和从父组件接收的updateGames函数定义作为属性。在集成此实现后,游戏的制作者将能够从平台上删除游戏。
访问 MERN VR 游戏应用的用户可以浏览在这些视图中渲染的游戏列表,并通过点击相应的GameDetail组件中渲染的“播放游戏”链接来选择播放游戏。在下一节中,我们将看到如何更新服务器以处理播放游戏的请求。
播放 VR 游戏
MERN VR 游戏应用的用户将能够打开并播放应用内的任何 VR 游戏。为了实现这一点,我们将在服务器上添加一个 API,该 API 渲染由 React 360 生成的index.html文件,如前一章所述,第十三章,开发基于 Web 的 VR 游戏。此后端 API 将在以下路径接收一个GET请求:
/game/play?id=<game ID>
此路径将一个游戏 ID值作为查询参数。此 URL 中的游戏 ID将在本章后面详细说明的 React 360 代码中使用,用于通过加载游戏 API 获取游戏详情。在下一节中,我们将查看实现后端 API 的过程,该 API 将处理用户点击“播放游戏”按钮时开始的GET请求。
实现渲染 VR 游戏视图的 API
为了实现将在浏览器中渲染 VR 游戏的 API,我们将在后端添加一个路由,该路由将接收一个GET请求并打开 React 360 的index.html页面。
此路由将在game.routes.js中声明,与其他游戏路由一起,如下所示:
mern-vrgame/server/routes/game.routes.js:
router.route('/game/play')
.get(gameCtrl.playGame)
在此路由接收到的GET请求将执行playGame控制器方法,该方法将返回响应请求的index.html页面。playGame控制器方法将定义如下代码:
mern-vrgame/server/controllers/game.controller.js:
const playGame = (req, res) => {
res.sendFile(process.cwd()+'/server/vr/index.html')
}
playGame控制器方法将简单地发送放置在/server/vr/文件夹中的index.html页面给请求客户端。
在浏览器中,这将渲染 React 360 游戏代码,该代码需要使用加载游戏 API 从数据库中获取游戏详情,并渲染游戏世界,以及用户可以与之交互的 VR 对象。在下一节中,我们将看到我们之前用 React 360 构建的游戏视图需要如何更新以动态加载这些游戏详情。
更新 React 360 中的游戏代码
在 MERN 应用中设置好游戏后端后,我们可以更新我们在第十三章,开发基于 Web 的 VR 游戏中开发的 React 360 项目代码,使其能够直接从数据库中的游戏集合中渲染游戏。
我们将使用在打开 React 360 应用程序的链接中的游戏 ID 来获取游戏详情,使用 React 360 代码内的加载游戏 API。然后,我们将检索到的游戏数据设置为状态,以便游戏从数据库加载详情,而不是我们在 第十三章,“开发基于 Web 的 VR 游戏” 中使用的静态示例数据。一旦代码更新,我们再次捆绑它,并将编译的文件放置在 MERN 应用程序中,然后尝试集成,如以下几节所述。
从链接中获取游戏 ID
为了根据用户在 MERN VR 游戏应用程序中选择要玩的游戏渲染 VR 游戏,我们需要从加载 VR 游戏视图的链接中检索相应的游戏 ID。在 React 360 项目文件夹的 index.js 文件中,我们将更新 componentDidMount 方法,首先从传入的 URL 中检索游戏 ID,然后对加载游戏 API 进行获取调用,如下面的代码所示:
/MERNVR/index.js:
componentDidMount = () => {
let gameId = Location.search.split('?id=')[1]
read({
gameId: gameId
}).then((data) => {
if (data.error) {
this.setState({error: data.error});
} else {
this.setState({
vrObjects: data.answerObjects.concat(data.wrongObjects),
game: data
});
Environment.setBackgroundImage(
{uri: data.world}
)
}
})
}
Location.search 允许我们访问加载 index.html 的传入 URL 中的查询字符串。检索到的查询字符串被 split 以从 URL 中附加的 id 查询参数中获取 gameId 值。我们使用这个 gameId 值从后端的加载游戏 API 获取游戏详情,并将其设置为游戏和 vrObjects 的状态值。为了能够在 React 360 项目中使用加载游戏 API,我们将在项目中定义一个相应的 fetch 方法,如下一节所述。
使用加载游戏 API 获取游戏数据
我们希望在 React 360 代码内部获取游戏数据。在 React 360 项目文件夹中,我们将添加一个 api-game.js 文件,该文件将包含一个 read 获取方法,该方法使用提供的游戏 ID 调用服务器上的加载游戏 API。这个 fetch 方法将定义如下:
/MERNVR/api-game.js:
const read = (params) => {
return fetch('/api/game/' + params.gameId, {
method: 'GET'
}).then((response) => {
return response.json()
}).catch((err) => console.log(err))
}
export {
read
}
这个 fetch 方法接收 params 中的游戏 ID 并对数据库中的相应游戏进行 API 调用。它用于 React 360 入口组件的 componentDidMount 中,该组件定义在 index.js 文件中,用于检索游戏详情,如前所述。
此更新后的 React 360 代码可在 GitHub 仓库的 dynamic-game-second-edition 分支上找到,网址为 github.com/shamahoque/…。
在更新 React 360 代码并能够根据传入 URL 中指定的游戏 ID 获取和渲染游戏详情后,我们可以将此更新代码捆绑并集成到 MERN VR 游戏应用程序中,如下一节所述。
捆绑和集成更新后的代码
当 React 360 代码更新为从服务器动态获取和渲染游戏详细信息时,我们可以使用提供的捆绑脚本来捆绑此代码,并将新编译的文件放置在 MERN VR 游戏项目目录的dist文件夹中。
要从命令行捆绑 React 360 代码,请转到 React 360 MERNVR项目文件夹并运行以下代码:
yarn bundle
这将在build/文件夹中生成带有更新后的 React 360 代码的client.bundle.js和index.bundle.js捆绑文件。这些文件,连同index.html文件和static_assets文件夹,需要添加到 MERN VR 游戏应用程序代码中,正如在第十三章[4f633dd6-f392-490d-b3a6-eb5430b58ec8.xhtml]“开发基于 Web 的 VR 游戏”中讨论的那样,以集成最新的 VR 游戏代码。
完成此集成后,如果我们运行 MERN VR 游戏应用程序并点击任何游戏中的 PLAY GAME 链接,它应该会打开游戏视图,在 VR 场景中渲染特定游戏的详细信息,并允许与 VR 对象进行交互,如游戏玩法中指定的。
摘要
在本章中,我们将 MERN 堆栈技术的功能与 React 360 集成,以开发一个用于 Web 的动态 VR 游戏应用程序。
我们扩展了 MERN 骨架应用程序,构建了一个可工作的后端,用于存储 VR 游戏详细信息,并允许我们通过 API 调用来操作这些详细信息。我们添加了 React 视图,让用户可以修改游戏并浏览游戏,有选项在由服务器直接渲染的指定路由上启动和玩 VR 游戏。
最后,我们通过从传入的 URL 中检索查询参数并使用 fetch 通过游戏 API 检索数据,更新了 React 360 项目代码,以便在 MERN 应用程序和 VR 游戏视图中传递数据。
将 React 360 代码与 MERN 堆栈应用程序集成产生了功能齐全且动态的基于 Web 的 VR 游戏应用程序,展示了如何使用和扩展 MERN 堆栈技术来创建独特的用户体验。您可以将这里揭示的能力应用于构建自己的 VR 增强全栈 Web 应用程序。
在下一章中,我们将回顾本书中构建的全栈 MERN 应用程序,讨论不仅遵循的最佳实践,还有改进和进一步发展的空间。