in-game-prompt.tsx 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. "use client";
  2. import type React from "react";
  3. import { useState, useEffect, useRef } from "react";
  4. import { ArrowUp, Loader2, MessageSquare } from "lucide-react";
  5. import { useEditorStore } from "@/lib/store";
  6. import { useIsMobile } from "@/lib/use-mobile";
  7. import type {
  8. ObjectType,
  9. TransformMode,
  10. SceneObject,
  11. Material,
  12. Physics,
  13. Damage,
  14. } from "@/lib/types";
  15. export function InGamePrompt() {
  16. const [isOpen, setIsOpen] = useState(false);
  17. const [prompt, setPrompt] = useState("");
  18. const [isProcessing, setIsProcessing] = useState(false);
  19. const [previousPrompts, setPreviousPrompts] = useState<string[]>([]);
  20. const textareaRef = useRef<HTMLTextAreaElement>(null);
  21. const isMobile = useIsMobile();
  22. const {
  23. addObject,
  24. scenes,
  25. activeSceneId,
  26. updateObjectTransform,
  27. updateObjectMaterial,
  28. updateObjectPhysics,
  29. updateDamage,
  30. selectObject,
  31. setTransformMode,
  32. createCustomObject,
  33. isPlaying,
  34. setIsPromptOpen,
  35. } = useEditorStore();
  36. const activeScene =
  37. scenes.find((scene) => scene.id === activeSceneId) || scenes[0];
  38. const objects = activeScene ? activeScene.objects : [];
  39. const processAction = (action: { function: string; args: unknown[] }) => {
  40. try {
  41. switch (action.function) {
  42. case "addObject":
  43. addObject(action.args[0] as ObjectType);
  44. break;
  45. case "createCustomObject":
  46. createCustomObject(
  47. action.args[0] as ObjectType,
  48. action.args[1] as Partial<SceneObject>,
  49. );
  50. break;
  51. case "updateObjectTransform":
  52. updateObjectTransform(
  53. action.args[0] as string,
  54. action.args[1] as Partial<
  55. Pick<SceneObject, "position" | "rotation" | "scale">
  56. >,
  57. );
  58. break;
  59. case "updateObjectMaterial":
  60. updateObjectMaterial(
  61. action.args[0] as string,
  62. action.args[1] as Partial<Material>,
  63. );
  64. break;
  65. case "updateObjectPhysics":
  66. updateObjectPhysics(
  67. action.args[0] as string,
  68. action.args[1] as Partial<Physics>,
  69. );
  70. break;
  71. case "updateDamage":
  72. updateDamage(
  73. action.args[0] as string,
  74. action.args[1] as Partial<Damage>,
  75. );
  76. break;
  77. case "selectObject":
  78. selectObject(action.args[0] as string);
  79. break;
  80. case "setTransformMode":
  81. setTransformMode(action.args[0] as TransformMode);
  82. break;
  83. }
  84. } catch (error) {
  85. console.error("Error processing action:", error, action);
  86. }
  87. };
  88. const handleSubmit = async (e?: React.FormEvent) => {
  89. if (e) e.preventDefault();
  90. if (!prompt.trim() || isProcessing) return;
  91. setIsProcessing(true);
  92. const currentPrompt = prompt;
  93. setPreviousPrompts((prev) => [...prev.slice(-4), currentPrompt]);
  94. setIsOpen(false);
  95. setIsPromptOpen(false);
  96. try {
  97. const response = await fetch("/api/ai-game", {
  98. method: "POST",
  99. headers: { "Content-Type": "application/json" },
  100. body: JSON.stringify({
  101. prompt: currentPrompt,
  102. objects,
  103. previousPrompts,
  104. }),
  105. });
  106. if (!response.ok || !response.body) {
  107. throw new Error("Failed to get AI response");
  108. }
  109. const reader = response.body.getReader();
  110. const decoder = new TextDecoder();
  111. let buffer = "";
  112. while (true) {
  113. const { done, value } = await reader.read();
  114. if (done) break;
  115. const chunk = decoder.decode(value, { stream: true });
  116. buffer += chunk;
  117. let startIndex = 0;
  118. while (true) {
  119. const openBrace = buffer.indexOf("{", startIndex);
  120. if (openBrace === -1) break;
  121. let depth = 0;
  122. let closeBrace = -1;
  123. for (let i = openBrace; i < buffer.length; i++) {
  124. if (buffer[i] === "{") depth++;
  125. else if (buffer[i] === "}") {
  126. depth--;
  127. if (depth === 0) {
  128. closeBrace = i;
  129. break;
  130. }
  131. }
  132. }
  133. if (closeBrace === -1) {
  134. startIndex = openBrace + 1;
  135. break;
  136. }
  137. const jsonStr = buffer.substring(openBrace, closeBrace + 1);
  138. try {
  139. const jsonObj = JSON.parse(jsonStr);
  140. if (
  141. jsonObj &&
  142. typeof jsonObj.function === "string" &&
  143. Array.isArray(jsonObj.args)
  144. ) {
  145. processAction(jsonObj);
  146. }
  147. buffer =
  148. buffer.substring(0, openBrace) + buffer.substring(closeBrace + 1);
  149. startIndex = 0;
  150. } catch {
  151. startIndex = openBrace + 1;
  152. }
  153. }
  154. }
  155. } catch (error) {
  156. console.error("Error processing AI prompt:", error);
  157. } finally {
  158. setIsProcessing(false);
  159. setPrompt("");
  160. }
  161. };
  162. useEffect(() => {
  163. if (!isPlaying) {
  164. setIsOpen(false);
  165. setIsPromptOpen(false);
  166. return;
  167. }
  168. if (isMobile) return;
  169. const handleKeyDown = (e: KeyboardEvent) => {
  170. if ((e.metaKey || e.ctrlKey) && e.key === "/") {
  171. e.preventDefault();
  172. const newIsOpen = !isOpen;
  173. setIsOpen(newIsOpen);
  174. setIsPromptOpen(newIsOpen);
  175. }
  176. };
  177. window.addEventListener("keydown", handleKeyDown);
  178. return () => window.removeEventListener("keydown", handleKeyDown);
  179. }, [isPlaying, isOpen, setIsPromptOpen, isMobile]);
  180. useEffect(() => {
  181. if (isOpen && textareaRef.current) {
  182. setTimeout(() => textareaRef.current?.focus(), 100);
  183. }
  184. }, [isOpen]);
  185. const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
  186. if (e.key === "Enter" && !e.shiftKey) {
  187. e.preventDefault();
  188. handleSubmit();
  189. }
  190. };
  191. const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
  192. setPrompt(e.target.value);
  193. e.target.style.height = "auto";
  194. e.target.style.height = `${e.target.scrollHeight}px`;
  195. };
  196. if (!isPlaying) return null;
  197. if (!isOpen) {
  198. if (isMobile) {
  199. return (
  200. <button
  201. onClick={() => {
  202. setIsOpen(true);
  203. setIsPromptOpen(true);
  204. }}
  205. className="absolute top-14 right-3 z-30 w-11 h-11 flex items-center justify-center rounded-full bg-black/40 backdrop-blur-sm text-white/70 active:bg-white/20 border border-white/10"
  206. >
  207. <MessageSquare size={18} />
  208. </button>
  209. );
  210. }
  211. return null;
  212. }
  213. return (
  214. <div
  215. className={`absolute ${isMobile ? "top-14 left-2 right-2 bottom-auto" : "top-[50px] left-0 right-0 bottom-0 flex items-center justify-center"} z-40 animate-fade-in`}
  216. >
  217. <div className="flex items-center justify-center">
  218. <div
  219. className={`flex items-center gap-2 bg-black/70 backdrop-blur-md border border-white/20 ${isMobile ? "rounded-xl w-full" : "rounded-full"} shadow-lg overflow-hidden ${isMobile ? "" : "max-w-[600px]"}`}
  220. >
  221. <div className={isMobile ? "pl-3" : "pl-5"}>
  222. <MessageSquare className="h-5 w-5 text-white/70" />
  223. </div>
  224. <form
  225. onSubmit={handleSubmit}
  226. className="flex items-center flex-1 min-w-0"
  227. >
  228. <textarea
  229. ref={textareaRef}
  230. value={prompt}
  231. onChange={handleTextareaChange}
  232. onKeyDown={handleKeyDown}
  233. placeholder="Describe what to add or change..."
  234. className={`bg-transparent border-none px-3 py-4 ${isMobile ? "flex-1 min-w-0 text-base" : "w-[400px] text-sm"} text-white placeholder-gray-400 focus:outline-none resize-none overflow-hidden min-h-[44px] max-h-[200px]`}
  235. rows={1}
  236. style={{ height: "auto" }}
  237. />
  238. <div className="flex items-center gap-1 mr-3">
  239. <button
  240. type="submit"
  241. className="bg-white/15 text-white p-2 rounded-full hover:bg-white/25 active:bg-white/35 transition-colors disabled:opacity-30"
  242. disabled={!prompt.trim() || isProcessing}
  243. >
  244. {isProcessing ? (
  245. <Loader2 className="h-5 w-5 animate-spin" />
  246. ) : (
  247. <ArrowUp className="h-5 w-5" />
  248. )}
  249. </button>
  250. </div>
  251. </form>
  252. </div>
  253. </div>
  254. </div>
  255. );
  256. }