This commit is contained in:
renzhiyuan 2025-07-28 02:25:14 +08:00
parent 60bc556807
commit ece104226d
51 changed files with 1530 additions and 1148 deletions

55
bak Normal file
View File

@ -0,0 +1,55 @@
let blackBoard: Graphics | null = null;
const createBlackBoard=()=>{
const container = new Container();
const newBlackboard = new Graphics()
.roundRect(0, 0, width, height/2,80)
.fill({
color: 'white',
alpha: 1,
});
container.addChild(newBlackboard);
container.position.set(
0 ,
300
);
app.stage.addChild(container);
blackBoard=newBlackboard
}
const createBlackBoardText=()=>{
const html = new HTMLText({
text: '<textarea id="output" rows="6" cols="50" readonly>asdasdsadsadsa</textarea>',
style: {
fontFamily: 'Arial',
fontSize: 24,
fill: '#ff1010',
align: 'center',
},
});
blackBoard.addChild(html)
}
const creatBackGround=()=>{
const container = new Container();
const background = new Graphics()
.rect(0, 0, width, height)
.fill({
fill:gradient,
color: 'yellow',
alpha: 0.5,
});
container.addChild(background);
container.position.set(
0 , // 居中(宽度一半)
0
);
container.addChild(background);
app.stage.addChild(container); // 直接添加到舞台
}

