first commit
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
3
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
5
README.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + TypeScript + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||||
14
index.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="transparent" content="true">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vite + Vue + TS</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
23
package.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "front-end",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.8.3",
|
||||||
|
"vue": "^3.5.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"@vue/tsconfig": "^0.7.0",
|
||||||
|
"sass-embedded": "^1.86.0",
|
||||||
|
"typescript": "~5.7.2",
|
||||||
|
"vite": "^6.2.0",
|
||||||
|
"vue-tsc": "^2.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
1344
pnpm-lock.yaml
generated
Normal file
1
public/vite.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
319
src/App.vue
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { graphicsSettings, skinNames, updateGraphicsSettings } from './config';
|
||||||
|
import icons from './assets/imgs/icons';
|
||||||
|
import VuplexAPI from './VuplexAPI';
|
||||||
|
import Button from './components/Button.vue';
|
||||||
|
import Note from './components/Note.vue';
|
||||||
|
import OptionGroup from './components/OptionGroup.vue';
|
||||||
|
import InGame from './components/InGame.vue';
|
||||||
|
import { EasyAudio } from './utils';
|
||||||
|
import audios from './assets/audios';
|
||||||
|
|
||||||
|
|
||||||
|
const mode = ref('home');
|
||||||
|
const scene = ref(0)
|
||||||
|
const skin = ref(0)
|
||||||
|
const gameName = ref('');
|
||||||
|
const username = ref('');
|
||||||
|
|
||||||
|
const Vuplex = new VuplexAPI()
|
||||||
|
|
||||||
|
watch(mode, newVal => {
|
||||||
|
switch (newVal) {
|
||||||
|
case 'home':
|
||||||
|
scene.value = 0;
|
||||||
|
gameName.value = '';
|
||||||
|
Vuplex.selectSkin(-1);
|
||||||
|
break;
|
||||||
|
case 'set_skins':
|
||||||
|
Vuplex.selectSkin(skin.value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
watch(scene, newVal => Vuplex.moveCamera(newVal));
|
||||||
|
watch(skin, newVal => Vuplex.selectSkin(newVal));
|
||||||
|
|
||||||
|
const inGame = ref(false);
|
||||||
|
const ea = new EasyAudio({
|
||||||
|
name: 'bgm-home',
|
||||||
|
audioUrl: audios.bgm_home,
|
||||||
|
loop: true,
|
||||||
|
volume: 0.5
|
||||||
|
});
|
||||||
|
ea.play('bgm-home');
|
||||||
|
watch(inGame, newVal => {
|
||||||
|
if (newVal) {
|
||||||
|
ea.stopAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function joinRoom() {
|
||||||
|
Vuplex.joinRoom(gameName.value, scene.value);
|
||||||
|
inGame.value = true;
|
||||||
|
}
|
||||||
|
type RouteNode = {
|
||||||
|
name: string;
|
||||||
|
children: RouteNode[];
|
||||||
|
} | string
|
||||||
|
|
||||||
|
const navigationTree: RouteNode = {
|
||||||
|
name: 'home',
|
||||||
|
children: [{
|
||||||
|
name: 'create',
|
||||||
|
children: ['create_2']
|
||||||
|
}, {
|
||||||
|
name: 'join',
|
||||||
|
children: ['join_2']
|
||||||
|
},
|
||||||
|
'set_skins',
|
||||||
|
'login',
|
||||||
|
'settings',
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const parentMap: Record<string, string> = {};
|
||||||
|
|
||||||
|
function buildParentMap(node: RouteNode, parent?: string) {
|
||||||
|
if (typeof node === 'string') {
|
||||||
|
if (parent) parentMap[node] = parent;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parent) parentMap[node.name] = parent;
|
||||||
|
|
||||||
|
node.children.forEach(child => {
|
||||||
|
if (typeof child === 'string') parentMap[child] = node.name;
|
||||||
|
else buildParentMap(child, node.name)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
buildParentMap(navigationTree);
|
||||||
|
|
||||||
|
function back() {
|
||||||
|
mode.value = parentMap[mode.value];
|
||||||
|
}
|
||||||
|
|
||||||
|
function login() {
|
||||||
|
mode.value = 'home';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function refresh() { window.location.reload(); }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="main">
|
||||||
|
<div class="not-in-game" v-if="!inGame">
|
||||||
|
<div class="title" :class="{ 'moveTop': mode != 'home' }">
|
||||||
|
<div class="secendary">- ai powered -</div>
|
||||||
|
<div class="primary">
|
||||||
|
<span class="large">W</span>erewolf
|
||||||
|
<span class="large">G</span>ame
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="menu" :class="{ 'moveBottom': mode != 'home' }">
|
||||||
|
<div class="home" v-if="mode == 'home'">
|
||||||
|
<Button theme="green" content="Create Game"
|
||||||
|
@click="mode = 'create'; scene = 1" />
|
||||||
|
<Button theme="blue" content="Join Game" @click="mode = 'join'" />
|
||||||
|
<Button theme="blue" content="Set Skins"
|
||||||
|
@click="mode = 'set_skins'" />
|
||||||
|
<Button theme="blue" content="Settings" @click="mode = 'settings'" />
|
||||||
|
<Button theme="red" content="Quit" @click="Vuplex.quitGame" />
|
||||||
|
</div>
|
||||||
|
<div class="create" v-if="mode == 'create'">
|
||||||
|
<div class="scene-index">Scene {{ scene }}</div>
|
||||||
|
<div class="select-btn">
|
||||||
|
<Button theme="blue" content="Previous"
|
||||||
|
@click="scene = scene === 1 ? 3 : scene - 1" />
|
||||||
|
<Button theme="blue" content="Next"
|
||||||
|
@click="scene = scene === 3 ? 1 : scene + 1" />
|
||||||
|
</div>
|
||||||
|
<Button theme="green" content="Confirm" @click="mode = 'create_2'" />
|
||||||
|
</div>
|
||||||
|
<div class="create-2" v-if="mode == 'create_2'">
|
||||||
|
<div>Input Game ID:</div>
|
||||||
|
<input type="text" v-model="gameName" spellcheck="false">
|
||||||
|
<Button theme="green" content="Confirm" @click="joinRoom()" />
|
||||||
|
<div class="obver"><input type="checkbox" name=""
|
||||||
|
id="obverse-checkbox"><label for="obverse-checkbox">As
|
||||||
|
Observers</label></div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="join" v-if="mode == 'join'">
|
||||||
|
<div>Input Game ID:</div>
|
||||||
|
<input type="text" v-model="gameName" spellcheck="false">
|
||||||
|
<Button theme="green" content="Confirm" @click="joinRoom()" />
|
||||||
|
</div>
|
||||||
|
<div class="set-skins" v-if="mode == 'set_skins'">
|
||||||
|
<div class="scene-index">
|
||||||
|
<div class="index">Skin {{ skin + 1 }}</div>
|
||||||
|
<div class="name" style="font-size: 3.5vh;">{{ skinNames[skin].name
|
||||||
|
}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="select-btn">
|
||||||
|
<Button theme="blue" content="Previous"
|
||||||
|
@click="skin = skin === 0 ? 11 : skin - 1" />
|
||||||
|
<Button theme="blue" content="Next"
|
||||||
|
@click="skin = skin === 11 ? 0 : skin + 1" />
|
||||||
|
</div>
|
||||||
|
<Button theme="green" content="Confirm" @click="mode = 'home'" />
|
||||||
|
</div>
|
||||||
|
<div class="login" v-if="mode == 'login'">
|
||||||
|
<div>Input User Name:</div>
|
||||||
|
<input type="text" v-model="username" spellcheck="false">
|
||||||
|
<Button theme="green" content="Confirm" @click="login()" />
|
||||||
|
</div>
|
||||||
|
<div class="settings" v-if="mode == 'settings'">
|
||||||
|
<OptionGroup :options-config="graphicsSettings"
|
||||||
|
@update="updateGraphicsSettings" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="back-btn" v-if="mode != 'home'">
|
||||||
|
<Button theme="blue" content="Back" @click="back()" />
|
||||||
|
</div>
|
||||||
|
<div class="left-bottom">
|
||||||
|
<Button theme="blue" :content="icons.earth" sm></Button>
|
||||||
|
<Button theme="blue" :content="icons.login" sm
|
||||||
|
@click="mode = 'login'"></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="in-game" v-if="inGame">
|
||||||
|
<InGame :base-u-r-l="'http://localhost:3000'" :game-id="gameName"
|
||||||
|
:player-id="1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- <div style="position: absolute; left: 0; bottom: 0;">
|
||||||
|
<span @click="refresh">Refresh</span>
|
||||||
|
<span @click="Vuplex.setTime('night')">Night</span>
|
||||||
|
<span @click="Vuplex.setTime('day')">Day</span>
|
||||||
|
</div> -->
|
||||||
|
<div
|
||||||
|
style="position: absolute; right: 0; bottom: 0; opacity: 0; pointer-events: none;">
|
||||||
|
<span v-for="f in ['ZCOOL XiaoWei', 'Adventure Time', 'Ma Shan Zheng']"
|
||||||
|
:style="{ fontFamily: f }">ABCabc阿斯顿</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.main {
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
text-shadow: 0 0 2vh #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
position: absolute;
|
||||||
|
top: 18%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
font-family: 'Adventure Time';
|
||||||
|
transition: all 0.5s ease;
|
||||||
|
|
||||||
|
&.moveTop {
|
||||||
|
top: 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
font-size: 12vh;
|
||||||
|
color: rgb(255, 220, 188);
|
||||||
|
text-shadow: 0 0 8vh #000000;
|
||||||
|
-webkit-text-stroke: .2vh rgb(255, 207, 161);
|
||||||
|
|
||||||
|
/* .large {
|
||||||
|
font-size: 12vh;
|
||||||
|
} */
|
||||||
|
}
|
||||||
|
|
||||||
|
.secendary {
|
||||||
|
position: relative;
|
||||||
|
top: 3vh;
|
||||||
|
font-size: 5vh;
|
||||||
|
color: #ffffff;
|
||||||
|
text-shadow: 0 0 2vh #00ffbf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-bottom {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
left: 2vh;
|
||||||
|
bottom: 2vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
position: absolute;
|
||||||
|
left: 2vh;
|
||||||
|
top: 2vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 26%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
text-align: center;
|
||||||
|
font-family: 'Adventure Time';
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3vh;
|
||||||
|
transition: all 0.5s ease;
|
||||||
|
|
||||||
|
&.moveBottom {
|
||||||
|
bottom: 5%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.home {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: .7vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create,
|
||||||
|
.set-skins {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3vh;
|
||||||
|
|
||||||
|
.scene-index {
|
||||||
|
font-size: 5vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-btn {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-2,
|
||||||
|
.join,
|
||||||
|
.login {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3vh;
|
||||||
|
font-size: 5vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obver {
|
||||||
|
font-size: 3vh;
|
||||||
|
height: 0;
|
||||||
|
position: relative;
|
||||||
|
bottom: 3vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
113
src/VuplexAPI.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
declare const window: any;
|
||||||
|
|
||||||
|
class VuplexEvents {
|
||||||
|
private vuplex: any;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
console.log(window.vuplex);
|
||||||
|
|
||||||
|
if (!window.vuplex) {
|
||||||
|
console.error('window.vuplex is not defined');
|
||||||
|
Object.assign(window, {
|
||||||
|
vuplex: {
|
||||||
|
postMessage: (msg: any) => {
|
||||||
|
/* console.log(`%c[vuplex]%c ${JSON.stringify(msg)}`, "color: lightgreen; font-style: italic; background-color: orange;padding: 2px", '');
|
||||||
|
*/ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.body.style.backgroundColor = 'lightgray';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建一个代理对象,拦截所有对 postMessage 的调用
|
||||||
|
this.vuplex = new Proxy(window.vuplex, {
|
||||||
|
get: (target, property) => {
|
||||||
|
if (property === 'postMessage') {
|
||||||
|
return (msg: any) => {
|
||||||
|
// 记录到控制台
|
||||||
|
console.log(`%c[Vuplex]%c ${JSON.stringify(msg)}`, "background-color: orange; color: white; font-weight: bold; padding: 2px 4px; border-radius: 2px", '');
|
||||||
|
// 调用原始方法
|
||||||
|
return target.postMessage(msg);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return target[property];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
selectSkin(index: number) {
|
||||||
|
this.vuplex.postMessage({
|
||||||
|
type: "selectSkin",
|
||||||
|
index: index
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
moveCamera(index: number) {
|
||||||
|
this.vuplex.postMessage({
|
||||||
|
type: "moveCamera",
|
||||||
|
index: index
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
quitGame() {
|
||||||
|
this.vuplex.postMessage({
|
||||||
|
type: "quit"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoom(gameName: string, sceneIndex: number) {
|
||||||
|
this.vuplex.postMessage({
|
||||||
|
type: "createRoom",
|
||||||
|
gameName,
|
||||||
|
sceneIndex
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
joinRoom(gameName: string, sceneIndex: number) {
|
||||||
|
this.vuplex.postMessage({
|
||||||
|
type: "joinRoom",
|
||||||
|
gameName,
|
||||||
|
sceneIndex
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSettings(settings: Record<string, number>) {
|
||||||
|
this.vuplex.postMessage({
|
||||||
|
type: "updateSettings",
|
||||||
|
settings
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
gameStart(data: {
|
||||||
|
players: { id: number, name: string, skin: number }[],
|
||||||
|
scene: number
|
||||||
|
}) {
|
||||||
|
this.vuplex.postMessage({
|
||||||
|
type: "gameStart",
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
facePlayer(playerId: number) {
|
||||||
|
this.vuplex.postMessage({
|
||||||
|
type: "facePlayer",
|
||||||
|
playerId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
playerAction(playerId: number, action: 'speak' | 'vote' | 'dead' |'reasoning') {
|
||||||
|
this.vuplex.postMessage({
|
||||||
|
type: "playerAction",
|
||||||
|
playerId,
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setTime(time: 'day'| 'night'){
|
||||||
|
this.vuplex.postMessage({
|
||||||
|
type: "setTime",
|
||||||
|
time
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VuplexEvents;
|
||||||
BIN
src/assets/AdventureTime.woff2
Normal file
BIN
src/assets/AdventureTimeLogo.woff2
Normal file
BIN
src/assets/MaShanZheng-Regular.woff2
Normal file
BIN
src/assets/ZCOOLXiaoWei-Regular.woff2
Normal file
BIN
src/assets/audios/bgm_day_1.ogg
Normal file
BIN
src/assets/audios/bgm_day_2.ogg
Normal file
BIN
src/assets/audios/bgm_day_3.ogg
Normal file
BIN
src/assets/audios/bgm_home.ogg
Normal file
BIN
src/assets/audios/bgm_night_1.ogg
Normal file
BIN
src/assets/audios/bgm_night_2.ogg
Normal file
BIN
src/assets/audios/bgm_night_3.ogg
Normal file
BIN
src/assets/audios/bgm_wait.ogg
Normal file
BIN
src/assets/audios/button_down.ogg
Normal file
BIN
src/assets/audios/button_up.ogg
Normal file
BIN
src/assets/audios/defeat.mp3
Normal file
BIN
src/assets/audios/game_over.ogg
Normal file
40
src/assets/audios/index.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import button_down from './button_down.ogg'
|
||||||
|
import button_up from './button_up.ogg'
|
||||||
|
import bgm_wait from './bgm_wait.ogg'
|
||||||
|
import bgm_day_1 from './bgm_day_1.ogg'
|
||||||
|
import bgm_day_2 from './bgm_day_2.ogg'
|
||||||
|
import bgm_day_3 from './bgm_day_3.ogg'
|
||||||
|
import bgm_home from './bgm_home.ogg'
|
||||||
|
import bgm_night_1 from './bgm_night_1.ogg'
|
||||||
|
import bgm_night_2 from './bgm_night_2.ogg'
|
||||||
|
import bgm_night_3 from './bgm_night_3.ogg'
|
||||||
|
import notice from './phase_change.ogg'
|
||||||
|
import witch from './witch.ogg'
|
||||||
|
import wolf from './wolf.ogg'
|
||||||
|
import vote from './vote.ogg'
|
||||||
|
import write from './write.ogg'
|
||||||
|
import note_drop from './note_drop.ogg'
|
||||||
|
import note_take from './note_take.ogg'
|
||||||
|
import game_over from './game_over.ogg'
|
||||||
|
import defeat from './defeat.mp3'
|
||||||
|
export default {
|
||||||
|
button_down,
|
||||||
|
button_up,
|
||||||
|
bgm_wait,
|
||||||
|
bgm_day_1,
|
||||||
|
bgm_day_2,
|
||||||
|
bgm_day_3,
|
||||||
|
bgm_home,
|
||||||
|
bgm_night_1,
|
||||||
|
bgm_night_2,
|
||||||
|
bgm_night_3,
|
||||||
|
notice,
|
||||||
|
witch,
|
||||||
|
wolf,
|
||||||
|
vote,
|
||||||
|
write,
|
||||||
|
note_drop,
|
||||||
|
note_take,
|
||||||
|
game_over,
|
||||||
|
defeat,
|
||||||
|
}
|
||||||
BIN
src/assets/audios/note_drop.ogg
Normal file
BIN
src/assets/audios/note_take.ogg
Normal file
35
src/assets/audios/ogg.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
def convert_mp3_to_ogg():
|
||||||
|
# 获取当前目录下所有文件
|
||||||
|
files = os.listdir('.')
|
||||||
|
|
||||||
|
# 筛选出所有MP3文件
|
||||||
|
mp3_files = [file for file in files if file.lower().endswith('.mp3')]
|
||||||
|
|
||||||
|
if not mp3_files:
|
||||||
|
print("当前目录下没有找到MP3文件。")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"找到 {len(mp3_files)} 个MP3文件,开始转换...")
|
||||||
|
|
||||||
|
# 遍历并转换每个MP3文件
|
||||||
|
for mp3_file in mp3_files:
|
||||||
|
# 构建输出文件名(替换扩展名为.ogg)
|
||||||
|
ogg_file = os.path.splitext(mp3_file)[0] + '.ogg'
|
||||||
|
|
||||||
|
# 构建ffmpeg命令
|
||||||
|
cmd = ['ffmpeg', '-i', mp3_file, '-c:a', 'libvorbis', '-q:a', '4', ogg_file]
|
||||||
|
|
||||||
|
try:
|
||||||
|
print(f"正在转换: {mp3_file} -> {ogg_file}")
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
print(f"成功转换: {ogg_file}")
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"转换失败: {mp3_file}, 错误: {e}")
|
||||||
|
|
||||||
|
print("转换完成!")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
convert_mp3_to_ogg()
|
||||||
BIN
src/assets/audios/phase_change.ogg
Normal file
BIN
src/assets/audios/vote.ogg
Normal file
BIN
src/assets/audios/witch.ogg
Normal file
BIN
src/assets/audios/wolf.ogg
Normal file
BIN
src/assets/audios/write.ogg
Normal file
27
src/assets/fonts.css
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'ZCOOL XiaoWei';
|
||||||
|
src: url('ZCOOLXiaoWei-Regular.woff2') format('woff2');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Adventure Time';
|
||||||
|
src: url('AdventureTime.woff2') format('woff2');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Ma Shan Zheng';
|
||||||
|
src: url('MaShanZheng-Regular.woff2') format('woff2');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
BIN
src/assets/imgs/BlueHover.webp
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src/assets/imgs/BluePlain.webp
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src/assets/imgs/BluePressed.webp
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src/assets/imgs/Board.webp
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src/assets/imgs/GreenHover.webp
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src/assets/imgs/GreenPlain.webp
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src/assets/imgs/GreenPressed.webp
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src/assets/imgs/Note.webp
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
src/assets/imgs/Notice.webp
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
src/assets/imgs/Plain.webp
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src/assets/imgs/RedHover.webp
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src/assets/imgs/RedPlain.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/assets/imgs/RedPressed.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/assets/imgs/SmHover.webp
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/assets/imgs/SmPlain.webp
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/assets/imgs/SmPressed.webp
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/assets/imgs/assistant/normal.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
5
src/assets/imgs/icons/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import login from './passkey_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg'
|
||||||
|
import earth from './public_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg'
|
||||||
|
import mic from './mic.svg'
|
||||||
|
|
||||||
|
export default { login, earth, mic }
|
||||||
1
src/assets/imgs/icons/mic.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24" fill="#FFFFFF"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.33 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z"/></svg>
|
||||||
|
After Width: | Height: | Size: 345 B |
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#FFFFFF"><path d="M120-160v-112q0-34 17.5-62.5T184-378q62-31 126-46.5T440-440q20 0 40 1.5t40 4.5q-4 58 21 109.5t73 84.5v80H120ZM760-40l-60-60v-186q-44-13-72-49.5T600-420q0-58 41-99t99-41q58 0 99 41t41 99q0 45-25.5 80T790-290l50 50-60 60 60 60-80 80ZM440-480q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47Zm300 80q17 0 28.5-11.5T780-440q0-17-11.5-28.5T740-480q-17 0-28.5 11.5T700-440q0 17 11.5 28.5T740-400Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 545 B |
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#FFFFFF"><path d="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm-40-82v-78q-33 0-56.5-23.5T360-320v-40L168-552q-3 18-5.5 36t-2.5 36q0 121 79.5 212T440-162Zm276-102q41-45 62.5-100.5T800-480q0-98-54.5-179T600-776v16q0 33-23.5 56.5T520-680h-80v80q0 17-11.5 28.5T400-560h-80v80h240q17 0 28.5 11.5T600-440v120h40q26 0 47 15.5t29 40.5Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 580 B |
35
src/assets/imgs/webp.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
def convert_png_to_webp():
|
||||||
|
# 获取当前目录下所有文件
|
||||||
|
files = os.listdir('.')
|
||||||
|
|
||||||
|
# 筛选出所有PNG文件
|
||||||
|
png_files = [file for file in files if file.lower().endswith('.png')]
|
||||||
|
|
||||||
|
if not png_files:
|
||||||
|
print("当前目录下没有找到PNG文件。")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"找到 {len(png_files)} 个PNG文件,开始转换...")
|
||||||
|
|
||||||
|
# 遍历并转换每个PNG文件
|
||||||
|
for png_file in png_files:
|
||||||
|
# 构建输出文件名(替换扩展名为.webp)
|
||||||
|
webp_file = os.path.splitext(png_file)[0] + '.webp'
|
||||||
|
|
||||||
|
# 构建ffmpeg命令,设置压缩质量为80%(可以根据需要调整)
|
||||||
|
cmd = ['ffmpeg', '-i', png_file, '-c:v', 'libwebp', '-quality', '80', webp_file]
|
||||||
|
|
||||||
|
try:
|
||||||
|
print(f"正在转换: {png_file} -> {webp_file}")
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
print(f"成功转换: {webp_file}")
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"转换失败: {png_file}, 错误: {e}")
|
||||||
|
|
||||||
|
print("转换完成!")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
convert_png_to_webp()
|
||||||
20
src/components/Board.vue
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<div class="board">
|
||||||
|
<slot> </slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.board {
|
||||||
|
min-width: 5vh;
|
||||||
|
min-height: 5vh;
|
||||||
|
border-image-source: url(../assets/imgs/Board.webp);
|
||||||
|
border-image-slice: 30 30 30 30 fill;
|
||||||
|
/* 设置四边的切片距离 */
|
||||||
|
border-image-width: 30px;
|
||||||
|
border-image-repeat: stretch;
|
||||||
|
/* 或 round, space, repeat */
|
||||||
|
border-image-outset: 0;
|
||||||
|
|
||||||
|
}
|
||||||
|
</style>
|
||||||
200
src/components/Button.vue
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const ea = new EasyAudio([{
|
||||||
|
name: 'button-down',
|
||||||
|
audioUrl: audios.button_down
|
||||||
|
}, {
|
||||||
|
name: 'button-up',
|
||||||
|
audioUrl: audios.button_up
|
||||||
|
}])
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
content: string;
|
||||||
|
theme: 'blue' | 'green' | 'red';
|
||||||
|
sm?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits(['click']);
|
||||||
|
|
||||||
|
// 导入背景图片
|
||||||
|
import bgPlainBlue from "../assets/imgs/BluePlain.webp";
|
||||||
|
import bgHoverBlue from "../assets/imgs/BlueHover.webp";
|
||||||
|
import bgPressedBlue from "../assets/imgs/BluePressed.webp";
|
||||||
|
import bgPlainGreen from "../assets/imgs/GreenPlain.webp";
|
||||||
|
import bgHoverGreen from "../assets/imgs/GreenHover.webp";
|
||||||
|
import bgPressedGreen from "../assets/imgs/GreenPressed.webp";
|
||||||
|
import bgPlainRed from "../assets/imgs/RedPlain.webp";
|
||||||
|
import bgHoverRed from "../assets/imgs/RedHover.webp";
|
||||||
|
import bgPressedRed from "../assets/imgs/RedPressed.webp";
|
||||||
|
import bgSmPlain from "../assets/imgs/SmPlain.webp";
|
||||||
|
import bgSmHover from "../assets/imgs/SmHover.webp";
|
||||||
|
import bgPressedSm from "../assets/imgs/SmPressed.webp";
|
||||||
|
import { EasyAudio } from "../utils";
|
||||||
|
import audios from "../assets/audios";
|
||||||
|
|
||||||
|
// 优化背景图片映射结构
|
||||||
|
const backgrounds = {
|
||||||
|
normal: {
|
||||||
|
blue: {
|
||||||
|
plain: bgPlainBlue,
|
||||||
|
hover: bgHoverBlue,
|
||||||
|
pressed: bgPressedBlue
|
||||||
|
},
|
||||||
|
green: {
|
||||||
|
plain: bgPlainGreen,
|
||||||
|
hover: bgHoverGreen,
|
||||||
|
pressed: bgPressedGreen
|
||||||
|
},
|
||||||
|
red: {
|
||||||
|
plain: bgPlainRed,
|
||||||
|
hover: bgHoverRed,
|
||||||
|
pressed: bgPressedRed
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sm: {
|
||||||
|
plain: bgSmPlain,
|
||||||
|
hover: bgSmHover,
|
||||||
|
pressed: bgPressedSm
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理点击事件
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
// 只有在非禁用状态下才触发事件
|
||||||
|
if (!props.disabled) {
|
||||||
|
emit('click', event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="button" @pointerdown="disabled||ea.play('button-down')"
|
||||||
|
@pointerup="disabled||ea.play('button-up')"
|
||||||
|
:class="{ 'sm': sm, 'disabled': disabled }" @click="handleClick">
|
||||||
|
<span class="content" v-if="!sm">{{ content }}</span>
|
||||||
|
<img class="content" v-else :src="content" alt="">
|
||||||
|
|
||||||
|
<!-- 背景图片 -->
|
||||||
|
<img class="bg plain"
|
||||||
|
:src="sm ? backgrounds.sm.plain : backgrounds.normal[theme].plain"
|
||||||
|
alt="">
|
||||||
|
<img class="bg hover"
|
||||||
|
:src="sm ? backgrounds.sm.hover : backgrounds.normal[theme].hover"
|
||||||
|
alt="">
|
||||||
|
<img class="bg pressed"
|
||||||
|
:src="sm ? backgrounds.sm.pressed : backgrounds.normal[theme].pressed"
|
||||||
|
alt="">
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.button {
|
||||||
|
width: 30vh;
|
||||||
|
aspect-ratio: 195/45;
|
||||||
|
background-color: transparent;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: cover;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
text-shadow: 1px 1px 2px black;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
user-select: none;
|
||||||
|
transition: filter 0.2s ease;
|
||||||
|
|
||||||
|
&.sm {
|
||||||
|
width: 10vh;
|
||||||
|
aspect-ratio: 1/.9;
|
||||||
|
|
||||||
|
.content {
|
||||||
|
top: 50%;
|
||||||
|
width: 40%;
|
||||||
|
filter: drop-shadow(0 0 2vh black);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
position: absolute;
|
||||||
|
top: 45%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
font-family: "Cambria";
|
||||||
|
text-shadow: 0 0vh 2vh rgba(0, 0, 0, 1);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
font-size: 2.8vh;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg.plain {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(.disabled) {
|
||||||
|
.bg.plain {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg.hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg.pressed {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(.disabled) {
|
||||||
|
.content {
|
||||||
|
opacity: .8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg.plain {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg.hover {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg.pressed {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
filter: grayscale(40%) brightness(80%);
|
||||||
|
|
||||||
|
.content {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg.plain {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg.hover,
|
||||||
|
.bg.pressed {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
547
src/components/GameAssistant.vue
Normal file
@ -0,0 +1,547 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, defineEmits, defineProps, onMounted } from 'vue';
|
||||||
|
import Board from './Board.vue';
|
||||||
|
import Button from './Button.vue';
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: number;
|
||||||
|
content: string;
|
||||||
|
sender: 'user' | 'assistant';
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Suggestion {
|
||||||
|
id: number;
|
||||||
|
content: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// 游戏状态,从父组件传入
|
||||||
|
gameState: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['askAssistant', 'ignoreSuggestion', 'acceptSuggestion']);
|
||||||
|
|
||||||
|
// 组件状态
|
||||||
|
const isExpanded = ref(false);
|
||||||
|
const messages = reactive<Message[]>([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
content: '你好!我是小狼侦探,你的狼人游戏助手。有任何线索或难题,都可以来找我寻求帮助!',
|
||||||
|
sender: 'assistant',
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
const currentSuggestion = ref<Suggestion | null>(null);
|
||||||
|
const userInput = ref('');
|
||||||
|
const messageId = ref(2);
|
||||||
|
const suggestionId = ref(1);
|
||||||
|
|
||||||
|
// 切换助手窗口展开/收起
|
||||||
|
const toggleAssistant = () => {
|
||||||
|
isExpanded.value = !isExpanded.value;
|
||||||
|
|
||||||
|
// 如果展开了,则当前建议被视为已读
|
||||||
|
if (isExpanded.value && currentSuggestion.value) {
|
||||||
|
clearSuggestion();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 发送消息给助手
|
||||||
|
const sendMessage = () => {
|
||||||
|
if (!userInput.value.trim()) return;
|
||||||
|
|
||||||
|
const newMessage: Message = {
|
||||||
|
id: messageId.value++,
|
||||||
|
content: userInput.value,
|
||||||
|
sender: 'user',
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
messages.push(newMessage);
|
||||||
|
|
||||||
|
// 通知父组件处理消息
|
||||||
|
emit('askAssistant', userInput.value);
|
||||||
|
|
||||||
|
userInput.value = '';
|
||||||
|
|
||||||
|
// 让消息区域滚动到底部
|
||||||
|
setTimeout(scrollToBottom, 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加助手回复
|
||||||
|
const addAssistantResponse = (content: string) => {
|
||||||
|
// 创建一个新消息,初始内容为空
|
||||||
|
const messageIndex = messages.length;
|
||||||
|
const newMessage: Message = {
|
||||||
|
id: messageId.value++,
|
||||||
|
content: '',
|
||||||
|
sender: 'assistant',
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
messages.push(newMessage);
|
||||||
|
|
||||||
|
// 逐个字符添加内容,实现打字机效果
|
||||||
|
let currentIndex = 0;
|
||||||
|
const typeInterval = 30; // 每个字符之间的时间间隔(毫秒)
|
||||||
|
let displayedContent = '';
|
||||||
|
|
||||||
|
const typeEffect = () => {
|
||||||
|
if (currentIndex < content.length) {
|
||||||
|
// 添加下一个字符到临时内容
|
||||||
|
displayedContent += content[currentIndex++];
|
||||||
|
|
||||||
|
// 使用数组替换方法来触发Vue的响应式更新
|
||||||
|
messages[messageIndex] = {
|
||||||
|
...messages[messageIndex],
|
||||||
|
content: displayedContent
|
||||||
|
};
|
||||||
|
|
||||||
|
// 每添加一个字符就滚动到底部
|
||||||
|
scrollToBottom();
|
||||||
|
|
||||||
|
// 安排下一个字符的添加
|
||||||
|
setTimeout(typeEffect, typeInterval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 开始打字效果
|
||||||
|
setTimeout(typeEffect, 800);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 显示建议
|
||||||
|
const showSuggestion = (content: string) => {
|
||||||
|
currentSuggestion.value = {
|
||||||
|
id: suggestionId.value++,
|
||||||
|
content,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清除当前建议
|
||||||
|
const clearSuggestion = () => {
|
||||||
|
currentSuggestion.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 接受建议
|
||||||
|
const acceptSuggestion = () => {
|
||||||
|
if (!currentSuggestion.value) return;
|
||||||
|
|
||||||
|
// 将建议添加到对话历史
|
||||||
|
messages.push({
|
||||||
|
id: messageId.value++,
|
||||||
|
content: `[建议] ${currentSuggestion.value.content}`,
|
||||||
|
sender: 'assistant',
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
// 通知父组件
|
||||||
|
emit('acceptSuggestion', currentSuggestion.value);
|
||||||
|
|
||||||
|
clearSuggestion();
|
||||||
|
|
||||||
|
// 如果没有展开,展开助手窗口
|
||||||
|
if (!isExpanded.value) {
|
||||||
|
isExpanded.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(scrollToBottom, 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 忽略建议
|
||||||
|
const ignoreSuggestion = () => {
|
||||||
|
if (!currentSuggestion.value) return;
|
||||||
|
|
||||||
|
// 通知父组件
|
||||||
|
emit('ignoreSuggestion', currentSuggestion.value);
|
||||||
|
|
||||||
|
clearSuggestion();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 让消息滚动到底部
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
const chatContainer = document.querySelector('.chat-messages');
|
||||||
|
if (chatContainer) {
|
||||||
|
chatContainer.scrollTop = chatContainer.scrollHeight;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const formatTime = (timestamp: number): string => {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 暴露给父组件的方法
|
||||||
|
defineExpose({
|
||||||
|
addAssistantResponse,
|
||||||
|
showSuggestion,
|
||||||
|
clearSuggestion
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="game-assistant" :class="{ expanded: isExpanded }">
|
||||||
|
<!-- 悬浮球 -->
|
||||||
|
<div class="assistant-ball" :class="{ expanded: isExpanded }"
|
||||||
|
@click="toggleAssistant">
|
||||||
|
<div class="assistant-avatar"></div>
|
||||||
|
<div v-if="currentSuggestion && !isExpanded"
|
||||||
|
class="suggestion-indicator"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 建议气泡 -->
|
||||||
|
<div v-if="currentSuggestion && !isExpanded" class="suggestion-bubble" @click="currentSuggestion = null" >
|
||||||
|
<div class="suggestion-content">{{ currentSuggestion.content }}
|
||||||
|
</div>
|
||||||
|
</div> <!-- 展开的聊天界面 -->
|
||||||
|
<Board v-show="isExpanded" class="assistant-panel"
|
||||||
|
:class="{ 'panel-visible': isExpanded }">
|
||||||
|
<div class="assistant-header">
|
||||||
|
<div class="assistant-title">小狼侦探助手</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-messages">
|
||||||
|
<div v-for="message in messages" :key="message.id"
|
||||||
|
class="message" :class="message.sender">
|
||||||
|
<div class="message-content">{{ message.content }}</div>
|
||||||
|
<div class="message-time">{{ formatTime(message.timestamp)
|
||||||
|
}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="assistant-input"> <input type="text" v-model="userInput"
|
||||||
|
@keyup.enter="sendMessage" placeholder="向小狼侦探询问线索..." />
|
||||||
|
<div class="send-arrow" @click="sendMessage">
|
||||||
|
<div class="arrow-icon">➤</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Board>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.game-assistant {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 100;
|
||||||
|
left: 2%;
|
||||||
|
top: 10%;
|
||||||
|
z-index: 999;
|
||||||
|
|
||||||
|
.assistant-ball {
|
||||||
|
width: 7vh;
|
||||||
|
height: 7vh;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #f0e6d2a9;
|
||||||
|
/* 更温暖的背景色,适合小狼崽侦探形象 */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 0 1vh rgba(0, 0, 0, 0.3);
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
z-index: 999;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 0 1.5vh rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.expanded {
|
||||||
|
background-color: #d0c0a0;
|
||||||
|
/* 深一点的暖色调 */
|
||||||
|
animation: pulse-light 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-avatar {
|
||||||
|
position: relative;
|
||||||
|
width: 6vh;
|
||||||
|
height: 6vh;
|
||||||
|
border-radius: 50%;
|
||||||
|
top: .5vh;
|
||||||
|
background-image: url('../assets/imgs/assistant/normal.png');
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: -0.5vh;
|
||||||
|
right: -0.5vh;
|
||||||
|
width: 1.5vh;
|
||||||
|
height: 1.5vh;
|
||||||
|
background-color: #8b4513;
|
||||||
|
/* 棕色,更符合侦探风格 */
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 0.2vh solid #f0e6d2;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-bubble {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 100%;
|
||||||
|
width: 30vh;
|
||||||
|
padding: 1.5vh;
|
||||||
|
background-color: #f9f2e2;
|
||||||
|
/* 更温暖的背景色,类似羊皮纸 */
|
||||||
|
border-radius: 1vh;
|
||||||
|
margin-left: 1.5vh;
|
||||||
|
box-shadow: 0 0 1vh rgba(77, 46, 12, 0.3);
|
||||||
|
/* 棕色阴影 */
|
||||||
|
border: 1px solid #d0b284;
|
||||||
|
/* 有边框的羊皮纸风格 */
|
||||||
|
cursor: pointer;
|
||||||
|
&:after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 2vh;
|
||||||
|
left: -1vh;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-top: 1vh solid transparent;
|
||||||
|
border-bottom: 1vh solid transparent;
|
||||||
|
border-right: 1vh solid #f9f2e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-content {
|
||||||
|
color: #5d4037;
|
||||||
|
/* 深棕色字体,更符合侦探风格 */
|
||||||
|
font-size: 1.8vh;
|
||||||
|
font-family: 'Times New Roman', serif;
|
||||||
|
/* 侦探风格字体 */
|
||||||
|
font-style: italic;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 1vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: -0vh;
|
||||||
|
left: 8vh;
|
||||||
|
width: 45vh;
|
||||||
|
height: 50vh;
|
||||||
|
padding: 0 1vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: #32281ebe;
|
||||||
|
/* 深棕色背景,侦探办公室风格 */
|
||||||
|
background-image: linear-gradient(to bottom, #3c2f2499, #32281e99);
|
||||||
|
/* 渐变效果 */
|
||||||
|
border: 0.2vh solid #8b6c42;
|
||||||
|
/* 金棕色边框 */
|
||||||
|
box-shadow: 0 0 2vh rgba(62, 39, 15, 0.7);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-10vh);
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&.panel-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5vh;
|
||||||
|
border-bottom: 0.2vh solid #8b6c4299;
|
||||||
|
/* 金棕色边框 */
|
||||||
|
background-image: linear-gradient(to right, #32281e99, #5a463499, #32281e99);
|
||||||
|
/* 渐变效果 */
|
||||||
|
|
||||||
|
.assistant-title {
|
||||||
|
font-size: 2.2vh;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #f0e6d2;
|
||||||
|
/* 暖色调文字 */
|
||||||
|
font-family: 'Times New Roman', serif;
|
||||||
|
/* 侦探风格字体 */
|
||||||
|
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1.5vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1vh;
|
||||||
|
|
||||||
|
.message {
|
||||||
|
max-width: 80%;
|
||||||
|
padding: 1vh;
|
||||||
|
border-radius: 1vh;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
font-size: 1.8vh;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-time {
|
||||||
|
font-size: 1.2vh;
|
||||||
|
opacity: 0.7;
|
||||||
|
text-align: right;
|
||||||
|
margin-top: 0.5vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.user {
|
||||||
|
align-self: flex-end;
|
||||||
|
background-color: #4a6741;
|
||||||
|
/* 深绿色,玩家风格 */
|
||||||
|
color: #f0e6d2;
|
||||||
|
/* 暖色调文字 */
|
||||||
|
border-bottom-right-radius: 0.2vh;
|
||||||
|
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid #5a7b4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.assistant {
|
||||||
|
align-self: flex-start;
|
||||||
|
background-color: #8b6c42;
|
||||||
|
/* 金棕色,侦探风格 */
|
||||||
|
color: #f0e6d2;
|
||||||
|
/* 暖色调文字 */
|
||||||
|
border-bottom-left-radius: 0.2vh;
|
||||||
|
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid #a58052;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-input {
|
||||||
|
display: flex;
|
||||||
|
padding: 1.5vh;
|
||||||
|
gap: 1vh;
|
||||||
|
border-top: 0.2vh solid #8b6c42;
|
||||||
|
|
||||||
|
/* 金棕色边框 */
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1vh;
|
||||||
|
border: 0.1vh solid #a58052;
|
||||||
|
border-radius: 0.5vh;
|
||||||
|
background-color: rgba(240, 230, 210, 0.1);
|
||||||
|
/* 略微透明的暖色调 */
|
||||||
|
color: #f0e6d2;
|
||||||
|
font-size: 1.8vh;
|
||||||
|
font-family: 'Times New Roman', serif;
|
||||||
|
/* 侦探风格字体 */
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: rgba(240, 230, 210, 0.5);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #d0b284;
|
||||||
|
box-shadow: 0 0 5px rgba(208, 178, 132, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-arrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 4vh;
|
||||||
|
height: 4vh;
|
||||||
|
background-color: #8b6c42;
|
||||||
|
/* 金棕色,侦探风格 */
|
||||||
|
border: 2px solid #a58052;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 0 5px rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #a58052;
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 0 10px rgba(208, 178, 132, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-icon {
|
||||||
|
color: #f0e6d2;
|
||||||
|
/* 暖色调文字 */
|
||||||
|
font-size: 2vh;
|
||||||
|
line-height: 1;
|
||||||
|
margin-left: 0.2vh;
|
||||||
|
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.95);
|
||||||
|
box-shadow: 0 0 0 0 rgba(139, 69, 19, 0.7);
|
||||||
|
/* 棕色光晕 */
|
||||||
|
}
|
||||||
|
|
||||||
|
70% {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 0 0 1vh rgba(139, 69, 19, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: scale(0.95);
|
||||||
|
box-shadow: 0 0 0 0 rgba(139, 69, 19, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-light {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(165, 128, 82, 0.7);
|
||||||
|
/* 金棕色光晕 */
|
||||||
|
}
|
||||||
|
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 0.8vh rgba(165, 128, 82, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(165, 128, 82, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义滚动条
|
||||||
|
.chat-messages::-webkit-scrollbar {
|
||||||
|
width: 0.5vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages::-webkit-scrollbar-track {
|
||||||
|
background: rgba(50, 40, 30, 0.3);
|
||||||
|
border-radius: 0.3vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(165, 128, 82, 0.7);
|
||||||
|
/* 金棕色滚动条 */
|
||||||
|
border-radius: 0.3vh;
|
||||||
|
border: 1px solid rgba(139, 108, 66, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(139, 108, 66, 0.9);
|
||||||
|
/* 深一点的金棕色 */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1391
src/components/InGame.vue
Normal file
0
src/components/Input.vue
Normal file
34
src/components/Note.vue
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<template>
|
||||||
|
<div class="board">
|
||||||
|
<div class="content">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.board {
|
||||||
|
min-width: 5vh;
|
||||||
|
min-height: 5vh;
|
||||||
|
background-image: url(../assets/imgs/Note.webp);
|
||||||
|
background-size: contain;
|
||||||
|
aspect-ratio: 1000/697;
|
||||||
|
width: 50vw;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
filter: drop-shadow(0 1vh 2vw #000000);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
font-family: 'Ma Shan Zheng';
|
||||||
|
font-size: 3vw;
|
||||||
|
line-height: 2;
|
||||||
|
text-wrap: pretty;
|
||||||
|
padding:5% 6% ;
|
||||||
|
opacity: .9;
|
||||||
|
mix-blend-mode: darken;
|
||||||
|
text-shadow: 0 0 .5vw #00000044;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
123
src/components/OptionGroup.vue
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
<template>
|
||||||
|
<div class="option-group">
|
||||||
|
<div v-for="(optionConfig, key) in optionsConfig" :key="key"
|
||||||
|
class="option-item">
|
||||||
|
<div class="option-label">{{ optionConfig.name }}:</div>
|
||||||
|
<OptionSelector :options="optionConfig.options"
|
||||||
|
:modelValue="currentValues[key]"
|
||||||
|
@update:modelValue="updateOption(key as string, $event)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, watch, onMounted } from 'vue';
|
||||||
|
import OptionSelector from './OptionSelector.vue';
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OptionConfig {
|
||||||
|
name: string;
|
||||||
|
options: Option[];
|
||||||
|
default: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OptionsConfig {
|
||||||
|
[key: string]: OptionConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
optionsConfig: OptionsConfig;
|
||||||
|
initialValues?: Record<string, number>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update', values: Record<string, number>): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Initialize current values with defaults or provided initial values
|
||||||
|
const currentValues = reactive<Record<string, number>>({});
|
||||||
|
|
||||||
|
// Setup initial values
|
||||||
|
onMounted(() => {
|
||||||
|
const initialValues: Record<string, number | string> = {};
|
||||||
|
|
||||||
|
// Initialize with defaults from config
|
||||||
|
Object.keys(props.optionsConfig).forEach(key => {
|
||||||
|
const config = props.optionsConfig[key];
|
||||||
|
initialValues[key] = config.default;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Override with any provided initial values
|
||||||
|
if (props.initialValues) {
|
||||||
|
Object.keys(props.initialValues).forEach(key => {
|
||||||
|
if (props.optionsConfig[key]) { // Only set if the key exists in config
|
||||||
|
initialValues[key] = props.initialValues![key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set all current values
|
||||||
|
Object.keys(initialValues).forEach(key => {
|
||||||
|
// @ts-ignore
|
||||||
|
currentValues[key] = initialValues[key];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit initial values
|
||||||
|
emit('update', { ...currentValues });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle option updates
|
||||||
|
const updateOption = (key: string, value: number) => {
|
||||||
|
currentValues[key] = value;
|
||||||
|
emit('update', { ...currentValues });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch for changes in the config and update values accordingly
|
||||||
|
watch(
|
||||||
|
() => props.optionsConfig,
|
||||||
|
(newConfig) => {
|
||||||
|
// Add any new options with their defaults
|
||||||
|
Object.keys(newConfig).forEach(key => {
|
||||||
|
if (currentValues[key] === undefined) {
|
||||||
|
currentValues[key] = newConfig[key].default;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove any options that no longer exist
|
||||||
|
Object.keys(currentValues).forEach(key => {
|
||||||
|
if (!newConfig[key]) {
|
||||||
|
delete currentValues[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit updated values
|
||||||
|
emit('update', { ...currentValues });
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.option-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2vh;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-label {
|
||||||
|
width: 30vh;
|
||||||
|
font-size: 3vh;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
165
src/components/OptionSelector.vue
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
<template>
|
||||||
|
<div class="option-selector">
|
||||||
|
<button class="nav-button" @click="selectPrevious"
|
||||||
|
:disabled="currentIndex <= 0"
|
||||||
|
@pointerdown="currentIndex <= 0 || ea.play('button-down')"
|
||||||
|
@pointerup="currentIndex <= 0 || ea.play('button-up')">
|
||||||
|
<span class="arrow">
|
||||||
|
< </span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="option-display">
|
||||||
|
<span class="option-label">{{ currentOption?.label || '' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="nav-button" @click="selectNext"
|
||||||
|
:disabled="currentIndex >= options.length - 1"
|
||||||
|
@pointerdown="currentIndex >= options.length - 1 || ea.play('button-down')"
|
||||||
|
@pointerup="currentIndex >= options.length - 1 || ea.play('button-up')">
|
||||||
|
<span class="arrow"> > </span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const ea = new EasyAudio([{
|
||||||
|
name: 'button-down',
|
||||||
|
audioUrl: audios.button_down
|
||||||
|
}, {
|
||||||
|
name: 'button-up',
|
||||||
|
audioUrl: audios.button_up
|
||||||
|
}])
|
||||||
|
interface Option {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue?: number;
|
||||||
|
options: Option[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
options: () => [],
|
||||||
|
modelValue: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: number): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
import { computed, watch } from 'vue';
|
||||||
|
import audios from '../assets/audios';
|
||||||
|
import { EasyAudio } from '../utils';
|
||||||
|
|
||||||
|
// Find the index of the current value in options
|
||||||
|
const currentIndex = computed(() => {
|
||||||
|
if (props.modelValue === undefined && props.options.length > 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.options.findIndex(option => option.value === props.modelValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the current option object
|
||||||
|
const currentOption = computed(() => {
|
||||||
|
const index = currentIndex.value;
|
||||||
|
return index >= 0 ? props.options[index] : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigate to previous option
|
||||||
|
const selectPrevious = () => {
|
||||||
|
if (currentIndex.value > 0) {
|
||||||
|
const newIndex = currentIndex.value - 1;
|
||||||
|
emit('update:modelValue', props.options[newIndex].value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Navigate to next option
|
||||||
|
const selectNext = () => {
|
||||||
|
if (currentIndex.value < props.options.length - 1) {
|
||||||
|
const newIndex = currentIndex.value + 1;
|
||||||
|
emit('update:modelValue', props.options[newIndex].value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle edge case: if value is not in options, select first option
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newValue) => {
|
||||||
|
if (newValue !== undefined && currentIndex.value === -1 && props.options.length > 0) {
|
||||||
|
emit('update:modelValue', props.options[0].value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize selection if none is provided
|
||||||
|
watch(
|
||||||
|
() => props.options,
|
||||||
|
(newOptions) => {
|
||||||
|
if (props.modelValue === undefined && newOptions.length > 0) {
|
||||||
|
emit('update:modelValue', newOptions[0].value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.option-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 30vh;
|
||||||
|
aspect-ratio: 195/45;
|
||||||
|
background-image: url(../assets/imgs/BluePlain.webp);
|
||||||
|
background-size: cover;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
padding: 0 2.5vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 4vh;
|
||||||
|
height: 4vh;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: transparent;
|
||||||
|
color: white;
|
||||||
|
text-shadow: 0 0 2vh #000000;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-shadow: 0 0 4vh #000000;
|
||||||
|
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
text-shadow: 0 0 1vh #000000;
|
||||||
|
color: #ffffff99;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button:hover:not(:disabled) {}
|
||||||
|
|
||||||
|
.nav-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
font-size: 2vh;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-display {
|
||||||
|
padding: 0 3vh;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-label {
|
||||||
|
font-size: 1.9vh;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: "Cambria";
|
||||||
|
}
|
||||||
|
</style>
|
||||||
64
src/components/ProgressBar.vue
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<div class="progress-container">
|
||||||
|
<div class="progress-bar"
|
||||||
|
:style="{ width: `${computedProgress}%`, backgroundColor: color }">
|
||||||
|
</div>
|
||||||
|
<span v-if="showPercentage" class="progress-text">{{ computedProgress
|
||||||
|
}}%</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
interface ProgressBarProps {
|
||||||
|
progress?: number;
|
||||||
|
color?: string;
|
||||||
|
showPercentage?: boolean;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<ProgressBarProps>(), {
|
||||||
|
progress: 0,
|
||||||
|
color: '#4caf50',
|
||||||
|
showPercentage: false,
|
||||||
|
min: 0,
|
||||||
|
max: 100
|
||||||
|
});
|
||||||
|
|
||||||
|
const computedProgress = computed(() => {
|
||||||
|
// Ensure progress is between min and max
|
||||||
|
const clampedProgress = Math.max(props.min, Math.min(props.progress, props.max));
|
||||||
|
|
||||||
|
// Convert to percentage based on min and max range
|
||||||
|
return Math.round(((clampedProgress - props.min) / (props.max - props.min)) * 100);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.progress-container {
|
||||||
|
width: 100%;
|
||||||
|
height: .5vh;
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: .25vh;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: #000;
|
||||||
|
font-size: 1vh;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
277
src/components/dialog-sound-effects.ts
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* 对话音效角色类型
|
||||||
|
*/
|
||||||
|
export type DialogCharacterType =
|
||||||
|
| 'undertale' // 类似Undertale的方波音效
|
||||||
|
| 'zelda' // 三角波为基础的音效
|
||||||
|
| 'pokemon' // 高音调短促音效
|
||||||
|
| 'robot' // 机械音效
|
||||||
|
| 'pixie' // 精灵/仙女音效
|
||||||
|
| 'monster' // 怪物/低沉音效
|
||||||
|
| 'custom'; // 自定义音效
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 音效配置接口
|
||||||
|
*/
|
||||||
|
export interface DialogSoundConfig {
|
||||||
|
oscillatorType: OscillatorType; // 振荡器类型
|
||||||
|
baseFrequency: number; // 基础频率
|
||||||
|
frequencyVariation: number; // 频率变化范围
|
||||||
|
gain: number; // 音量
|
||||||
|
attackTime: number; // 起音时间
|
||||||
|
releaseTime: number; // 释音时间
|
||||||
|
duration: number; // 持续时间
|
||||||
|
highPassFrequency?: number; // 高通滤波器频率
|
||||||
|
lowPassFrequency?: number; // 低通滤波器频率
|
||||||
|
distortion?: number; // 失真度
|
||||||
|
detune?: number; // 音高微调
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为每种角色类型预设的音效配置
|
||||||
|
*/
|
||||||
|
export const CHARACTER_SOUND_CONFIGS: Record<Exclude<DialogCharacterType, 'custom'>, DialogSoundConfig> = {
|
||||||
|
// Undertale风格 - 清脆的方波
|
||||||
|
undertale: {
|
||||||
|
oscillatorType: 'square',
|
||||||
|
baseFrequency: 380,
|
||||||
|
frequencyVariation: 10,
|
||||||
|
gain: 0.05,
|
||||||
|
attackTime: 0.01,
|
||||||
|
releaseTime: 0.02,
|
||||||
|
duration: 0.05,
|
||||||
|
highPassFrequency: 700
|
||||||
|
},
|
||||||
|
|
||||||
|
// 塞尔达风格 - 三角波
|
||||||
|
zelda: {
|
||||||
|
oscillatorType: 'triangle',
|
||||||
|
baseFrequency: 300,
|
||||||
|
frequencyVariation: 8,
|
||||||
|
gain: 0.08,
|
||||||
|
attackTime: 0.01,
|
||||||
|
releaseTime: 0.08,
|
||||||
|
duration: 0.12,
|
||||||
|
highPassFrequency: 400
|
||||||
|
},
|
||||||
|
|
||||||
|
// 宝可梦风格 - 高音调短促
|
||||||
|
pokemon: {
|
||||||
|
oscillatorType: 'sine',
|
||||||
|
baseFrequency: 850,
|
||||||
|
frequencyVariation: 5,
|
||||||
|
gain: 0.06,
|
||||||
|
attackTime: 0.005,
|
||||||
|
releaseTime: 0.015,
|
||||||
|
duration: 0.03
|
||||||
|
},
|
||||||
|
|
||||||
|
// 机器人风格 - 方波带失真
|
||||||
|
robot: {
|
||||||
|
oscillatorType: 'square',
|
||||||
|
baseFrequency: 200,
|
||||||
|
frequencyVariation: 12,
|
||||||
|
gain: 0.04,
|
||||||
|
attackTime: 0.005,
|
||||||
|
releaseTime: 0.03,
|
||||||
|
duration: 0.07,
|
||||||
|
distortion: 15,
|
||||||
|
detune: 5
|
||||||
|
},
|
||||||
|
|
||||||
|
// 精灵/仙女风格 - 高音调正弦波
|
||||||
|
pixie: {
|
||||||
|
oscillatorType: 'sine',
|
||||||
|
baseFrequency: 1200,
|
||||||
|
frequencyVariation: 15,
|
||||||
|
gain: 0.03,
|
||||||
|
attackTime: 0.005,
|
||||||
|
releaseTime: 0.1,
|
||||||
|
duration: 0.15,
|
||||||
|
highPassFrequency: 900
|
||||||
|
},
|
||||||
|
|
||||||
|
// 怪物/低沉风格 - 低音锯齿波
|
||||||
|
monster: {
|
||||||
|
oscillatorType: 'sawtooth',
|
||||||
|
baseFrequency: 150,
|
||||||
|
frequencyVariation: 6,
|
||||||
|
gain: 0.07,
|
||||||
|
attackTime: 0.02,
|
||||||
|
releaseTime: 0.05,
|
||||||
|
duration: 0.1,
|
||||||
|
lowPassFrequency: 300,
|
||||||
|
distortion: 8
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用于存储AudioContext单例
|
||||||
|
*/
|
||||||
|
let audioContextInstance: AudioContext | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取或创建AudioContext
|
||||||
|
*/
|
||||||
|
export function getAudioContext(): AudioContext {
|
||||||
|
if (!audioContextInstance) {
|
||||||
|
audioContextInstance = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||||
|
}
|
||||||
|
return audioContextInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 销毁AudioContext
|
||||||
|
*/
|
||||||
|
export function destroyAudioContext(): void {
|
||||||
|
if (audioContextInstance && audioContextInstance.state !== 'closed') {
|
||||||
|
audioContextInstance.close();
|
||||||
|
audioContextInstance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建失真效果
|
||||||
|
*/
|
||||||
|
function createDistortionEffect(
|
||||||
|
audioContext: AudioContext,
|
||||||
|
distortionAmount: number
|
||||||
|
): WaveShaperNode {
|
||||||
|
const waveShaperNode = audioContext.createWaveShaper();
|
||||||
|
|
||||||
|
// 创建失真曲线
|
||||||
|
function makeDistortionCurve(amount: number): Float32Array {
|
||||||
|
const k = amount;
|
||||||
|
const samples = 44100;
|
||||||
|
const curve = new Float32Array(samples);
|
||||||
|
|
||||||
|
for (let i = 0; i < samples; ++i) {
|
||||||
|
const x = (i * 2) / samples - 1;
|
||||||
|
curve[i] = ((3 + k) * x * 0.5 * (1 - Math.abs(x))) / (3 + k * Math.abs(x));
|
||||||
|
}
|
||||||
|
|
||||||
|
return curve;
|
||||||
|
}
|
||||||
|
|
||||||
|
waveShaperNode.curve = makeDistortionCurve(distortionAmount);
|
||||||
|
return waveShaperNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 播放单个字符的对话音效
|
||||||
|
* @param char 要为其播放音效的字符
|
||||||
|
* @param characterType 角色类型
|
||||||
|
* @param customConfig 可选的自定义配置
|
||||||
|
* @returns 音效的持续时间(秒)
|
||||||
|
*/
|
||||||
|
export function playDialogSound(
|
||||||
|
char: string,
|
||||||
|
characterType: DialogCharacterType | number,
|
||||||
|
customConfig?: Partial<DialogSoundConfig>
|
||||||
|
): number {
|
||||||
|
// 空格不播放音效
|
||||||
|
if (char === ' ') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取音频上下文
|
||||||
|
const audioContext = getAudioContext();
|
||||||
|
const now = audioContext.currentTime;
|
||||||
|
|
||||||
|
// 获取基础配置
|
||||||
|
let config: DialogSoundConfig;
|
||||||
|
|
||||||
|
if (characterType === 'custom' && customConfig) {
|
||||||
|
// 使用自定义配置
|
||||||
|
config = {
|
||||||
|
oscillatorType: customConfig.oscillatorType || 'square',
|
||||||
|
baseFrequency: customConfig.baseFrequency || 400,
|
||||||
|
frequencyVariation: customConfig.frequencyVariation || 10,
|
||||||
|
gain: customConfig.gain || 0.05,
|
||||||
|
attackTime: customConfig.attackTime || 0.01,
|
||||||
|
releaseTime: customConfig.releaseTime || 0.05,
|
||||||
|
duration: customConfig.duration || 0.08,
|
||||||
|
...customConfig
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 使用预设配置
|
||||||
|
if (typeof characterType === 'number') {
|
||||||
|
config = Object.values(CHARACTER_SOUND_CONFIGS)[characterType] || CHARACTER_SOUND_CONFIGS.undertale;
|
||||||
|
} else {
|
||||||
|
config = { ...CHARACTER_SOUND_CONFIGS[characterType as Exclude<DialogCharacterType, 'custom'>] };
|
||||||
|
}
|
||||||
|
// 应用自定义配置覆盖(如果有)
|
||||||
|
if (customConfig) {
|
||||||
|
config = { ...config, ...customConfig };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建音频节点
|
||||||
|
const oscillator = audioContext.createOscillator();
|
||||||
|
const gainNode = audioContext.createGain();
|
||||||
|
|
||||||
|
// 设置振荡器
|
||||||
|
oscillator.type = config.oscillatorType;
|
||||||
|
|
||||||
|
// 基于字符计算频率变化
|
||||||
|
const charCode = char.charCodeAt(0);
|
||||||
|
const frequencyOffset = (charCode % config.frequencyVariation) / config.frequencyVariation;
|
||||||
|
|
||||||
|
// 设置频率和可选的失谐
|
||||||
|
oscillator.frequency.value = config.baseFrequency + (config.baseFrequency * 0.2 * frequencyOffset);
|
||||||
|
|
||||||
|
if (config.detune) {
|
||||||
|
// 添加轻微的失谐,使声音更有特色
|
||||||
|
oscillator.detune.value = (charCode % 10) * config.detune;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接音频节点链
|
||||||
|
let currentNode: AudioNode = oscillator;
|
||||||
|
|
||||||
|
// 连接到增益节点
|
||||||
|
currentNode.connect(gainNode);
|
||||||
|
currentNode = gainNode;
|
||||||
|
|
||||||
|
// 添加失真效果(如果指定)
|
||||||
|
if (config.distortion && config.distortion > 0) {
|
||||||
|
const distortionNode = createDistortionEffect(audioContext, config.distortion);
|
||||||
|
currentNode.connect(distortionNode);
|
||||||
|
currentNode = distortionNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加低通滤波器(如果指定)
|
||||||
|
if (config.lowPassFrequency) {
|
||||||
|
const lowPassFilter = audioContext.createBiquadFilter();
|
||||||
|
lowPassFilter.type = 'lowpass';
|
||||||
|
lowPassFilter.frequency.value = config.lowPassFrequency;
|
||||||
|
lowPassFilter.Q.value = 1;
|
||||||
|
|
||||||
|
currentNode.connect(lowPassFilter);
|
||||||
|
currentNode = lowPassFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加高通滤波器(如果指定)
|
||||||
|
if (config.highPassFrequency) {
|
||||||
|
const highPassFilter = audioContext.createBiquadFilter();
|
||||||
|
highPassFilter.type = 'highpass';
|
||||||
|
highPassFilter.frequency.value = config.highPassFrequency;
|
||||||
|
highPassFilter.Q.value = 1;
|
||||||
|
|
||||||
|
currentNode.connect(highPassFilter);
|
||||||
|
currentNode = highPassFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接到输出
|
||||||
|
currentNode.connect(audioContext.destination);
|
||||||
|
|
||||||
|
// 设置音量包络
|
||||||
|
gainNode.gain.setValueAtTime(0, now);
|
||||||
|
gainNode.gain.linearRampToValueAtTime(config.gain, now + config.attackTime);
|
||||||
|
gainNode.gain.linearRampToValueAtTime(0, now + config.duration);
|
||||||
|
|
||||||
|
// 播放音效
|
||||||
|
oscillator.start(now);
|
||||||
|
oscillator.stop(now + config.duration);
|
||||||
|
|
||||||
|
return config.duration;
|
||||||
|
}
|
||||||
643
src/components/useWerewolfGame.ts
Normal file
@ -0,0 +1,643 @@
|
|||||||
|
import { ref, reactive, onMounted, onUnmounted } from 'vue';
|
||||||
|
import axios, { type AxiosInstance } from 'axios';
|
||||||
|
import type { Ref } from 'vue';
|
||||||
|
import VuplexAPI from '../VuplexAPI';
|
||||||
|
import { EasyAudio } from '../utils';
|
||||||
|
import audios from '../assets/audios';
|
||||||
|
|
||||||
|
// 玩家接口
|
||||||
|
interface Player {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
observer: boolean;
|
||||||
|
role?: string;
|
||||||
|
alive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消息接口
|
||||||
|
interface Message {
|
||||||
|
type: 'speak' | 'reasoning'
|
||||||
|
player: number;
|
||||||
|
message: string;
|
||||||
|
timestamp: number;
|
||||||
|
voice?: string; // 新增音频字段
|
||||||
|
}
|
||||||
|
|
||||||
|
// 投票接口
|
||||||
|
interface Vote {
|
||||||
|
player: number;
|
||||||
|
target: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局通知接口
|
||||||
|
interface GlobalNotice {
|
||||||
|
message: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotePaperAction = {
|
||||||
|
type: 'appear',
|
||||||
|
content: string,
|
||||||
|
} | {
|
||||||
|
type: 'modify'
|
||||||
|
originalContent: string
|
||||||
|
newContent: string
|
||||||
|
startTime: number
|
||||||
|
} | {
|
||||||
|
type: 'drop'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 游戏状态接口
|
||||||
|
interface GameState {
|
||||||
|
hasPrepared: boolean; // 是否准备好
|
||||||
|
phase: 'waiting' | 'day' | 'night' | 'over';
|
||||||
|
day: number;
|
||||||
|
players: Player[];
|
||||||
|
role: string;
|
||||||
|
messages: Message[];
|
||||||
|
votes: Vote[];
|
||||||
|
killed: number[];
|
||||||
|
actionRequired: boolean;
|
||||||
|
notePaperActions: NotePaperAction[]; // 小纸条操作
|
||||||
|
actionType: string | null;
|
||||||
|
result?: 'wolf' | 'village' | 'victory/defeat',
|
||||||
|
globalNotices: GlobalNotice[]; // 新增全局通知
|
||||||
|
gameoverMessage: string | null; // 游戏结束消息
|
||||||
|
cameraFacing: number; // 镜头朝向
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户输入状态接口
|
||||||
|
interface UserInput {
|
||||||
|
message: string;
|
||||||
|
voteTarget: number | null;
|
||||||
|
seerTarget: number | null;
|
||||||
|
witchAntidote: boolean;
|
||||||
|
witchAntidoteTarget: number | null;
|
||||||
|
witchPoison: boolean;
|
||||||
|
witchPoisonTarget: number | null;
|
||||||
|
wolfKillTarget: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 游戏事件接口
|
||||||
|
interface GameEvent {
|
||||||
|
type: string;
|
||||||
|
data?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 狼人杀游戏客户端API封装
|
||||||
|
*/
|
||||||
|
class WerewolfGameClient {
|
||||||
|
private api: AxiosInstance;
|
||||||
|
private gameId: string;
|
||||||
|
private playerId: number;
|
||||||
|
|
||||||
|
constructor(baseURL: string, gameId: string, playerId: number) {
|
||||||
|
this.api = axios.create({
|
||||||
|
baseURL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.gameId = gameId;
|
||||||
|
this.playerId = playerId;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送发言请求
|
||||||
|
async speak(message: string): Promise<void> {
|
||||||
|
await this.api.post(`/${this.gameId}/speak`, {
|
||||||
|
player: this.playerId,
|
||||||
|
message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送投票请求
|
||||||
|
async vote(targetId: number): Promise<void> {
|
||||||
|
await this.api.post(`/${this.gameId}/vote`, {
|
||||||
|
player: this.playerId,
|
||||||
|
target: targetId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 狼人聊天
|
||||||
|
async wolfChat(message: string): Promise<void> {
|
||||||
|
await this.api.post(`/${this.gameId}/wolf/chat`, {
|
||||||
|
player: this.playerId,
|
||||||
|
message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 狼人杀人
|
||||||
|
async wolfKill(targetId: number): Promise<void> {
|
||||||
|
await this.api.post(`/${this.gameId}/wolf/action`, {
|
||||||
|
player: this.playerId,
|
||||||
|
action: "kill",
|
||||||
|
target: targetId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预言家查验
|
||||||
|
async seerCheck(targetId: number): Promise<void> {
|
||||||
|
await this.api.post(`/${this.gameId}/seer/action`, {
|
||||||
|
player: this.playerId,
|
||||||
|
target: targetId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 女巫行动
|
||||||
|
async witchAction(
|
||||||
|
usedAntidote: boolean,
|
||||||
|
antidoteTarget?: number,
|
||||||
|
usedPoison: boolean = false,
|
||||||
|
poisonTarget?: number
|
||||||
|
): Promise<void> {
|
||||||
|
const payload: any = {
|
||||||
|
player: this.playerId,
|
||||||
|
usedAntidote,
|
||||||
|
usedPoison
|
||||||
|
};
|
||||||
|
|
||||||
|
if (usedAntidote && antidoteTarget !== undefined) {
|
||||||
|
payload.antidoteTarget = antidoteTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usedPoison && poisonTarget !== undefined) {
|
||||||
|
payload.poisonTarget = poisonTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.api.post(`/${this.gameId}/witch/action`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求提前开始游戏
|
||||||
|
async requestGameStart(): Promise<void> {
|
||||||
|
await this.api.post(`/${this.gameId}/game/start`, {
|
||||||
|
player: this.playerId,
|
||||||
|
agree: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 狼人杀游戏 Vue Composition API
|
||||||
|
*/
|
||||||
|
export function useWerewolfGame(baseURL: string, gameId: string, playerId: number) {
|
||||||
|
// 创建客户端实例
|
||||||
|
const client = new WerewolfGameClient(baseURL, gameId, playerId);
|
||||||
|
const Vuplex = new VuplexAPI()
|
||||||
|
// 游戏状态
|
||||||
|
const gameState = reactive<GameState>({
|
||||||
|
hasPrepared: false,
|
||||||
|
phase: 'waiting',
|
||||||
|
day: 0,
|
||||||
|
players: [],
|
||||||
|
role: '',
|
||||||
|
messages: [],
|
||||||
|
votes: [],
|
||||||
|
killed: [],
|
||||||
|
actionRequired: false,
|
||||||
|
actionType: null,
|
||||||
|
globalNotices: [], // 全局通知列表
|
||||||
|
gameoverMessage: null, // 游戏结束消息
|
||||||
|
cameraFacing: -1,// 镜头朝向
|
||||||
|
notePaperActions: []
|
||||||
|
});
|
||||||
|
const ea = new EasyAudio([{
|
||||||
|
name: 'notice',
|
||||||
|
audioUrl: audios.notice,
|
||||||
|
}, {
|
||||||
|
name: 'wolf',
|
||||||
|
audioUrl: audios.wolf,
|
||||||
|
}, {
|
||||||
|
name: 'witch',
|
||||||
|
audioUrl: audios.witch,
|
||||||
|
}, {
|
||||||
|
name: 'vote',
|
||||||
|
audioUrl: audios.vote,
|
||||||
|
}, {
|
||||||
|
name: 'note_take',
|
||||||
|
audioUrl: audios.note_take,
|
||||||
|
}, {
|
||||||
|
name: 'note_drop',
|
||||||
|
audioUrl: audios.note_drop,
|
||||||
|
}, {
|
||||||
|
name: 'write',
|
||||||
|
audioUrl: audios.write,
|
||||||
|
}, {
|
||||||
|
name: 'game_over',
|
||||||
|
audioUrl: audios.game_over,
|
||||||
|
}, {
|
||||||
|
name: 'defeat',
|
||||||
|
audioUrl: audios.defeat,
|
||||||
|
}]);
|
||||||
|
// 用户交互状态
|
||||||
|
const userInput = reactive<UserInput>({
|
||||||
|
message: '',
|
||||||
|
voteTarget: null,
|
||||||
|
seerTarget: null,
|
||||||
|
witchAntidote: false,
|
||||||
|
witchAntidoteTarget: null,
|
||||||
|
witchPoison: false,
|
||||||
|
witchPoisonTarget: null,
|
||||||
|
wolfKillTarget: null
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存被杀的玩家列表 (用于女巫救人)
|
||||||
|
const playersToBeAntidoted = ref<number[]>([]);
|
||||||
|
|
||||||
|
// 事件源引用
|
||||||
|
let eventSource: EventSource | null = null;
|
||||||
|
|
||||||
|
// 连接到服务器事件流
|
||||||
|
const connectToEventSource = () => {
|
||||||
|
// 关闭已有连接
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新连接
|
||||||
|
eventSource = new EventSource(`${baseURL}/${gameId}/events`);
|
||||||
|
|
||||||
|
// 处理接收到的消息
|
||||||
|
eventSource.onmessage = (message) => {
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(message.data) as GameEvent;
|
||||||
|
console.log('收到事件:', event.type, event.data);
|
||||||
|
|
||||||
|
// 处理事件
|
||||||
|
handleGameEvent(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解析事件数据失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理连接错误
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
console.error('SSE连接错误:', error);
|
||||||
|
// 重新连接
|
||||||
|
setTimeout(connectToEventSource, 5000);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理游戏事件
|
||||||
|
const handleGameEvent = (event: GameEvent) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'gameStartRequest':
|
||||||
|
gameState.players = event.data.players.map((p: Player) => ({ ...p, alive: true }));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'gameStart':
|
||||||
|
gameState.phase = 'night';
|
||||||
|
gameState.players = event.data.players.map((p: Player) => ({ ...p, alive: true }));
|
||||||
|
gameState.role = event.data.role;
|
||||||
|
Vuplex.moveCamera(event.data.scene)
|
||||||
|
Vuplex.gameStart(event.data)
|
||||||
|
/* Vuplex.facePlayer(-1) */
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'phaseChange':
|
||||||
|
gameState.phase = event.data.phase;
|
||||||
|
gameState.day = event.data.day;
|
||||||
|
/* Vuplex.facePlayer(-1) */
|
||||||
|
if (!(event.data.phase == 'night' && gameState.day == 1)) {
|
||||||
|
Vuplex.facePlayer(-1)
|
||||||
|
}
|
||||||
|
gameState.cameraFacing = -1
|
||||||
|
Vuplex.setTime(event.data.phase)
|
||||||
|
ea.play('notice')
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'globalNotice':
|
||||||
|
// 处理全局通知
|
||||||
|
gameState.globalNotices.push({
|
||||||
|
message: event.data.message,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
Vuplex.facePlayer(-1)
|
||||||
|
gameState.cameraFacing = -1
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'turnToSpeak':
|
||||||
|
gameState.actionRequired = true;
|
||||||
|
gameState.actionType = 'speak';
|
||||||
|
Vuplex.facePlayer(event.data.target)
|
||||||
|
gameState.cameraFacing = event.data.target
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'speak':
|
||||||
|
gameState.actionType = 'speak';
|
||||||
|
gameState.messages.push({
|
||||||
|
type: 'speak',
|
||||||
|
player: event.data.player,
|
||||||
|
message: event.data.message,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
voice: event.data.voice // 新增音频字段
|
||||||
|
});
|
||||||
|
Vuplex.facePlayer(event.data.player)
|
||||||
|
gameState.cameraFacing = event.data.player
|
||||||
|
Vuplex.playerAction(event.data.player, 'speak')
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'wolfChat':
|
||||||
|
gameState.actionType = 'speak';
|
||||||
|
gameState.messages.push({
|
||||||
|
type: 'speak',
|
||||||
|
player: event.data.player,
|
||||||
|
message: event.data.message,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
voice: event.data.voice // 新增音频字段
|
||||||
|
});
|
||||||
|
Vuplex.facePlayer(event.data.player)
|
||||||
|
gameState.cameraFacing = event.data.player
|
||||||
|
Vuplex.playerAction(event.data.player, 'speak')
|
||||||
|
break;
|
||||||
|
case 'reasoning':
|
||||||
|
gameState.messages.push({
|
||||||
|
type: 'reasoning',
|
||||||
|
player: event.data.player,
|
||||||
|
message: event.data.message,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
Vuplex.facePlayer(event.data.player)
|
||||||
|
gameState.cameraFacing = event.data.player
|
||||||
|
Vuplex.playerAction(event.data.player, 'reasoning')
|
||||||
|
break;
|
||||||
|
case 'turnToVote':
|
||||||
|
gameState.actionRequired = true;
|
||||||
|
gameState.actionType = 'vote';
|
||||||
|
Vuplex.facePlayer(event.data.target)
|
||||||
|
gameState.cameraFacing = event.data.target
|
||||||
|
|
||||||
|
// 清空之前的投票记录(每轮投票开始时)
|
||||||
|
gameState.votes = [];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'vote':
|
||||||
|
gameState.actionType = 'vote';
|
||||||
|
gameState.votes.push({
|
||||||
|
player: event.data.player,
|
||||||
|
target: event.data.target
|
||||||
|
});
|
||||||
|
gameState.globalNotices.push({
|
||||||
|
message: `${event.data.player}号玩家投票给${event.data.target}号玩家`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
Vuplex.facePlayer(-1)
|
||||||
|
gameState.cameraFacing = -1
|
||||||
|
Vuplex.playerAction(event.data.player, 'speak')
|
||||||
|
break;
|
||||||
|
case 'voteEnd':
|
||||||
|
ea.play('vote')
|
||||||
|
break;
|
||||||
|
case 'playerKilled':
|
||||||
|
// 更新被杀玩家状态
|
||||||
|
event.data.targets.forEach((id: number) => {
|
||||||
|
gameState.killed.push(id);
|
||||||
|
|
||||||
|
const player = gameState.players.find(p => p.id === id);
|
||||||
|
if (player) {
|
||||||
|
player.alive = false;
|
||||||
|
}
|
||||||
|
Vuplex.facePlayer(id)
|
||||||
|
Vuplex.playerAction(id, 'dead')
|
||||||
|
gameState.cameraFacing = id
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'wolfTurn':
|
||||||
|
gameState.role = 'wolf'
|
||||||
|
gameState.actionRequired = true;
|
||||||
|
gameState.actionType = 'wolf';
|
||||||
|
Vuplex.facePlayer(event.data.target)
|
||||||
|
gameState.cameraFacing = event.data.target
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'seerTurn':
|
||||||
|
gameState.role = 'seer'
|
||||||
|
gameState.actionRequired = true;
|
||||||
|
gameState.actionType = 'seer';
|
||||||
|
Vuplex.facePlayer(event.data.target)
|
||||||
|
gameState.cameraFacing = event.data.target
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'witchTurn':
|
||||||
|
gameState.role = 'witch'
|
||||||
|
gameState.actionRequired = true;
|
||||||
|
gameState.actionType = 'witch';
|
||||||
|
playersToBeAntidoted.value = event.data.playersToBeAntidoted || [];
|
||||||
|
Vuplex.facePlayer(event.data.target)
|
||||||
|
gameState.cameraFacing = event.data.target
|
||||||
|
|
||||||
|
break;
|
||||||
|
case 'turnToNote':
|
||||||
|
gameState.actionRequired = true;
|
||||||
|
gameState.actionType = 'note';
|
||||||
|
Vuplex.facePlayer(event.data.target)
|
||||||
|
gameState.cameraFacing = event.data.target
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'gameOver':
|
||||||
|
gameState.phase = 'over';
|
||||||
|
gameState.result = event.data.winningSide;
|
||||||
|
gameState.gameoverMessage = event.data.reason;
|
||||||
|
if (event.data.result == 'victory') {
|
||||||
|
|
||||||
|
} else if (event.data.result == 'defeat') {
|
||||||
|
ea.play('defeat')
|
||||||
|
} else {
|
||||||
|
ea.play('game_over')
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// 增加其他特殊角色事件处理
|
||||||
|
case 'wolfAction':
|
||||||
|
gameState.globalNotices.push({
|
||||||
|
message: `狼人杀了${event.data.target}号玩家`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
ea.play('wolf')
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'witchAction':
|
||||||
|
const actionStr = []
|
||||||
|
if (event.data.usedAntidote) {
|
||||||
|
actionStr.push(`对${event.data.antidoteTarget}号玩家使用了解药`)
|
||||||
|
}
|
||||||
|
if (event.data.usedPoison) {
|
||||||
|
actionStr.push(`对${event.data.poisonTarget}号玩家使用了毒药`)
|
||||||
|
}
|
||||||
|
gameState.globalNotices.push({
|
||||||
|
message: `女巫${actionStr.join(',')}。`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
ea.play('witch')
|
||||||
|
break;
|
||||||
|
case 'seerResult':
|
||||||
|
// 预言家查验结果
|
||||||
|
const resultText = `预言家查验了${event.data.target}号玩家,TA是${translateRole(event.data.role)}`;
|
||||||
|
gameState.globalNotices.push({
|
||||||
|
message: resultText,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'noteAppear':
|
||||||
|
// 处理全局通知
|
||||||
|
gameState.globalNotices.push({
|
||||||
|
message: event.data.message,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
Vuplex.facePlayer(-1)
|
||||||
|
gameState.cameraFacing = -1
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'notePick':
|
||||||
|
gameState.actionType = 'note';
|
||||||
|
Vuplex.facePlayer(event.data.player)
|
||||||
|
gameState.cameraFacing = event.data.player
|
||||||
|
Vuplex.playerAction(event.data.player, 'reasoning')
|
||||||
|
setTimeout(() => {
|
||||||
|
ea.play('note_take')
|
||||||
|
setTimeout(() => {
|
||||||
|
gameState.notePaperActions.push({
|
||||||
|
type: 'appear',
|
||||||
|
content: event.data.content
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
}, 800);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'noteAction':
|
||||||
|
gameState.actionType = 'note';
|
||||||
|
switch (event.data.action) {
|
||||||
|
case 'modify':
|
||||||
|
gameState.notePaperActions.push({
|
||||||
|
type: 'modify',
|
||||||
|
originalContent: event.data.originalContent,
|
||||||
|
newContent: event.data.newContent,
|
||||||
|
startTime: Date.now()
|
||||||
|
});
|
||||||
|
Vuplex.facePlayer(event.data.player)
|
||||||
|
ea.play('write')
|
||||||
|
break;
|
||||||
|
case 'drop':
|
||||||
|
gameState.notePaperActions.push({
|
||||||
|
type: 'drop',
|
||||||
|
});
|
||||||
|
Vuplex.facePlayer(-1)
|
||||||
|
ea.play('note_drop')
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 提交当前操作
|
||||||
|
const submitAction = async () => {
|
||||||
|
try {
|
||||||
|
switch (gameState.actionType) {
|
||||||
|
case 'speak':
|
||||||
|
if (userInput.message) {
|
||||||
|
await client.speak(userInput.message);
|
||||||
|
userInput.message = '';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'vote':
|
||||||
|
if (userInput.voteTarget !== null) {
|
||||||
|
await client.vote(userInput.voteTarget);
|
||||||
|
userInput.voteTarget = null;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'wolf':
|
||||||
|
if (userInput.wolfKillTarget !== null) {
|
||||||
|
await client.wolfKill(userInput.wolfKillTarget);
|
||||||
|
userInput.wolfKillTarget = null;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'seer':
|
||||||
|
if (userInput.seerTarget !== null) {
|
||||||
|
await client.seerCheck(userInput.seerTarget);
|
||||||
|
userInput.seerTarget = null;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'witch':
|
||||||
|
await client.witchAction(
|
||||||
|
userInput.witchAntidote,
|
||||||
|
userInput.witchAntidoteTarget || undefined,
|
||||||
|
userInput.witchPoison,
|
||||||
|
userInput.witchPoisonTarget || undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
userInput.witchAntidote = false;
|
||||||
|
userInput.witchAntidoteTarget = null;
|
||||||
|
userInput.witchPoison = false;
|
||||||
|
userInput.witchPoisonTarget = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置操作状态
|
||||||
|
gameState.actionRequired = false;
|
||||||
|
gameState.actionType = null;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('提交操作失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 请求开始游戏
|
||||||
|
const requestGameStart = () => {
|
||||||
|
if (gameState.hasPrepared) {
|
||||||
|
console.warn('游戏已经准备好,无法重复请求开始游戏。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
gameState.hasPrepared = true;
|
||||||
|
client.requestGameStart();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 角色名称翻译
|
||||||
|
const translateRole = (role: string): string => {
|
||||||
|
const roleMap: Record<string, string> = {
|
||||||
|
'wolf': '狼人',
|
||||||
|
'village': '村民',
|
||||||
|
'seer': '预言家',
|
||||||
|
'witch': '女巫',
|
||||||
|
'observer': '旁观者'
|
||||||
|
};
|
||||||
|
return roleMap[role] || role;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 组件挂载时连接到事件源
|
||||||
|
onMounted(() => {
|
||||||
|
connectToEventSource();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 组件卸载时关闭连接
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
eventSource = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 返回响应式状态和方法
|
||||||
|
return {
|
||||||
|
gameState,
|
||||||
|
userInput,
|
||||||
|
playersToBeAntidoted,
|
||||||
|
submitAction,
|
||||||
|
requestGameStart,
|
||||||
|
translateRole
|
||||||
|
};
|
||||||
|
}
|
||||||
85
src/config.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { debounce } from "./utils";
|
||||||
|
import VuplexAPI from './VuplexAPI';
|
||||||
|
const Vuplex = new VuplexAPI()
|
||||||
|
|
||||||
|
const graphicsSettings = {
|
||||||
|
// Max Frame Rate options
|
||||||
|
frameRate: {
|
||||||
|
name: "Max Frame Rate",
|
||||||
|
options: [
|
||||||
|
{ value: 30, label: "30 FPS" },
|
||||||
|
{ value: 60, label: "60 FPS" },
|
||||||
|
{ value: 120, label: "120 FPS" },
|
||||||
|
{ value: -1, label: "Unlimited" }
|
||||||
|
],
|
||||||
|
default: 60
|
||||||
|
},
|
||||||
|
|
||||||
|
// Overall quality preset
|
||||||
|
qualityPreset: {
|
||||||
|
name: "Quality Preset",
|
||||||
|
options: [
|
||||||
|
{ value: 0, label: "Very Low" },
|
||||||
|
{ value: 1, label: "Low" },
|
||||||
|
{ value: 2, label: "Medium" },
|
||||||
|
{ value: 3, label: "High" },
|
||||||
|
{ value: 4, label: "Very High" },
|
||||||
|
{ value: 5, label: "Ultra" }
|
||||||
|
],
|
||||||
|
default: 3
|
||||||
|
},
|
||||||
|
|
||||||
|
// Anti-aliasing
|
||||||
|
antiAliasing: {
|
||||||
|
name: "Anti-aliasing",
|
||||||
|
options: [
|
||||||
|
{ value: 0, label: "Off" },
|
||||||
|
{ value: 2, label: "2x MSAA" },
|
||||||
|
{ value: 4, label: "4x MSAA" },
|
||||||
|
{ value: 8, label: "8x MSAA" }
|
||||||
|
],
|
||||||
|
default: 2
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyGraphicsSettings = (settings: Record<string, number>) => {
|
||||||
|
Object.entries(settings).forEach(([key, value]) => {
|
||||||
|
if (key in graphicsSettings) {
|
||||||
|
graphicsSettings[key as keyof typeof graphicsSettings].default = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const SETTINGS_KEY = 'graphicsSettings';
|
||||||
|
|
||||||
|
const updateGraphicsSettings = debounce((newSettings: Record<string, number>) => {
|
||||||
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(newSettings));
|
||||||
|
applyGraphicsSettings(newSettings);
|
||||||
|
Vuplex.updateSettings(newSettings);
|
||||||
|
}, 40);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storedSettings = localStorage.getItem(SETTINGS_KEY);
|
||||||
|
if (storedSettings) {
|
||||||
|
applyGraphicsSettings(JSON.parse(storedSettings));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载存储设置失败:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const skinNames = [
|
||||||
|
{ id: 0, name: 'Female Villager' },
|
||||||
|
{ id: 1, name: 'Female Knight' },
|
||||||
|
{ id: 2, name: 'Male Knight' },
|
||||||
|
{ id: 3, name: 'Male Warrior' },
|
||||||
|
{ id: 4, name: 'Female Rogue' },
|
||||||
|
{ id: 5, name: 'Male Ranger' },
|
||||||
|
{ id: 6, name: 'Female Villager' },
|
||||||
|
{ id: 7, name: 'Male Villager' },
|
||||||
|
{ id: 8, name: 'Female Witch' },
|
||||||
|
{ id: 9, name: 'Male Wizard' },
|
||||||
|
{ id: 10, name: 'Female Cleric' },
|
||||||
|
{ id: 11, name: 'Male Cleric' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export { graphicsSettings, updateGraphicsSettings, skinNames }
|
||||||
6
src/main.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import './style.css'
|
||||||
|
import './assets/fonts.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
24
src/style.css
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
body{
|
||||||
|
margin: 0;
|
||||||
|
user-select: none;
|
||||||
|
font-family: 'Adventure Time', 'ZCOOL XiaoWei';
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: .3vh solid white;
|
||||||
|
color: white;
|
||||||
|
outline: none;
|
||||||
|
font-size: 3.4vh;
|
||||||
|
font-family: 'Adventure Time', 'ZCOOL XiaoWei';
|
||||||
|
text-align: center;
|
||||||
|
text-shadow: 0 0 1vh black;
|
||||||
|
}
|
||||||
|
input::placeholder{
|
||||||
|
color: rgba(245, 245, 220, 0.737);
|
||||||
|
}
|
||||||
|
|
||||||
|
*{
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
663
src/utils.ts
Normal file
@ -0,0 +1,663 @@
|
|||||||
|
/**
|
||||||
|
* 防抖函数
|
||||||
|
* @param fn 需要防抖的函数
|
||||||
|
* @param delay 延迟时间,单位毫秒
|
||||||
|
* @param immediate 是否立即执行
|
||||||
|
* @returns 防抖处理后的函数
|
||||||
|
*/
|
||||||
|
export function debounce<T extends (...args: any[]) => any>(
|
||||||
|
fn: T,
|
||||||
|
delay: number,
|
||||||
|
immediate: boolean = false
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
return function (this: any, ...args: Parameters<T>): void {
|
||||||
|
const context = this;
|
||||||
|
|
||||||
|
// 如果已经设定过定时器,则清空上次的定时器
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
|
||||||
|
if (immediate) {
|
||||||
|
// 如果是立即执行
|
||||||
|
const callNow = !timer;
|
||||||
|
|
||||||
|
// 设置定时器,在delay毫秒后将timer设为null
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timer = null;
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
// 如果是第一次触发,则立即执行函数
|
||||||
|
if (callNow) fn.apply(context, args);
|
||||||
|
} else {
|
||||||
|
// 如果不是立即执行,则设置定时器,在delay毫秒后执行函数
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
fn.apply(context, args);
|
||||||
|
timer = null;
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file utils.ts
|
||||||
|
* @description 这个文件包含了一些实用的函数和一个简化 Canvas 操作的类 EasyCanvas。
|
||||||
|
* @version 1.0.0
|
||||||
|
* @date 2024-08-31
|
||||||
|
* @author feie9454
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在每一帧调用指定的回调函数,直到达到指定的帧数。
|
||||||
|
* @param {(index: number, total: number) => void} callback - 每一帧调用的回调函数。
|
||||||
|
* @param {number} frameCount - 总帧数。
|
||||||
|
*/
|
||||||
|
export function forEachFrame(callback: (index: number, total: number) => void, frameCount: number) {
|
||||||
|
if (frameCount <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let index = 0;
|
||||||
|
const total = frameCount;
|
||||||
|
const f = () => {
|
||||||
|
callback(index, total);
|
||||||
|
index++;
|
||||||
|
if (index < total) {
|
||||||
|
requestAnimationFrame(f);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
requestAnimationFrame(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在指定的毫秒数内,每一帧调用指定的回调函数。
|
||||||
|
* @param {(elapsedTime: number, totalTime: number) => void} callback - 每一帧调用的回调函数。
|
||||||
|
* @param {number} milliseconds - 总时间(毫秒)。
|
||||||
|
*/
|
||||||
|
export function forEachFrameInMilliseconds(callback: (elapsedTime: number, totalTime: number) => void, milliseconds: number) {
|
||||||
|
let startTime = Date.now();
|
||||||
|
let totalTime = milliseconds
|
||||||
|
const f = () => {
|
||||||
|
let elapsedTime = Date.now() - startTime;
|
||||||
|
if (elapsedTime < totalTime) {
|
||||||
|
callback(elapsedTime, totalTime);
|
||||||
|
requestAnimationFrame(f);
|
||||||
|
} else {
|
||||||
|
callback(totalTime, totalTime);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
requestAnimationFrame(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建一个线性到三次贝塞尔曲线的转换函数。
|
||||||
|
* @param {[number, number, number, number]} [f=[0.42, 0, 0.58, 1]] - 贝塞尔曲线参数。
|
||||||
|
* @returns {(t: number) => number} 转换函数。
|
||||||
|
*/
|
||||||
|
export function createLinear2CubicBezier(f: [number, number, number, number] = [0.42, 0, 0.58, 1]): (t: number) => number {
|
||||||
|
return (t: number) => {
|
||||||
|
return linear2CubicBezier(t, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将线性值转换为三次贝塞尔曲线值。
|
||||||
|
* @param {number} source - 源值(0 到 1 之间)。
|
||||||
|
* @param {[number, number, number, number]} [f=[0.42, 0, 0.58, 1]] - 贝塞尔曲线参数。
|
||||||
|
* @returns {number} 转换后的值。
|
||||||
|
* @throws {Error} 如果源值不在 0 到 1 之间,抛出错误。
|
||||||
|
*/
|
||||||
|
export function linear2CubicBezier(source: number, f: [number, number, number, number] = [0.42, 0, 0.58, 1]): number {
|
||||||
|
if (source < 0 || source > 1) {
|
||||||
|
throw new Error("Source must be between 0 and 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [x1, y1, x2, y2] = f;
|
||||||
|
|
||||||
|
const cubicBezier = (t: number, p0: number, p1: number, p2: number, p3: number) => {
|
||||||
|
const u = 1 - t;
|
||||||
|
return (u * u * u * p0) + (3 * u * u * t * p1) + (3 * u * t * t * p2) + (t * t * t * p3);
|
||||||
|
}
|
||||||
|
|
||||||
|
const solveCubicBezierX = (xTarget: number, epsilon = 0.00001) => {
|
||||||
|
let t = xTarget;
|
||||||
|
let x = cubicBezier(t, 0, x1, x2, 1);
|
||||||
|
let iteration = 0;
|
||||||
|
|
||||||
|
while (Math.abs(x - xTarget) > epsilon && iteration < 100) {
|
||||||
|
const d = 3 * (1 - t) * (1 - t) * (x1 - 0) + 6 * (1 - t) * t * (x2 - x1) + 3 * t * t * (1 - x2);
|
||||||
|
if (d === 0) break;
|
||||||
|
t -= (x - xTarget) / d;
|
||||||
|
x = cubicBezier(t, 0, x1, x2, 1);
|
||||||
|
iteration++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = solveCubicBezierX(source);
|
||||||
|
const y = cubicBezier(t, 0, y1, y2, 1);
|
||||||
|
|
||||||
|
return y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成指定长度的随机十六进制字符串。
|
||||||
|
* @param {number} length - 字符串长度。
|
||||||
|
* @returns {string} 随机十六进制字符串。
|
||||||
|
*/
|
||||||
|
export function generateRandomHexString(length: number): string {
|
||||||
|
const characters = '0123456789abcdef';
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
const randomIndex = Math.floor(Math.random() * characters.length);
|
||||||
|
result += characters[randomIndex];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EasyCanvas 类,用于简化 Canvas 操作。
|
||||||
|
*/
|
||||||
|
export class EasyCanvas {
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
ctx: CanvasRenderingContext2D;
|
||||||
|
statusMap = new Map<string, ImageData>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造函数
|
||||||
|
* @param {HTMLCanvasElement} canvas - Canvas 元素。
|
||||||
|
*/
|
||||||
|
constructor(canvas: HTMLCanvasElement) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = canvas.getContext("2d")!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将文本按最大宽度进行换行,并返回每行文本的数组。
|
||||||
|
* @param {CanvasRenderingContext2D} ctx - Canvas 渲染上下文。
|
||||||
|
* @param {string} text - 要换行的文本。
|
||||||
|
* @param {number} maxWidth - 每行的最大宽度。
|
||||||
|
* @returns {string[]} 换行后的文本数组。
|
||||||
|
*/
|
||||||
|
static getWrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number, considerMarkInLineEnd = true): string[] {
|
||||||
|
const txtList = [];
|
||||||
|
const markList = [
|
||||||
|
",", ".", "!", "?", ";", ":", ",", "。", "!", "?", ";", ":",
|
||||||
|
"-", "–", "—", "(", ")", "[", "]", "{", "}", "<", ">", "‘", "’",
|
||||||
|
"“", "”", "\"", "'", "`", "~", "@", "#", "$", "%", "^", "&", "*",
|
||||||
|
"_", "+", "=", "|", "\\", "/", "、", "·", "…", "《", "》", "〈",
|
||||||
|
"〉", "「", "」", "『", "』", "【", "】", "〖", "〗", "(", ")",
|
||||||
|
"〔", "〕", "!", "?", "﹏", "﹋", "﹌", "﹍", "﹎", "﹏", "﹐",
|
||||||
|
"﹑", "﹒", "﹔", "﹖", "﹗", "﹟", "﹠", "﹡", "﹢", "﹣", "﹤",
|
||||||
|
"﹥", "﹦", "﹨", "﹩", "﹪", "﹫"
|
||||||
|
];
|
||||||
|
let str = "";
|
||||||
|
for (let i = 0, len = text.length; i < len; i++) {
|
||||||
|
str += text.charAt(i);
|
||||||
|
if (ctx.measureText(str).width > maxWidth) {
|
||||||
|
if (considerMarkInLineEnd && markList.includes(text.charAt(i))) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
txtList.push(str.substring(0, str.length - 1))
|
||||||
|
str = ""
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
txtList.push(str)
|
||||||
|
return txtList;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 Canvas 上绘制文本。
|
||||||
|
* @param {string} font - 字体样式。
|
||||||
|
* @param {string} fillStyle - 填充样式。
|
||||||
|
* @param {string} text - 文本内容。
|
||||||
|
* @param {number} x - 文本的 x 坐标。
|
||||||
|
* @param {number} y - 文本的 y 坐标。
|
||||||
|
* @param {Object} [options] - 其他选项。
|
||||||
|
* @returns {this} 当前 EasyCanvas 实例。
|
||||||
|
*/
|
||||||
|
fillText(font: string, fillStyle: string, text: string, x: number, y: number,
|
||||||
|
options?:
|
||||||
|
| { align?: "left" | "right" }
|
||||||
|
| { align?: "left" | "right", maxWidth: number, lineHeight: number }
|
||||||
|
| { align: "centerW", width: number }
|
||||||
|
| { align: "centerW", width: number, maxWidth: number, lineHeight: number }
|
||||||
|
| { align: "centerP" }
|
||||||
|
| { align: "centerP", maxWidth: number, lineHeight: number }
|
||||||
|
): this {
|
||||||
|
this.ctx.font = font;
|
||||||
|
this.ctx.fillStyle = fillStyle;
|
||||||
|
|
||||||
|
if (options) {
|
||||||
|
if ("align" in options && options.align == "right") {
|
||||||
|
if ("maxWidth" in options && "lineHeight" in options) {
|
||||||
|
EasyCanvas.getWrapText(this.ctx, text, options.maxWidth).forEach((str, index) => {
|
||||||
|
this.ctx.fillText(str, x - this.ctx.measureText(str).width, y + options.lineHeight * index)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.ctx.fillText(text, x - this.ctx.measureText(text).width, y)
|
||||||
|
}
|
||||||
|
} else if ("align" in options && options.align == "centerW") {
|
||||||
|
if ("maxWidth" in options && "lineHeight" in options) {
|
||||||
|
EasyCanvas.getWrapText(this.ctx, text, options.maxWidth).forEach((str, index) => {
|
||||||
|
let textWidth = this.ctx.measureText(str).width;
|
||||||
|
this.ctx.fillText(str, x + (options.width - textWidth) / 2, y + options.lineHeight * index)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
let textWidth = this.ctx.measureText(text).width;
|
||||||
|
this.ctx.fillText(text, x + (options.width - textWidth) / 2, y)
|
||||||
|
}
|
||||||
|
} else if ("align" in options && options.align == "centerP") {
|
||||||
|
if ("maxWidth" in options && "lineHeight" in options) {
|
||||||
|
EasyCanvas.getWrapText(this.ctx, text, options.maxWidth).forEach((str, index) => {
|
||||||
|
let textWidth = this.ctx.measureText(str).width;
|
||||||
|
this.ctx.fillText(str, x - textWidth / 2, y + options.lineHeight * index)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
let textWidth = this.ctx.measureText(text).width;
|
||||||
|
this.ctx.fillText(text, x - textWidth / 2, y)
|
||||||
|
}
|
||||||
|
} else if ("align" in options && options.align == "left" || !("align" in options)) {
|
||||||
|
if ("maxWidth" in options && "lineHeight" in options) {
|
||||||
|
EasyCanvas.getWrapText(this.ctx, text, options.maxWidth).forEach((str, index) => {
|
||||||
|
this.ctx.fillText(str, x, y + options.lineHeight * index)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.ctx.fillText(text, x, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.ctx.fillText(text, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 Canvas 上绘制图像。
|
||||||
|
* @param {CanvasImageSource} image - 图像源。
|
||||||
|
* @param {number} dx - 图像在目标 canvas 上的 x 坐标。
|
||||||
|
* @param {number} dy - 图像在目标 canvas 上的 y 坐标。
|
||||||
|
* @param {number} dWidth - 在目标 canvas 上绘制图像的宽度。
|
||||||
|
* @param {number} dHeight - 在目标 canvas 上绘制图像的高度。
|
||||||
|
* @returns {this} 当前 EasyCanvas 实例。
|
||||||
|
*/
|
||||||
|
drawImage(image: CanvasImageSource, dx: number, dy: number, dWidth: number, dHeight: number): this {
|
||||||
|
this.ctx.drawImage(image, dx, dy, dWidth, dHeight);
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存当前 Canvas 状态。
|
||||||
|
* @param {string} statusName - 状态名称。
|
||||||
|
* @returns {this} 当前 EasyCanvas 实例。
|
||||||
|
*/
|
||||||
|
saveStatus(statusName: string): this {
|
||||||
|
let canvasData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
this.statusMap.set(statusName, canvasData);
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 恢复之前保存的 Canvas 状态。
|
||||||
|
* @param {string} statusName - 状态名称。
|
||||||
|
* @returns {this} 当前 EasyCanvas 实例。
|
||||||
|
*/
|
||||||
|
restoreStatus(statusName: string): this {
|
||||||
|
if (this.statusMap.has(statusName)) {
|
||||||
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
this.ctx.putImageData(this.statusMap.get(statusName)!, 0, 0);
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除保存的 Canvas 状态。
|
||||||
|
* @param {string} statusName - 状态名称。
|
||||||
|
* @returns {this} 当前 EasyCanvas 实例。
|
||||||
|
*/
|
||||||
|
deleteStatus(statusName: string): this {
|
||||||
|
this.statusMap.delete(statusName);
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 Canvas 上绘制线段。
|
||||||
|
* @param {...[number, number][]} points - 线段的点坐标。
|
||||||
|
* @returns {this} 当前 EasyCanvas 实例。
|
||||||
|
*/
|
||||||
|
lineTo(...points: [number, number][]): this {
|
||||||
|
points.forEach(([x, y]) => this.ctx.lineTo(x, y));
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建路径。
|
||||||
|
* @param {...[number, number][]} points - 路径的点坐标。
|
||||||
|
* @returns {this} 当前 EasyCanvas 实例。
|
||||||
|
*/
|
||||||
|
createPath(...points: [number, number][]): this {
|
||||||
|
this.ctx.beginPath();
|
||||||
|
points.forEach(([x, y], index) => {
|
||||||
|
if (index === 0) this.ctx.moveTo(x, y);
|
||||||
|
this.ctx.lineTo(x, y)
|
||||||
|
});
|
||||||
|
this.ctx.closePath();
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制路径的轮廓。
|
||||||
|
* @param {string} strokeStyle - 轮廓样式。
|
||||||
|
* @returns {this} 当前 EasyCanvas 实例。
|
||||||
|
*/
|
||||||
|
stroke(strokeStyle: string): this {
|
||||||
|
this.ctx.strokeStyle = strokeStyle;
|
||||||
|
this.ctx.stroke();
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 填充路径。
|
||||||
|
* @param {string} fillStyle - 填充样式。
|
||||||
|
* @returns {this} 当前 EasyCanvas 实例。
|
||||||
|
*/
|
||||||
|
fill(fillStyle: string): this {
|
||||||
|
this.ctx.fillStyle = fillStyle;
|
||||||
|
this.ctx.fill();
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建动画。
|
||||||
|
* @param {(progress: number, p: this) => void} onFrame - 每一帧调用的回调函数。
|
||||||
|
* @param {Object} options - 动画选项。
|
||||||
|
* @param {number} options.duration - 动画持续时间。
|
||||||
|
* @param {(t: number) => number} options.easing - 缓动函数。
|
||||||
|
* @param {boolean} [options.autoRecover] - 是否自动恢复状态。
|
||||||
|
* @param {number} [options.delay] - 动画延迟时间。
|
||||||
|
* @returns {this} 当前 EasyCanvas 实例。
|
||||||
|
*/
|
||||||
|
createAnimation(
|
||||||
|
onFrame: (progress: number, p: this) => void,
|
||||||
|
options: { duration: number, easing: (t: number) => number, autoRecover?: boolean, delay?: number }
|
||||||
|
): this {
|
||||||
|
const { duration, easing } = options;
|
||||||
|
let animationId = generateRandomHexString(16);
|
||||||
|
if (options.autoRecover) this.saveStatus(animationId);
|
||||||
|
setTimeout(() => {
|
||||||
|
forEachFrameInMilliseconds((index, total) => {
|
||||||
|
const progress = index / total;
|
||||||
|
if (options.autoRecover) this.restoreStatus(animationId);
|
||||||
|
onFrame(easing(progress), this);
|
||||||
|
}, duration);
|
||||||
|
}, options.delay);
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建图形。
|
||||||
|
* @param {Object} options - 图形选项。
|
||||||
|
* @param {[number, number]} options.centerPoint - 中心点坐标。
|
||||||
|
* @param {number} options.r - 半径。
|
||||||
|
* @param {string} options.fillStyle - 填充样式。
|
||||||
|
* @param {string} options.strokeStyle - 轮廓样式。
|
||||||
|
* @param {number} [options.startAngle] - 起始角度。
|
||||||
|
* @param {(index: number, domainCount: number) => number} [options.angleCal] - 角度计算函数。
|
||||||
|
* @param {...number[]} value - 值数组。
|
||||||
|
* @returns {this} 当前 EasyCanvas 实例。
|
||||||
|
*/
|
||||||
|
createGraph(options: { centerPoint: [number, number], r: number, fillStyle: string, strokeStyle: string, startAngle?: number, angleCal?: (index: number, domainCount: number) => number }, ...value: number[]): this {
|
||||||
|
const { centerPoint, r, strokeStyle, fillStyle } = options;
|
||||||
|
let points: [number, number][] = [];
|
||||||
|
const domainCount = value.length;
|
||||||
|
for (let i = 0; i < domainCount; i++) {
|
||||||
|
if (!options.angleCal) {
|
||||||
|
options.angleCal = (index: number, domainCount: number) => index / domainCount * Math.PI * 2;
|
||||||
|
}
|
||||||
|
if (!options.startAngle) {
|
||||||
|
options.startAngle = 0;
|
||||||
|
}
|
||||||
|
const angle = options.angleCal(i, domainCount) + options.startAngle;
|
||||||
|
const x = centerPoint[0] + r * Math.cos(angle) * value[i];
|
||||||
|
const y = centerPoint[1] + r * Math.sin(angle) * value[i];
|
||||||
|
points.push([x, y]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.createPath(...points)
|
||||||
|
.fill(fillStyle)
|
||||||
|
.stroke(strokeStyle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class EasyAudio {
|
||||||
|
private static audioCtx: AudioContext | null = null;
|
||||||
|
private buffers: Map<string, AudioBuffer> = new Map();
|
||||||
|
private sources: Map<string, AudioBufferSourceNode> = new Map(); // 保持不变
|
||||||
|
private gains: Map<string, GainNode> = new Map(); // 新增:存储GainNode以便控制音量或断开连接
|
||||||
|
private audioOptions: Map<
|
||||||
|
string,
|
||||||
|
{ audioUrl: string; loop?: boolean; volume?: number }
|
||||||
|
> = new Map();
|
||||||
|
|
||||||
|
private get audioCtx(): AudioContext {
|
||||||
|
if (!EasyAudio.audioCtx) {
|
||||||
|
// 最好在用户交互后创建 AudioContext
|
||||||
|
try {
|
||||||
|
EasyAudio.audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Web Audio API is not supported in this browser", e);
|
||||||
|
// 可以抛出错误或返回一个模拟/null对象,具体取决于你的错误处理策略
|
||||||
|
throw new Error("Web Audio API is not supported");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return EasyAudio.audioCtx;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
audios?:
|
||||||
|
| { name: string; audioUrl: string | URL; loop?: boolean; volume?: number }
|
||||||
|
| { name: string; audioUrl: string | URL; loop?: boolean; volume?: number }[]
|
||||||
|
) {
|
||||||
|
if (audios) {
|
||||||
|
this.add(audios);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createAudioBuffer(audioUrl: string): Promise<AudioBuffer> {
|
||||||
|
const response = await fetch(audioUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch audio: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
// 使用 Promise 包装 decodeAudioData 以支持旧浏览器可能需要的回调方式
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.audioCtx.decodeAudioData(
|
||||||
|
arrayBuffer,
|
||||||
|
buffer => resolve(buffer),
|
||||||
|
error => reject(new Error(`Failed to decode audio data: ${error?.message || error}`))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadAudio(name: string): Promise<AudioBuffer> {
|
||||||
|
// 检查是否已加载
|
||||||
|
if (this.buffers.has(name)) {
|
||||||
|
return this.buffers.get(name)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = this.audioOptions.get(name);
|
||||||
|
if (!options) {
|
||||||
|
throw new Error(`音频 ${name} 的配置未找到`);
|
||||||
|
}
|
||||||
|
console.log(`开始加载音频: ${name}`);
|
||||||
|
try {
|
||||||
|
const buffer = await this.createAudioBuffer(options.audioUrl);
|
||||||
|
this.buffers.set(name, buffer);
|
||||||
|
console.log(`音频加载完成: ${name}`);
|
||||||
|
return buffer;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`加载音频 ${name} 失败:`, error);
|
||||||
|
throw error; // 重新抛出错误,让调用者知道失败了
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(): Promise<void> {
|
||||||
|
const promises = Array.from(this.audioOptions.keys()).map(name =>
|
||||||
|
this.loadAudio(name).catch(e => {
|
||||||
|
// 容错处理,避免一个失败导致 Promise.all 直接 reject
|
||||||
|
console.error(`预加载音频 ${name} 失败:`, e);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.all(promises);
|
||||||
|
console.log("所有请求的音频已尝试加载。");
|
||||||
|
}
|
||||||
|
|
||||||
|
add(
|
||||||
|
audios:
|
||||||
|
| { name: string; audioUrl: string | URL; loop?: boolean; volume?: number }
|
||||||
|
| { name: string; audioUrl: string | URL; loop?: boolean; volume?: number }[]
|
||||||
|
): void {
|
||||||
|
const processAudio = (audio: { name: string; audioUrl: string | URL; loop?: boolean; volume?: number }) => {
|
||||||
|
if (this.audioOptions.has(audio.name)) {
|
||||||
|
console.warn(`音频名称 "${audio.name}" 已存在,将被覆盖。`);
|
||||||
|
// 如果正在播放,可能需要先停止?取决于你的需求
|
||||||
|
// this.stop(audio.name);
|
||||||
|
}
|
||||||
|
this.audioOptions.set(audio.name, {
|
||||||
|
audioUrl: typeof (audio.audioUrl) === "string" ? audio.audioUrl : audio.audioUrl.href,
|
||||||
|
loop: audio.loop,
|
||||||
|
// 确保音量在 0 到 1 (或更高,但通常是0-1) 之间,且是数字
|
||||||
|
volume: typeof audio.volume === 'number' ? Math.max(0, audio.volume) : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Array.isArray(audios)) {
|
||||||
|
audios.forEach(processAudio);
|
||||||
|
} else if (audios) {
|
||||||
|
processAudio(audios);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async play(name: string, onEnded?: () => void): Promise<void> { // 返回 Promise 以便知道何时开始播放(或失败)
|
||||||
|
const options = this.audioOptions.get(name);
|
||||||
|
if (!options) {
|
||||||
|
throw new Error(`音频 ${name} 的配置未找到`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 关键改动点 ---
|
||||||
|
// 1. 如果已存在同名source,先停止它
|
||||||
|
this.stop(name); // 调用 stop 会处理 source 和 gain 的清理
|
||||||
|
|
||||||
|
// 2. 立即创建 Source 和 Gain Node
|
||||||
|
const source = this.audioCtx.createBufferSource();
|
||||||
|
let gainNode: GainNode | null = null;
|
||||||
|
|
||||||
|
// 3. 立即存储 Source 和 Gain Node
|
||||||
|
this.sources.set(name, source);
|
||||||
|
if (options.volume !== undefined) {
|
||||||
|
gainNode = this.audioCtx.createGain();
|
||||||
|
gainNode.gain.value = options.volume;
|
||||||
|
this.gains.set(name, gainNode); // 存储 GainNode
|
||||||
|
}
|
||||||
|
// --- 结束关键改动点 ---
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 4. 加载或获取 Buffer (如果需要等待,stop 也能找到 source 了)
|
||||||
|
const buffer = await this.loadAudio(name);
|
||||||
|
|
||||||
|
// 5. 配置 Source
|
||||||
|
source.buffer = buffer;
|
||||||
|
source.loop = options.loop || false;
|
||||||
|
|
||||||
|
// 6. 连接节点
|
||||||
|
const destinationNode = gainNode || this.audioCtx.destination;
|
||||||
|
if (gainNode) {
|
||||||
|
source.connect(gainNode);
|
||||||
|
gainNode.connect(this.audioCtx.destination);
|
||||||
|
} else {
|
||||||
|
source.connect(destinationNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
source.onended = () => {
|
||||||
|
if (this.sources.get(name) === source) {
|
||||||
|
this.stop(name); // 调用 stop 来统一处理清理逻辑
|
||||||
|
}
|
||||||
|
onEnded?.(); // 调用外部传入的回调
|
||||||
|
};
|
||||||
|
|
||||||
|
// 8. 开始播放
|
||||||
|
source.start();
|
||||||
|
console.log(`音频 ${name} 已调用 start()`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`播放音频 ${name} 时出错:`, error);
|
||||||
|
// 出错时也要清理
|
||||||
|
this.stop(name); // 清理可能已创建的节点
|
||||||
|
throw error; // 将错误继续抛出
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(name: string): void {
|
||||||
|
const source = this.sources.get(name);
|
||||||
|
const gainNode = this.gains.get(name);
|
||||||
|
|
||||||
|
if (source) {
|
||||||
|
try {
|
||||||
|
// source.stop() 可能会在某些状态下(如已结束)抛出错误,但通常可以安全调用
|
||||||
|
source.stop();
|
||||||
|
console.log(`音频 ${name} 已调用 stop()`);
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略调用 stop() 时可能发生的错误 (例如 InvalidStateError)
|
||||||
|
// console.warn(`调用 source.stop() for ${name} 时出现异常 (可能已停止):`, e);
|
||||||
|
} finally {
|
||||||
|
// 清理 source
|
||||||
|
source.onended = null; // 移除 onended 监听器,防止 stop 后再次触发清理
|
||||||
|
source.disconnect(); // 断开连接
|
||||||
|
this.sources.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gainNode) {
|
||||||
|
gainNode.disconnect(); // 断开 GainNode 的连接
|
||||||
|
this.gains.delete(name); // 从 Map 中移除 GainNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 source 或 gainNode 不存在,说明没有在播放或已被清理,无需操作
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAll(): void {
|
||||||
|
console.log("停止所有音频...");
|
||||||
|
// 复制一份 keys 来遍历,因为 stop(name) 会修改 this.sources
|
||||||
|
const names = Array.from(this.sources.keys());
|
||||||
|
names.forEach(name => this.stop(name));
|
||||||
|
// 理论上 stop(name) 已经清理了 map,但为保险起见可以清空
|
||||||
|
if (this.sources.size > 0) {
|
||||||
|
console.warn("stopAll 后 sources Map 仍有残留,强制清空。这可能表示 stop() 逻辑有误。");
|
||||||
|
this.sources.clear();
|
||||||
|
this.gains.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (可选)添加清理方法,例如在组件卸载时调用
|
||||||
|
dispose(): void {
|
||||||
|
this.stopAll();
|
||||||
|
this.buffers.clear();
|
||||||
|
this.audioOptions.clear();
|
||||||
|
if (EasyAudio.audioCtx && EasyAudio.audioCtx.state !== 'closed') {
|
||||||
|
EasyAudio.audioCtx.close().then(() => {
|
||||||
|
console.log("AudioContext closed.");
|
||||||
|
EasyAudio.audioCtx = null;
|
||||||
|
}).catch(e => {
|
||||||
|
console.error("Error closing AudioContext:", e);
|
||||||
|
EasyAudio.audioCtx = null; // 即使关闭失败也设为 null
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
EasyAudio.audioCtx = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
// Web Speech API 类型定义
|
||||||
|
declare class SpeechRecognition extends EventTarget {
|
||||||
|
continuous: boolean;
|
||||||
|
interimResults: boolean;
|
||||||
|
lang: string;
|
||||||
|
maxAlternatives: number;
|
||||||
|
start(): void;
|
||||||
|
stop(): void;
|
||||||
|
abort(): void;
|
||||||
|
onresult: (event: SpeechRecognitionEvent) => void;
|
||||||
|
onerror: (event: SpeechRecognitionErrorEvent) => void;
|
||||||
|
onend: () => void;
|
||||||
|
onstart: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
SpeechRecognition: typeof SpeechRecognition;
|
||||||
|
webkitSpeechRecognition: typeof SpeechRecognition;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeechRecognitionErrorEvent extends Event {
|
||||||
|
error: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeechRecognitionResultList {
|
||||||
|
length: number;
|
||||||
|
item(index: number): SpeechRecognitionResult;
|
||||||
|
[index: number]: SpeechRecognitionResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeechRecognitionResult {
|
||||||
|
length: number;
|
||||||
|
item(index: number): SpeechRecognitionAlternative;
|
||||||
|
[index: number]: SpeechRecognitionAlternative;
|
||||||
|
isFinal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeechRecognitionAlternative {
|
||||||
|
transcript: string;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeechRecognitionEvent extends Event {
|
||||||
|
resultIndex: number;
|
||||||
|
results: SpeechRecognitionResultList;
|
||||||
|
}
|
||||||
12
tsconfig.app.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
22
tsconfig.node.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
10
vite.config.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
build: {
|
||||||
|
assetsInlineLimit: 256 * 1024
|
||||||
|
}
|
||||||
|
})
|
||||||