Skip to content

Commit ac3a9ad

Browse files
authored
refactor: Implement custom keyword AI generation to server and client (#37)
* refactor: Use Open AI in fetchWords and apply new prompt message * refactor: Migrate DrawingWord from Clova to OpenAI API - Add OpenAI service with API integration - Implement word generation using OpenAI - Add drawing validation using OpenAI vision model - Add random category combination feature - Update game service to use new OpenAI implementation * Revise the prompt so that inappropriate words trigger a response of `부적절` * feat: Add more Korean internet slang words and memes to nickname generator - Add modern Korean internet slang terms and memes to adjectives - Add new nouns reflecting internet culture * refactor: Optimize word fetching logic for immediate return on inappropriate content - Add immediate return condition when first word is marked as '부적절' - Before: Made 5 attempts to fetch words before falling back - After: Returns default words immediately if inappropriate content detected * fix: Remove inappropriate filter from Drawing Word generation prompt - Remove explicit inappropriate return condition to allow more flexible word selection * refactor: Change `/component/settings` to `/component/room-settings` - Making the component's purpose more explicit as room-specific settings * fix: Add special characters filter from Drawing Word generation prompt - Add settings because special characters appeared * feat: Add `category` type in game.types.ts * feat: Add category settings with modal UI - Add category display in Settings header - Create CategorySettingsModalContent for category input - Enable host to update category through modal - Update both server and local state on category change * refactor: Change `category` to `wordsTheme` * feat: Add a description span for drawing word theme modal * Fix: Lower background music and sound effects volume
1 parent 691c05e commit ac3a9ad

File tree

12 files changed

+187
-31
lines changed

12 files changed

+187
-31
lines changed

client/src/components/setting/Setting.tsx renamed to client/src/components/room-setting/Setting.tsx

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import { HTMLAttributes, memo, useCallback, useEffect, useState } from 'react';
22
import { RoomSettings } from '@troublepainter/core';
3-
import { SettingContent } from '@/components/setting/SettingContent';
3+
import { SettingContent } from '@/components/room-setting/SettingContent';
4+
import { WordsThemeModalContentContent } from '@/components/room-setting/WordsThemeModalContent';
5+
import { Button } from '@/components/ui/Button';
6+
import { Modal } from '@/components/ui/Modal';
47
import { SHORTCUT_KEYS } from '@/constants/shortcutKeys';
58
import { gameSocketHandlers } from '@/handlers/socket/gameSocket.handler';
9+
import { useModal } from '@/hooks/useModal';
610
import { useGameSocketStore } from '@/stores/socket/gameSocket.store';
711
import { cn } from '@/utils/cn';
812

@@ -27,6 +31,8 @@ const Setting = memo(({ className, ...props }: HTMLAttributes<HTMLDivElement>) =
2731
const roomSettings = useGameSocketStore((state) => state.roomSettings);
2832
const isHost = useGameSocketStore((state) => state.isHost);
2933
const actions = useGameSocketStore((state) => state.actions);
34+
// 모달
35+
const { isModalOpened, openModal, closeModal, handleKeyDown } = useModal();
3036

3137
const [selectedValues, setSelectedValues] = useState<RoomSettings>(
3238
roomSettings ?? {
@@ -56,17 +62,39 @@ const Setting = memo(({ className, ...props }: HTMLAttributes<HTMLDivElement>) =
5662
[selectedValues, actions],
5763
);
5864

65+
// 제시어 테마
66+
const headerText = roomSettings?.wordsTheme ? roomSettings.wordsTheme : 'Setting';
67+
5968
return (
6069
<section
6170
className={cn('flex w-full flex-col border-0 border-violet-950 sm:rounded-xl sm:border-2', className)}
6271
{...props}
6372
>
6473
{/* Setting title */}
65-
<div className="flex h-14 w-full items-center justify-center border-0 border-violet-950 bg-violet-500 sm:h-16 sm:rounded-t-xl sm:border-b-2">
66-
<h2 className="text-2xl text-white text-stroke-md sm:translate-y-1 lg:text-3xl">Setting</h2>
74+
<div className="flex h-14 w-full items-center justify-between border-0 border-violet-950 bg-violet-500 px-4 sm:h-16 sm:rounded-t-[0.625rem] sm:border-b-2">
75+
<h2 className="text-2xl text-white text-stroke-md sm:translate-y-1 lg:text-3xl">{headerText}</h2>
76+
{isHost && (
77+
<Button
78+
variant="secondary"
79+
size={'text'}
80+
onClick={openModal}
81+
className="h-10 w-28 text-lg lg:w-32 lg:text-xl"
82+
>
83+
제시어 테마
84+
</Button>
85+
)}
6786
</div>
6887

6988
{/* Setting content */}
89+
<Modal
90+
title="제시어 테마 설정"
91+
isModalOpened={isModalOpened}
92+
closeModal={closeModal}
93+
handleKeyDown={handleKeyDown} // handleKeyDown 추가
94+
className="min-w-72 max-w-lg"
95+
>
96+
<WordsThemeModalContentContent isModalOpened={isModalOpened} closeModal={closeModal} />
97+
</Modal>
7098
<SettingContent
7199
settings={ROOM_SETTINGS}
72100
values={selectedValues}

client/src/components/setting/SettingContent.tsx renamed to client/src/components/room-setting/SettingContent.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { memo } from 'react';
22
import { RoomSettings } from '@troublepainter/core';
3-
import { RoomSettingItem } from '@/components/setting/Setting';
4-
import { SettingItem } from '@/components/setting/SettingItem';
3+
import { RoomSettingItem } from '@/components/room-setting/Setting';
4+
import { SettingItem } from '@/components/room-setting/SettingItem';
55

66
interface SettingContentProps {
77
settings: RoomSettingItem[];
@@ -11,14 +11,14 @@ interface SettingContentProps {
1111
}
1212

1313
export const SettingContent = memo(({ settings, values, isHost, onSettingChange }: SettingContentProps) => (
14-
<div className="flex min-h-[16.125rem] items-center justify-center bg-violet-200 sm:min-h-[18.56rem] sm:rounded-b-xl sm:px-6">
14+
<div className="flex min-h-[16.125rem] items-center justify-center bg-violet-200 sm:min-h-[18.56rem] sm:rounded-b-[0.625rem] sm:px-6">
1515
<div className="flex min-h-[13.8rem] w-full flex-col items-center justify-center gap-4 border-0 border-violet-950 bg-violet-50 p-4 text-xl sm:h-auto sm:rounded-[0.625rem] sm:border-2 lg:gap-6 lg:text-2xl">
1616
{settings.map(({ label, key, options, shortcutKey }) => (
1717
<SettingItem
1818
key={key}
1919
label={label}
2020
settingKey={key}
21-
value={values[key]}
21+
value={values[key] as number}
2222
options={options}
2323
onSettingChange={onSettingChange}
2424
isHost={isHost}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { useEffect, useState } from 'react';
2+
import { Button } from '@/components/ui/Button';
3+
import { Input } from '@/components/ui/Input';
4+
import { gameSocketHandlers } from '@/handlers/socket/gameSocket.handler';
5+
import { useGameSocketStore } from '@/stores/socket/gameSocket.store';
6+
7+
interface WordsThemeModalContentContentProps {
8+
isModalOpened: boolean;
9+
closeModal: () => void;
10+
}
11+
12+
const WordsThemeModalContentContent = ({ isModalOpened, closeModal }: WordsThemeModalContentContentProps) => {
13+
const roomSettings = useGameSocketStore((state) => state.roomSettings);
14+
const actions = useGameSocketStore((state) => state.actions);
15+
const [wordsTheme, setWordsTheme] = useState(roomSettings?.wordsTheme || '');
16+
17+
useEffect(() => {
18+
// 모달이 열릴 때마다 현재 제시어 테마로 초기화
19+
if (isModalOpened) {
20+
setWordsTheme(roomSettings?.wordsTheme || '');
21+
}
22+
}, [isModalOpened, roomSettings?.wordsTheme]);
23+
24+
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
25+
e.preventDefault();
26+
if (!wordsTheme.trim()) return;
27+
28+
const trimmedWordsTheme = wordsTheme.trim();
29+
30+
// 서버에 업데이트 요청
31+
await gameSocketHandlers.updateSettings({
32+
settings: { wordsTheme: trimmedWordsTheme },
33+
});
34+
35+
// 로컬 상태 업데이트
36+
if (roomSettings) {
37+
actions.updateRoomSettings({
38+
...roomSettings,
39+
wordsTheme: trimmedWordsTheme,
40+
});
41+
}
42+
43+
closeModal();
44+
};
45+
46+
return (
47+
<form onSubmit={(e: React.FormEvent<HTMLFormElement>) => void handleSubmit(e)} className="flex flex-col gap-3">
48+
<span className="text-center text-lg text-eastbay-800">
49+
게임에서 사용될 제시어의 테마를 설정해보세요!
50+
<br />
51+
<span className="text-base text-eastbay-600">예시) 동물, 음식, 직업, 캐릭터, 스포츠 등 1가지 테마 입력</span>
52+
</span>
53+
54+
<Input
55+
placeholder="동물, 음식, 직업, 캐릭터, 스포츠 등"
56+
value={wordsTheme}
57+
onChange={(e) => setWordsTheme(e.target.value)}
58+
/>
59+
60+
{/* 입력 가이드 메시지 추가 */}
61+
<span className="text-center text-base text-eastbay-500">입력한 테마를 바탕으로 AI가 제시어를 생성합니다.</span>
62+
63+
<div className="flex gap-2">
64+
<Button type="button" onClick={closeModal} variant="secondary" className="flex-1">
65+
취소
66+
</Button>
67+
<Button type="submit" disabled={!wordsTheme.trim()} className="flex-1">
68+
확인
69+
</Button>
70+
</div>
71+
</form>
72+
);
73+
};
74+
75+
export { WordsThemeModalContentContent };

client/src/hooks/useBackgroundMusic.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import { CDN } from '@/constants/cdn';
4141
export const useBackgroundMusic = () => {
4242
const audioRef = useRef<HTMLAudioElement | null>(null);
4343
const [volume, setVolume] = useState(0); // 초기 볼륨 0으로 시작
44-
const previousVolume = useRef(0.5); // 이전 볼륨값 저장용 (기본값 0.5)
44+
const previousVolume = useRef(0.1); // 이전 볼륨값 저장용, 기본값 0.1 (기존 0.5에서 변경)
4545

4646
useEffect(() => {
4747
audioRef.current = new Audio(CDN.BACKGROUND_MUSIC);
@@ -54,8 +54,8 @@ export const useBackgroundMusic = () => {
5454
try {
5555
// 자동 재생 시도
5656
await audioRef.current.play();
57-
// 자동 재생 성공하면 기본 볼륨(0.5)으로 설정
58-
setVolume(0.5);
57+
// 자동 재생 성공하면 기본 볼륨(0.1)으로 설정
58+
setVolume(0.1);
5959
} catch (err) {
6060
// 자동 재생이 차단된 경우
6161
console.error('Auto-play prevented:', err);

client/src/pages/LobbyPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { InviteButton } from '@/components/lobby/InviteButton';
22
import { StartButton } from '@/components/lobby/StartButton';
3-
import { Setting } from '@/components/setting/Setting';
3+
import { Setting } from '@/components/room-setting/Setting';
44

55
const LobbyPage = () => {
66
return (

client/src/utils/soundManager.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,14 @@ export class SoundManager {
4949
* 미리 로드된 사운드를 재생합니다.
5050
*
5151
* @param id - 재생할 사운드의 식별자
52-
* @param volume - 볼륨 레벨 (0.0에서 1.0), 기본값은 1입니다.
52+
* @param volume - 볼륨 레벨 (0.0에서 1.0), 기본값은 0.5입니다.
5353
* @returns 사운드가 재생되기 시작할 때까지 해결되는 Promise
5454
*
5555
* @remarks
5656
* - 재생이 끝나면 오디오를 처음으로 되감습니다.
5757
* - 자동 재생 제한을 처리하고 적절한 메시지를 로그로 남깁니다.
5858
*/
59-
async playSound(id: string, volume = 1): Promise<void> {
59+
async playSound(id: string, volume = 0.5): Promise<void> {
6060
const audio = this.audioMap.get(id);
6161
if (!audio) return;
6262

core/types/game.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface RoomSettings {
1919
maxPlayers: number; // 최대 플레이어 수
2020
drawTime: number; // 그리기 제한시간
2121
totalRounds: number; // 총 라운드 수
22+
wordsTheme?: string; // 퀴즈 제시어 테마 입력
2223
}
2324

2425
export enum PlayerStatus {

server/src/common/services/openai/openai.service.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,38 @@ export class OpenAIService {
121121
console.error('OpenAI API Error:', error);
122122
}
123123
}
124+
125+
// 제시어 생성
126+
async getDrawingWords(difficulty: string, count: number, wordsTheme?: string): Promise<string[]> {
127+
const categories = ['영화', '음식', '일상용품', '스포츠', '동물', '교통수단', '캐릭터', '악기', '직업', 'IT'];
128+
const basicCategories = categories.sort(() => Math.random() - 0.5).slice(0, Math.floor(Math.random() * 2) + 2);
129+
130+
try {
131+
const response = await this.openai.chat.completions.create({
132+
model: 'gpt-4o-mini',
133+
messages: [
134+
{
135+
role: 'system',
136+
content:
137+
'당신은 창의적인 드로잉 게임의 출제자, 재밌고 다양한 단어들을 아래처럼 생각해 출제.\n1. 추상적X, 30초 내 그리기 가능성 생각\n2. 방해 요소 존재 게임성 생각\n3. 초등학생 수준 난이도인지 생각\n4. 해당하는 단어 선택\n\n<Input>\n{난이도}, {개수}, {제시어테마들}\n<Output>\n"특수문자나 부연설명없이 단어만", 단어(Meme)를 쉼표로 구분 및 나열\nex) `사과, 컵, 우산, 모자, 엄마`',
138+
},
139+
{
140+
role: 'user',
141+
content: `난이도=${difficulty},개수=${count},제시어테마=${wordsTheme ?? basicCategories.join(',')}`,
142+
},
143+
],
144+
response_format: { type: 'text' },
145+
temperature: 0,
146+
max_tokens: 128,
147+
top_p: 1,
148+
frequency_penalty: 2,
149+
presence_penalty: 2,
150+
});
151+
152+
return response.choices[0].message.content.split(',').map((word) => word.trim());
153+
} catch (error) {
154+
console.error('OpenAI API Error:', error);
155+
return []; // 에러 시 빈 배열 반환
156+
}
157+
}
124158
}

server/src/common/types/game.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,5 @@ export interface RoomSettings {
2121
maxPlayers: number;
2222
totalRounds: number;
2323
drawTime: number;
24+
wordsTheme?: string;
2425
}

0 commit comments

Comments
 (0)