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