character-dialog.tsx 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. "use client";
  2. import { useState, useEffect, useCallback, useRef } from "react";
  3. import { X, Volume2, VolumeX } from "lucide-react";
  4. import { useIsMobile } from "@/lib/use-mobile";
  5. interface DialogMessage {
  6. text: string;
  7. audioUrl?: string;
  8. }
  9. export function CharacterDialog() {
  10. const [isOpen, setIsOpen] = useState(false);
  11. const [messages, setMessages] = useState<DialogMessage[]>([]);
  12. const [currentIndex, setCurrentIndex] = useState(0);
  13. const [characterRole, setCharacterRole] = useState("");
  14. const [isLoading, setIsLoading] = useState(false);
  15. const [isMuted, setIsMuted] = useState(false);
  16. const audioRef = useRef<HTMLAudioElement | null>(null);
  17. const isMobile = useIsMobile();
  18. useEffect(() => {
  19. const audio = new Audio();
  20. audioRef.current = audio;
  21. return () => {
  22. audio.pause();
  23. audio.src = "";
  24. };
  25. }, []);
  26. useEffect(() => {
  27. const handleInteract = (e: Event) => {
  28. const detail = (e as CustomEvent).detail as {
  29. id?: string;
  30. role?: string;
  31. messages?: DialogMessage[];
  32. };
  33. setCharacterRole(detail?.role || "NPC");
  34. setCurrentIndex(0);
  35. setIsOpen(true);
  36. window.dispatchEvent(new CustomEvent("dialog-open"));
  37. if (detail?.messages && detail.messages.length > 0) {
  38. setMessages(detail.messages);
  39. setIsLoading(false);
  40. } else {
  41. setIsLoading(true);
  42. fetchCharacterResponses(detail?.role || "villager").then(
  43. (responses) => {
  44. setMessages(responses);
  45. setIsLoading(false);
  46. },
  47. );
  48. }
  49. };
  50. window.addEventListener("character-interact", handleInteract);
  51. return () =>
  52. window.removeEventListener("character-interact", handleInteract);
  53. }, []);
  54. useEffect(() => {
  55. if (!isLoading && messages.length > 0 && !isMuted && audioRef.current) {
  56. const current = messages[currentIndex];
  57. if (current?.audioUrl) {
  58. audioRef.current.src = current.audioUrl;
  59. audioRef.current.volume = 1.0;
  60. audioRef.current.play().catch(() => {});
  61. }
  62. }
  63. }, [currentIndex, messages, isLoading, isMuted]);
  64. const handleClose = useCallback(() => {
  65. setIsOpen(false);
  66. setMessages([]);
  67. setCurrentIndex(0);
  68. if (audioRef.current) audioRef.current.pause();
  69. window.dispatchEvent(new CustomEvent("dialog-close"));
  70. }, []);
  71. const handleNext = useCallback(() => {
  72. if (audioRef.current) audioRef.current.pause();
  73. if (currentIndex < messages.length - 1) {
  74. setCurrentIndex((i) => i + 1);
  75. } else {
  76. handleClose();
  77. }
  78. }, [currentIndex, messages.length, handleClose]);
  79. useEffect(() => {
  80. if (!isOpen) return;
  81. const handleKey = (e: KeyboardEvent) => {
  82. if (e.key === "Enter") {
  83. handleNext();
  84. } else if (e.key === "Escape") {
  85. handleClose();
  86. }
  87. };
  88. window.addEventListener("keydown", handleKey);
  89. return () => window.removeEventListener("keydown", handleKey);
  90. }, [isOpen, handleNext, handleClose]);
  91. const toggleMute = () => {
  92. setIsMuted(!isMuted);
  93. if (audioRef.current) {
  94. if (!isMuted) {
  95. audioRef.current.pause();
  96. } else if (messages[currentIndex]?.audioUrl) {
  97. audioRef.current.src = messages[currentIndex].audioUrl!;
  98. audioRef.current.volume = 1.0;
  99. audioRef.current.play().catch(() => {});
  100. }
  101. }
  102. };
  103. if (!isOpen) return null;
  104. if (isLoading) {
  105. return (
  106. <div className="absolute bottom-20 left-1/2 -translate-x-1/2 z-20 w-[480px] max-w-[90vw]">
  107. <div className="bg-[#111]/95 border border-[#333] rounded-lg p-4 backdrop-blur-sm">
  108. <div className="flex items-start justify-between mb-2">
  109. <span className="text-[10px] text-[#666] uppercase tracking-wider">
  110. {characterRole}
  111. </span>
  112. <button
  113. onClick={handleClose}
  114. className="p-1.5 text-[#666] hover:text-white active:text-white"
  115. >
  116. <X size={14} />
  117. </button>
  118. </div>
  119. <div className="animate-pulse h-4 bg-[#222] rounded w-3/4 mb-2" />
  120. <div className="animate-pulse h-4 bg-[#222] rounded w-1/2" />
  121. <p className="text-[9px] text-[#555] mt-3">Loading...</p>
  122. </div>
  123. </div>
  124. );
  125. }
  126. if (messages.length === 0) return null;
  127. const current = messages[currentIndex];
  128. const isLast = currentIndex >= messages.length - 1;
  129. return (
  130. <div className="absolute bottom-20 left-1/2 -translate-x-1/2 z-20 w-[480px] max-w-[90vw]">
  131. <div className="bg-[#111]/95 border border-[#333] rounded-lg p-4 backdrop-blur-sm">
  132. <div className="flex items-start justify-between mb-2">
  133. <span className="text-[10px] text-[#666] uppercase tracking-wider">
  134. {characterRole || "NPC"}
  135. </span>
  136. <div className="flex items-center gap-2">
  137. <button
  138. onClick={toggleMute}
  139. className="p-1.5 text-[#666] hover:text-white active:text-white"
  140. title={isMuted ? "Unmute" : "Mute"}
  141. >
  142. {isMuted ? <VolumeX size={14} /> : <Volume2 size={14} />}
  143. </button>
  144. <button
  145. onClick={handleClose}
  146. className="p-1.5 text-[#666] hover:text-white active:text-white"
  147. >
  148. <X size={14} />
  149. </button>
  150. </div>
  151. </div>
  152. <p className="text-sm text-[#ddd] leading-relaxed">{current?.text}</p>
  153. <div className="mt-3 flex items-center justify-between">
  154. <span className="text-[9px] text-[#555]">
  155. {currentIndex + 1} / {messages.length}
  156. </span>
  157. {isMobile ? (
  158. <div className="flex items-center gap-2">
  159. <button
  160. onClick={handleClose}
  161. className="px-3 py-1.5 text-xs text-[#888] bg-white/5 rounded active:bg-white/15"
  162. >
  163. Close
  164. </button>
  165. {!isLast && (
  166. <button
  167. onClick={handleNext}
  168. className="px-3 py-1.5 text-xs text-white bg-white/10 rounded active:bg-white/25"
  169. >
  170. Next
  171. </button>
  172. )}
  173. </div>
  174. ) : (
  175. <span className="text-[9px] text-[#555]">
  176. {isLast
  177. ? "Press Enter or Esc to close"
  178. : "Press Enter to continue"}
  179. </span>
  180. )}
  181. </div>
  182. </div>
  183. </div>
  184. );
  185. }
  186. async function fetchCharacterResponses(
  187. role: string,
  188. ): Promise<{ text: string; audioUrl?: string }[]> {
  189. try {
  190. const res = await fetch("/api/character-responses", {
  191. method: "POST",
  192. headers: { "Content-Type": "application/json" },
  193. body: JSON.stringify({ role }),
  194. });
  195. if (!res.ok) throw new Error("Failed to fetch responses");
  196. const data = await res.json();
  197. return (
  198. data.messages || [
  199. { text: "Hello there! How can I help you?" },
  200. { text: "It's a beautiful day, isn't it?" },
  201. ]
  202. );
  203. } catch {
  204. return [
  205. { text: "Hello there! How can I help you?" },
  206. { text: "It's a beautiful day, isn't it?" },
  207. ];
  208. }
  209. }