Commit e141102a authored by Chunchi Che's avatar Chunchi Che

Merge branch 'feat/audio' into 'main'

Feat/audio

See merge request !374
parents f4da8169 43568f9f
Pipeline #27233 passed with stages
in 7 minutes and 18 seconds
......@@ -21,6 +21,6 @@ export default (data: Uint8Array) => {
code,
from: fromLocation,
to: toLocation,
reason: reader.inner.readUint8(),
reason: reader.inner.readUint32(),
});
};
......@@ -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 state() {
return this._musicAudioContext.state;
}
public get closed() {
return this._isClosed;
}
/**
* 触发自动播放
*/
private _triggerAutoPlay() {
if (this.state === "suspended") {
const autoPlay = () => {
document.removeEventListener("click", autoPlay);
this.resume();
};
document.addEventListener("click", autoPlay);
}
}
/**
* 监听未启播
*/
private _observerPlayState(source: AudioBufferSourceNode) {
// 50ms 未启播,说明播放失败了,重新尝试播放
const timeout = setTimeout(async () => {
if (source.loop) return;
if (this.state === "closed") return;
if (source.context.currentTime === 0) {
await this.suspend();
await this.resume();
}
}, 50);
// 播放结束后关闭音频
const Ended = () => {
source.removeEventListener("ended", Ended);
this.emit("ended");
clearTimeout(timeout);
this.close();
};
source.addEventListener("ended", Ended);
}
/**
* 播放音频
* @param audio 音频数据
*/
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();
this._triggerAutoPlay();
this._observerPlayState(source);
}
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 { 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,
);
}
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;
}
}
// 初始化音频设置
function initAudioSetting() {
audioContextManger.updateMusicVolume(settingStore.audio.musicVolume);
audioContextManger.enableBGM = settingStore.audio.enableMusic ?? false;
}
initAudioSetting();
/** 音效类型 */
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,
}
......@@ -3,10 +3,6 @@
//
// 因此封装了一个`WebSocketStream`类,当每次Websocket连接中有消息到达时,往流中添加event,
import { useConfig } from "@/config";
import { sleep } from "./sleep";
// 同时执行器会不断地从流中获取event进行处理。
export class WebSocketStream {
public ws: WebSocket;
......@@ -32,8 +28,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();
};
......@@ -68,7 +65,11 @@ export class WebSocketStream {
if (value) {
// wait some time, and then handle message from server
await sleep(useConfig().streamInterval);
//
// but now it seems that we don't need wait any more,
// so comment the following line and check if it's ok without it.
//
// await sleep(useConfig().streamInterval);
await onMessage(value);
} else {
console.warn("value from ReadableStream is undefined!");
......
import { ygopro } from "@/api";
import { AudioActionType, playEffect } from "@/infra/audio";
import { cardStore, fetchEsHintMeta } from "@/stores";
import { callCardAttack } from "@/ui/Duel/PlayMat/Card";
......@@ -16,10 +17,12 @@ export default async (attack: ygopro.StocGameMessage.MsgAttack) => {
if (attacker) {
if (attack.direct_attack) {
playEffect(AudioActionType.SOUND_DIRECT_ATTACK);
await callCardAttack(attacker.uuid, {
directAttack: true,
});
} else {
playEffect(AudioActionType.SOUND_ATTACK);
await callCardAttack(attacker.uuid, {
directAttack: false,
target: attack.target_location,
......
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 { fetchCard, ygopro } from "@/api";
import { AudioActionType, playEffect } from "@/infra/audio";
import { cardStore, fetchEsHintMeta } from "@/stores";
import { callCardMove } from "@/ui/Duel/PlayMat/Card";
......@@ -24,6 +25,8 @@ export default async (draw: ygopro.StocGameMessage.MsgDraw) => {
card.location.sequence = Number(idx) + handsLength;
}
playEffect(AudioActionType.SOUND_DRAW);
// 抽卡动画
await Promise.all(
cardStore
......
......@@ -2,6 +2,7 @@ import { ygopro } from "@/api";
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;
const { HAND, GRAVE, REMOVED, DECK, EXTRA, MZONE, SZONE, TZONE } =
ygopro.CardZone;
const { FACEDOWN, FACEDOWN_ATTACK, FACEDOWN_DEFENSE } = ygopro.CardPosition;
const overlayStack: ygopro.CardLocation[] = [];
......@@ -161,7 +164,21 @@ export default async (move: MsgMove) => {
target.code = code;
target.location = to;
// 维护完了之后,开始动画
// 维护完了之后,开始播放音效和动画
if (to.zone === REMOVED) {
playEffect(AudioActionType.SOUND_BANISHED);
} else if (
(to.zone === MZONE || to.zone === SZONE) &&
(to.position === FACEDOWN ||
to.position === FACEDOWN_ATTACK ||
to.position === FACEDOWN_DEFENSE)
) {
playEffect(AudioActionType.SOUND_SET);
} else if (reason === REASON_DESTROY) {
playEffect(AudioActionType.SOUND_DESTROYED);
}
const p = callCardMove(target.uuid, { fromZone: from.zone });
// 如果from或者to是手卡,那么需要刷新除了这张卡之外,这个玩家的所有手卡
if ([from.zone, to.zone].includes(HAND)) {
......
import { ygopro } from "@/api";
import { AudioActionType, playEffect } from "@/infra/audio";
import { matStore } from "@/stores";
export default (newPhase: ygopro.StocGameMessage.MsgNewPhase) => {
playEffect(AudioActionType.SOUND_PHASE);
// ts本身还没有这么智能,所以需要手动指定类型
matStore.phase.currentPhase = newPhase.phase_type;
};
import { ygopro } from "@/api";
import { AudioActionType, playEffect } from "@/infra/audio";
import { matStore } from "@/stores";
export default (newTurn: ygopro.StocGameMessage.MsgNewTurn) => {
playEffect(AudioActionType.SOUND_NEXT_TURN);
const player = newTurn.player;
matStore.currentPlayer = player;
};
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 { 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,
......
......@@ -2,6 +2,10 @@ import { ygopro } from "@/api";
import { fetchEsHintMeta } from "@/stores";
export default (summoning: ygopro.StocGameMessage.MsgSummoning) => {
/* 因为现在Neos动画架构的问题,这里播放音效的话会滞后于移动动画,
* 因此这里先注释掉,等解决掉上述问题后再加上召唤的音效。
* */
// playEffect(AudioActionType.SOUND_SUMMON);
fetchEsHintMeta({
originMsg: "「[?]」通常召唤宣言时",
cardID: summoning.code,
......
......@@ -2,6 +2,7 @@ import { fetchStrings, Region, ygopro } from "@/api";
import { sleep } from "@/infra";
import { matStore } from "@/stores";
import MsgToss = ygopro.StocGameMessage.MsgToss;
import { AudioActionType, playEffect } from "@/infra/audio";
export default async (toss: MsgToss) => {
const player = toss.player;
......@@ -11,8 +12,10 @@ export default async (toss: MsgToss) => {
for (const x of toss.res) {
if (tossType === MsgToss.TossType.DICE) {
playEffect(AudioActionType.SOUND_DICE);
matStore.tossResult = prefix + fetchStrings(Region.System, 1624) + x;
} else if (tossType === MsgToss.TossType.COIN) {
playEffect(AudioActionType.SOUND_COIN);
matStore.tossResult =
prefix +
fetchStrings(Region.System, 1623) +
......
import { ygopro } from "@/api";
import { AudioActionType, playEffect } from "@/infra/audio";
import { cardStore } from "@/stores";
type MsgUpdateCounter = ygopro.StocGameMessage.MsgUpdateCounter;
......@@ -12,8 +13,10 @@ export default (updateCounter: MsgUpdateCounter) => {
case ygopro.StocGameMessage.MsgUpdateCounter.ActionType.ADD: {
if (counterType in target.counters) {
target.counters[counterType] += count;
playEffect(AudioActionType.SOUND_COUNTER_ADD);
} else {
target.counters[counterType] = count;
playEffect(AudioActionType.SOUND_COUNTER_REMOVE);
}
break;
}
......
......@@ -2,13 +2,22 @@ import { ygopro } from "@/api";
import { fetchEsHintMeta, matStore } from "@/stores";
import MsgUpdateHp = ygopro.StocGameMessage.MsgUpdateHp;
import { AudioActionType, changeScene, playEffect } from "@/infra/audio";
export default (msgUpdateHp: MsgUpdateHp) => {
if (msgUpdateHp.type_ === MsgUpdateHp.ActionType.DAMAGE) {
playEffect(AudioActionType.SOUND_DAMAGE);
fetchEsHintMeta({ originMsg: "玩家收到伤害时" }); // TODO: i18n
matStore.initInfo.of(msgUpdateHp.player).life -= msgUpdateHp.value;
} else if (msgUpdateHp.type_ === MsgUpdateHp.ActionType.RECOVER) {
playEffect(AudioActionType.SOUND_RECOVER);
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);
}
};
......@@ -2,6 +2,7 @@ import { fetchStrings, Region, ygopro } from "@/api";
import { matStore } from "@/stores";
import { displayEndModal } from "@/ui/Duel/Message";
import MsgWin = ygopro.StocGameMessage.MsgWin;
import { AudioActionType, changeScene } from "@/infra/audio";
export default async (win: MsgWin) => {
const { win_player, reason } = win;
......@@ -10,4 +11,10 @@ export default async (win: MsgWin) => {
matStore.isMe(win_player),
fetchStrings(Region.Victory, `0x${reason.toString(16)}`),
);
if (matStore.isMe(win_player)) {
changeScene(AudioActionType.BGM_WIN);
} else {
changeScene(AudioActionType.BGM_LOSE);
}
};
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: 是时候需要一个统一管理国际化文案的模块了
......@@ -16,6 +17,7 @@ const DECKERROR_NOTAVAIL = 0x9;
export default async function handleErrorMsg(errorMsg: ygopro.StocErrorMsg) {
const { error_type, error_code } = errorMsg;
playEffect(AudioActionType.SOUND_INFO);
switch (error_type) {
case ErrorType.JOINERROR: {
roomStore.errorMsg = fetchStrings(Region.System, 1403 + error_code);
......
import { ygopro } from "@/api";
import { AudioActionType, playEffect } from "@/infra/audio";
import { roomStore } from "@/stores";
export default function handleHsPlayerEnter(pb: ygopro.YgoStocMsg) {
playEffect(AudioActionType.SOUND_PLAYER_ENTER);
const name = pb.stoc_hs_player_enter.name;
const pos = pb.stoc_hs_player_enter.pos;
......
/** 音频设置 */
export interface AudioConfig {
/** 是否开启音乐 */
enableMusic?: boolean;
/** 是否开启音效 */
enableSoundEffects?: boolean;
/** 音乐音量大小 */
musicVolume?: number;
/** 音效音量大小 */
soundEffectsVolume?: number;
/** 是否根据环境切换音乐 */
enableMusicSwitchByEnv?: boolean;
}
export const defaultAudioConfig: AudioConfig = {
enableMusic: false,
enableSoundEffects: false,
musicVolume: 0.7,
soundEffectsVolume: 0.7,
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;
};
......
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";
......@@ -20,6 +20,7 @@ import { useConfig } from "@/config";
import { accountStore } from "@/stores";
import { I18NSelector } from "../I18N";
import { Setting } from "../Setting";
import styles from "./index.module.scss";
import {
getLoginStatus,
......@@ -73,6 +74,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));
......@@ -137,6 +139,18 @@ export const Component = () => {
</a>
),
},
{
label: "系统设置",
onClick: () => {
modal.info({
content: <Setting />,
centered: true,
maskClosable: true,
icon: null,
footer: null,
});
},
},
{
label: logined ? i18n("LogOut") : i18n("Login"),
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,
);
}
......@@ -7,13 +7,22 @@ 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