LoginCharacters.vue 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. <template>
  2. <div class="characters-stage" style="width:550px;height:400px">
  3. <!-- Red tall character - back layer -->
  4. <div ref="redRef" class="ch" :style="redStyle">
  5. <div class="eyes-wrap" :style="redEyesStyle">
  6. <div class="eye" :style="{ width: '18px', height: isRedBlink ? '2px' : '18px' }">
  7. <div class="pupil-dot" style="width:7px;height:7px" />
  8. </div>
  9. <div class="eye" :style="{ width: '18px', height: isRedBlink ? '2px' : '18px' }">
  10. <div class="pupil-dot" style="width:7px;height:7px" />
  11. </div>
  12. </div>
  13. </div>
  14. <!-- Dark character - middle layer -->
  15. <div ref="darkRef" class="ch" :style="darkStyle">
  16. <div class="eyes-wrap" :style="darkEyesStyle">
  17. <div class="eye" :style="{ width: '16px', height: isDarkBlink ? '2px' : '16px' }">
  18. <div class="pupil-dot" style="width:6px;height:6px" />
  19. </div>
  20. <div class="eye" :style="{ width: '16px', height: isDarkBlink ? '2px' : '16px' }">
  21. <div class="pupil-dot" style="width:6px;height:6px" />
  22. </div>
  23. </div>
  24. </div>
  25. <!-- Terracotta semi-circle - front left -->
  26. <div ref="terraRef" class="ch" :style="terraStyle">
  27. <div class="eyes-wrap" style="gap:32px" :style="terraEyesStyle">
  28. <div class="pup-solo" style="width:12px;height:12px" />
  29. <div class="pup-solo" style="width:12px;height:12px" />
  30. </div>
  31. </div>
  32. <!-- Gold semi-circle - front right -->
  33. <div ref="goldRef" class="ch" :style="goldStyle">
  34. <div class="eyes-wrap" style="gap:32px" :style="goldEyesStyle">
  35. <div class="pup-solo" style="width:12px;height:12px" />
  36. <div class="pup-solo" style="width:12px;height:12px" />
  37. </div>
  38. <div class="mouth" :style="mouthStyle" />
  39. </div>
  40. </div>
  41. </template>
  42. <script setup lang="ts">
  43. import { ref, reactive, computed, onMounted, onUnmounted, watch } from "vue";
  44. const props = defineProps<{
  45. isTyping?: boolean;
  46. showPassword?: boolean;
  47. passwordLength?: number;
  48. }>();
  49. // Mouse position
  50. const mx = ref(0);
  51. const my = ref(0);
  52. function onMouseMove(e: MouseEvent) { mx.value = e.clientX; my.value = e.clientY; }
  53. // Blink state
  54. const isRedBlink = ref(false);
  55. const isDarkBlink = ref(false);
  56. // Behavior state
  57. const isLook = ref(false);
  58. // Refs
  59. const redRef = ref<HTMLElement>();
  60. const darkRef = ref<HTMLElement>();
  61. const terraRef = ref<HTMLElement>();
  62. const goldRef = ref<HTMLElement>();
  63. // Computed flags
  64. const hidePw = computed(() => (props.passwordLength ?? 0) > 0 && !props.showPassword);
  65. const showPw = computed(() => (props.passwordLength ?? 0) > 0 && props.showPassword);
  66. // Face tracking data
  67. const red = reactive({ fx: 0, fy: 0, sk: 0 });
  68. const dark = reactive({ fx: 0, fy: 0, sk: 0 });
  69. const terra = reactive({ fx: 0, fy: 0, sk: 0 });
  70. const gold = reactive({ fx: 0, fy: 0, sk: 0 });
  71. function calcFace(el: HTMLElement | undefined) {
  72. if (!el) return { fx: 0, fy: 0, sk: 0 };
  73. const r = el.getBoundingClientRect();
  74. return {
  75. fx: Math.max(-15, Math.min(15, (mx.value - (r.left + r.width / 2)) / 20)),
  76. fy: Math.max(-10, Math.min(10, (my.value - (r.top + r.height / 3)) / 30)),
  77. sk: Math.max(-6, Math.min(6, -(mx.value - (r.left + r.width / 2)) / 120)),
  78. };
  79. }
  80. // Vue 3.4+ 推荐使用 getter 函数数组
  81. watch([() => mx.value, () => my.value], () => {
  82. Object.assign(red, calcFace(redRef.value));
  83. Object.assign(dark, calcFace(darkRef.value));
  84. Object.assign(terra, calcFace(terraRef.value));
  85. Object.assign(gold, calcFace(goldRef.value));
  86. });
  87. // --- Character styles ---
  88. const redStyle = computed(() => ({
  89. left: '70px',
  90. width: '180px',
  91. height: props.isTyping || hidePw.value ? '440px' : '400px',
  92. backgroundColor: '#C83A35',
  93. borderRadius: '10px 10px 0 0',
  94. zIndex: 1,
  95. transform: showPw.value
  96. ? 'skewX(0deg)'
  97. : props.isTyping || hidePw.value
  98. ? `skewX(${red.sk - 12}deg) translateX(40px)`
  99. : `skewX(${red.sk}deg)`,
  100. transformOrigin: 'bottom center',
  101. }));
  102. const redEyesStyle = computed(() => ({
  103. left: `${showPw.value ? 20 : isLook.value ? 55 : 45 + red.fx}px`,
  104. top: `${showPw.value ? 35 : isLook.value ? 65 : 40 + red.fy}px`,
  105. }));
  106. const darkStyle = computed(() => ({
  107. left: '240px',
  108. width: '120px',
  109. height: '310px',
  110. backgroundColor: '#2D2D2D',
  111. borderRadius: '8px 8px 0 0',
  112. zIndex: 2,
  113. transform: showPw.value
  114. ? 'skewX(0deg)'
  115. : isLook.value
  116. ? `skewX(${dark.sk * 1.5 + 10}deg) translateX(20px)`
  117. : `skewX(${dark.sk}deg)`,
  118. transformOrigin: 'bottom center',
  119. }));
  120. const darkEyesStyle = computed(() => ({
  121. left: `${showPw.value ? 10 : isLook.value ? 32 : 26 + dark.fx}px`,
  122. top: `${showPw.value ? 28 : isLook.value ? 12 : 32 + dark.fy}px`,
  123. }));
  124. const terraStyle = computed(() => ({
  125. left: '0px',
  126. width: '240px',
  127. height: '200px',
  128. zIndex: 3,
  129. backgroundColor: '#c4958a',
  130. borderRadius: '120px 120px 0 0',
  131. transform: showPw.value ? 'skewX(0deg)' : `skewX(${terra.sk}deg)`,
  132. transformOrigin: 'bottom center',
  133. }));
  134. const terraEyesStyle = computed(() => ({
  135. left: `${showPw.value ? 50 : 82 + terra.fx}px`,
  136. top: `${showPw.value ? 85 : 90 + terra.fy}px`,
  137. }));
  138. const goldStyle = computed(() => ({
  139. left: '310px',
  140. width: '140px',
  141. height: '230px',
  142. backgroundColor: '#c4a86a',
  143. borderRadius: '70px 70px 0 0',
  144. zIndex: 4,
  145. transform: showPw.value ? 'skewX(0deg)' : `skewX(${gold.sk}deg)`,
  146. transformOrigin: 'bottom center',
  147. }));
  148. const goldEyesStyle = computed(() => ({
  149. left: `${showPw.value ? 20 : 52 + gold.fx}px`,
  150. top: `${showPw.value ? 35 : 40 + gold.fy}px`,
  151. }));
  152. const mouthStyle = computed(() => ({
  153. left: `${showPw.value ? 10 : 40 + gold.fx}px`,
  154. top: `${showPw.value ? 88 : 88 + gold.fy}px`,
  155. }));
  156. // --- Typing reaction ---
  157. watch(
  158. () => props.isTyping,
  159. (v) => {
  160. if (v) {
  161. isLook.value = true;
  162. window.setTimeout(() => { isLook.value = false; }, 800);
  163. }
  164. }
  165. );
  166. // --- Password peek reaction ---
  167. let peekT = 0;
  168. watch(
  169. [() => props.passwordLength, () => props.showPassword],
  170. ([len, show]) => {
  171. clearTimeout(peekT);
  172. if ((len ?? 0) > 0 && show) {
  173. const sched = () => {
  174. peekT = window.setTimeout(() => {
  175. peekT = window.setTimeout(() => { sched(); }, 800);
  176. }, Math.random() * 3000 + 2000);
  177. };
  178. sched();
  179. }
  180. }
  181. );
  182. // --- Blink timers ---
  183. const timers: number[] = [];
  184. function scheduleBlink(setter: (v: boolean) => void) {
  185. const tick = () => {
  186. timers.push(window.setTimeout(() => {
  187. setter(true);
  188. timers.push(window.setTimeout(() => { setter(false); tick(); }, 150));
  189. }, Math.random() * 4000 + 3000));
  190. };
  191. tick();
  192. }
  193. onMounted(() => {
  194. window.addEventListener("mousemove", onMouseMove);
  195. scheduleBlink((v) => (isRedBlink.value = v));
  196. scheduleBlink((v) => (isDarkBlink.value = v));
  197. });
  198. onUnmounted(() => {
  199. window.removeEventListener("mousemove", onMouseMove);
  200. timers.forEach(clearTimeout);
  201. clearTimeout(peekT);
  202. });
  203. </script>
  204. <style scoped>
  205. .characters-stage { position: relative; }
  206. .ch {
  207. position: absolute;
  208. bottom: 0;
  209. transition: all 0.7s ease-in-out;
  210. }
  211. .eyes-wrap {
  212. position: absolute;
  213. display: flex;
  214. transition: all 0.2s ease-out;
  215. }
  216. .eye {
  217. background: white;
  218. border-radius: 50%;
  219. display: flex;
  220. align-items: center;
  221. justify-content: center;
  222. overflow: hidden;
  223. transition: height 0.15s ease;
  224. }
  225. .pupil-dot {
  226. background: #2D2D2D;
  227. border-radius: 50%;
  228. }
  229. .pup-solo {
  230. background: #2D2D2D;
  231. border-radius: 50%;
  232. }
  233. .mouth {
  234. position: absolute;
  235. width: 80px;
  236. height: 4px;
  237. background: #2D2D2D;
  238. border-radius: 999px;
  239. transition: all 0.2s ease-out;
  240. }
  241. </style>