|
|
@@ -0,0 +1,270 @@
|
|
|
+<template>
|
|
|
+ <div class="characters-stage" style="width:550px;height:400px">
|
|
|
+ <!-- Red tall character - back layer -->
|
|
|
+ <div ref="redRef" class="ch" :style="redStyle">
|
|
|
+ <div class="eyes-wrap" :style="redEyesStyle">
|
|
|
+ <div class="eye" :style="{ width: '18px', height: isRedBlink ? '2px' : '18px' }">
|
|
|
+ <div class="pupil-dot" style="width:7px;height:7px" />
|
|
|
+ </div>
|
|
|
+ <div class="eye" :style="{ width: '18px', height: isRedBlink ? '2px' : '18px' }">
|
|
|
+ <div class="pupil-dot" style="width:7px;height:7px" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Dark character - middle layer -->
|
|
|
+ <div ref="darkRef" class="ch" :style="darkStyle">
|
|
|
+ <div class="eyes-wrap" :style="darkEyesStyle">
|
|
|
+ <div class="eye" :style="{ width: '16px', height: isDarkBlink ? '2px' : '16px' }">
|
|
|
+ <div class="pupil-dot" style="width:6px;height:6px" />
|
|
|
+ </div>
|
|
|
+ <div class="eye" :style="{ width: '16px', height: isDarkBlink ? '2px' : '16px' }">
|
|
|
+ <div class="pupil-dot" style="width:6px;height:6px" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Terracotta semi-circle - front left -->
|
|
|
+ <div ref="terraRef" class="ch" :style="terraStyle">
|
|
|
+ <div class="eyes-wrap" style="gap:32px" :style="terraEyesStyle">
|
|
|
+ <div class="pup-solo" style="width:12px;height:12px" />
|
|
|
+ <div class="pup-solo" style="width:12px;height:12px" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Gold semi-circle - front right -->
|
|
|
+ <div ref="goldRef" class="ch" :style="goldStyle">
|
|
|
+ <div class="eyes-wrap" style="gap:32px" :style="goldEyesStyle">
|
|
|
+ <div class="pup-solo" style="width:12px;height:12px" />
|
|
|
+ <div class="pup-solo" style="width:12px;height:12px" />
|
|
|
+ </div>
|
|
|
+ <div class="mouth" :style="mouthStyle" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { ref, reactive, computed, onMounted, onUnmounted, watch } from "vue";
|
|
|
+
|
|
|
+const props = defineProps<{
|
|
|
+ isTyping?: boolean;
|
|
|
+ showPassword?: boolean;
|
|
|
+ passwordLength?: number;
|
|
|
+}>();
|
|
|
+
|
|
|
+// Mouse position
|
|
|
+const mx = ref(0);
|
|
|
+const my = ref(0);
|
|
|
+function onMouseMove(e: MouseEvent) { mx.value = e.clientX; my.value = e.clientY; }
|
|
|
+
|
|
|
+// Blink state
|
|
|
+const isRedBlink = ref(false);
|
|
|
+const isDarkBlink = ref(false);
|
|
|
+
|
|
|
+// Behavior state
|
|
|
+const isLook = ref(false);
|
|
|
+
|
|
|
+// Refs
|
|
|
+const redRef = ref<HTMLElement>();
|
|
|
+const darkRef = ref<HTMLElement>();
|
|
|
+const terraRef = ref<HTMLElement>();
|
|
|
+const goldRef = ref<HTMLElement>();
|
|
|
+
|
|
|
+// Computed flags
|
|
|
+const hidePw = computed(() => (props.passwordLength ?? 0) > 0 && !props.showPassword);
|
|
|
+const showPw = computed(() => (props.passwordLength ?? 0) > 0 && props.showPassword);
|
|
|
+
|
|
|
+// Face tracking data
|
|
|
+const red = reactive({ fx: 0, fy: 0, sk: 0 });
|
|
|
+const dark = reactive({ fx: 0, fy: 0, sk: 0 });
|
|
|
+const terra = reactive({ fx: 0, fy: 0, sk: 0 });
|
|
|
+const gold = reactive({ fx: 0, fy: 0, sk: 0 });
|
|
|
+
|
|
|
+function calcFace(el: HTMLElement | undefined) {
|
|
|
+ if (!el) return { fx: 0, fy: 0, sk: 0 };
|
|
|
+ const r = el.getBoundingClientRect();
|
|
|
+ return {
|
|
|
+ fx: Math.max(-15, Math.min(15, (mx.value - (r.left + r.width / 2)) / 20)),
|
|
|
+ fy: Math.max(-10, Math.min(10, (my.value - (r.top + r.height / 3)) / 30)),
|
|
|
+ sk: Math.max(-6, Math.min(6, -(mx.value - (r.left + r.width / 2)) / 120)),
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+// Vue 3.4+ 推荐使用 getter 函数数组
|
|
|
+watch([() => mx.value, () => my.value], () => {
|
|
|
+ Object.assign(red, calcFace(redRef.value));
|
|
|
+ Object.assign(dark, calcFace(darkRef.value));
|
|
|
+ Object.assign(terra, calcFace(terraRef.value));
|
|
|
+ Object.assign(gold, calcFace(goldRef.value));
|
|
|
+});
|
|
|
+
|
|
|
+// --- Character styles ---
|
|
|
+
|
|
|
+const redStyle = computed(() => ({
|
|
|
+ left: '70px',
|
|
|
+ width: '180px',
|
|
|
+ height: props.isTyping || hidePw.value ? '440px' : '400px',
|
|
|
+ backgroundColor: '#C83A35',
|
|
|
+ borderRadius: '10px 10px 0 0',
|
|
|
+ zIndex: 1,
|
|
|
+ transform: showPw.value
|
|
|
+ ? 'skewX(0deg)'
|
|
|
+ : props.isTyping || hidePw.value
|
|
|
+ ? `skewX(${red.sk - 12}deg) translateX(40px)`
|
|
|
+ : `skewX(${red.sk}deg)`,
|
|
|
+ transformOrigin: 'bottom center',
|
|
|
+}));
|
|
|
+
|
|
|
+const redEyesStyle = computed(() => ({
|
|
|
+ left: `${showPw.value ? 20 : isLook.value ? 55 : 45 + red.fx}px`,
|
|
|
+ top: `${showPw.value ? 35 : isLook.value ? 65 : 40 + red.fy}px`,
|
|
|
+}));
|
|
|
+
|
|
|
+const darkStyle = computed(() => ({
|
|
|
+ left: '240px',
|
|
|
+ width: '120px',
|
|
|
+ height: '310px',
|
|
|
+ backgroundColor: '#2D2D2D',
|
|
|
+ borderRadius: '8px 8px 0 0',
|
|
|
+ zIndex: 2,
|
|
|
+ transform: showPw.value
|
|
|
+ ? 'skewX(0deg)'
|
|
|
+ : isLook.value
|
|
|
+ ? `skewX(${dark.sk * 1.5 + 10}deg) translateX(20px)`
|
|
|
+ : `skewX(${dark.sk}deg)`,
|
|
|
+ transformOrigin: 'bottom center',
|
|
|
+}));
|
|
|
+
|
|
|
+const darkEyesStyle = computed(() => ({
|
|
|
+ left: `${showPw.value ? 10 : isLook.value ? 32 : 26 + dark.fx}px`,
|
|
|
+ top: `${showPw.value ? 28 : isLook.value ? 12 : 32 + dark.fy}px`,
|
|
|
+}));
|
|
|
+
|
|
|
+const terraStyle = computed(() => ({
|
|
|
+ left: '0px',
|
|
|
+ width: '240px',
|
|
|
+ height: '200px',
|
|
|
+ zIndex: 3,
|
|
|
+ backgroundColor: '#c4958a',
|
|
|
+ borderRadius: '120px 120px 0 0',
|
|
|
+ transform: showPw.value ? 'skewX(0deg)' : `skewX(${terra.sk}deg)`,
|
|
|
+ transformOrigin: 'bottom center',
|
|
|
+}));
|
|
|
+
|
|
|
+const terraEyesStyle = computed(() => ({
|
|
|
+ left: `${showPw.value ? 50 : 82 + terra.fx}px`,
|
|
|
+ top: `${showPw.value ? 85 : 90 + terra.fy}px`,
|
|
|
+}));
|
|
|
+
|
|
|
+const goldStyle = computed(() => ({
|
|
|
+ left: '310px',
|
|
|
+ width: '140px',
|
|
|
+ height: '230px',
|
|
|
+ backgroundColor: '#c4a86a',
|
|
|
+ borderRadius: '70px 70px 0 0',
|
|
|
+ zIndex: 4,
|
|
|
+ transform: showPw.value ? 'skewX(0deg)' : `skewX(${gold.sk}deg)`,
|
|
|
+ transformOrigin: 'bottom center',
|
|
|
+}));
|
|
|
+
|
|
|
+const goldEyesStyle = computed(() => ({
|
|
|
+ left: `${showPw.value ? 20 : 52 + gold.fx}px`,
|
|
|
+ top: `${showPw.value ? 35 : 40 + gold.fy}px`,
|
|
|
+}));
|
|
|
+
|
|
|
+const mouthStyle = computed(() => ({
|
|
|
+ left: `${showPw.value ? 10 : 40 + gold.fx}px`,
|
|
|
+ top: `${showPw.value ? 88 : 88 + gold.fy}px`,
|
|
|
+}));
|
|
|
+
|
|
|
+// --- Typing reaction ---
|
|
|
+watch(
|
|
|
+ () => props.isTyping,
|
|
|
+ (v) => {
|
|
|
+ if (v) {
|
|
|
+ isLook.value = true;
|
|
|
+ window.setTimeout(() => { isLook.value = false; }, 800);
|
|
|
+ }
|
|
|
+ }
|
|
|
+);
|
|
|
+
|
|
|
+// --- Password peek reaction ---
|
|
|
+let peekT = 0;
|
|
|
+watch(
|
|
|
+ [() => props.passwordLength, () => props.showPassword],
|
|
|
+ ([len, show]) => {
|
|
|
+ clearTimeout(peekT);
|
|
|
+ if ((len ?? 0) > 0 && show) {
|
|
|
+ const sched = () => {
|
|
|
+ peekT = window.setTimeout(() => {
|
|
|
+ peekT = window.setTimeout(() => { sched(); }, 800);
|
|
|
+ }, Math.random() * 3000 + 2000);
|
|
|
+ };
|
|
|
+ sched();
|
|
|
+ }
|
|
|
+ }
|
|
|
+);
|
|
|
+
|
|
|
+// --- Blink timers ---
|
|
|
+const timers: number[] = [];
|
|
|
+function scheduleBlink(setter: (v: boolean) => void) {
|
|
|
+ const tick = () => {
|
|
|
+ timers.push(window.setTimeout(() => {
|
|
|
+ setter(true);
|
|
|
+ timers.push(window.setTimeout(() => { setter(false); tick(); }, 150));
|
|
|
+ }, Math.random() * 4000 + 3000));
|
|
|
+ };
|
|
|
+ tick();
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ window.addEventListener("mousemove", onMouseMove);
|
|
|
+ scheduleBlink((v) => (isRedBlink.value = v));
|
|
|
+ scheduleBlink((v) => (isDarkBlink.value = v));
|
|
|
+});
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ window.removeEventListener("mousemove", onMouseMove);
|
|
|
+ timers.forEach(clearTimeout);
|
|
|
+ clearTimeout(peekT);
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.characters-stage { position: relative; }
|
|
|
+.ch {
|
|
|
+ position: absolute;
|
|
|
+ bottom: 0;
|
|
|
+ transition: all 0.7s ease-in-out;
|
|
|
+}
|
|
|
+.eyes-wrap {
|
|
|
+ position: absolute;
|
|
|
+ display: flex;
|
|
|
+ transition: all 0.2s ease-out;
|
|
|
+}
|
|
|
+.eye {
|
|
|
+ background: white;
|
|
|
+ border-radius: 50%;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ overflow: hidden;
|
|
|
+ transition: height 0.15s ease;
|
|
|
+}
|
|
|
+.pupil-dot {
|
|
|
+ background: #2D2D2D;
|
|
|
+ border-radius: 50%;
|
|
|
+}
|
|
|
+.pup-solo {
|
|
|
+ background: #2D2D2D;
|
|
|
+ border-radius: 50%;
|
|
|
+}
|
|
|
+.mouth {
|
|
|
+ position: absolute;
|
|
|
+ width: 80px;
|
|
|
+ height: 4px;
|
|
|
+ background: #2D2D2D;
|
|
|
+ border-radius: 999px;
|
|
|
+ transition: all 0.2s ease-out;
|
|
|
+}
|
|
|
+</style>
|