Commit 3fb612f0 authored by love_飞影's avatar love_飞影

feat: 添加音乐控制模块框架

parent cda9dcde
Pipeline #26159 passed with stages
in 9 minutes and 52 seconds
...@@ -32,8 +32,9 @@ export class WebSocketStream { ...@@ -32,8 +32,9 @@ export class WebSocketStream {
ws.onmessage = (event) => { ws.onmessage = (event) => {
controller.enqueue(event); controller.enqueue(event);
}; };
ws.onclose = () => { ws.onclose = (ev) => {
console.info("Websocket closed."); // 后续可能根据断线原因做处理,先暴露出来
console.info("Websocket closed.", ev);
// 下面这行注释掉,因为虽然websocket关掉了,但是已经收到的数据可能还在处理中 // 下面这行注释掉,因为虽然websocket关掉了,但是已经收到的数据可能还在处理中
// controller.close(); // controller.close();
}; };
......
...@@ -18,6 +18,7 @@ import "u-reset.css"; ...@@ -18,6 +18,7 @@ import "u-reset.css";
import "overlayscrollbars/overlayscrollbars.css"; import "overlayscrollbars/overlayscrollbars.css";
import "@/styles/core.scss"; import "@/styles/core.scss";
import "@/styles/inject.scss"; import "@/styles/inject.scss";
import "@/plugins/audio";
import { ProConfigProvider } from "@ant-design/pro-provider"; import { ProConfigProvider } from "@ant-design/pro-provider";
import { App, ConfigProvider } from "antd"; import { App, ConfigProvider } from "antd";
......
import EventEmitter from "eventemitter3";
export class NeosAudioContext extends EventEmitter<AudioScheduledSourceNodeEventMap> {
private _musicAudioContext = new AudioContext();
private _gainNode = this._musicAudioContext.createGain();
constructor(volume = 1) {
super();
this._gainNode.gain.value = volume;
}
public get musicAudioContext() {
return this._musicAudioContext;
}
public get state() {
return this._musicAudioContext.state;
}
public async play(audio: ArrayBuffer) {
const source = this._musicAudioContext.createBufferSource();
const buffer = await this._musicAudioContext.decodeAudioData(audio);
source.buffer = buffer;
source.connect(this._gainNode).connect(this._musicAudioContext.destination);
source.start();
if (this.state === "suspended") {
const autoPlay = () => {
document.removeEventListener("click", autoPlay);
this.resume();
};
document.addEventListener("click", autoPlay);
}
const Ended = () => {
source.removeEventListener("ended", Ended);
this.emit("ended");
};
source.addEventListener("ended", Ended);
}
public async resume() {
if (this.state !== "suspended") return;
return this._musicAudioContext.resume();
}
public async suspend() {
if (this.state !== "running") return;
return this._musicAudioContext.suspend();
}
public close() {
if (this._musicAudioContext.state === "closed") return;
return this._musicAudioContext.close();
}
public updateVolume(volume: number) {
this._gainNode.gain.value = volume;
}
}
import { AudioActionType } from "../type";
import { NeosAudioContext } from "./context";
import {
getEffectName,
getMusicName,
loadAudio,
removeAudio,
} from "./resource";
class AudioManager {
private musicContext = new NeosAudioContext();
private effectContextMap: Map<AudioActionType, NeosAudioContext> = new Map();
/** 当前播放的音频路径 */
private _currentMusicPath: string = "";
private _scene: AudioActionType | null = null;
private _musicVolume = 1;
public enableBGM = false;
public get scene() {
return this._scene;
}
public set scene(scene: AudioActionType | null) {
this._scene = scene;
if (this.enableBGM) {
this.playMusic();
}
}
public async playMusic() {
if (!this.enableBGM || !this._scene) return;
await this.musicContext.close();
this.musicContext = new NeosAudioContext(this._musicVolume);
const name = getMusicName(this._scene, this._currentMusicPath);
try {
const resource = await loadAudio(name);
await this.musicContext.play(resource);
this._currentMusicPath = name;
} catch {
// 音频资源有问题
removeAudio(name);
}
}
public async playEffect(effect: AudioActionType, volume = 1) {
let name = getEffectName(effect);
try {
const audioContext = new NeosAudioContext(volume);
const resource = await loadAudio(name);
await audioContext.play(resource);
audioContext.once("ended", () => {
this.effectContextMap.delete(effect);
});
this.effectContextMap.set(effect, audioContext);
} catch {
// 音频资源有问题
removeAudio(name);
}
}
public updateMusicVolume(volume = 1) {
this._musicVolume = volume;
this.musicContext.updateVolume(volume);
}
public updateEffectVolume(volume = 1) {
this.effectContextMap.forEach((context) => context.updateVolume(volume));
}
public async enableMusic() {
this.enableBGM = true;
await this.playMusic();
}
public disableMusic() {
this.enableBGM = false;
this.musicContext.suspend();
}
}
export const audioContextManger = new AudioManager();
import { clear, createStore, del, get, set } from "idb-keyval";
import { useConfig } from "@/config";
import { AudioActionType } from "../type";
const AUDIO_DB_NAME = "audio";
const sourceDb = createStore(AUDIO_DB_NAME, "sources");
const { assetsPath } = useConfig();
export async function loadAudio(name: string) {
const prefix = `${assetsPath}/sound/`;
// Check if the file is cached in indexedDB
const cachedFile = await get<ArrayBuffer>(name, sourceDb);
if (cachedFile) {
// File is already cached, use it
return cachedFile;
}
// File is not cached, fetch it and store in indexedDB
const response = await fetch(`${prefix}${name}`);
const fileBlob = await response.arrayBuffer();
await set(name, fileBlob, sourceDb);
return fileBlob;
}
export async function removeAudio(name: string) {
try {
await del(name, sourceDb);
} catch {
// 资源未落库,不做处理
}
}
export async function clearAudioCache() {
return clear(sourceDb);
}
export function getEffectName(effect: AudioActionType) {
switch (effect) {
/** ******************** effect ********************/
case AudioActionType.SOUND_SUMMON:
return "summon.wav";
case AudioActionType.SOUND_SPECIAL_SUMMON:
return "specialsummon.wav";
case AudioActionType.SOUND_ACTIVATE:
return "activate.wav";
case AudioActionType.SOUND_SET:
return "set.wav";
case AudioActionType.SOUND_FILP:
return "flip.wav";
case AudioActionType.SOUND_REVEAL:
return "reveal.wav";
case AudioActionType.SOUND_EQUIP:
return "equip.wav";
case AudioActionType.SOUND_DESTROYED:
return "destroyed.wav";
case AudioActionType.SOUND_BANISHED:
return "banished.wav";
case AudioActionType.SOUND_TOKEN:
return "token.wav";
case AudioActionType.SOUND_ATTACK:
return "attack.wav";
case AudioActionType.SOUND_DIRECT_ATTACK:
return "directattack.wav";
case AudioActionType.SOUND_DRAW:
return "draw.wav";
case AudioActionType.SOUND_SHUFFLE:
return "shuffle.wav";
case AudioActionType.SOUND_DAMAGE:
return "damage.wav";
case AudioActionType.SOUND_RECOVER:
return "gainlp.wav";
case AudioActionType.SOUND_COUNTER_ADD:
return "addcounter.wav";
case AudioActionType.SOUND_COUNTER_REMOVE:
return "removecounter.wav";
case AudioActionType.SOUND_COIN:
return "coinflip.wav";
case AudioActionType.SOUND_DICE:
return "diceroll.wav";
case AudioActionType.SOUND_NEXT_TURN:
return "nextturn.wav";
case AudioActionType.SOUND_PHASE:
return "phase.wav";
case AudioActionType.SOUND_MENU:
return "menu.wav";
case AudioActionType.SOUND_BUTTON:
return "button.wav";
case AudioActionType.SOUND_INFO:
return "info.wav";
case AudioActionType.SOUND_QUESTION:
return "question.wav";
case AudioActionType.SOUND_CARD_PICK:
return "cardpick.wav";
case AudioActionType.SOUND_CARD_DROP:
return "carddrop.wav";
case AudioActionType.SOUND_PLAYER_ENTER:
return "playerenter.wav";
case AudioActionType.SOUND_CHAT:
return "chatmessage.wav";
default:
return "";
}
}
export function getMusicName(music: AudioActionType, current?: string) {
let res: string[] = [];
switch (music) {
/** ******************** bgm ********************/
case AudioActionType.BGM_MENU:
res = [
"BGM/menu/福田康文 - ディスク:1.mp3",
"BGM/menu/光宗信吉 - 伝説の决闘(デュエル)(D3).mp3",
"BGM/menu/蓑部雄崇 - 十代のテーマ.mp3",
];
break;
case AudioActionType.BGM_DECK:
res = ["BGM/deck/bgm_deck.mp3", "BGM/deck/bgm_shop.mp3"];
break;
case AudioActionType.BGM_DUEL:
res = [
"BGM/duel/蓑部雄崇 - 悲しいデュエル.mp3",
"BGM/duel/蓑部雄崇 - 鬼柳京介.mp3",
"BGM/duel/蓑部雄崇 - 游星バトル.mp3",
"BGM/duel/蓑部雄崇 - スピードワールド.mp3",
"BGM/duel/中川幸太郎 - 不動のデュエル.mp3",
"BGM/duel/中川幸太郎 - 反逆のデュエル.mp3",
];
break;
case AudioActionType.BGM_ADVANTAGE:
res = [
"BGM/advantage/池頼広 - 熱き決闘者たち (Re-arranged).mp3",
"BGM/advantage/蓑部雄崇 - 游星テーマ.mp3",
"BGM/advantage/蓑部雄崇 - ピンチ!.mp3",
];
break;
case AudioActionType.BGM_DISADVANTAGE:
res = [
"BGM/disadvantage/池頼広 - 神の怒り (Re-arranged:type one).mp3",
"BGM/disadvantage/光宗信吉 - 热き决闘者たち.mp3",
"BGM/disadvantage/蓑部雄崇 - 逆転の一手!.mp3",
];
break;
case AudioActionType.BGM_WIN:
res = ["BGM/win/bgm_result.mp3"];
break;
case AudioActionType.BGM_LOSE:
res = ["BGM/lose/bgm_result_lose1.mp3"];
break;
default:
break;
}
const filterRes = res.filter((name) => name !== current);
return filterRes[Math.floor(Math.random() * filterRes.length)];
}
import { Button } from "antd";
import { subscribe } from "valtio";
import { settingStore } from "@/stores/settingStore";
import { audioContextManger } from "../core/manager";
import { AudioActionType } from "../type";
export function playEffect(effect: AudioActionType) {
if (!settingStore.audio.enableSoundEffects) return;
return audioContextManger.playEffect(
effect,
settingStore.audio.soundEffectsVolume,
);
}
subscribe(settingStore.audio, () => {
audioContextManger.updateEffectVolume(settingStore.audio.soundEffectsVolume);
});
Reflect.set(
Button,
"render",
new Proxy(Reflect.get(Button, "render"), {
apply(target, thisArg, args) {
const [props] = args;
const { onClick } = props;
// onClick is read-only, so we need to use the following code
const _onClick: React.MouseEventHandler<HTMLElement> = (...args) => {
playEffect(AudioActionType.SOUND_BUTTON);
onClick(...args);
};
return target.apply(thisArg, [
{
...props,
onClick: _onClick,
},
...args.slice(1),
]);
},
}),
);
export * from "./usePlayEffect";
import { useEffect, useRef } from "react";
import { AudioActionType, playEffect } from "@/plugins/audio";
export function usePlayEffect<T extends HTMLElement>(effect: AudioActionType) {
const effectRef = useRef<T | null>(null);
useEffect(() => {
const handleClick = () => {
playEffect(effect);
};
effectRef.current?.addEventListener("click", handleClick);
return () => {
effectRef.current?.removeEventListener("click", handleClick);
};
}, [playEffect]);
// 留一些扩展性,并且方便修改引用 key
return [effectRef] as const;
}
export * from "./effect";
export * from "./hooks";
export * from "./music";
export * from "./type";
import { subscribe } from "valtio";
import { settingStore } from "@/stores/settingStore";
import { audioContextManger } from "../core/manager";
import { AudioActionType } from "../type";
// 监听设置改动
subscribe(settingStore.audio, () => {
if (audioContextManger.enableBGM && !settingStore.audio.enableMusic) {
console.log("disable");
audioContextManger.disableMusic();
}
if (!audioContextManger.enableBGM && settingStore.audio.enableMusic) {
console.log("enable");
audioContextManger.enableMusic();
}
audioContextManger.updateMusicVolume(settingStore.audio.musicVolume);
});
// 切换场景
export function changeScene(scene: AudioActionType) {
if (audioContextManger.scene !== scene) {
audioContextManger.scene = scene;
}
}
audioContextManger.updateMusicVolume(settingStore.audio.musicVolume);
audioContextManger.enableBGM = settingStore.audio.enableMusic ?? false;
/** 音效类型 */
export enum AudioActionType {
/** 召唤 */
SOUND_SUMMON = 101,
/** 特殊召唤 */
SOUND_SPECIAL_SUMMON = 102,
/** 发动 */
SOUND_ACTIVATE = 103,
/** 设置 */
SOUND_SET = 104,
/** 翻转 */
SOUND_FILP = 105,
/** 揭示 */
SOUND_REVEAL = 106,
/** 装备 */
SOUND_EQUIP = 107,
/** 破坏 */
SOUND_DESTROYED = 108,
/** 除外 */
SOUND_BANISHED = 109,
/** 生成 */
SOUND_TOKEN = 110,
/** 攻击 */
SOUND_ATTACK = 201,
/** 直接攻击 */
SOUND_DIRECT_ATTACK = 202,
/** 抽卡 */
SOUND_DRAW = 203,
/** 洗卡 */
SOUND_SHUFFLE = 204,
/** 伤害 */
SOUND_DAMAGE = 205,
/** 恢复 */
SOUND_RECOVER = 206,
/** 计数增加 */
SOUND_COUNTER_ADD = 207,
/** 计数减少 */
SOUND_COUNTER_REMOVE = 208,
/** 抛硬币 */
SOUND_COIN = 209,
/** 抛骰子 */
SOUND_DICE = 210,
/** 下一回合 */
SOUND_NEXT_TURN = 211,
/** 阶段 */
SOUND_PHASE = 212,
/** 菜单 */
SOUND_MENU = 301,
/** 按钮 */
SOUND_BUTTON = 302,
/** 信息 */
SOUND_INFO = 303,
/** 问题 */
SOUND_QUESTION = 304,
/** 选卡 */
SOUND_CARD_PICK = 305,
/** 放卡 */
SOUND_CARD_DROP = 306,
/** 玩家进入 */
SOUND_PLAYER_ENTER = 307,
/** 聊天 */
SOUND_CHAT = 308,
/** 所有 */
BGM_ALL = 0,
/** 决斗 */
BGM_DUEL = 1,
/** 菜单 */
BGM_MENU = 2,
/** 卡组 */
BGM_DECK = 3,
/** 优势 */
BGM_ADVANTAGE = 4,
/** 劣势 */
BGM_DISADVANTAGE = 5,
/** 胜利 */
BGM_WIN = 6,
/** 失败 */
BGM_LOSE = 7,
}
import { ygopro } from "@/api"; import { ygopro } from "@/api";
import { AudioActionType, playEffect } from "@/plugins/audio";
import { replayStore } from "@/stores"; import { replayStore } from "@/stores";
import { showWaiting } from "@/ui/Duel/Message"; import { showWaiting } from "@/ui/Duel/Message";
...@@ -109,16 +110,22 @@ export default async function handleGameMsg( ...@@ -109,16 +110,22 @@ export default async function handleGameMsg(
case "draw": { case "draw": {
await onMsgDraw(msg.draw); await onMsgDraw(msg.draw);
playEffect(AudioActionType.SOUND_DRAW);
break; break;
} }
case "new_turn": { case "new_turn": {
onMsgNewTurn(msg.new_turn); onMsgNewTurn(msg.new_turn);
playEffect(AudioActionType.SOUND_NEXT_TURN);
break; break;
} }
case "new_phase": { case "new_phase": {
onMsgNewPhase(msg.new_phase); onMsgNewPhase(msg.new_phase);
playEffect(AudioActionType.SOUND_PHASE);
break; break;
} }
case "hint": { case "hint": {
...@@ -134,6 +141,8 @@ export default async function handleGameMsg( ...@@ -134,6 +141,8 @@ export default async function handleGameMsg(
case "select_place": { case "select_place": {
onMsgSelectPlace(msg.select_place); onMsgSelectPlace(msg.select_place);
playEffect(AudioActionType.SOUND_SET);
break; break;
} }
case "move": { case "move": {
...@@ -168,6 +177,8 @@ export default async function handleGameMsg( ...@@ -168,6 +177,8 @@ export default async function handleGameMsg(
case "shuffle_hand_extra": { case "shuffle_hand_extra": {
await onMsgShuffleHandExtra(msg.shuffle_hand_extra); await onMsgShuffleHandExtra(msg.shuffle_hand_extra);
playEffect(AudioActionType.SOUND_SHUFFLE);
break; break;
} }
case "select_battle_cmd": { case "select_battle_cmd": {
...@@ -253,6 +264,8 @@ export default async function handleGameMsg( ...@@ -253,6 +264,8 @@ export default async function handleGameMsg(
case "attack": { case "attack": {
await onMsgAttack(msg.attack); await onMsgAttack(msg.attack);
playEffect(AudioActionType.SOUND_ATTACK);
break; break;
} }
case "attack_disable": { case "attack_disable": {
...@@ -262,6 +275,7 @@ export default async function handleGameMsg( ...@@ -262,6 +275,7 @@ export default async function handleGameMsg(
} }
case "chaining": { case "chaining": {
await onMsgChaining(msg.chaining); await onMsgChaining(msg.chaining);
break; break;
} }
case "chain_solved": { case "chain_solved": {
......
import { ygopro } from "@/api"; import { ygopro } from "@/api";
import { AudioActionType, playEffect } from "@/plugins/audio";
import { cardStore } from "@/stores"; import { cardStore } from "@/stores";
type MsgUpdateCounter = ygopro.StocGameMessage.MsgUpdateCounter; type MsgUpdateCounter = ygopro.StocGameMessage.MsgUpdateCounter;
...@@ -12,6 +13,7 @@ export default (updateCounter: MsgUpdateCounter) => { ...@@ -12,6 +13,7 @@ export default (updateCounter: MsgUpdateCounter) => {
case ygopro.StocGameMessage.MsgUpdateCounter.ActionType.ADD: { case ygopro.StocGameMessage.MsgUpdateCounter.ActionType.ADD: {
if (counterType in target.counters) { if (counterType in target.counters) {
target.counters[counterType] += count; target.counters[counterType] += count;
playEffect(AudioActionType.SOUND_COUNTER_ADD);
} else { } else {
target.counters[counterType] = count; target.counters[counterType] = count;
} }
...@@ -20,6 +22,7 @@ export default (updateCounter: MsgUpdateCounter) => { ...@@ -20,6 +22,7 @@ export default (updateCounter: MsgUpdateCounter) => {
case ygopro.StocGameMessage.MsgUpdateCounter.ActionType.REMOVE: { case ygopro.StocGameMessage.MsgUpdateCounter.ActionType.REMOVE: {
if (counterType in target.counters) { if (counterType in target.counters) {
target.counters[counterType] -= count; target.counters[counterType] -= count;
playEffect(AudioActionType.SOUND_COUNTER_REMOVE);
} }
break; break;
} }
......
/** 音频设置 */
export interface AudioConfig {
/** 是否开启音乐 */
enableMusic?: boolean;
/** 是否开启音效 */
enableSoundEffects?: boolean;
/** 音乐音量大小 */
musicVolume?: number;
/** 音效音量大小 */
soundEffectsVolume?: number;
/** 是否根据环境切换音乐 */
enableMusicSwitchByEnv?: boolean;
}
export const defaultAudioConfig: AudioConfig = {
enableMusic: false,
enableSoundEffects: false,
musicVolume: 1,
soundEffectsVolume: 1,
enableMusicSwitchByEnv: false,
};
import { isSSR } from "@react-spring/shared";
import { pick } from "lodash-es";
import { proxy, subscribe } from "valtio";
import { type NeosStore } from "../shared";
import { AudioConfig, defaultAudioConfig } from "./audio";
/** 将设置保存到本地 */
const NEO_SETTING_CONFIG = "__neo_setting_config__";
/** 设置项 */
type SettingStoreConfig = Pick<SettingStore, "audio">;
/** 默认设置 */
const defaultSettingConfig: SettingStoreConfig = {
audio: defaultAudioConfig,
};
/** 获取默认设置 */
function getDefaultSetting() {
if (!isSSR()) {
/** 获取默认设置 */
const setting = localStorage.getItem(NEO_SETTING_CONFIG);
if (setting) return JSON.parse(setting) as SettingStoreConfig;
}
return defaultSettingConfig;
}
const defaultSetting = getDefaultSetting();
/** 设置模块 */
class SettingStore implements NeosStore {
/** 音频设置 */
audio: AudioConfig = defaultSetting.audio;
/** 保存音频设置 */
saveAudioConfig(config: Partial<AudioConfig>): void {
Object.assign(this.audio, config);
}
reset(): void {
const defaultSetting = getDefaultSetting();
this.audio = defaultSetting.audio;
}
}
/** 设置项 */
export const settingStore = proxy(new SettingStore());
/** 持久化设置项 */
subscribe(settingStore, () => {
if (!isSSR()) {
localStorage.setItem(
NEO_SETTING_CONFIG,
JSON.stringify(pick(settingStore, ["audio"])),
);
}
});
import { Avatar, Dropdown } from "antd"; import { App, Avatar, Dropdown } from "antd";
import classNames from "classnames"; import classNames from "classnames";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { import {
...@@ -16,8 +16,10 @@ import { ...@@ -16,8 +16,10 @@ import {
removeCookie, removeCookie,
} from "@/api"; } from "@/api";
import { useConfig } from "@/config"; import { useConfig } from "@/config";
import { AudioActionType, usePlayEffect } from "@/plugins/audio";
import { accountStore } from "@/stores"; import { accountStore } from "@/stores";
import { Setting } from "../Setting";
import styles from "./index.module.scss"; import styles from "./index.module.scss";
import { import {
getLoginStatus, getLoginStatus,
...@@ -45,8 +47,10 @@ const HeaderBtn: React.FC< ...@@ -45,8 +47,10 @@ const HeaderBtn: React.FC<
React.PropsWithChildren<{ to: string; disabled?: boolean }> React.PropsWithChildren<{ to: string; disabled?: boolean }>
> = ({ to, children, disabled = false }) => { > = ({ to, children, disabled = false }) => {
const Element = disabled ? "div" : NavLink; const Element = disabled ? "div" : NavLink;
const [effectRef] = usePlayEffect<any>(AudioActionType.SOUND_MENU);
return ( return (
<Element <Element
ref={effectRef}
to={disabled ? "/" : to} to={disabled ? "/" : to}
className={classNames(styles.link, { [styles.disabled]: disabled })} className={classNames(styles.link, { [styles.disabled]: disabled })}
> >
...@@ -67,6 +71,7 @@ export const Component = () => { ...@@ -67,6 +71,7 @@ export const Component = () => {
const { pathname } = routerLocation; const { pathname } = routerLocation;
const pathnamesHideHeader = ["/waitroom", "/duel", "/side"]; const pathnamesHideHeader = ["/waitroom", "/duel", "/side"];
const { modal } = App.useApp();
const callbackUrl = `${location.origin}/match/`; const callbackUrl = `${location.origin}/match/`;
const onLogin = () => location.replace(getSSOSignInUrl(callbackUrl)); const onLogin = () => location.replace(getSSOSignInUrl(callbackUrl));
...@@ -130,6 +135,18 @@ export const Component = () => { ...@@ -130,6 +135,18 @@ export const Component = () => {
</a> </a>
), ),
}, },
{
label: "系统设置",
onClick: () => {
modal.info({
content: <Setting />,
centered: true,
maskClosable: true,
icon: null,
footer: null,
});
},
},
{ {
label: logined ? "退出登录" : "登录萌卡", label: logined ? "退出登录" : "登录萌卡",
onClick: logined ? onLogout : onLogin, onClick: logined ? onLogout : onLogin,
......
...@@ -17,6 +17,7 @@ import { ...@@ -17,6 +17,7 @@ import {
match, match,
} from "@/api"; } from "@/api";
import { useConfig } from "@/config"; import { useConfig } from "@/config";
import { AudioActionType, usePlayEffect } from "@/plugins/audio";
import { accountStore, deckStore, resetUniverse, roomStore } from "@/stores"; import { accountStore, deckStore, resetUniverse, roomStore } from "@/stores";
import { Background, IconFont, Select } from "@/ui/Shared"; import { Background, IconFont, Select } from "@/ui/Shared";
...@@ -326,10 +327,13 @@ const Mode: React.FC<{ ...@@ -326,10 +327,13 @@ const Mode: React.FC<{
desc: string; desc: string;
icon: React.ReactNode; icon: React.ReactNode;
onClick?: () => void; onClick?: () => void;
}> = ({ title, desc, icon, onClick }) => ( }> = ({ title, desc, icon, onClick }) => {
<div className={styles.mode} onClick={onClick}> const [effectRef] = usePlayEffect<HTMLDivElement>(AudioActionType.SOUND_MENU);
<div className={styles.icon}>{icon}</div> return (
<div className={styles.title}>{title}</div> <div ref={effectRef} className={styles.mode} onClick={onClick}>
<div className={styles.desc}>{desc}</div> <div className={styles.icon}>{icon}</div>
</div> <div className={styles.title}>{title}</div>
); <div className={styles.desc}>{desc}</div>
</div>
);
};
import { createBrowserRouter, RouterProvider } from "react-router-dom"; import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { AudioActionType, changeScene } from "@/plugins/audio";
import { Component, loader } from "./Layout"; import { Component, loader } from "./Layout";
const router = createBrowserRouter([ const router = createBrowserRouter([
...@@ -10,26 +12,50 @@ const router = createBrowserRouter([ ...@@ -10,26 +12,50 @@ const router = createBrowserRouter([
children: [ children: [
{ {
path: "/", path: "/",
loader: async () => {
changeScene(AudioActionType.BGM_MENU);
return null;
},
lazy: () => import("./Start"), lazy: () => import("./Start"),
}, },
{ {
path: "/match/*", path: "/match/*",
loader: async () => {
changeScene(AudioActionType.BGM_MENU);
return null;
},
lazy: () => import("./Match"), lazy: () => import("./Match"),
}, },
{ {
path: "/build", path: "/build",
loader: async () => {
changeScene(AudioActionType.BGM_DECK);
return null;
},
lazy: () => import("./BuildDeck"), lazy: () => import("./BuildDeck"),
}, },
{ {
path: "/waitroom", path: "/waitroom",
loader: async () => {
changeScene(AudioActionType.BGM_MENU);
return null;
},
lazy: () => import("./WaitRoom"), lazy: () => import("./WaitRoom"),
}, },
{ {
path: "/duel", path: "/duel",
loader: async () => {
changeScene(AudioActionType.BGM_DUEL);
return null;
},
lazy: () => import("./Duel/Main"), lazy: () => import("./Duel/Main"),
}, },
{ {
path: "/side", path: "/side",
loader: async () => {
changeScene(AudioActionType.BGM_DECK);
return null;
},
lazy: () => import("./Side"), lazy: () => import("./Side"),
}, },
], ],
......
import { Checkbox, Form, Slider, Space, Switch } from "antd";
import React from "react";
import { useSnapshot } from "valtio";
import { settingStore } from "@/stores/settingStore";
export const AudioSetting: React.FC = () => {
const { audio } = useSnapshot(settingStore);
return (
<Form
initialValues={audio}
onValuesChange={(config) => {
settingStore.saveAudioConfig(config);
}}
labelAlign="left"
>
<Form.Item label="开启音乐">
<Space size={16}>
<Form.Item name="enableMusic" noStyle valuePropName="checked">
<Checkbox />
</Form.Item>
<Form.Item name="musicVolume" noStyle>
<Slider
style={{ width: 200 }}
min={0}
max={1}
step={0.01}
tooltip={{
formatter: (value) => ((value || 0) * 100).toFixed(0),
}}
/>
</Form.Item>
</Space>
</Form.Item>
<Form.Item label="开启音效">
<Space size={16}>
<Form.Item name="enableSoundEffects" noStyle valuePropName="checked">
<Checkbox />
</Form.Item>
<Form.Item name="soundEffectsVolume" noStyle>
<Slider
style={{ width: 200 }}
min={0}
max={1}
step={0.01}
tooltip={{
formatter: (value) => ((value || 0) * 100).toFixed(0),
}}
/>
</Form.Item>
</Space>
</Form.Item>
<Form.Item
name="enableMusicSwitchByEnv"
label="根据环境切换音乐"
valuePropName="checked"
>
<Switch />
</Form.Item>
</Form>
);
};
import { ConfigProvider, Modal, Tabs, TabsProps } from "antd";
import zhCN from "antd/locale/zh_CN";
import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { theme } from "../theme";
import { AudioSetting } from "./Audio";
/** 设置面板属性 */
export interface SettingProps {
/** 默认设置页 */
defaultKey?: "audio" | "other";
}
export const Setting = (props: SettingProps) => {
const { defaultKey = "audio" } = props;
const items: TabsProps["items"] = [
{
key: "audio",
label: "音频设置",
children: <AudioSetting />,
},
];
return <Tabs defaultActiveKey={defaultKey} items={items} />;
};
/**
* 打开设置面板,允许在非组件内通过此 API 打开设置面板
*/
export function openSettingPanel(props: SettingProps) {
const div = document.createElement("div");
document.body.appendChild(div);
const destroy = () => {
const result = unmountComponentAtNode(div);
if (result && div.parentNode) {
div.parentNode.removeChild(div);
}
};
render(
<ConfigProvider theme={theme} locale={zhCN}>
<Modal open centered footer={null} onCancel={destroy} closeIcon={null}>
<Setting {...props} />
</Modal>
</ConfigProvider>,
div,
);
}
import classNames from "classnames"; import classNames from "classnames";
import { AudioActionType, usePlayEffect } from "@/plugins/audio";
import styles from "./index.module.scss"; import styles from "./index.module.scss";
// TODO: SpecialButton能不能做个Loading? // TODO: SpecialButton能不能做个Loading?
...@@ -7,13 +9,19 @@ export const SpecialButton: React.FC< ...@@ -7,13 +9,19 @@ export const SpecialButton: React.FC<
React.PropsWithChildren<React.ComponentProps<"span">> & { React.PropsWithChildren<React.ComponentProps<"span">> & {
disabled?: boolean; disabled?: boolean;
} }
> = ({ children, className, disabled, ...rest }) => ( > = ({ children, className, disabled, ...rest }) => {
<span const [effectRef] = usePlayEffect<HTMLSpanElement>(
className={classNames(styles["special-btn"], className, { AudioActionType.SOUND_BUTTON,
[styles.disabled]: disabled, );
})} return (
{...rest} <span
> ref={effectRef}
{children} className={classNames(styles["special-btn"], className, {
</span> [styles.disabled]: disabled,
); })}
{...rest}
>
{children}
</span>
);
};
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment