first commit

This commit is contained in:
feie9456 2025-06-27 08:42:27 +08:00
commit 83a3135b01
77 changed files with 6333 additions and 0 deletions

24
.gitignore vendored Normal file
View 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
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
README.md Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

1
public/vite.svg Normal file
View 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
View 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
View 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;

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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,
}

Binary file not shown.

Binary file not shown.

35
src/assets/audios/ogg.py Normal file
View 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()

Binary file not shown.

BIN
src/assets/audios/vote.ogg Normal file

Binary file not shown.

BIN
src/assets/audios/witch.ogg Normal file

Binary file not shown.

BIN
src/assets/audios/wolf.ogg Normal file

Binary file not shown.

BIN
src/assets/audios/write.ogg Normal file

Binary file not shown.

27
src/assets/fonts.css Normal file
View 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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src/assets/imgs/Board.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src/assets/imgs/Note.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

BIN
src/assets/imgs/Notice.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
src/assets/imgs/Plain.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View 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 }

View 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

View File

@ -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

View File

@ -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
View 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
View 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
View 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>

View 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

File diff suppressed because it is too large Load Diff

0
src/components/Input.vue Normal file
View File

34
src/components/Note.vue Normal file
View 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>

View 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>

View 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>

View 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>

View 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;
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

22
tsconfig.node.json Normal file
View 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
View 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
}
})