Commit 2a315f61 authored by love_飞影's avatar love_飞影 Committed by Chunchi Che

feat: 音乐与音效

parent a746f294
import { AudioActionType, playEffect } from "@/infra/audio";
import { ygopro } from "../../idl/ocgcore";
import { StocAdapter, YgoProPacket } from "../packet";
......@@ -14,6 +16,7 @@ export default class ChatAdapter implements StocAdapter {
constructor(packet: YgoProPacket) {
this.packet = packet;
playEffect(AudioActionType.SOUND_CHAT);
}
upcast(): ygopro.YgoStocMsg {
......
import { BufferReader } from "rust-src";
import { AudioActionType, playEffect } from "@/infra/audio";
import { ygopro } from "../../idl/ocgcore";
import { StocAdapter, YgoProPacket } from "../packet";
......@@ -14,6 +16,7 @@ export default class ErrorMsg implements StocAdapter {
constructor(packet: YgoProPacket) {
this.packet = packet;
playEffect(AudioActionType.SOUND_INFO);
}
upcast(): ygopro.YgoStocMsg {
......
import { ygopro } from "../../../idl/ocgcore";
import { BufferReaderExt } from "../../bufferIO";
import MsgUpdateCounter = ygopro.StocGameMessage.MsgUpdateCounter;
import { AudioActionType, playEffect } from "@/infra/audio";
/*
* Msg Add Counter
......@@ -9,6 +10,7 @@ import MsgUpdateCounter = ygopro.StocGameMessage.MsgUpdateCounter;
* @usage - TODO
* */
export default (data: Uint8Array) => {
playEffect(AudioActionType.SOUND_COUNTER_ADD);
const reader = new BufferReaderExt(data);
const counterType = reader.inner.readUint16();
......
......@@ -2,6 +2,7 @@ import { ygopro } from "@/api/ocgcore/idl/ocgcore";
import { BufferReaderExt } from "../../bufferIO";
import MsgAttack = ygopro.StocGameMessage.MsgAttack;
import { AudioActionType, playEffect } from "@/infra/audio";
/*
* Msg Attack
......@@ -21,12 +22,14 @@ export default (data: Uint8Array) => {
target_location.zone === 0 &&
target_location.sequence === 0
) {
playEffect(AudioActionType.SOUND_DIRECT_ATTACK);
// 全零表示直接攻击玩家
return new MsgAttack({
attacker_location,
direct_attack: true,
});
} else {
playEffect(AudioActionType.SOUND_ATTACK);
return new MsgAttack({
attacker_location,
target_location,
......
import { ocgDamageAdapter } from "rust-src";
import { AudioActionType, playEffect } from "@/infra/audio";
import { ygopro } from "../../../idl/ocgcore";
/*
......@@ -9,6 +11,7 @@ import { ygopro } from "../../../idl/ocgcore";
* @param value - 减少的Hp数值
* */
export default (data: Uint8Array) => {
playEffect(AudioActionType.SOUND_DAMAGE);
const damage = ocgDamageAdapter(data);
return new ygopro.StocGameMessage.MsgUpdateHp(damage);
......
import { BufferReader } from "rust-src";
import { AudioActionType, playEffect } from "@/infra/audio";
import { ygopro } from "../../../idl/ocgcore";
/*
......@@ -18,6 +20,7 @@ export default (data: Uint8Array) => {
let cards: number[] = [];
for (let i = 0; i < count; i++) {
cards.push(reader.readUint32());
playEffect(AudioActionType.SOUND_DRAW);
}
return new ygopro.StocGameMessage.MsgDraw({
......
import { BufferReader } from "rust-src";
import { AudioActionType, playEffect } from "@/infra/audio";
import { ygopro } from "../../../idl/ocgcore";
/*
......@@ -11,6 +13,7 @@ import { ygopro } from "../../../idl/ocgcore";
* */
export default (data: Uint8Array) => {
playEffect(AudioActionType.SOUND_PHASE);
const reader = new BufferReader(data);
const phase = reader.readUint16();
......
import { BufferReader } from "rust-src";
import { AudioActionType, playEffect } from "@/infra/audio";
import { ygopro } from "../../../idl/ocgcore";
/*
......@@ -11,6 +13,7 @@ import { ygopro } from "../../../idl/ocgcore";
* */
export default (data: Uint8Array) => {
playEffect(AudioActionType.SOUND_NEXT_TURN);
const reader = new BufferReader(data);
const player = reader.readUint8();
......
import { BufferReader } from "rust-src";
import { AudioActionType, playEffect } from "@/infra/audio";
import { ygopro } from "../../../idl/ocgcore";
/*
......@@ -9,6 +11,7 @@ import { ygopro } from "../../../idl/ocgcore";
* @param value - 回复的Hp数值
* */
export default (data: Uint8Array) => {
playEffect(AudioActionType.SOUND_RECOVER);
const reader = new BufferReader(data);
const player = reader.readUint8();
......
import { ygopro } from "../../../idl/ocgcore";
import { BufferReaderExt } from "../../bufferIO";
import MsgUpdateCounter = ygopro.StocGameMessage.MsgUpdateCounter;
import { AudioActionType, playEffect } from "@/infra/audio";
/*
* Msg Remove Counter
......@@ -9,6 +10,7 @@ import MsgUpdateCounter = ygopro.StocGameMessage.MsgUpdateCounter;
* @usage - TODO
* */
export default (data: Uint8Array) => {
playEffect(AudioActionType.SOUND_COUNTER_REMOVE);
const reader = new BufferReaderExt(data);
const counterType = reader.inner.readUint16();
......
import { BufferReader } from "../../../../../../rust-src/pkg/rust_src";
import { ygopro } from "../../../idl/ocgcore";
import MsgToss = ygopro.StocGameMessage.MsgToss;
import { AudioActionType, playEffect } from "@/infra/audio";
/*
* Msg Toss
......@@ -9,6 +10,7 @@ import MsgToss = ygopro.StocGameMessage.MsgToss;
* @usage 骰子/硬币结果
* */
export default (data: Uint8Array, toss_type: MsgToss.TossType) => {
playEffect(AudioActionType.SOUND_DICE);
const reader = new BufferReader(data);
const player = reader.readUint8();
const count = reader.readUint8();
......
......@@ -2,6 +2,8 @@ import { BufferReader } from "rust-src";
import { ygopro } from "../../../idl/ocgcore";
import MsgWin = ygopro.StocGameMessage.MsgWin;
import { AudioActionType, changeScene } from "@/infra/audio";
import { matStore } from "@/stores";
/*
* Msg Win
......@@ -14,6 +16,12 @@ export default (data: Uint8Array) => {
const win_player = reader.readUint8();
const reason = reader.readUint8();
// 双打需要改这里判断
if (matStore.isMe(win_player)) {
changeScene(AudioActionType.BGM_WIN);
} else {
changeScene(AudioActionType.BGM_LOSE);
}
return new MsgWin({
win_player,
......
import { AudioActionType, playEffect } from "@/infra/audio";
import { ygopro } from "../../idl/ocgcore";
import { StocAdapter, YgoProPacket } from "../packet";
import { _cutoff_name, UTF16_BUFFER_MAX_LEN } from "../util";
......@@ -17,6 +19,7 @@ export default class HsPlayerEnterAdapter implements StocAdapter {
constructor(packet: YgoProPacket) {
this.packet = packet;
playEffect(AudioActionType.SOUND_PLAYER_ENTER);
}
upcast(): ygopro.YgoStocMsg {
......
......@@ -220,7 +220,7 @@ export const Race2StringCodeMap: Map<number, number> = new Map([
[RACE_CYBERSE, 1044],
]);
// const REASON_DESTROY = 0x1; //
export const REASON_DESTROY = 0x1; //
// const REASON_RELEASE = 0x2; //
// const REASON_TEMPORARY = 0x4; //
export const REASON_MATERIAL = 0x8; //
......
import EventEmitter from "eventemitter3";
export class NeosAudioContext extends EventEmitter<AudioScheduledSourceNodeEventMap> {
private _musicAudioContext = new AudioContext();
private _gainNode = this._musicAudioContext.createGain();
private _isClosed = false;
constructor(volume = 1) {
super();
this._gainNode.gain.value = volume;
}
public get musicAudioContext() {
return this._musicAudioContext;
}
public get state() {
return this._musicAudioContext.state;
}
public get closed() {
return this._isClosed;
}
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 timeout = setTimeout(async () => {
if (source.loop) return;
if (this.state === "closed") return;
await this.suspend();
await this.resume();
}, buffer.duration * 1000);
// 播放结束后关闭音频
const Ended = () => {
source.removeEventListener("ended", Ended);
this.emit("ended");
clearTimeout(timeout);
this.close();
};
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._isClosed) return;
this._isClosed = true;
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 effectContextSet: WeakSet<NeosAudioContext> = new WeakSet();
/** 当前播放的音频路径 */
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;
if (!this.musicContext.closed) {
this.musicContext.close();
}
this.musicContext = new NeosAudioContext(this._musicVolume);
this.musicContext.once("ended", () => {
this.playMusic();
});
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);
audioContext.once("ended", () => {
this.effectContextSet.delete(audioContext);
});
this.effectContextSet.add(audioContext);
const resource = await loadAudio(name);
await audioContext.play(resource);
} catch {
// 音频资源有问题
removeAudio(name);
}
}
public updateMusicVolume(volume = 1) {
this._musicVolume = volume;
this.musicContext.updateVolume(volume);
}
public enableMusic() {
this.enableBGM = true;
this.playMusic();
}
public disableMusic() {
this.enableBGM = false;
this.musicContext.suspend();
}
public switchDisableMusic() {
if (this.enableBGM) {
this.disableMusic();
} else {
this.enableMusic();
}
}
}
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();
/** 从网络加载音频资源 */
async function _loadFromNet(name: string) {
const prefix = `${assetsPath}/sound/`;
const response = await fetch(`${prefix}${name}`);
const fileBlob = await response.arrayBuffer();
_cacheResource(name, fileBlob);
return fileBlob;
}
/** 从缓存中加载音频资源 */
function _loadFromCache(name: string) {
return get(name, sourceDb);
}
/** 缓存资源 */
function _cacheResource(name: string, fileBlob: ArrayBuffer) {
set(name, fileBlob, sourceDb);
}
/** 加载音频资源 */
export async function loadAudio(name: string) {
// 从缓存资源获取
const cachedFile = await _loadFromCache(name);
if (cachedFile) {
return cachedFile;
}
// 从网络获取
const fileBlob = await _loadFromNet(name);
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)];
}
# 这里记录一下未实现音效,需要一起研究下什么时机插入该音效
- Connect timeout SOUND_INFO
- Client event change SOUND_INFO
- STOC_HS_PLAYER_CHANGE PLAYER_ENTER
- confirm_cards 未分类,待确定是否包含 decktop extratop cards
- Shuffle extra SOUND_SHUFFLE
- Random selected SOUND_DICE
- EQUIP
import { Button } from "antd";
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,
);
}
// 代理所有 antd button 音效
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 "@/infra/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, (opts) => {
for (let [_op, path, newValue] of opts) {
if (
path.includes("enableMusic") &&
newValue !== audioContextManger.enableBGM
) {
audioContextManger.switchDisableMusic();
}
if (path.includes("musicVolume")) {
audioContextManger.updateMusicVolume(newValue as number);
}
}
});
// 切换场景
export function changeScene(scene: AudioActionType) {
if (
// 场景切换
audioContextManger.scene !== scene &&
// 允许跟随场景切换音乐
settingStore.audio.enableMusicSwitchByEnv
) {
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,
}
......@@ -32,8 +32,9 @@ export class WebSocketStream {
ws.onmessage = (event) => {
controller.enqueue(event);
};
ws.onclose = () => {
console.info("Websocket closed.");
ws.onclose = (ev) => {
// 后续可能根据断线原因做处理,先暴露出来
console.info("Websocket closed.", ev);
// 下面这行注释掉,因为虽然websocket关掉了,但是已经收到的数据可能还在处理中
// controller.close();
};
......
import { fetchCard, ygopro } from "@/api";
import { AudioActionType, playEffect } from "@/infra/audio";
import { cardStore, fetchEsHintMeta, matStore, placeStore } from "@/stores";
import { callCardFocus } from "@/ui/Duel/PlayMat/Card";
export default async (chaining: ygopro.StocGameMessage.MsgChaining) => {
playEffect(AudioActionType.SOUND_ACTIVATE);
fetchEsHintMeta({
originMsg: "「[?]」被发动时",
cardID: chaining.code,
......
import { fetchCard, ygopro } from "@/api";
import { sleep } from "@/infra";
import { AudioActionType, playEffect } from "@/infra/audio";
import { cardStore } from "@/stores";
import { callCardFocus, callCardMove } from "@/ui/Duel/PlayMat/Card";
......@@ -10,6 +11,7 @@ const { FACEUP_ATTACK, FACEDOWN_ATTACK, FACEDOWN_DEFENSE, FACEDOWN } =
const WAIT_TIME = 100;
export default async (confirmCards: ygopro.StocGameMessage.MsgConfirmCards) => {
playEffect(AudioActionType.SOUND_REVEAL);
const cards = confirmCards.cards;
console.color("pink")(`confirmCards: ${cards}`);
......
import { ygopro } from "@/api";
import { AudioActionType, playEffect } from "@/infra/audio";
import { fetchEsHintMeta } from "@/stores";
export default (flipSummoning: ygopro.StocGameMessage.MsgFlipSummoning) => {
playEffect(AudioActionType.SOUND_FILP);
fetchEsHintMeta({
originMsg: "「[?]」反转召唤宣言时",
cardID: flipSummoning.code,
......
import { fetchCard, ygopro } from "@/api";
import { AudioActionType, playEffect } from "@/infra/audio";
import { cardStore, CardType } from "@/stores";
import { callCardMove } from "@/ui/Duel/PlayMat/Card";
import { REASON_MATERIAL, TYPE_TOKEN } from "../../common";
import { REASON_DESTROY, REASON_MATERIAL, TYPE_TOKEN } from "../../common";
type MsgMove = ygopro.StocGameMessage.MsgMove;
const { HAND, GRAVE, REMOVED, DECK, EXTRA, MZONE, TZONE } = ygopro.CardZone;
......@@ -47,6 +48,22 @@ export default async (move: MsgMove) => {
}
}
switch (to.zone) {
case REMOVED:
playEffect(AudioActionType.SOUND_BANISHED);
break;
default:
break;
}
switch (reason) {
case REASON_DESTROY:
playEffect(AudioActionType.SOUND_DESTROYED);
break;
default:
break;
}
// log出来看看
console.color("green")(
`${meta.text.name} ${ygopro.CardZone[from.zone]}:${from.sequence}${
......
import { ygopro } from "@/api";
import { AudioActionType, playEffect } from "@/infra/audio";
import { InteractType, placeStore } from "@/stores";
type MsgSelectPlace = ygopro.StocGameMessage.MsgSelectPlace;
......@@ -10,6 +11,7 @@ export default (selectPlace: MsgSelectPlace) => {
}
for (const place of selectPlace.places) {
playEffect(AudioActionType.SOUND_SET);
switch (place.zone) {
case ygopro.CardZone.MZONE:
case ygopro.CardZone.SZONE:
......
import { ygopro } from "@/api";
import { AudioActionType, playEffect } from "@/infra/audio";
import { fetchEsHintMeta } from "@/stores";
export default (_set: ygopro.StocGameMessage.MsgSet) => {
playEffect(AudioActionType.SOUND_SET);
fetchEsHintMeta({ originMsg: 1601 });
};
import { ygopro } from "@/api";
import { AudioActionType, playEffect } from "@/infra/audio";
import { cardStore } from "@/stores";
export default (shuffleDeck: ygopro.StocGameMessage.MsgShuffleDeck) => {
playEffect(AudioActionType.SOUND_SHUFFLE);
const player = shuffleDeck.player;
for (const card of cardStore.at(ygopro.CardZone.DECK, player)) {
// 把数据抹掉就好了
......
import { ygopro } from "@/api";
import { AudioActionType, playEffect } from "@/infra/audio";
import { cardStore } from "@/stores";
import { callCardMove } from "@/ui/Duel/PlayMat/Card";
type MsgShuffleHandExtra = ygopro.StocGameMessage.MsgShuffleHandExtra;
export default async (shuffleHandExtra: MsgShuffleHandExtra) => {
playEffect(AudioActionType.SOUND_SHUFFLE);
const { cards: codes, player: controller, zone } = shuffleHandExtra;
// 本质上是要将手卡/额外卡组的sequence变成和codes一样的顺序
......
......@@ -2,9 +2,11 @@ import { ygopro } from "@/api";
import { cardStore } from "@/stores";
import { callCardMove } from "@/ui/Duel/PlayMat/Card";
import MsgShuffleSetCard = ygopro.StocGameMessage.MsgShuffleSetCard;
import { AudioActionType, playEffect } from "@/infra/audio";
// 后端传过来的`from_locations`的列表是切洗前场上卡的location,它们在列表里面按照切洗后的顺序排列
export default async (shuffleSetCard: MsgShuffleSetCard) => {
playEffect(AudioActionType.SOUND_SHUFFLE);
const from_locations = shuffleSetCard.from_locations;
const overlay_locations = shuffleSetCard.overlay_locations;
if (from_locations.length === 0) {
......
import { ygopro } from "@/api";
import { fetchCard, ygopro } from "@/api";
import { TYPE_TOKEN } from "@/common";
import { AudioActionType, playEffect } from "@/infra/audio";
import { fetchEsHintMeta } from "@/stores";
export default (spSummoning: ygopro.StocGameMessage.MsgSpSummoning) => {
const card = fetchCard(spSummoning.code);
if (card.data.type && card.data.type & TYPE_TOKEN) {
playEffect(AudioActionType.SOUND_TOKEN);
} else {
playEffect(AudioActionType.SOUND_SPECIAL_SUMMON);
}
fetchEsHintMeta({
originMsg: "「[?]」特殊召唤宣言时",
cardID: spSummoning.code,
......
import { ygopro } from "@/api";
import { AudioActionType, playEffect } from "@/infra/audio";
import { fetchEsHintMeta } from "@/stores";
export default (summoning: ygopro.StocGameMessage.MsgSummoning) => {
playEffect(AudioActionType.SOUND_SUMMON);
fetchEsHintMeta({
originMsg: "「[?]」通常召唤宣言时",
cardID: summoning.code,
......
......@@ -2,6 +2,7 @@ import { ygopro } from "@/api";
import { fetchEsHintMeta, matStore } from "@/stores";
import MsgUpdateHp = ygopro.StocGameMessage.MsgUpdateHp;
import { AudioActionType, changeScene } from "@/infra/audio";
export default (msgUpdateHp: MsgUpdateHp) => {
if (msgUpdateHp.type_ === MsgUpdateHp.ActionType.DAMAGE) {
......@@ -11,4 +12,10 @@ export default (msgUpdateHp: MsgUpdateHp) => {
fetchEsHintMeta({ originMsg: "玩家生命值回复时" }); // TODO: i18n
matStore.initInfo.of(msgUpdateHp.player).life += msgUpdateHp.value;
}
if (matStore.initInfo.me.life > matStore.initInfo.op.life * 2) {
changeScene(AudioActionType.BGM_ADVANTAGE);
}
if (matStore.initInfo.me.life * 2 < matStore.initInfo.op.life) {
changeScene(AudioActionType.BGM_DISADVANTAGE);
}
};
import { ygopro } from "@/api";
import { AudioActionType, playEffect } from "@/infra/audio";
import { chatStore } from "@/stores";
export default function handleChat(pb: ygopro.YgoStocMsg) {
playEffect(AudioActionType.SOUND_CHAT);
const chat = pb.stoc_chat;
chatStore.message = chat.msg;
chatStore.sender = chat.player;
......
import { fetchCard, fetchStrings, Region, ygopro } from "@/api";
import { roomStore } from "@/stores";
import ErrorType = ygopro.StocErrorMsg.ErrorType;
import { AudioActionType, playEffect } from "@/infra/audio";
// TODO: 是时候需要一个统一管理国际化文案的模块了
......@@ -18,10 +19,12 @@ export default async function handleErrorMsg(errorMsg: ygopro.StocErrorMsg) {
const { error_type, error_code } = errorMsg;
switch (error_type) {
case ErrorType.JOINERROR: {
playEffect(AudioActionType.SOUND_INFO);
roomStore.errorMsg = fetchStrings(Region.System, 1403 + error_code);
break;
}
case ErrorType.DECKERROR: {
playEffect(AudioActionType.SOUND_INFO);
const flag = error_code >> 28;
const code = error_code & 0xfffffff;
const card = fetchCard(code);
......@@ -78,10 +81,12 @@ export default async function handleErrorMsg(errorMsg: ygopro.StocErrorMsg) {
break;
}
case ErrorType.SIDEERROR: {
playEffect(AudioActionType.SOUND_INFO);
roomStore.errorMsg = "更换副卡组失败,请检查卡片张数是否一致。";
break;
}
case ErrorType.VERSIONERROR: {
playEffect(AudioActionType.SOUND_INFO);
roomStore.errorMsg = "版本不匹配,请联系技术人员解决";
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"])),
);
}
});
......@@ -33,6 +33,7 @@ import { subscribeKey } from "valtio/utils";
import { type CardMeta, searchCards } from "@/api";
import { isExtraDeckCard, isToken } from "@/common";
import { AudioActionType, changeScene } from "@/infra/audio";
import { emptySearchConditions, FtsConditions } from "@/middleware/sqlite/fts";
import { deckStore, emptyDeck, type IDeck, initStore } from "@/stores";
import {
......@@ -80,6 +81,9 @@ export const loader: LoaderFunction = async () => {
});
}
// 更新场景
changeScene(AudioActionType.BGM_DECK);
return null;
};
......
......@@ -3,6 +3,7 @@ import { proxy } from "valtio";
import { type CardMeta } from "@/api";
import { isExtraDeckCard, isToken } from "@/common";
import { AudioActionType, playEffect } from "@/infra/audio";
import { Type } from "@/ui/Shared/DeckZone";
import { compareCards, type EditingDeck } from "./utils";
......@@ -21,6 +22,7 @@ export const editDeckStore = proxy({
editDeckStore[type].push(card);
editDeckStore[type].sort(compareCards);
editDeckStore.edited = true;
playEffect(AudioActionType.SOUND_CARD_PICK);
},
remove(type: Type, card: CardMeta) {
const index = editDeckStore[type].findIndex((item) => item.id === card.id);
......@@ -28,6 +30,7 @@ export const editDeckStore = proxy({
editDeckStore[type].splice(index, 1);
editDeckStore.edited = true;
}
playEffect(AudioActionType.SOUND_CARD_DROP);
},
set(deck: EditingDeck) {
editDeckStore.deckName = deck.deckName;
......
import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { LoaderFunction, useNavigate } from "react-router-dom";
import { useSnapshot } from "valtio";
import { AudioActionType, changeScene } from "@/infra/audio";
import { matStore, SideStage, sideStore } from "@/stores";
import {
......@@ -23,6 +24,12 @@ import { LifeBar, Mat, Menu, Underlying } from "./PlayMat";
import { ChatBox } from "./PlayMat/ChatBox";
import { HandChain } from "./PlayMat/HandChain";
export const loader: LoaderFunction = async () => {
// 更新场景
changeScene(AudioActionType.BGM_DUEL);
return null;
};
export const Component: React.FC = () => {
const { stage } = useSnapshot(sideStore);
const { duelEnd } = useSnapshot(matStore);
......
import { Avatar, Dropdown } from "antd";
import { App, Avatar, Dropdown } from "antd";
import classNames from "classnames";
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
......@@ -19,7 +19,11 @@ import {
import { useConfig } from "@/config";
import { accountStore } from "@/stores";
<<<<<<< HEAD
import { I18NSelector } from "../I18N";
=======
import { Setting } from "../Setting";
>>>>>>> d1129464 (feat: 音乐与音效)
import styles from "./index.module.scss";
import {
getLoginStatus,
......@@ -73,6 +77,7 @@ export const Component = () => {
const { pathname } = routerLocation;
const pathnamesHideHeader = ["/waitroom", "/duel", "/side"];
const { modal } = App.useApp();
const callbackUrl = `${location.origin}/match/`;
const onLogin = () => location.replace(getSSOSignInUrl(callbackUrl));
......@@ -138,7 +143,23 @@ export const Component = () => {
),
},
{
<<<<<<< HEAD
label: logined ? i18n("LogOut") : i18n("Login"),
=======
label: "系统设置",
onClick: () => {
modal.info({
content: <Setting />,
centered: true,
maskClosable: true,
icon: null,
footer: null,
});
},
},
{
label: logined ? "退出登录" : "登录萌卡",
>>>>>>> d1129464 (feat: 音乐与音效)
onClick: logined ? onLogout : onLogin,
},
{
......
......@@ -18,6 +18,7 @@ import {
match,
} from "@/api";
import { useConfig } from "@/config";
import { AudioActionType, changeScene } from "@/infra/audio";
import { accountStore, deckStore, resetUniverse, roomStore } from "@/stores";
import { Background, IconFont, ScrollableArea, Select } from "@/ui/Shared";
......@@ -37,6 +38,8 @@ const { servers: serverList } = useConfig();
export const loader: LoaderFunction = () => {
// 在加载这个页面之前先重置一些store,清掉上局游戏遗留的数据
resetUniverse();
// 更新当前场景
changeScene(AudioActionType.BGM_MENU);
return null;
};
......
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 { AudioActionType, usePlayEffect } from "@/infra/audio";
import styles from "./index.module.scss";
// TODO: SpecialButton能不能做个Loading?
......@@ -7,13 +9,19 @@ export const SpecialButton: React.FC<
React.PropsWithChildren<React.ComponentProps<"span">> & {
disabled?: boolean;
}
> = ({ children, className, disabled, ...rest }) => (
<span
className={classNames(styles["special-btn"], className, {
[styles.disabled]: disabled,
})}
{...rest}
>
{children}
</span>
);
> = ({ children, className, disabled, ...rest }) => {
const [effectRef] = usePlayEffect<HTMLSpanElement>(
AudioActionType.SOUND_BUTTON,
);
return (
<span
ref={effectRef}
className={classNames(styles["special-btn"], className, {
[styles.disabled]: disabled,
})}
{...rest}
>
{children}
</span>
);
};
......@@ -3,11 +3,12 @@ import { App, Button, Space } from "antd";
import { HTML5toTouch } from "rdndmb-html5-to-touch";
import React, { useEffect, useState } from "react";
import { DndProvider } from "react-dnd-multi-backend";
import { useNavigate } from "react-router-dom";
import { LoaderFunction, useNavigate } from "react-router-dom";
import { useSnapshot } from "valtio";
import { CardMeta, fetchCard, sendUpdateDeck } from "@/api";
import { isExtraDeckCard } from "@/common";
import { AudioActionType, changeScene } from "@/infra/audio";
import { IDeck, roomStore, SideStage, sideStore } from "@/stores";
import { CardDetail } from "../BuildDeck/CardDetail";
......@@ -16,6 +17,12 @@ import { Chat } from "../WaitRoom/Chat";
import styles from "./index.module.scss";
import { TpModal } from "./TpModal";
export const loader: LoaderFunction = async () => {
// 更新场景
changeScene(AudioActionType.BGM_DECK);
return null;
};
export const Component: React.FC = () => {
const { message } = App.useApp();
const initialDeck = sideStore.getSideDeck();
......
import { RightOutlined } from "@ant-design/icons";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { LoaderFunction, useNavigate } from "react-router-dom";
import { useSnapshot } from "valtio";
import { getSSOSignInUrl } from "@/api";
import { useConfig } from "@/config";
import { AudioActionType, changeScene } from "@/infra/audio";
import { accountStore } from "@/stores";
import { Background, SpecialButton } from "@/ui/Shared";
......@@ -12,6 +13,12 @@ import styles from "./index.module.scss";
const NeosConfig = useConfig();
export const loader: LoaderFunction = async () => {
// 更新场景
changeScene(AudioActionType.BGM_MENU);
return null;
};
export const Component: React.FC = () => {
const { t } = useTranslation("Start");
const { user } = useSnapshot(accountStore);
......
......@@ -17,10 +17,11 @@ import SelfType = ygopro.StocTypeChange.SelfType;
import { App, Avatar, Button, Skeleton, Space } from "antd";
import classNames from "classnames";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { LoaderFunction, useNavigate } from "react-router-dom";
import { useSnapshot } from "valtio";
import { useConfig } from "@/config";
import { AudioActionType, changeScene } from "@/infra/audio";
import {
accountStore,
deckStore,
......@@ -39,6 +40,12 @@ import { Mora, MoraPopover, Tp, TpPopover } from "./Popover";
const NeosConfig = useConfig();
export const loader: LoaderFunction = async () => {
// 更新场景
changeScene(AudioActionType.BGM_MENU);
return null;
};
export const Component: React.FC = () => {
const { message } = App.useApp();
const { user } = useSnapshot(accountStore);
......
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