实现《羊了个羊》小游戏(低配版)
后面有总结过一篇《繁难动物版的羊了个羊》的实现,明天参考了鱼皮大佬的鱼了个鱼,我好奇钻研了一下他的源码实现,并联合本人的了解,也用react + antd实现了一版,在实现的过程中,我只是简略批改了一下外围逻辑和游戏配置,而后借助于reactivue这个库将vue的composition API用到了这里。
游戏的外围逻辑我也大抵理了一遍,只是做了一些小改变就拿过去用了,外面也有源码实现的正文,所以不必多讲,次要在于外围UI的构建,这里能够大抵讲一下。
在这里我也用到了之前文章手写一个mini版本的React状态管理工具来用作游戏参数的状态治理,因而对于这里的实现也没有必要多做细讲。接下来,咱们就来看它在这里的应用。如下所示:
import { createModel } from './createModel';import { useState } from "react"import { defaultGameConfig } from '../core/gameConfig';export interface ConfigType { gameConfig: GameConfigType customGameConfig: GameConfigType}const useConfig = () => { const [config,setConfig] = useState<ConfigType>({ gameConfig:{ ...defaultGameConfig }, customGameConfig:{ ...defaultGameConfig } }) const updateConfig = (config:ConfigType) => { setConfig(config) } // 重置为初始值 const reset = () => setConfig({ gameConfig:{ ...defaultGameConfig },customGameConfig:{ ...defaultGameConfig } }) return { config, updateConfig, reset }}const GameConfigStore = createModel(useConfig);export default GameConfigStore;
首先导入createModel办法还有react的useState办法以及游戏参数的默认配置,接着定义一个hooks用作createModel的参数,返回游戏的配置以及更新配置函数以及还原最后初始配置的办法,即updateConfig和reset办法,这里其实也没有多大的难度,次要可能在于类型的定义。
咱们来看接口的定义,也在全局type.d.ts文件下,如下所示:
/** * 块类型 */interface BlockType { id: number; x: number; y: number; level: number; type: string; // 0 - 失常, 1 - 已点击, 2 - 已打消 status: 0 | 1 | 2; // 压住的其余块 higherThanBlocks: BlockType[]; // 被哪些块压住(为空示意可点击) lowerThanBlocks: BlockType[];}/** * 每个格子单元类型 */interface BlockUnitType { // 放到以后格子里的块(层级越高下标越大) blocks: BlockType[];} /** * 游戏配置类型 */interface GameConfigType { // 槽容量 slotNum: number; // 须要多少个一样块的能力合成 composeNum: number; // 素材类别数 materialTypeNum: number; // 每层块数(大抵) levelBlockNum: number; // 边界膨胀步长 borderStep: number; // 总层数(最小为 2) levelNum: number; // 随机区块数(数组长度代表随机区数量,值示意每个随机区生产多少块) randomBlocks: number[]; // 素材列表 materialList: Record<string,string> []; // 最上层块数(已废除) // topBlockNum: 40, // 最上层块数最小值(已废除) // minBottomBlockNum: 20,}/** * 技能类型 */interface SkillType { name: string; desc: string; icon: string; action: function;}
接口的定义大同小异,也只是在原版的根底上略微做了一点批改。
定义好状态之后,咱们在App.tsx中导入应用,App.tsx中代码如下:
import styled from '@emotion/styled';import { BASE_IMAGE_URL } from './core/gameConfig';import Router from './router/router';import GameConfigStore from './store/store';const StyleApp = styled.div({ background:`url(${BASE_IMAGE_URL}1.jpg)no-repeat center/cover`, padding:'16px 16px 50px', minHeight:'100vh', backgroundSize:"100% 100%", width:"100%"});const StyleContent = styled.div` max-width:480px; margin: 0 auto;`const App = () => { const { Provider } = GameConfigStore; return ( <StyleApp> <Provider> <StyleContent> <Router /> </StyleContent> </Provider> </StyleApp> )}export default App
能够看到,咱们通过对象构造的形式在App组件中获得Provider组件,将Provider组件包裹两头的路由组件,事实上Provider组件还能够传入一个默认参数的值,在这里咱们并没有传入。
这个组件其实次要考查了2个知识点,第一个就是styled-component,这里用到的是@emotion/styled这个库,无关语法的应用能够具体看官网文档。
第二个知识点就是路由的应用,咱们来看Router.tsx的代码,如下:
import React from 'react';import { useRoutes } from 'react-router-dom';import type { RouteObject } from 'react-router-dom';import Load from '../components/Loader';const lazy = (component: <T>() => Promise<{ default: React.ComponentType<T> }>) => { const LazyComponent = React.lazy(component); return ( <React.Suspense fallback={<Load></Load>} > <LazyComponent></LazyComponent> </React.Suspense> )}const routes:RouteObject [] = [ { path:"/", element:lazy(() => import('../views/IndexPage')) }, { path:"/config", element:lazy(() => import('../views/ConfigPage')) }, { path:"/game", element:lazy(() => import('../views/GamePage')) }]export default () => useRoutes(routes)
路由外面,其实也就是用到了懒加载lazy函数,以及Suspense组件,而后通过useRoutes办法导出一个路由组件应用,这样就能够像Vue那样通过配置路由的形式来应用路由。
这里有一个load组件的实现,咱们来看它的源码,如下所示:
import styled from '@emotion/styled';import { Spin } from 'antd';const StyleLoad = styled.div({ display:'flex', minHeight:'100vh', justifyContent:'center', alignItems:"center"});export interface LoadProps { message?: string}const Load = (props: Partial<LoadProps>) => { const { message = 'loading....' } = props; return ( <StyleLoad> <Spin tip={message}></Spin> </StyleLoad> )}export default Load;
其实也很简略,就是增加了一个message的props配置,而后应用antd的Spin组件。
接下来就是外围的三个页面,别离是首页,抉择游戏模式,以及自定义游戏配置和游戏页面,这个咱们前面再看,这里咱们来看一下有一个hooks函数,也就是强制更新的hooks函数useForceUpdate,如下:
import { useReducer } from 'react';function useForceUpdate() { const [, dispatch] = useReducer(() => Object.create(null), {}); return dispatch;}export default useForceUpdate;
其实也就是利用useReducer函数强制更新组件,这里为什么要用这个hook函数呢?答案其实就在game.ts外面,咱们来看game.ts外面:
import _ from "lodash";import { createSetup } from 'reactivue'import GameConfigStore from "../store/store";const useGame = () => { const { config: { gameConfig } } = GameConfigStore.useModel(); const setup = createSetup(() => { // 外围游戏逻辑,参考源码正文 }); return setup();};export default useGame;
能够看到这里,咱们通过导出一个useGame函数,就能够在游戏页面里应用这些外围的游戏逻辑接口,然而咱们游戏外面的外围逻辑是应用的Vue的响应式数据对象,只管vue帮咱们更新了数据,可是react并不知道数据是否更新,所以这里就采纳useForceUpdate函数来更新数据,尽管这并不是一种好的更新数据的形式。
如果有更好的更新视图和数据的形式,还望大佬指点迷津。
至于游戏配置文件也没什么好解释的,能够本人看一下源码。
接下来,咱们来看组件的实现,这里有几个公共组件About.tsx,Footer.tsx,Win.tsx以及Title.tsx的实现,其中可能也就Win.tsx中的爱心组件和Title.tsx组件的实现能够说一下,咱们先来看Title.tsx的实现。如下:
import { ElementType, ReactNode } from "react";export interface TitleProps extends Record<string,any>{ children: ReactNode level: number | string}const levelList = [1,2,3,4,5,6];const Title = (props: Partial<TitleProps>) => { const { level,children,...rest } = props; const Component = (level && levelList.includes(+level) ? `h${level}` : 'h1') as ElementType; return ( <Component {...rest}>{ children }</Component> )}export default Title;
这里值得说一下的就是将Component断言成ElementType元素,从逻辑上仿佛有些说不通,因为自身Component就是一个字符串,可是字符串在react当中也能够算作是一个元素组件,所以也就断言成ElementType也就天经地义没问题啦。
而后就是Heart组件的实现,实际上也就是利用css画一个爱心,如下所示:
const StyleHeart = styled.div({ width: 25, height: 25, background:"#e63f0c", position: 'relative', margin: '1em auto', transform:'rotate(45deg)', animation:'scale 2s linear infinite', '@keyframes scale':{ '0%':{ transform:'scale(1) rotate(45deg)' }, '100%':{ transform:"scale(1.2) rotate(45deg)" } }, '&::before,&::after':{ content:'""', width:'100%', height:'100%', borderRadius:'50%', position:'absolute', background:"#e63f0c", }, '&::before':{ left:'-15px', top: 0 }, '&::after':{ top:'-15px', left: 0 }});
这是emotion的语法。
接下来就是对三个页面的详解,首先咱们来看首页,代码如下:
import styled from '@emotion/styled';import Title from '../components/Title';import { Button } from 'antd';import About from '../components/About';import Footer from '../components/Footer';import { useNavigate } from 'react-router-dom';import { easyGameConfig, hardGameConfig, lunaticGameConfig, middleGameConfig, skyGameConfig, yangGameConfig } from '../core/gameConfig';import GameConfigStore from '../store/store';const StyleIndexPage = styled.div({ textAlign:'center'});const StyleTitle = styled(Title)({});const StyleDescription = styled.div` margin-bottom: 16px; color: rgba(0,0,0,.8);`;const StyleButton = styled(Button)({ marginBottom: 16})const ButtonList = [ { text:"简略模式", config:easyGameConfig }, { text:"中等模式", config:middleGameConfig }, { text:"艰难模式", config:hardGameConfig }, { text:"天堂模式", config:lunaticGameConfig }, { text:"天狱模式", config:skyGameConfig }, { text:"羊了个羊模式", config:yangGameConfig }, { text:"自定义", config:null }]const IndexPage = () => { const navigate = useNavigate(); const { config:{ customGameConfig },updateConfig } = GameConfigStore.useModel(); const toGame = (config:GameConfigType | null) => { if(config){ updateConfig({ gameConfig: config,customGameConfig }); navigate('/game'); }else{ navigate('/config'); } } return ( <StyleIndexPage> <StyleTitle level={2}>羊了个羊(美女版)</StyleTitle> <StyleDescription>低配版羊了个羊小游戏,仅供消遣</StyleDescription> { ButtonList.map((item,index: number) => ( <StyleButton block onClick={() => toGame(item.config)} key={`${item.text}-${index}`}>{ item.text }</StyleButton> )) } <About /> <Footer /> </StyleIndexPage> )}export default IndexPage;
其实也比拟好了解,就是渲染一个按钮列表,而后给按钮列表增加导航跳转,将游戏的配置传过来,而外围当然是用到咱们定义的状态来传递。
useNavigate办法也就是react-router-dom提供的API,也没什么好说的,咱们持续来看ConfigPage.tsx,实质上这个页面就是一个表单页面,所以也没什么好说的。代码如下:
import styled from '@emotion/styled'import Title from '../components/Title'import { Button, Form, InputNumber, Select, Image, Tooltip } from 'antd'import { useNavigate } from 'react-router-dom'import { materialList } from '../core/gameConfig'import { useEffect, useState } from 'react'import GameConfigStore from '../store/store'const StyleConfigPage = styled.div` padding: 5px;`const { Item } = Formconst { Option } = Selectconst StyleTitle = styled(Title)({ '&::before,&::after': { clear: 'both', display: 'block', content: '""' }})const StyleButton = styled(Button)({ float: 'right'})const StyleInputNumber = styled(InputNumber)({ 'width': "100%"})const StyleFooterButton = styled(Button)({ marginBottom: 16})const ConfigPage = () => { const navigate = useNavigate() const [form] = Form.useForm() const { config: { customGameConfig },reset,updateConfig } = GameConfigStore.useModel() const setFormConfig = (config: GameConfigType) => { const { materialList: configMaterialList, ...rest } = config return { ...rest, materialNum: configMaterialList.map(item => item.value), randomAreaNum: 2, randomBlockNum: 8, } } const [customFormConfig,setCustomFormConfig] = useState(setFormConfig(customGameConfig)) useEffect(() => { setCustomFormConfig(setFormConfig(customGameConfig)) },[customGameConfig]) const goBack = () => { navigate(-1); } const onFinishHandler = () => { const config = form.getFieldsValue(true); config.materialList = config.materialNum.map((key: string) => materialList.find(item => item.value === key)); delete config.materialNum; updateConfig({ gameConfig: config, customGameConfig: config }); navigate('/game'); } return ( <StyleConfigPage> <StyleTitle level={2}> 自定义难度 <StyleButton onClick={goBack}>返回</StyleButton> </StyleTitle> <Form autoComplete="off" label-align="left" labelCol={{ span: 4 }} onFinish={onFinishHandler} form={form} initialValues={customFormConfig} > <Item label="槽容量" name="slotNum"> <StyleInputNumber /> </Item> <Item label="合成数" name="composeNum"> <StyleInputNumber /> </Item> <Item label="素材类别数" name="materialTypeNum"> <StyleInputNumber /> </Item> <Item label="素材列表" name="materialNum"> <Select mode='multiple'> { materialList.map((item, index) => ( <Option key={`${item.value}-${index}`} value={item.value}> <Tooltip title={item.label} placement="leftTop"> <Image src={item.value} width={40} height={40}></Image> </Tooltip> </Option> )) } </Select> </Item> <Item label="总层数" name="levelNum"> <StyleInputNumber /> </Item> <Item label="每层块数" name="levelBlockNum"> <StyleInputNumber /> </Item> <Item label="边界膨胀" name="borderStep"> <StyleInputNumber /> </Item> <Item label="随机区数" name="randomAreaNum"> <StyleInputNumber /> </Item> <Item label="随机区块数" name="randomBlockNum"> <StyleInputNumber /> </Item> <Item> <StyleFooterButton htmlType='submit' block>开始</StyleFooterButton> <StyleFooterButton block onClick={() => form.resetFields()}>重置</StyleFooterButton> <Button danger block onClick={reset}>还原初始配置</Button> </Item> </Form> </StyleConfigPage> )}export default ConfigPage
antd提供了useForm办法能够将表单数据很好的治理在一个状态中,咱们也就不必为每个表单元素绑定value和change事件了。
接下来就是游戏外围页面,如下:
import styled from "@emotion/styled";import { Row, Button, Space } from "antd";import { useEffect, useState } from "react";import { useNavigate } from "react-router-dom";import Win from "../components/Win";import useGame from "../core/game";import { AUDIO_URL } from "../core/gameConfig";import useForceUpdate from "../hooks/useForceUpdate";const StyleGamePage = styled.div({ padding: 5,});const StyleBackButton = styled(Button)({ marginBottom: 8,});const StyleLevelBoard = styled.div<{ show: boolean }>` display: ${({ show }) => (show ? "block" : "none")}; position: relative;`;const StyleBlock = styled.div({ width: 42, height: 42, border: "1px solid #eee", display: "inline-block", verticalAlign: "top", background: "#fff", cursor: "pointer", "& .image ": { width: "100%", height: "100%", border: "none", objectFit: "cover", }, "&.disabled": { background: "rgba(0,0,0,.85)", cursor: "not-allowed", "& .image": { display: "none", }, },});const StyleLevelBlock = styled(StyleBlock)` position: absolute;`;const StyleRandomBoard = styled(Row)({ marginTop: 8,});const StyleRandomArea = styled.div({ marginTop: 8,});const StyleSlotBoard = styled(Row)({ border: "10px solid #2396ef", margin: "16px auto", width: "fit-content",});const StyleSkillBoard = styled.div({ textAlign: "center",});const skillList = [ { method: "doRevert", text: "撤回", }, { method: "doRemove", text: "移出", }, { method: "doShuffle", text: "洗牌", }, { method: "doBroke", text: "毁坏", }, { method: "doHolyLight", text: "圣光", }, { method: "doSeeRandom", text: "透视", },];const GamePage = () => { const navigate = useNavigate(); const forceUpdate = useForceUpdate(); const onBack = () => navigate(-1); const [isPlayed, setIsPlayed] = useState(false); const [audio, setAudio] = useState<HTMLAudioElement>(); const { clearBlockNum, totalBlockNum, gameStatus, levelBlocksVal, doClickBlock, isHolyLight, widthUnit, heightUnit, randomBlocksVal, canSeeRandom, slotAreaVal, ...rest } = useGame(); useEffect(() => { if (!audio) { const audio = new Audio(); audio.src = AUDIO_URL; setAudio(audio); } if (isPlayed) { audio?.play(); } else { audio?.pause(); } }, [isPlayed]); return ( <StyleGamePage> <Row justify="space-between"> <StyleBackButton onClick={onBack}>返回</StyleBackButton> <Button onClick={() => setIsPlayed(!isPlayed)}> {isPlayed ? "暂停" : "播放"} </Button> <Button> 块数: {clearBlockNum} / {totalBlockNum} </Button> </Row> <Win isWin={gameStatus === 3}></Win> <Row justify="center"> <StyleLevelBoard className="level-board" show={gameStatus > 0}> {levelBlocksVal?.map((block, index) => ( <div key={`${block.id}-${index}`}> {block.status === 0 ? ( <StyleLevelBlock className={`${ !isHolyLight && block.lowerThanBlocks.length > 0 ? "disabled" : "" }`} data-id={block.id} style={{ zIndex: 100 + block.level, left: block.x * widthUnit + "px", top: block.y * heightUnit + "px", }} onClick={() => { doClickBlock(block); forceUpdate(); }} > <img className="image" src={block.type} alt={block.type} /> </StyleLevelBlock> ) : null} </div> ))} </StyleLevelBoard> </Row> <StyleRandomBoard justify="space-between"> {randomBlocksVal?.map((item, index) => ( <StyleRandomArea key={`${item}-${index}`}> {item.length > 0 ? ( <StyleBlock data-id={item[0].id} onClick={() => { doClickBlock(item[0], index); forceUpdate(); }} > <img className="image" src={item[0].type} alt={item[0].type} /> </StyleBlock> ) : null} {item?.slice(1).map((randomBlock, index) => ( <StyleBlock className="disabled" key={`${randomBlock.id}-${index}`} > <img className="image" src={randomBlock.type} alt={randomBlock.type} style={{ display: canSeeRandom ? "inline-block" : "none", }} /> </StyleBlock> ))} </StyleRandomArea> ))} </StyleRandomBoard> { <StyleSlotBoard> {slotAreaVal?.map((item, index) => ( <StyleBlock key={`${item?.id}-${index}`}> <img src={item?.type} alt={item?.type} className="image" /> </StyleBlock> ))} </StyleSlotBoard> } <StyleSkillBoard> <Space> {skillList.map((item, index) => ( <Button size="small" key={item.method + "-" + index} onClick={() => { const methods = { ...rest }; const handler = methods[item.method as keyof typeof methods]; if (typeof handler === "function") { handler(); forceUpdate(); } }} > {item.text} </Button> ))} </Space> </StyleSkillBoard> </StyleGamePage> );};export default GamePage;
能够看到这个页面,咱们恰好就用了forceUpdate办法,一个是在点击块的时候应用了,还要一个就是点击对应的圣光,撤销等按钮的时候也调用了这个办法强行更新视图。
这里也增加了一个音乐的播放配置,代码也很简略,就是监听一个播放状态,而后创立audio元素。可能比拟不好了解的是这段代码:
const methods = { ...rest };const handler = methods[item.method as keyof typeof methods];if (typeof handler === "function") { handler(); forceUpdate();}
其实就是对应的字符串办法名从咱们导出的useGame外围逻辑拿办法并调用而已。
到此为止,咱们这个游戏就算是实现了,感激鱼皮大佬的奉献,让我对这个游戏的原理实现有了更粗浅的意识。
以下是源码和示例,
游戏源码
在线示例