1710
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,9 @@
"serve": "vite preview"
},
"dependencies": {
"@pixi/devtools": "^2.0.1",
"easy-live2d": "^0.4.0-1",
"recorder-js": "^1.0.7",
"vue": "^3.2.8"
},
"devDependencies": {

21
public/audio-processor.js Normal file
View File

@ -0,0 +1,21 @@
class AudioProcessor extends AudioWorkletProcessor {
process(inputs, outputs, parameters) {
const input = inputs[0];
if (input.length === 0) return true;
const channelData = input[0]; // 获取第一个声道的数据
const int16Data = new Int16Array(channelData.length);
// 转换为 16-bit PCM
for (let i = 0; i < channelData.length; i++) {
int16Data[i] = Math.max(-1, Math.min(1, channelData[i])) * 32767;
}
// 发送到主线程
this.port.postMessage(int16Data.buffer, [int16Data.buffer]);
return true; // 保持处理器运行
}
}
registerProcessor("audio-processor", AudioProcessor);

BIN
public/resource/img/log.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

Before

Width:  |  Height:  |  Size: 470 KiB

After

Width:  |  Height:  |  Size: 470 KiB

View File

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

View File

@ -1,115 +1,354 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { onUnmounted, ref } from 'vue'
import { Config, Live2DSprite, LogLevel, Priority } from 'easy-live2d'
import { Application, Ticker } from 'pixi.js'
import { Application, Ticker,Graphics,Container,FillGradient,HTMLText} from 'pixi.js'
import { initDevtools } from '@pixi/devtools'
import AudioStreamer from "./components/AudioStreamer.vue";
import AppPreloader from "./components/AppPreloader.vue";
import { resourceMap } from './utils/resource.js';
import TypewriterTextBox from "./components/TypewriterTextBox.vue";
const log = resourceMap.get("log")
const showPreloader = ref(true)
let app: Application | null = null;
const live2DSprite = new Live2DSprite()
const canvasRef = ref<HTMLCanvasElement>()
const app = new Application()
let width=0
let height=0
// Config
Config.MotionGroupIdle = 'Idle' //
Config.MouseFollow = true //
Config.CubismLoggingLevel = LogLevel.LogLevel_Off //
const x=ref(30)
const y=ref(0)
const wavFile=ref('/wav/introduce_self.wav')
///models/hiyori_free_t08/hiyori_free_t08.model3.json
const model=ref('models/UG/ugofficial.model3.json')
// // Live2D
const live2DSprite = new Live2DSprite()
live2DSprite.init({
modelPath: model.value,
ticker: Ticker.shared
Config.CubismLoggingLevel = LogLevel.LogLevel_Verbose //
const gradient = new FillGradient({
colorStops: [
{ offset: 0, color: 'blue' },
{ offset: 1, color: 'red' },
],
});
//
live2DSprite.onLive2D('hit', ({ hitAreaName, x, y }) => {
console.log('hit', hitAreaName, x, y);
})
const text=ref("")
const showQues=ref(false)
onMounted(async () => {
//
await app.init({
view: canvasRef.value,
backgroundAlpha: 0, // alpha0
})
if (canvasRef.value) {
// Live2D
live2DSprite.x = x.value
live2DSprite.y = y.value
live2DSprite.width = canvasRef.value.clientWidth * window.devicePixelRatio
live2DSprite.height = canvasRef.value.clientHeight * window.devicePixelRatio-200
app.stage.addChild(live2DSprite);
//
live2DSprite.setExpression({
expressionId: '4OAO',//normal
})
//
live2DSprite.playVoice({
// wav
voicePath: wavFile.value,
})
//
// live2DSprite.stopVoice()
setTimeout(() => {
//
live2DSprite.playVoice({
voicePath: wavFile.value,
immediate: true // : true
const handleClick = () => {
talk(resourceMap.get('start'))
.then(() => {
startStreaming()
text.value="请简单阐述mysql的索引机制,请简单阐述mysql的索引机制请简单阐述mysql的索引机制请简单阐述mysql的索引机制请简单阐述mysql的索引机制"
Ques()
})
}, 10000)
};
const overClick = () => {
app.ticker.stop()
//stopStreaming()
};
function ansTran(delta){
live2DSprite.x += 8;
live2DSprite.y += -15;
}
const Ques=()=>{
app.ticker.add(delta =>ansTran(delta))
setTimeout(() => {
app.ticker.stop()
showQues.value=true
}, 1000);
}
const onPreloadComplete = async() => {
if (!canvasRef.value) {
return;
}
app = new Application();
await app.init({
view: canvasRef.value,
backgroundAlpha: 0.5, // alpha0
resizeTo: window, //
preference: 'webgl',
//
live2DSprite.startMotion({
group: '4OAO',
no: 0,
priority: 3,
})
}
})
width=canvasRef.value.clientWidth * window.devicePixelRatio
height=canvasRef.value.clientHeight * window.devicePixelRatio
console.log('预加载完成,开始初始化模型');
showPreloader.value = false;
app.renderer.resize(width, height);
try {
await modelInit(resourceMap.get('model'));
} catch (error) {
console.error('初始化失败:', error);
showPreloader.value = true; //
}
}
const initInfo={
x:-100,
y:300,
vx:50,
vy:0,
}
const modelInit=async (modelPath)=>{
if (!modelPath) throw new Error('模型路径未找到');
// canvas
live2DSprite.init({
modelPath: modelPath,
ticker: Ticker.shared
});
if (canvasRef.value) {
// Live2D
live2DSprite.x = initInfo.x
live2DSprite.y = initInfo.y
live2DSprite.width = width+200
live2DSprite.height = height-800
live2DSprite.alpha = 0
app.stage.addChild(live2DSprite);
//
live2DSprite.startMotion({
group: 'loop',
no: 0,
priority: 3,
})
}
}
onUnmounted(() => {
//
console.log("释放实例")
live2DSprite.destroy()
})
const talk = async (voicePath) => {
return new Promise((resolve, reject) => {
try {
const tempAudio = new Audio(voicePath);
tempAudio.onloadedmetadata = () => {
const duration = tempAudio.duration * 1000; //
// 2.
live2DSprite.playVoice({ voicePath, immediate: true });
// 3. 使 setTimeout
setTimeout(() => {
console.log("语音播放完成(估算时间)");
resolve(); //
}, duration);
};
// 4.
tempAudio.onerror = () => {
reject(new Error("语音加载失败"));
};
} catch (error) {
reject(error); //
}
});
};
const isStreaming = ref(false);
const status = ref("");
const startStreaming = () => {
isStreaming.value = true;
status.value = "Initializing audio stream...";
};
const stopStreaming = () => {
isStreaming.value = false;
};
const handleStatusUpdate = (message) => {
status.value = message;
};
const handleError = (error) => {
status.value = `Error: ${error}`;
isStreaming.value = false;
};
</script>
<template>
<div class="test">
</div>
<canvas
ref="canvasRef"
id="live2d"
/>
<view class="logo-container">
<image
class="logo"
:src="log"
mode="aspectFit"
/>
</view>
<AppPreloader v-if="showPreloader" @loaded="onPreloadComplete" />
<view class="main-content">
<canvas ref="canvasRef" id="live2d"/>
<button class="cute-button start" @click="handleClick">
<span>开始 🎉</span>
</button>
<button class="cute-button over" @click="overClick">
<span>结束 🎉</span>
</button>
</view>
<view class="textbox-layer" v-show="showQues">
<TypewriterTextBox
:text="text"
:speed="50"
@typingComplete=""
/>
</view>
<AudioStreamer
:is-streaming="isStreaming"
@update-status="handleStatusUpdate"
@streaming-error="handleError"
/>
<p v-if="status">{{ status }}</p>
</template>
<style>
#live2d {
position: absolute;
top: 0%;
right: 0%;
width: 100%;
height: 100%;
background: linear-gradient(45deg, #ff9a9e, #fad0c4, #fbc2eb);
}
.test {
display: inline-block;
position: absolute;
width: 100%;
height: 70%;
background-color: pink;
.start {
bottom: 9rem;
}
.over {
bottom: 5rem;
}
.cute-button {
position: fixed;
left: 50%;
transform: translateX(-50%);
padding: 14px 28px;
background: linear-gradient(45deg, #ff9a9e, #fad0c4, #fbc2eb); /* 多色渐变 */
color: white;
border: none;
border-radius: 50px;
box-shadow: 0 4px 15px rgba(255, 154, 158, 0.4);
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.68, -0.55, 0.27, 1.55); /* 弹性动画 */
z-index: 100;
overflow: hidden;
}
.cute-button:hover {
transform: translateX(-50%) scale(1.1);
box-shadow: 0 8px 25px rgba(255, 154, 158, 0.6);
}
.cute-button:active {
transform: translateX(-50%) scale(0.95);
}
/* 点击时加一个波纹效果 */
.cute-button::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 5px;
height: 5px;
background: rgba(255, 255, 255, 0.5);
border-radius: 50%;
transform: translate(-50%, -50%) scale(0);
transition: transform 0.6s;
}
.cute-button:active::after {
transform: translate(-50%, -50%) scale(20);
opacity: 0;
}
/* 按钮文字图标 */
.cute-button span::after {
content: "🐾";
margin-left: 8px;
animation: paw 1.5s infinite;
}
@keyframes paw {
0%, 100% { transform: rotate(0deg); }
50% { transform: rotate(15deg); }
}
.main-content {
opacity: 1;
animation: fadeIn 0.5s ease 0.3s forwards;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.textbox-layer {
position: relative; /* 或 absolute/fixed取决于需求 */
z-index:1000; /* 文本框在上层 */
}
/* 添加样式确保图片居中 */
.logo-container {
position: relative;
justify-content: center;
padding: 20px 0; /* 根据需要调整上下间距 */
width: 100%;
z-index:1000; /* 文本框在上层 */
}
.logo {
width: 100%;
height: auto;
display: block; /* 确保图片是块级元素 */
margin: 0 auto; /* 水平居中 */
border: 2px solid red; /* 检查图片容器是否存在 */
}
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -0,0 +1,92 @@
<!-- src/components/AppPreloader.vue -->
<template>
<div v-if="isLoading" class="preloader-overlay">
<div class="preloader-content">
<div class="progress-bar">
<div
class="progress"
:style="{ width: progress + '%' }"
></div>
</div>
<div class="loading-text">
正在加载资源... {{ progress.toFixed(0) }}%
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { preloadResources } from '../utils/preload'
import { resourceMap } from '../utils/resource'
const emit = defineEmits(['loaded'])
const isLoading = ref(true)
const progress = ref(0)
onMounted(async () => {
await startPreloading()
})
const startPreloading = async () => {
return new Promise((resolve) => {
//
const interval = setInterval(() => {
if (progress.value < 95) {
progress.value += Math.random() * 5
}
}, 300)
//
preloadResources(resourceMap).finally(() => {
clearInterval(interval)
progress.value = 100
setTimeout(() => {
isLoading.value = false
emit('loaded')
resolve()
}, 500)
})
})
}
</script>
<style scoped>
.preloader-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #f5f7fa;
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
transition: opacity 0.5s ease;
}
.preloader-content {
text-align: center;
}
.progress-bar {
width: 300px;
height: 6px;
background: #fff;
border-radius: 3px;
overflow: hidden;
margin-bottom: 16px;
}
.progress {
height: 100%;
background: #409eff;
transition: width 0.3s ease;
}
.loading-text {
color: #666;
font-size: 14px;
}
</style>

View File

@ -0,0 +1,127 @@
<template>
<div>
<p v-if="status">{{ status }}</p>
</div>
</template>
<script>
import { ref, watch, onUnmounted } from "vue";
export default {
props: {
isStreaming: Boolean,
},
emits: ["update-status", "streaming-error"],
setup(props, { emit }) {
const status = ref("");
let audioContext = null;
let mediaStream = null;
let socket = null;
let audioWorkletNode = null;
const SAMPLE_RATE = 16000;
const CHANNELS = 1;
//
const cleanupResources = () => {
if (audioWorkletNode) {
audioWorkletNode.disconnect();
audioWorkletNode.port.onmessage = null;
audioWorkletNode = null;
}
if (mediaStream) {
mediaStream.getTracks().forEach(track => track.stop());
mediaStream = null;
}
if (socket) {
socket.onopen = null;
socket.onclose = null;
socket.onmessage = null;
socket.onerror = null;
if (socket.readyState === WebSocket.OPEN) socket.close();
socket = null;
}
if (audioContext) {
audioContext.close().catch(() => {});
audioContext = null;
}
};
const startStreaming = async () => {
try {
//
cleanupResources();
// 1. WebSocket
socket = new WebSocket("ws://localhost:8000/ws");
socket.binaryType = "arraybuffer";
socket.onopen = () => {
emit("update-status", "WebSocket connected");
};
socket.onclose = () => {
emit("update-status", "WebSocket disconnected");
};
socket.onerror = (error) => {
emit("streaming-error", `WebSocket error: ${error.message}`);
cleanupResources();
};
// 2. AudioContext
audioContext = new AudioContext({ sampleRate: SAMPLE_RATE });
// 3.
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
const source = audioContext.createMediaStreamSource(mediaStream);
// 4. AudioWorklet
await audioContext.audioWorklet.addModule("/audio-processor.js");
// 5. AudioWorkletNode
audioWorkletNode = new AudioWorkletNode(audioContext, "audio-processor");
// 6. WebSocket
audioWorkletNode.port.onmessage = (event) => {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(event.data);
}
};
// 7.
source.connect(audioWorkletNode);
audioWorkletNode.connect(audioContext.destination);
emit("update-status", "Streaming started");
} catch (error) {
cleanupResources();
emit("streaming-error", error.message);
}
};
const stopStreaming = () => {
cleanupResources();
emit("update-status", "Streaming stopped");
};
watch(
() => props.isStreaming,
(newVal) => {
if (newVal) {
startStreaming();
} else {
stopStreaming();
}
},
{ immediate: true }
);
onUnmounted(() => {
stopStreaming();
});
return { status };
},
};
</script>

View File

@ -1,38 +0,0 @@
<script setup>
import { ref } from 'vue'
defineProps({
msg: String
})
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<p>
Welcome:
<a href="https://hx.dcloud.net.cn/" target="_blank">HBuilderX</a>
</p>
<p>
<a href="https://vitejs.dev/guide/features.html" target="_blank">
Vite Documentation
</a>
|
<a href="https://v3.vuejs.org/" target="_blank">Vue 3 Documentation</a>
</p>
<button type="button" @click="count++">count is: {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test hot module replacement.
</p>
</template>
<style scoped>
a {
color: #42b983;
}
</style>

View File

@ -0,0 +1,169 @@
<template>
<div class="typewriter-container">
<!-- 响应式文本框 -->
<div
class="typewriter-box"
:style="{
padding: padding,
borderRadius: borderRadius,
border: border,
backgroundColor: backgroundColor,
color: textColor,
fontSize: fontSize,
textAlign: textAlign,
boxShadow: boxShadow,
minHeight: minHeight,
}"
>
<!-- 逐字显示的文本 -->
<span v-for="(char, index) in displayedText" :key="index">
{{ char }}
</span>
<!-- 光标效果可选 -->
<span v-if="isTyping" class="cursor">|</span>
</div>
</div>
</template>
<script>
export default {
name: "TypewriterTextBox",
props: {
//
text: {
type: String,
required: true,
},
// ms
speed: {
type: Number,
default: 100,
},
//
padding: {
type: String,
default: "12px 16px",
},
borderRadius: {
type: String,
default: "8px",
},
border: {
type: String,
default: "1px solid #ddd",
},
backgroundColor: {
type: String,
default: "white", //
},
textColor: {
type: String,
default: "#333",
},
fontSize: {
type: String,
default: "16px",
},
textAlign: {
type: String,
default: "left",
},
boxShadow: {
type: String,
default: "0 2px 4px rgba(0, 0, 0, 0.1)",
},
minHeight: {
type: String,
default: "auto",
},
},
data() {
return {
displayedText: "", //
isTyping: false, //
typingInterval: null, //
};
},
watch: {
// text
text(newText) {
this.resetTyping();
this.startTyping();
},
// speed
speed() {
this.resetTyping();
this.startTyping();
},
},
mounted() {
this.startTyping();
},
beforeDestroy() {
this.clearTypingInterval();
},
methods: {
//
startTyping() {
this.clearTypingInterval();
this.isTyping = true;
this.displayedText = ""; //
let currentIndex = 0;
const text = this.text || "";
this.typingInterval = setInterval(() => {
if (currentIndex < text.length) {
this.displayedText += text[currentIndex];
currentIndex++;
} else {
this.isTyping = false;
this.clearTypingInterval();
this.$emit("typingComplete"); //
}
}, this.speed);
},
//
clearTypingInterval() {
if (this.typingInterval) {
clearInterval(this.typingInterval);
this.typingInterval = null;
}
},
//
resetTyping() {
this.clearTypingInterval();
this.displayedText = "";
this.isTyping = false;
},
},
};
</script>
<style scoped>
.typewriter-container {
width: 90%;
max-width: 90%;
margin: 1rem auto;
}
.typewriter-box {
width: 90%;
min-height: 1em;
line-height: 1.5;
word-wrap: break-word;
transition: all 0.3s ease;
}
.cursor {
display: inline-block;
width: 1px;
background-color: #333;
animation: blink 0.8s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
</style>

47
src/utils/preload.js Normal file
View File

@ -0,0 +1,47 @@
// src/utils/preload.js
export const preloadResources = (resourceMap) => {
return new Promise((resolve) => {
let loadedCount = 0;
const total = resourceMap.size;
if (total === 0) {
return resolve();
}
const checkComplete = () => {
loadedCount++;
if (window.updatePreloadProgress) {
window.updatePreloadProgress((loadedCount / total) * 100);
}
if (loadedCount === total) {
resolve();
}
};
// 遍历 Map 并预加载
resourceMap.forEach((url) => {
if (/\.(jpg|jpeg|png|gif|svg|webp)$/i.test(url)) {
const img = new Image();
img.onload = checkComplete;
img.onerror = checkComplete;
img.src = url;
} else if (/\.(json)$/i.test(url)) {
fetch(url)
.then(() => checkComplete())
.catch(() => checkComplete());
} else if (/\.(wav|mp3|ogg|aac)$/i.test(url)) {
const audio = new Audio();
audio.oncanplaythrough = checkComplete;
audio.onerror = checkComplete;
audio.src = url;
audio.load();
} else {
fetch(url, { method: 'HEAD' })
.then(() => checkComplete())
.catch(() => checkComplete());
}
});
});
};

8
src/utils/resource.js Normal file
View File

@ -0,0 +1,8 @@
// src/utils/resource.js
export const resourceMap = new Map([
['start', '/resource/sounds/start.wav'],
['model', '/resource/models/UG/ugofficial.model3.json'],
['log', '/resource/img/log.png'],
// 其他资源...
]);

View File

@ -1,7 +1,9 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()]
})
plugins: [vue()],
server: {
port: 3000, // 可选:指定端口
},
})

Binary file not shown.