first commit

This commit is contained in:
2025-06-27 08:42:27 +08:00
commit 83a3135b01
77 changed files with 6333 additions and 0 deletions
+24
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
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+5
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
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
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
View File
File diff suppressed because it is too large Load Diff
+1
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
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
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.
+40
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
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+27
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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

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

+5
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 }
+1
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

@@ -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
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
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
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>
+547
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>
File diff suppressed because it is too large Load Diff
View File
+34
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>
+123
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>
+165
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>
+64
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>
+277
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;
}
+643
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
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
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
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
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
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
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
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+22
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
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
}
})