feat: 主题切换
12
README.md
|
@ -1,6 +1,6 @@
|
|||
# 能够解出来的 "羊了个羊" 小游戏 Demo
|
||||
|
||||

|
||||

|
||||
|
||||
坑爹的小游戏(本来玩法挺有意思的,非得恶心人),根本无解(99.99%无解),气的我自己写了个 demo,
|
||||
扫码或:<a href="https://solvable-sheep-game.streakingman.com/" target="_blank">pc 浏览器体验</a>
|
||||
|
@ -14,22 +14,24 @@
|
|||
|
||||
开心就好 😄
|
||||
|
||||

|
||||

|
||||
|
||||
## Contribution
|
||||
|
||||
vite+react 实现,欢迎 star、issue、pr、fork(尽量标注原仓库地址)
|
||||
|
||||
切换主题参考 `src/themes` 下的代码,欢迎整活
|
||||
|
||||
## Todo List
|
||||
|
||||
- [x] 基础操作
|
||||
- [x] 关卡生成
|
||||
- [ ] UI/UX 优化
|
||||
- [ ] 多主题
|
||||
- [x] 多主题
|
||||
- [ ] 计时
|
||||
- [ ] 性能优化
|
||||
- [ ] BGM/音效
|
||||
- [ ] 点击时的缓冲队列,优化交互动画效果
|
||||
- [x] BGM/音效
|
||||
- [ ] ~~点击时的缓冲队列,优化交互动画效果~~
|
||||
- [ ] 该游戏似乎涉嫌抄袭,考证后补充来源说明
|
||||
|
||||
## License
|
||||
|
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 245 KiB |
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
@ -50,6 +50,12 @@
|
|||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.symbol-inner img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.queue-container {
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
|
|
99
src/App.tsx
|
@ -1,4 +1,5 @@
|
|||
import React, {
|
||||
ChangeEventHandler,
|
||||
FC,
|
||||
MouseEventHandler,
|
||||
useEffect,
|
||||
|
@ -9,8 +10,11 @@ import React, {
|
|||
import './App.css';
|
||||
import { GithubIcon } from './GithubIcon';
|
||||
import { randomString, waitTimeout } from './utils';
|
||||
import { DefaultSoundNames, defaultTheme } from './themes/default';
|
||||
import { Icon, Theme } from './themes/interface';
|
||||
import { fishermanTheme } from './themes/fisherman';
|
||||
|
||||
const icons = [`🎨`, `🌈`, `⚙️`, `💻`, `📚`, `🐯`, `🐤`, `🐼`, `🐏`, `🍀`];
|
||||
const themes = [defaultTheme, fishermanTheme];
|
||||
|
||||
// 最大关卡
|
||||
const maxLevel = 50;
|
||||
|
@ -21,13 +25,13 @@ interface MySymbol {
|
|||
isCover: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
icon: string;
|
||||
icon: Icon;
|
||||
}
|
||||
|
||||
type Scene = MySymbol[];
|
||||
|
||||
// 8*8网格 4*4->8*8
|
||||
const makeScene: (level: number) => Scene = (level) => {
|
||||
const makeScene: (level: number, icons: Icon[]) => Scene = (level, icons) => {
|
||||
const curLevel = Math.min(maxLevel, level);
|
||||
const iconPool = icons.slice(0, 2 * curLevel);
|
||||
const offsetPool = [0, 25, -25, 50, -50].slice(0, 1 + curLevel);
|
||||
|
@ -42,7 +46,7 @@ const makeScene: (level: number) => Scene = (level) => {
|
|||
[0, 8],
|
||||
][Math.min(4, curLevel - 1)];
|
||||
|
||||
const randomSet = (icon: string) => {
|
||||
const randomSet = (icon: Icon) => {
|
||||
const offset =
|
||||
offsetPool[Math.floor(offsetPool.length * Math.random())];
|
||||
const row =
|
||||
|
@ -127,14 +131,20 @@ const Symbol: FC<SymbolProps> = ({ x, y, icon, isCover, status, onClick }) => {
|
|||
className="symbol-inner"
|
||||
style={{ backgroundColor: isCover ? '#999' : 'white' }}
|
||||
>
|
||||
<i>{icon}</i>
|
||||
{typeof icon.content === 'string' ? (
|
||||
<i>{icon.content}</i>
|
||||
) : (
|
||||
icon.content
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const App: FC = () => {
|
||||
const [scene, setScene] = useState<Scene>(makeScene(1));
|
||||
const [curTheme, setCurTheme] =
|
||||
useState<Theme<DefaultSoundNames>>(defaultTheme);
|
||||
const [scene, setScene] = useState<Scene>(makeScene(1, curTheme.icons));
|
||||
const [level, setLevel] = useState<number>(1);
|
||||
const [queue, setQueue] = useState<MySymbol[]>([]);
|
||||
const [sortedQueue, setSortedQueue] = useState<
|
||||
|
@ -143,13 +153,14 @@ const App: FC = () => {
|
|||
const [finished, setFinished] = useState<boolean>(false);
|
||||
const [tipText, setTipText] = useState<string>('');
|
||||
const [animating, setAnimating] = useState<boolean>(false);
|
||||
|
||||
// 音效
|
||||
const soundRefMap = useRef<Record<string, HTMLAudioElement>>({});
|
||||
|
||||
// 第一次点击时播放bgm
|
||||
const bgmRef = useRef<HTMLAudioElement>(null);
|
||||
const [bgmOn, setBgmOn] = useState<boolean>(false);
|
||||
const [once, setOnce] = useState<boolean>(false);
|
||||
const tapSoundRef = useRef<HTMLAudioElement>(null);
|
||||
const tripleSoundRef = useRef<HTMLAudioElement>(null);
|
||||
const levelUpSoundRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (bgmOn) {
|
||||
bgmRef.current?.play();
|
||||
|
@ -158,14 +169,19 @@ const App: FC = () => {
|
|||
}
|
||||
}, [bgmOn]);
|
||||
|
||||
// 主题切换
|
||||
useEffect(() => {
|
||||
restart();
|
||||
}, [curTheme]);
|
||||
|
||||
// 队列区排序
|
||||
useEffect(() => {
|
||||
const cache: Record<string, MySymbol[]> = {};
|
||||
for (const symbol of queue) {
|
||||
if (cache[symbol.icon]) {
|
||||
cache[symbol.icon].push(symbol);
|
||||
if (cache[symbol.icon.name]) {
|
||||
cache[symbol.icon.name].push(symbol);
|
||||
} else {
|
||||
cache[symbol.icon] = [symbol];
|
||||
cache[symbol.icon.name] = [symbol];
|
||||
}
|
||||
}
|
||||
const temp = [];
|
||||
|
@ -181,12 +197,6 @@ const App: FC = () => {
|
|||
setSortedQueue(updateSortedQueue);
|
||||
}, [queue]);
|
||||
|
||||
const test = () => {
|
||||
const level = Math.ceil(maxLevel * Math.random());
|
||||
setLevel(level);
|
||||
checkCover(makeScene(level));
|
||||
};
|
||||
|
||||
// 初始化覆盖状态
|
||||
useEffect(() => {
|
||||
checkCover(scene);
|
||||
|
@ -264,7 +274,7 @@ const App: FC = () => {
|
|||
setFinished(false);
|
||||
setLevel(level + 1);
|
||||
setQueue([]);
|
||||
checkCover(makeScene(level + 1));
|
||||
checkCover(makeScene(level + 1, curTheme.icons));
|
||||
};
|
||||
|
||||
// 重开
|
||||
|
@ -272,7 +282,7 @@ const App: FC = () => {
|
|||
setFinished(false);
|
||||
setLevel(1);
|
||||
setQueue([]);
|
||||
checkCover(makeScene(1));
|
||||
checkCover(makeScene(1, curTheme.icons));
|
||||
};
|
||||
|
||||
// 点击item
|
||||
|
@ -289,9 +299,10 @@ const App: FC = () => {
|
|||
if (symbol.isCover || symbol.status !== 0) return;
|
||||
symbol.status = 1;
|
||||
|
||||
if (tapSoundRef.current) {
|
||||
tapSoundRef.current.currentTime = 0;
|
||||
tapSoundRef.current.play();
|
||||
// 点击音效
|
||||
if (soundRefMap.current) {
|
||||
soundRefMap.current[symbol.icon.clickSound].currentTime = 0;
|
||||
soundRefMap.current[symbol.icon.clickSound].play();
|
||||
}
|
||||
|
||||
let updateQueue = queue.slice();
|
||||
|
@ -312,9 +323,12 @@ const App: FC = () => {
|
|||
const find = updateScene.find((i) => i.id === sb.id);
|
||||
if (find) {
|
||||
find.status = 2;
|
||||
if (tripleSoundRef.current) {
|
||||
tripleSoundRef.current.currentTime = 0.55;
|
||||
tripleSoundRef.current.play();
|
||||
// 三连音效
|
||||
if (soundRefMap.current) {
|
||||
soundRefMap.current[
|
||||
symbol.icon.tripleSound
|
||||
].currentTime = 0;
|
||||
soundRefMap.current[symbol.icon.tripleSound].play();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -336,7 +350,7 @@ const App: FC = () => {
|
|||
// 升级
|
||||
setLevel(level + 1);
|
||||
setQueue([]);
|
||||
checkCover(makeScene(level + 1));
|
||||
checkCover(makeScene(level + 1, curTheme.icons));
|
||||
} else {
|
||||
setQueue(updateQueue);
|
||||
checkCover(updateScene);
|
||||
|
@ -351,7 +365,21 @@ const App: FC = () => {
|
|||
<h6>
|
||||
<GithubIcon />
|
||||
</h6>
|
||||
<h3>Level: {level} </h3>
|
||||
<h3 className="flex-container flex-center">
|
||||
主题:
|
||||
<select
|
||||
onChange={(e) =>
|
||||
setCurTheme(themes[Number(e.target.value)])
|
||||
}
|
||||
>
|
||||
{themes.map((t, idx) => (
|
||||
<option key={t.name} value={idx}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
Level: {level}
|
||||
</h3>
|
||||
|
||||
<div className="app">
|
||||
<div className="scene-container">
|
||||
|
@ -404,13 +432,22 @@ const App: FC = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/*bgm*/}
|
||||
<button className="bgm-button" onClick={() => setBgmOn(!bgmOn)}>
|
||||
{bgmOn ? '🔊' : '🔈'}
|
||||
<audio ref={bgmRef} loop src="/sound-disco.mp3" />
|
||||
</button>
|
||||
|
||||
<audio ref={tapSoundRef} src="/sound-button-click.mp3" />
|
||||
<audio ref={tripleSoundRef} src="/sound-triple.mp3" />
|
||||
{/*音效*/}
|
||||
{curTheme.sounds.map((sound) => (
|
||||
<audio
|
||||
key={sound.name}
|
||||
ref={(ref) => {
|
||||
if (ref) soundRefMap.current[sound.name] = ref;
|
||||
}}
|
||||
src={sound.src}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -33,6 +33,12 @@ h1 {
|
|||
line-height: 1.1;
|
||||
}
|
||||
|
||||
select {
|
||||
border: 2px solid gray;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
|
|
40
src/themes/default/index.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { Theme } from '../interface';
|
||||
|
||||
const icons = <const>[
|
||||
`🎨`,
|
||||
`🌈`,
|
||||
`⚙️`,
|
||||
`💻`,
|
||||
`📚`,
|
||||
`🐯`,
|
||||
`🐤`,
|
||||
`🐼`,
|
||||
`🐏`,
|
||||
`🍀`,
|
||||
];
|
||||
|
||||
export type DefaultSoundNames = 'button-click' | 'triple';
|
||||
|
||||
import soundButtonClickUrl from './sounds/sound-button-click.mp3';
|
||||
import soundTripleUrl from './sounds/sound-triple.mp3';
|
||||
export const defaultSounds: Theme<DefaultSoundNames>['sounds'] = [
|
||||
{
|
||||
name: 'button-click',
|
||||
src: soundButtonClickUrl,
|
||||
},
|
||||
{
|
||||
name: 'triple',
|
||||
src: soundTripleUrl,
|
||||
},
|
||||
];
|
||||
|
||||
export const defaultTheme: Theme<DefaultSoundNames> = {
|
||||
name: '默认',
|
||||
icons: icons.map((icon) => ({
|
||||
name: icon,
|
||||
content: icon,
|
||||
clickSound: 'button-click',
|
||||
tripleSound: 'triple',
|
||||
})),
|
||||
sounds: defaultSounds,
|
||||
};
|
BIN
src/themes/fisherman/images/塘鳢鱼.png
Normal file
After Width: | Height: | Size: 5.3 KiB |
BIN
src/themes/fisherman/images/河豚.png
Normal file
After Width: | Height: | Size: 5.4 KiB |
BIN
src/themes/fisherman/images/锦鲤.png
Normal file
After Width: | Height: | Size: 7.5 KiB |
BIN
src/themes/fisherman/images/鲈鱼.png
Normal file
After Width: | Height: | Size: 7.6 KiB |
BIN
src/themes/fisherman/images/鲑鱼.png
Normal file
After Width: | Height: | Size: 6.7 KiB |
BIN
src/themes/fisherman/images/鲤鱼.png
Normal file
After Width: | Height: | Size: 7.2 KiB |
BIN
src/themes/fisherman/images/鲨鱼.png
Normal file
After Width: | Height: | Size: 6.6 KiB |
BIN
src/themes/fisherman/images/鲫鱼.png
Normal file
After Width: | Height: | Size: 5.7 KiB |
BIN
src/themes/fisherman/images/鳖.png
Normal file
After Width: | Height: | Size: 6.1 KiB |
BIN
src/themes/fisherman/images/黑鱼.png
Normal file
After Width: | Height: | Size: 6.2 KiB |
27
src/themes/fisherman/index.tsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
// 钓鱼佬主题
|
||||
import React from 'react';
|
||||
import { Theme } from '../interface';
|
||||
import { DefaultSoundNames, defaultSounds } from '../default';
|
||||
|
||||
const imagesUrls = import.meta.glob('./images/*.png', {
|
||||
import: 'default',
|
||||
eager: true,
|
||||
});
|
||||
|
||||
const fishes = Object.entries(imagesUrls).map(([key, value]) => ({
|
||||
name: key.slice(9, -4),
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
content: <img src={value} alt="" />,
|
||||
}));
|
||||
|
||||
export const fishermanTheme: Theme<DefaultSoundNames> = {
|
||||
name: '钓鱼佬',
|
||||
icons: fishes.map(({ name, content }) => ({
|
||||
name,
|
||||
content,
|
||||
clickSound: 'button-click',
|
||||
tripleSound: 'triple',
|
||||
})),
|
||||
sounds: defaultSounds,
|
||||
};
|
19
src/themes/interface.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { ReactNode } from 'react';
|
||||
|
||||
export interface Icon<T = string> {
|
||||
name: string;
|
||||
content: ReactNode;
|
||||
clickSound: T;
|
||||
tripleSound: T;
|
||||
}
|
||||
|
||||
interface Sound<T = string> {
|
||||
name: T;
|
||||
src: string;
|
||||
}
|
||||
|
||||
export interface Theme<SoundNames> {
|
||||
name: string;
|
||||
icons: Icon<SoundNames>[];
|
||||
sounds: Sound<SoundNames>[];
|
||||
}
|