关于javascript:实现羊了个羊美女版小游戏低配版

4次阅读

共计 14335 个字符,预计需要花费 36 分钟才能阅读完成。

实现《羊了个羊》小游戏(低配版)

后面有总结过一篇《繁难动物版的羊了个羊》的实现,明天参考了鱼皮大佬的鱼了个鱼, 我好奇钻研了一下他的源码实现,并联合本人的了解,也用 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 外围逻辑拿办法并调用而已。

到此为止,咱们这个游戏就算是实现了,感激鱼皮大佬的奉献,让我对这个游戏的原理实现有了更粗浅的意识。

以下是源码和示例,

游戏源码

在线示例

正文完
 0