diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3b24e561..f6baed4f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -75,6 +75,20 @@ fn save_base64_to_image(base64_str: &str, file_name: &str) -> Result<(), String> } } +/// 读取 MP3 文件并返回其 Base64 编码字符串 +#[tauri::command] +fn read_mp3_file(path: String) -> Result { + let mut file = File::open(&path).map_err(|e| format!("无法打开文件: {}", e))?; + let mut buffer = Vec::new(); + file.read_to_end(&mut buffer).map_err(|e| format!("读取文件时出错: {}", e))?; + + // 将文件内容编码为 Base64 + let base64_str = general_purpose::STANDARD.encode(&buffer); + Ok(base64_str) +} + + + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { println!("程序运行了!"); @@ -95,7 +109,8 @@ pub fn run() { save_file_by_path, convert_image_to_base64, save_base64_to_image, - check_json_exist + check_json_exist, + read_mp3_file ]) .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_fs::init()) diff --git a/src/core/Settings.tsx b/src/core/Settings.tsx index db583a86..d46299e7 100644 --- a/src/core/Settings.tsx +++ b/src/core/Settings.tsx @@ -38,7 +38,12 @@ export namespace Settings { moveAmplitude: number; moveFriction: number; gamepadDeadzone: number; - + // 音效相关 + soundEnabled: boolean; + cuttingLineStartSoundFile: string; + connectLineStartSoundFile: string; + connectFindTargetSoundFile: string; + cuttingLineReleaseSoundFile: string; // github 相关 githubToken: string; githubUser: string; @@ -73,7 +78,12 @@ export namespace Settings { moveAmplitude: 2, moveFriction: 0.1, gamepadDeadzone: 0.1, - + // 音效相关 + soundEnabled: true, + cuttingLineStartSoundFile: "", + connectLineStartSoundFile: "", + connectFindTargetSoundFile: "", + cuttingLineReleaseSoundFile: "", // github 相关 githubToken: "", githubUser: "", diff --git a/src/core/SoundService.tsx b/src/core/SoundService.tsx new file mode 100644 index 00000000..c146bcbf --- /dev/null +++ b/src/core/SoundService.tsx @@ -0,0 +1,115 @@ +// 实测发现 不可行: +// @tauri-apps/plugin-fs 只能读取文本文件,不能强行读取流文件并强转为ArrayBuffer +// import { readTextFile } from "@tauri-apps/plugin-fs"; + +import { invoke } from "@tauri-apps/api/core"; +import { StringDict } from "./dataStruct/StringDict"; +import { Settings } from "./Settings"; + +/** + * 播放音效的服务 + * 这个音效播放服务是用户自定义的 + */ +export namespace SoundService { + + let cuttingLineStartSoundFile = ""; + let connectLineStartSoundFile = ""; + let connectFindTargetSoundFile = ""; + let cuttingLineReleaseSoundFile = ""; + + export function init() { + Settings.watch("cuttingLineStartSoundFile", (value) => { + cuttingLineStartSoundFile = value; + }); + Settings.watch("connectLineStartSoundFile", (value) => { + connectLineStartSoundFile = value; + }); + Settings.watch("connectFindTargetSoundFile", (value) => { + connectFindTargetSoundFile = value; + }); + Settings.watch("cuttingLineReleaseSoundFile", (value) => { + cuttingLineReleaseSoundFile = value; + }); + } + + export namespace play { + // 开始切断 + export function cuttingLineStart() { + loadAndPlaySound(cuttingLineStartSoundFile); + } + + // 开始连接 + export function connectLineStart() { + loadAndPlaySound(connectLineStartSoundFile); + } + + // 连接吸附到目标点 + export function connectFindTarget() { + loadAndPlaySound(connectFindTargetSoundFile); + } + + // 自动保存执行特效 + // 自动备份执行特效 + + // 框选增加物体音效 + + // 切断特效声音 + export function cuttingLineRelease() { + loadAndPlaySound(cuttingLineReleaseSoundFile); + } + // 连接成功 + } + + const audioContext = new window.AudioContext(); + + async function loadAndPlaySound(filePath: string) { + if (filePath.trim() === "") { + console.log("filePath is empty"); + return; + } + + // 解码音频数据 + const audioBuffer = await getAudioBufferByFilePath(filePath); + const source = audioContext.createBufferSource(); + source.buffer = audioBuffer; + source.connect(audioContext.destination); + source.start(0); + } + + const pathAudioBufferMap = new StringDict(); + + async function getAudioBufferByFilePath(filePath: string) { + // 先从缓存中获取音频数据 + if (pathAudioBufferMap.hasId(filePath)) { + const result = pathAudioBufferMap.getById(filePath); + if (result) { + return result; + } + } + + // 缓存中没有 + + // 读取文件为字符串 + const base64Data: string = await invoke("read_mp3_file", { + path: filePath, + }); + // 解码 Base64 字符串 + const byteCharacters = atob(base64Data); // 使用 atob 解码 Base64 字符串 + const byteNumbers = new Uint8Array(byteCharacters.length); + + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); // 转换为字节数组 + } + + // 创建 ArrayBuffer + const arrayBuffer = byteNumbers.buffer; + + // 解码音频数据 + const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); + + // 加入缓存 + pathAudioBufferMap.setById(filePath, audioBuffer); + + return audioBuffer; + } +} diff --git a/src/core/controller/concrete/ControllerCutting.tsx b/src/core/controller/concrete/ControllerCutting.tsx index f6947da0..b1765342 100644 --- a/src/core/controller/concrete/ControllerCutting.tsx +++ b/src/core/controller/concrete/ControllerCutting.tsx @@ -6,6 +6,7 @@ import { CircleFlameEffect } from "../../effect/concrete/CircleFlameEffect"; import { LineCuttingEffect } from "../../effect/concrete/LineCuttingEffect"; import { EdgeRenderer } from "../../render/canvas2d/entityRenderer/edge/EdgeRenderer"; import { Renderer } from "../../render/canvas2d/renderer"; +import { SoundService } from "../../SoundService"; import { Stage } from "../../stage/Stage"; import { StageManager } from "../../stage/stageManager/StageManager"; import { Section } from "../../stageObject/entity/Section"; @@ -45,6 +46,8 @@ ControllerCutting.mousedown = (event: MouseEvent) => { cuttingStartLocation, cuttingStartLocation.clone(), ); + // 添加音效提示 + SoundService.play.cuttingLineStart(); } else { Stage.isCutting = false; } @@ -144,4 +147,5 @@ ControllerCutting.mouseup = (event: MouseEvent) => { cuttingStartLocation.distance(ControllerCutting.lastMoveLocation) / 10, ), ); + SoundService.play.cuttingLineRelease(); }; diff --git a/src/core/controller/concrete/ControllerNodeConnection.tsx b/src/core/controller/concrete/ControllerNodeConnection.tsx index bf7ffc05..12b3ed5c 100644 --- a/src/core/controller/concrete/ControllerNodeConnection.tsx +++ b/src/core/controller/concrete/ControllerNodeConnection.tsx @@ -12,6 +12,7 @@ import { EdgeRenderer } from "../../render/canvas2d/entityRenderer/edge/EdgeRend import { ConnectableEntity } from "../../stageObject/StageObject"; import { ConnectPoint } from "../../stageObject/entity/ConnectPoint"; import { v4 } from "uuid"; +import { SoundService } from "../../SoundService"; /** * 右键连线功能 的控制器 @@ -109,6 +110,8 @@ ControllerNodeConnection.mousedown = (event: MouseEvent) => { ), ); } + // 播放音效 + SoundService.play.connectLineStart(); } }; @@ -126,9 +129,10 @@ ControllerNodeConnection.mousemove = (event: MouseEvent) => { let isFindConnectToNode = false; for (const entity of StageManager.getConnectableEntity()) { if (entity.collisionBox.isPointInCollisionBox(worldLocation)) { + // 找到了连接的节点,吸附上去 Stage.connectToEntity = entity; isFindConnectToNode = true; - + SoundService.play.connectFindTarget(); break; } } diff --git a/src/main.tsx b/src/main.tsx index 5d1340b0..774bf315 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -22,6 +22,7 @@ import { StartFilesManager } from "./core/StartFilesManager"; import "./index.pcss"; import { DialogProvider } from "./utils/dialog"; import { PopupDialogProvider } from "./utils/popupDialog"; +import { SoundService } from "./core/SoundService"; const router = createMemoryRouter(routes); const Routes = () => ; @@ -66,6 +67,7 @@ async function loadSyncModules() { Stage.init(); StageHistoryManager.init(); StageStyleManager.init(); + SoundService.init(); } /** 加载语言文件 */ diff --git a/src/pages/settings/_layout.tsx b/src/pages/settings/_layout.tsx index 8e4ccca8..9c1483f9 100644 --- a/src/pages/settings/_layout.tsx +++ b/src/pages/settings/_layout.tsx @@ -17,6 +17,7 @@ export default function SettingsLayout() { {t("control")} {t("ai")} {t("github")} + sounds
diff --git a/src/pages/settings/sounds.tsx b/src/pages/settings/sounds.tsx new file mode 100644 index 00000000..c64816be --- /dev/null +++ b/src/pages/settings/sounds.tsx @@ -0,0 +1,37 @@ +import { Folder } from "lucide-react"; +import { SettingField } from "./_field"; + +export default function Sounds() { + return ( + <> + } + settingKey="cuttingLineStartSoundFile" + title={"切割线开始音效"} + details={"右键按下时刚开始创建切割线准备切割东西时播放的音效文件。"} + type="text" + /> + } + settingKey="connectLineStartSoundFile" + title={"连接线开始音效"} + details={"右键按下时刚开始创建连接时播放的音效文件。"} + type="text" + /> + } + settingKey="connectFindTargetSoundFile" + title={"连接线查找目标音效"} + details={"当"} + type="text" + /> + } + settingKey="cuttingLineReleaseSoundFile" + title={"切断线释放特效"} + details={"纯粹解压用的"} + type="text" + /> + + ); +} diff --git a/src/pages/test.tsx b/src/pages/test.tsx index 81804e25..5b7cb99a 100644 --- a/src/pages/test.tsx +++ b/src/pages/test.tsx @@ -10,6 +10,7 @@ import { LastLaunch } from "../core/LastLaunch"; import { StageDumper } from "../core/stage/StageDumper"; import { usePopupDialog } from "../utils/popupDialog"; import { XML } from "../utils/xml"; +import { SoundService } from "../core/SoundService"; export default function TestPage() { const [switchValue, setSwitchValue] = React.useState(false); @@ -50,6 +51,7 @@ export default function TestPage() {
+ last launch: {LastLaunch.version} ); diff --git a/src/router.ts b/src/router.ts index 1818c86e..f1fe4e4d 100644 --- a/src/router.ts +++ b/src/router.ts @@ -13,6 +13,7 @@ export type Path = | `/settings/github` | `/settings/performance` | `/settings/physical` + | `/settings/sounds` | `/settings/visual` | `/test` | `/welcome`;