完成《羊了个羊》小游戏(低配版)
前面有总结过一篇《简易动物版的羊了个羊》的完成,今日参阅了鱼皮大佬的鱼了个鱼,我猎奇研究了一下他的源码完成,并结合自己的了解,也用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 } = Form
const { Option } = Select
const 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中心逻辑拿办法并调用罢了。
到此为止,咱们这个游戏就算是完成了,感谢鱼皮大佬的奉献,让我对这个游戏的原理完成有了更深入的知道。
以下是源码和示例,
游戏源码
在线示例