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

|

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

|

|
||||||
|
|
||||||
## Contribution
|
## Contribution
|
||||||
|
|
||||||
vite+react 实现,欢迎 star、issue、pr、fork(尽量标注原仓库地址)
|
vite+react 实现,欢迎 star、issue、pr、fork(尽量标注原仓库地址)
|
||||||
|
|
||||||
|
切换主题参考 `src/themes` 下的代码,欢迎整活
|
||||||
|
|
||||||
## Todo List
|
## Todo List
|
||||||
|
|
||||||
- [x] 基础操作
|
- [x] 基础操作
|
||||||
- [x] 关卡生成
|
- [x] 关卡生成
|
||||||
- [ ] UI/UX 优化
|
- [ ] UI/UX 优化
|
||||||
- [ ] 多主题
|
- [x] 多主题
|
||||||
- [ ] 计时
|
- [ ] 计时
|
||||||
- [ ] 性能优化
|
- [ ] 性能优化
|
||||||
- [ ] BGM/音效
|
- [x] BGM/音效
|
||||||
- [ ] 点击时的缓冲队列,优化交互动画效果
|
- [ ] ~~点击时的缓冲队列,优化交互动画效果~~
|
||||||
- [ ] 该游戏似乎涉嫌抄袭,考证后补充来源说明
|
- [ ] 该游戏似乎涉嫌抄袭,考证后补充来源说明
|
||||||
|
|
||||||
## License
|
## 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;
|
transition: 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.symbol-inner img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.queue-container {
|
.queue-container {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
99
src/App.tsx
|
@ -1,4 +1,5 @@
|
||||||
import React, {
|
import React, {
|
||||||
|
ChangeEventHandler,
|
||||||
FC,
|
FC,
|
||||||
MouseEventHandler,
|
MouseEventHandler,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
@ -9,8 +10,11 @@ import React, {
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import { GithubIcon } from './GithubIcon';
|
import { GithubIcon } from './GithubIcon';
|
||||||
import { randomString, waitTimeout } from './utils';
|
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;
|
const maxLevel = 50;
|
||||||
|
@ -21,13 +25,13 @@ interface MySymbol {
|
||||||
isCover: boolean;
|
isCover: boolean;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
icon: string;
|
icon: Icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Scene = MySymbol[];
|
type Scene = MySymbol[];
|
||||||
|
|
||||||
// 8*8网格 4*4->8*8
|
// 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 curLevel = Math.min(maxLevel, level);
|
||||||
const iconPool = icons.slice(0, 2 * curLevel);
|
const iconPool = icons.slice(0, 2 * curLevel);
|
||||||
const offsetPool = [0, 25, -25, 50, -50].slice(0, 1 + curLevel);
|
const offsetPool = [0, 25, -25, 50, -50].slice(0, 1 + curLevel);
|
||||||
|
@ -42,7 +46,7 @@ const makeScene: (level: number) => Scene = (level) => {
|
||||||
[0, 8],
|
[0, 8],
|
||||||
][Math.min(4, curLevel - 1)];
|
][Math.min(4, curLevel - 1)];
|
||||||
|
|
||||||
const randomSet = (icon: string) => {
|
const randomSet = (icon: Icon) => {
|
||||||
const offset =
|
const offset =
|
||||||
offsetPool[Math.floor(offsetPool.length * Math.random())];
|
offsetPool[Math.floor(offsetPool.length * Math.random())];
|
||||||
const row =
|
const row =
|
||||||
|
@ -127,14 +131,20 @@ const Symbol: FC<SymbolProps> = ({ x, y, icon, isCover, status, onClick }) => {
|
||||||
className="symbol-inner"
|
className="symbol-inner"
|
||||||
style={{ backgroundColor: isCover ? '#999' : 'white' }}
|
style={{ backgroundColor: isCover ? '#999' : 'white' }}
|
||||||
>
|
>
|
||||||
<i>{icon}</i>
|
{typeof icon.content === 'string' ? (
|
||||||
|
<i>{icon.content}</i>
|
||||||
|
) : (
|
||||||
|
icon.content
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const App: FC = () => {
|
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 [level, setLevel] = useState<number>(1);
|
||||||
const [queue, setQueue] = useState<MySymbol[]>([]);
|
const [queue, setQueue] = useState<MySymbol[]>([]);
|
||||||
const [sortedQueue, setSortedQueue] = useState<
|
const [sortedQueue, setSortedQueue] = useState<
|
||||||
|
@ -143,13 +153,14 @@ const App: FC = () => {
|
||||||
const [finished, setFinished] = useState<boolean>(false);
|
const [finished, setFinished] = useState<boolean>(false);
|
||||||
const [tipText, setTipText] = useState<string>('');
|
const [tipText, setTipText] = useState<string>('');
|
||||||
const [animating, setAnimating] = useState<boolean>(false);
|
const [animating, setAnimating] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// 音效
|
||||||
|
const soundRefMap = useRef<Record<string, HTMLAudioElement>>({});
|
||||||
|
|
||||||
|
// 第一次点击时播放bgm
|
||||||
const bgmRef = useRef<HTMLAudioElement>(null);
|
const bgmRef = useRef<HTMLAudioElement>(null);
|
||||||
const [bgmOn, setBgmOn] = useState<boolean>(false);
|
const [bgmOn, setBgmOn] = useState<boolean>(false);
|
||||||
const [once, setOnce] = 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(() => {
|
useEffect(() => {
|
||||||
if (bgmOn) {
|
if (bgmOn) {
|
||||||
bgmRef.current?.play();
|
bgmRef.current?.play();
|
||||||
|
@ -158,14 +169,19 @@ const App: FC = () => {
|
||||||
}
|
}
|
||||||
}, [bgmOn]);
|
}, [bgmOn]);
|
||||||
|
|
||||||
|
// 主题切换
|
||||||
|
useEffect(() => {
|
||||||
|
restart();
|
||||||
|
}, [curTheme]);
|
||||||
|
|
||||||
// 队列区排序
|
// 队列区排序
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cache: Record<string, MySymbol[]> = {};
|
const cache: Record<string, MySymbol[]> = {};
|
||||||
for (const symbol of queue) {
|
for (const symbol of queue) {
|
||||||
if (cache[symbol.icon]) {
|
if (cache[symbol.icon.name]) {
|
||||||
cache[symbol.icon].push(symbol);
|
cache[symbol.icon.name].push(symbol);
|
||||||
} else {
|
} else {
|
||||||
cache[symbol.icon] = [symbol];
|
cache[symbol.icon.name] = [symbol];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const temp = [];
|
const temp = [];
|
||||||
|
@ -181,12 +197,6 @@ const App: FC = () => {
|
||||||
setSortedQueue(updateSortedQueue);
|
setSortedQueue(updateSortedQueue);
|
||||||
}, [queue]);
|
}, [queue]);
|
||||||
|
|
||||||
const test = () => {
|
|
||||||
const level = Math.ceil(maxLevel * Math.random());
|
|
||||||
setLevel(level);
|
|
||||||
checkCover(makeScene(level));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 初始化覆盖状态
|
// 初始化覆盖状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkCover(scene);
|
checkCover(scene);
|
||||||
|
@ -264,7 +274,7 @@ const App: FC = () => {
|
||||||
setFinished(false);
|
setFinished(false);
|
||||||
setLevel(level + 1);
|
setLevel(level + 1);
|
||||||
setQueue([]);
|
setQueue([]);
|
||||||
checkCover(makeScene(level + 1));
|
checkCover(makeScene(level + 1, curTheme.icons));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 重开
|
// 重开
|
||||||
|
@ -272,7 +282,7 @@ const App: FC = () => {
|
||||||
setFinished(false);
|
setFinished(false);
|
||||||
setLevel(1);
|
setLevel(1);
|
||||||
setQueue([]);
|
setQueue([]);
|
||||||
checkCover(makeScene(1));
|
checkCover(makeScene(1, curTheme.icons));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 点击item
|
// 点击item
|
||||||
|
@ -289,9 +299,10 @@ const App: FC = () => {
|
||||||
if (symbol.isCover || symbol.status !== 0) return;
|
if (symbol.isCover || symbol.status !== 0) return;
|
||||||
symbol.status = 1;
|
symbol.status = 1;
|
||||||
|
|
||||||
if (tapSoundRef.current) {
|
// 点击音效
|
||||||
tapSoundRef.current.currentTime = 0;
|
if (soundRefMap.current) {
|
||||||
tapSoundRef.current.play();
|
soundRefMap.current[symbol.icon.clickSound].currentTime = 0;
|
||||||
|
soundRefMap.current[symbol.icon.clickSound].play();
|
||||||
}
|
}
|
||||||
|
|
||||||
let updateQueue = queue.slice();
|
let updateQueue = queue.slice();
|
||||||
|
@ -312,9 +323,12 @@ const App: FC = () => {
|
||||||
const find = updateScene.find((i) => i.id === sb.id);
|
const find = updateScene.find((i) => i.id === sb.id);
|
||||||
if (find) {
|
if (find) {
|
||||||
find.status = 2;
|
find.status = 2;
|
||||||
if (tripleSoundRef.current) {
|
// 三连音效
|
||||||
tripleSoundRef.current.currentTime = 0.55;
|
if (soundRefMap.current) {
|
||||||
tripleSoundRef.current.play();
|
soundRefMap.current[
|
||||||
|
symbol.icon.tripleSound
|
||||||
|
].currentTime = 0;
|
||||||
|
soundRefMap.current[symbol.icon.tripleSound].play();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -336,7 +350,7 @@ const App: FC = () => {
|
||||||
// 升级
|
// 升级
|
||||||
setLevel(level + 1);
|
setLevel(level + 1);
|
||||||
setQueue([]);
|
setQueue([]);
|
||||||
checkCover(makeScene(level + 1));
|
checkCover(makeScene(level + 1, curTheme.icons));
|
||||||
} else {
|
} else {
|
||||||
setQueue(updateQueue);
|
setQueue(updateQueue);
|
||||||
checkCover(updateScene);
|
checkCover(updateScene);
|
||||||
|
@ -351,7 +365,21 @@ const App: FC = () => {
|
||||||
<h6>
|
<h6>
|
||||||
<GithubIcon />
|
<GithubIcon />
|
||||||
</h6>
|
</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="app">
|
||||||
<div className="scene-container">
|
<div className="scene-container">
|
||||||
|
@ -404,13 +432,22 @@ const App: FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/*bgm*/}
|
||||||
<button className="bgm-button" onClick={() => setBgmOn(!bgmOn)}>
|
<button className="bgm-button" onClick={() => setBgmOn(!bgmOn)}>
|
||||||
{bgmOn ? '🔊' : '🔈'}
|
{bgmOn ? '🔊' : '🔈'}
|
||||||
<audio ref={bgmRef} loop src="/sound-disco.mp3" />
|
<audio ref={bgmRef} loop src="/sound-disco.mp3" />
|
||||||
</button>
|
</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;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
border: 2px solid gray;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid transparent;
|
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>[];
|
||||||
|
}
|