diff --git "a/app.py" "b/app.py" new file mode 100644--- /dev/null +++ "b/app.py" @@ -0,0 +1,5434 @@ +import streamlit as st +import random +import time +from PIL import Image +import numpy as np +import io +import base64 +import threading +import google.generativeai as genai +import re +from datetime import datetime + +# 페이지 설정 +st.set_page_config( + page_title="TRPG 주사위 기반 스토리텔링", + layout="wide", + initial_sidebar_state="expanded" +) + +# 커스텀 CSS +st.markdown(""" + +""", unsafe_allow_html=True) + +# 테마별 이미지 생성 함수 +def create_theme_image(theme): + """테마별 이미지/박스 생성""" + if theme == "fantasy": + color = "#4b5d78" + text = "판타지" + elif theme == "sci-fi": + color = "#3a7b9c" + text = "SF" + else: # dystopia + color = "#8b4045" + text = "디스토피아" + + # HTML��� 색상 박스 표시 + return f""" +
+ {text} +
+ """ + +# 인벤토리 업데이트 함수 +def update_inventory(action, item, inventory=None): + """인벤토리 아이템 추가/제거""" + # inventory가 전달되지 않으면 세션 상태의 인벤토리 사용 + if inventory is None: + inventory = st.session_state.character['inventory'] + + if action == "add": + if item not in inventory: + inventory.append(item) + elif action == "remove": + if item in inventory: + inventory.remove(item) + +# 세션 상태 초기화 +if 'initialized' not in st.session_state: + st.session_state.stage = 'theme_selection' + st.session_state.world_description = "" + st.session_state.character = { + 'profession': '', + 'stats': {'STR': 0, 'INT': 0, 'DEX': 0, 'CON': 0, 'WIS': 0, 'CHA': 0}, + 'backstory': '', + 'inventory': ['기본 의류', '작은 주머니 (5 골드)'] + } + st.session_state.story_log = [] + st.session_state.current_location = "" + # 백업 모드 플래그 추가 + st.session_state.use_backup_mode = False + # 단일 생성 제어를 위한 키 + st.session_state.world_generated = False + st.session_state.world_accepted = False + st.session_state.question_answers = [] + st.session_state.question_count = 0 + st.session_state.question_submitted = False + st.session_state.question_answered = False + st.session_state.question_current = "" + st.session_state.answer_current = "" + + st.session_state.background_options_generated = False + st.session_state.character_backgrounds = [] + + st.session_state.dice_rolled = False + st.session_state.dice_result = 0 + st.session_state.dice_rolling_animation = False + + st.session_state.action_submitted = False + st.session_state.action_processed = False + st.session_state.current_action = "" + st.session_state.action_response = "" + st.session_state.ability_check_done = False + + st.session_state.suggestions_generated = False + st.session_state.action_suggestions = [] + + st.session_state.master_question_submitted = False + st.session_state.master_question_answered = False + st.session_state.master_question = "" + st.session_state.master_answer = "" + + # 이동 기능 관련 상태 (중복 제거를 위해 하나만 유지) + st.session_state.move_submitted = False + st.session_state.move_processed = False + st.session_state.move_destination = "" + st.session_state.move_response = "" + + # 가능한 위치 목록 추가 + st.session_state.available_locations = [] + + # 행동 단계 관련 상태 추가 + st.session_state.action_phase = 'suggestions' + + # 이어서 작성하기 관련 상태 추가 + st.session_state.continuation_mode = False + st.session_state.continuation_text = "" + + # 아이템 알림 관련 상태 추가 + st.session_state.item_notification = "" + st.session_state.show_item_notification = False + + # 세계관 질문 관련 상태 추가 + st.session_state.world_questions = [] + st.session_state.world_question_count = 0 + + # 세계관 페이지 활성 섹션 추가 + st.session_state.active_section = None + + st.session_state.master_message = "어서 오세요, 모험가님. 어떤 세계를 탐험하고 싶으신가요?" + + st.session_state.initialized = True + +@st.cache_resource(ttl=3600) # 1시간 캐싱 +def setup_gemini(): + """Gemini API 초기화 - 캐싱 및 오류 처리 개선""" + try: + # Streamlit Secrets에서 API 키 가져오기 + api_key = st.secrets.get("GEMINI_NEW_0226", None) + + if not api_key: + st.sidebar.error("API 키가 설정되지 않음") + st.session_state.use_backup_mode = True + return None + + # Gemini API 초기화 + genai.configure(api_key=api_key) + + # 최신 모델 이름으로 시도 + try: + model = genai.GenerativeModel("gemini-1.5-pro") + return model + except Exception as e: + # 이전 모델 이름으로 시도 + try: + model = genai.GenerativeModel("gemini-pro") + return model + except Exception as inner_e: + st.error(f"사용 가능한 Gemini 모델을 찾을 수 없습니다. 백업 응답을 사용합니다.") + st.session_state.use_backup_mode = True + return None + + except Exception as e: + st.error(f"Gemini 모델 초기화 오류: {e}") + st.session_state.use_backup_mode = True + return None + +# 백업 응답 준비 +backup_responses = { + "world": "당신이 선택한 세계는 신비로운 곳으로, 다양한 인종과 마법이 공존합니다. 북쪽의 산맥에는 고대 종족이 살고 있으며, 남쪽의 숲에��� 미지의 생물이 서식합니다. 중앙 평원에는 인간 문명이 발달했으며, 동쪽 바다에는 무역 항로가 발달했습니다. 세계의 균형은 최근 어둠의 세력으로 인해 위협받고 있습니다.", + "character": "당신은 멀리서 온 여행자로 특별한 재능을 가지고 있습니다. 어린 시절 신비로운 사건을 경험한 후, 그 진실을 찾아 여행하게 되었습니다. 길을 떠나는 동안 다양한 기술을 익혔고, 이제는 자신의 운명을 찾아 나서고 있습니다.", + "story": "당신은 조심스럽게 앞으로 나아갔습니다. 주변 환경을 잘 살피며 위험 요소를 확인합니다. 다행히 위험은 발견되지 않았고, 앞길이 열렸습니다. 계속해서 탐험을 이어나갈 수 있습니다.", + "question": "흥미로운 질문입니다! 이 세계의 그 부분은 아직 완전히 탐험되지 않았지만, 전설에 따르면 그곳에는 고대의 지식이 숨겨져 있다고 합니다. 더 알고 싶다면 직접 탐험해보는 것이 좋겠습니다." +} + +# Gemini API 호출 개선 - 오류 처리 및 재시도 로직 추가 +def generate_gemini_text(prompt, max_tokens=500, retries=2, timeout=10): + """ + Gemini API를 사용하여 텍스트 생성 - 오류 처리 및 재시도 로직 추가 + """ + # 백업 모드 확인 + if getattr(st.session_state, 'use_backup_mode', False): + # 백업 모드면 즉시 백업 응답 반환 + if "world" in prompt.lower(): + return backup_responses["world"] + elif "character" in prompt.lower(): + return backup_responses["character"] + elif "질문" in prompt.lower() or "question" in prompt.lower(): + return backup_responses["question"] + else: + return backup_responses["story"] + + # 재시도 로직 + for attempt in range(retries + 1): + try: + model = setup_gemini() + + if not model: + # 모델 초기화 실패 시 백업 응답 사용 + if "world" in prompt.lower(): + return backup_responses["world"] + elif "character" in prompt.lower(): + return backup_responses["character"] + elif "질문" in prompt.lower() or "question" in prompt.lower(): + return backup_responses["question"] + else: + return backup_responses["story"] + + # 안전 설정 + safety_settings = [ + {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"}, + {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_MEDIUM_AND_ABOVE"}, + {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"}, + {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"} + ] + + # 모델 생성 구성 + generation_config = { + "temperature": 0.7, + "top_p": 0.95, + "top_k": 40, + "max_output_tokens": max_tokens, + "stop_sequences": ["USER:", "ASSISTANT:"] + } + + # 텍스트 생성 + response = model.generate_content( + prompt, + generation_config=generation_config, + safety_settings=safety_settings + ) + + # 응답 텍스트 추출 및 길이 제한 + text = response.text + if len(text) > max_tokens * 4: + text = text[:max_tokens * 4] + "..." + + return text + + except Exception as e: + if attempt < retries: + st.warning(f"API 호출 오류, 재시도 중... ({attempt+1}/{retries})") + time.sleep(1) # 잠시 대기 후 재시도 + continue + else: + st.error(f"Gemini API 호출 오류: {e}") + st.session_state.use_backup_mode = True + + # 오류 발생 시 백업 응답 사용 + if "world" in prompt.lower(): + return backup_responses["world"] + elif "character" in prompt.lower(): + return backup_responses["character"] + elif "질문" in prompt.lower() or "question" in prompt.lower(): + return backup_responses["question"] + else: + return backup_responses["story"] + + # 이 코드는 실행되지 않음 (위에서 항상 반환함) + return backup_responses["story"] + + +def display_dice_animation(placeholder, dice_expression='1d20', duration=1.0): + """주사위 굴리기 애니메이션 표시 - 개선된 버전""" + import re + + # 주사위 표현식 파싱 + pattern = r'(\d+)d(\d+)([+-]\d+)?' + match = re.match(pattern, dice_expression.lower().replace(' ', '')) + + if match: + num_dice = int(match.group(1)) + dice_type = int(match.group(2)) + modifier = match.group(3) or "+0" + modifier_value = int(modifier) + else: + # 기본값 + num_dice = 1 + dice_type = 20 + modifier_value = 0 + modifier = "+0" + + # 굴리기 시작 시간 + start_time = time.time() + + # 주사위 아이콘 선택 + dice_icons = { + 4: "🎲 (d4)", + 6: "🎲 (d6)", + 8: "🎲 (d8)", + 10: "🎲 (d10)", + 12: "🎲 (d12)", + 20: "🎲 (d20)", + 100: "🎲 (d%)" + } + dice_icon = dice_icons.get(dice_type, "🎲") + + # 애니메이션 표시 (간략화) + while time.time() - start_time < duration: + # 임시 주사위 결과 생성 + temp_rolls = [random.randint(1, dice_type) for _ in range(num_dice)] + temp_total = sum(temp_rolls) + modifier_value + + # 간소화된 애니메이션 표시 + dice_html = f""" +
+
+ {dice_icon}
+ {' + '.join([str(r) for r in temp_rolls])}{modifier if modifier_value != 0 else ""}
+ = {temp_total} +
+
+ """ + placeholder.markdown(dice_html, unsafe_allow_html=True) + time.sleep(0.1) + + # 최종 주사위 결과 계산 + result = calculate_dice_result(dice_expression) + + # 간소화된 결과 표시 + final_html = f""" +
+
{dice_icon}
+
{dice_expression.upper()}
+
+ """ + + # 각 주사위 결과를 간소화하여 표시 + for roll in result['rolls']: + color = "#4CAF50" if roll == dice_type else "#F44336" if roll == 1 else "#e0e0ff" + final_html += f"{roll}" + + # 수정자 및 총점 + if result['modifier'] != 0: + modifier_sign = "+" if result['modifier'] > 0 else "" + final_html += f"
수정자: {modifier_sign}{result['modifier']}" + + final_html += f"
{result['total']}
" + + placeholder.markdown(final_html, unsafe_allow_html=True) + return result + +# 위치 이미지 생성 함수 (임시) +def get_location_image(location, theme): + """위치 이미지 생성 함수 (플레이스홀더)""" + colors = { + 'fantasy': (100, 80, 200), + 'sci-fi': (80, 180, 200), + 'dystopia': (200, 100, 80) + } + color = colors.get(theme, (150, 150, 150)) + + # 색상 이미지 생성 + img = Image.new('RGB', (400, 300), color) + return img + +# 테마별 직업 생성 함수 +def generate_professions(theme): + """테마에 따른 직업 목록 반환""" + professions = { + 'fantasy': ['마법사', '전사', '도적', '성직자', '음유시인', '연금술사'], + 'sci-fi': ['우주 파일럿', '사이버 해커', '생체공학자', '보안 요원', '외계종족 전문가', '기계공학자'], + 'dystopia': ['정보 브로커', '밀수업자', '저항군 요원', '엘리트 경비원', '스카운터', '의료 기술자'] + } + return professions.get(theme, ['모험가', '전문가', '기술자']) + +# 테마별 위치 생성 함수 +def generate_locations(theme): + """테마에 따른 위치 목록 반환""" + locations = { + 'fantasy': ["왕국의 수도", "마법사의 탑", "고대 숲", "상인 거리", "지하 미궁"], + 'sci-fi': ["중앙 우주 정거장", "연구 시설", "거주 구역", "우주선 정비소", "외계 식민지"], + 'dystopia': ["지하 피난처", "통제 구역", "폐허 지대", "저항군 은신처", "권력자 거주구"] + } + return locations.get(theme, ["시작 지점", "미지의 땅", "중심부", "외곽 지역", "비밀 장소"]) + +# 마스터(AI)가 세계관 생성하는 함수 +def generate_world_description(theme): + """선택한 테마에 기반한 세계관 생성 - 개선된 버전""" + + prompt = f""" + 당신은 TRPG 게임 마스터입니다. '{theme}' 테마의 몰입감 있는 세계를 한국어로 만들어주세요. + 다음 구조에 따라 체계적으로 세계관을 구축해주세요: + + # 1. 기본 골격 수립 + ## 핵심 테마와 분위기 + - '{theme}'의 특성이 뚜렷하게 드러나는 세계의 중심 이념이나 분위기 + + ## 세계의 독창적 규칙 + - 이 세계만의 특별한 물리법칙이나 마법/기술 체계 + + # 2. 구조적 요소 + ## 주요 지역 (3~5개) + - 각 지역의 특성과 분위기 + + ## 주요 세력 (2~3개) + - 세력 간의 관계와 갈등 구��� + + # 3. 현재 상황 + ## 중심 갈등 + - 플레이어가 직면하게 될 세계의 주요 문제나 갈등 + + ## 잠재적 위협 + - 세계를 위협하는 요소나 임박한 위기 + + # 4. 플레이어 개입 지점 + - 플레이어가 이 세계에서 영향력을 행사할 수 있는 방법 + - 탐험 가능한 비밀이나 수수께끼 + + 모든 문장은 반드시 완성된 형태로 작성하세요. 중간에 문장이 끊기지 않도록 해주세요. + 전체 내용은 약 400-500단어로 작성해주세요. + """ + + return generate_gemini_text(prompt, 800) + +# 마스터(AI)가 세계관 질문에 대답하는 함수 +def master_answer_question(question, world_desc, theme): + """세계관에 대한 질문에 마스터가 답변 - 개선된 버전""" + try: + prompt = f""" + 당신은 TRPG 게임 마스터입니다. 플레이어가 '{theme}' 테마의 다음 세계에 대해 질문했습니다: + + 세계 설명: + {world_desc[:500]}... + + 플레이어 질문: + {question} + + ## 응답 지침: + 1. 게임 마스터로서 이 질문에 대한 답변을 한국어로 작성해주세요. + 2. 세계관을 풍부하게 하면서 플레이어의 상상력을 자극하는 답변을 제공하세요. + 3. 플레이어가 알 수 없는 신비한 요소를 한두 가지 남겨두세요. + 4. 질문에 관련된 세계의 역사, 전설, 소문 등을 포함하세요. + 5. 150단어 이내로 간결하게 답변하세요. + + 모든 문장은 완결된 형태로 작성하세요. + """ + + return generate_gemini_text(prompt, 400) + except Exception as e: + st.error(f"질문 응답 생성 중 오류: {e}") + return backup_responses["question"] # 백업 응답 반환 + +def generate_character_options(profession, theme): + """직업과 테마에 기반한 캐릭터 배경 옵션 생성 - 개선된 버전""" + + prompt = f""" + 당신은 TRPG 게임 마스터입니다. '{theme}' 테마의 세계에서 '{profession}' 직업을 가진 + 캐릭터의 3가지 다른 배경 스토리 옵션을 한국어로 제안해주세요. + + 각 옵션은 다음 요소를 포함해야 합니다: + + ## 삼위일체 구조 + 1. **배경 서사**: 캐릭터가 겪은 결정적 사건 3개 + 2. **도덕적 축**: 캐릭터의 행동을 결정하는 2가지 가치관이나 신념 + 3. **동기 구조**: 표면적 목표, 개인적 욕망, 그리고 숨겨진 공포 + + ## 개성화를 위한 요소 + - 캐릭터만의 독특한 특성이나 버릇 + - 관계망 (가족, 멘토, 적대자 등) + - 물리적 특징이나 외형적 특성 + + ## 직업 연계성 + - 이 캐릭터가 해당 직업을 가지게 된 이유 + - 직업 관련 전문 기술이나 지식 + + ## 필수 테마 요소 + - 각 배경 스토리는 다음 테마 요소 중 최소 2개 이상을 포함해야 합니다: + - 영웅적 요소 (영웅, 구원, 정의 등) + - 비극적 요소 (비극, 상실, 슬픔, 고통 등) + - 신비로운 요소 (신비, 마법, 초자연 등) + - 학자적 요소 (학자, 연구, 지식, 서적 등) + - 범죄 요소 (범죄, 도둑, 불법, 암흑가 등) + - 전사 요소 (전사, 전투, 군인, 검술 등) + - 귀족 요소 (귀족, 왕족, 부유, 상류층 등) + - 서민 요소 (서민, 평민, 일반인, 농부 등) + - 이방인 요소 (이방인, 외지인, 여행자, 이주민 등) + - 운명적 요소 (운명, 예언, 선택받은 등) + + 각 옵션을 120단어 내외로 작성해주세요. + 모든 문장은 완결된 형태로 작성하세요. + + 다음 형식으로 반환해주세요: + + #옵션 1: + (첫 번째 배경 스토리) + + #옵션 2: + (두 번째 배경 스토리) + + #옵션 3: + (세 번째 배경 스토리) + """ + + response = generate_gemini_text(prompt, 800) + + # 옵션 분리 + options = [] + current_option = "" + for line in response.split('\n'): + if line.startswith('#옵션') or line.startswith('# 옵션') or line.startswith('옵션'): + if current_option: + options.append(current_option.strip()) + current_option = "" + else: + current_option += line + "\n" + + if current_option: + options.append(current_option.strip()) + + # 옵션이 3개 미만이면 백업 옵션 추가 + while len(options) < 3: + options.append(f"당신은 {profession}으로, 험난한 세계에서 살아남기 위해 기술을 연마했습니다. 특별한 재능을 가지고 있으며, 자신의 운명을 개척하고자 합니다.") + + return options[:3] # 최대 3개까지만 반환 + +# 스탯별 색상 및 설명 함수 구현 +def get_stat_info(stat, value, profession): + # 스탯별 색상 설정 (낮음 - 중간 - 높음) + if value < 8: + color = "#F44336" # 빨강 (낮음) + level = "낮음" + elif value < 12: + color = "#FFC107" # 노랑 (보통) + level = "보통" + elif value < 16: + color = "#4CAF50" # 초록 (높음) + level = "높음" + else: + color = "#3F51B5" # 파랑 (매우 높음) + level = "매우 높음" + + # 직업별 스탯 적합성 설명 + profession_stat_match = { + '마법사': {'INT': '핵심', 'WIS': '중요', 'CON': '생존용'}, + '전사': {'STR': '핵심', 'CON': '중요', 'DEX': '유용'}, + '도적': {'DEX': '핵심', 'INT': '유용', 'CHA': '보조'}, + '성직자': {'WIS': '핵심', 'CHA': '중요', 'CON': '유용'}, + '음유시인': {'CHA': '핵심', 'DEX': '중요', 'WIS': '유용'}, + '연금술사': {'INT': '핵심', 'CON': '중요', 'WIS': '유용'}, + '우주 파일럿': {'DEX': '핵심', 'INT': '중요', 'WIS': '유용'}, + '사이버 해커': {'INT': '핵심', 'DEX': '유용', 'WIS': '보조'}, + '생체공학자': {'INT': '핵심', 'WIS': '중요', 'DEX': '유용'}, + '보안 요원': {'STR': '핵심', 'DEX': '중요', 'CON': '유용'}, + '외계종족 전문가': {'INT': '핵심', 'CHA': '중요', 'WIS': '유용'}, + '기계공학자': {'INT': '핵심', 'DEX': '중요', 'STR': '유용'}, + '정보 브로커': {'INT': '핵심', 'CHA': '중요', 'WIS': '유용'}, + '밀수업자': {'DEX': '핵심', 'CHA': '중요', 'CON': '유용'}, + '저항군 요원': {'DEX': '핵심', 'STR': '중요', 'INT': '유용'}, + '엘리트 경비원': {'STR': '핵심', 'DEX': '중요', 'CON': '유용'}, + '스카운터': {'DEX': '핵심', 'WIS': '중요', 'CON': '유용'}, + '의료 기술자': {'INT': '핵심', 'DEX': '중요', 'WIS': '유용'} + } + + # 현재 직업에 대한 스탯 적합성 확인 + if profession in profession_stat_match and stat in profession_stat_match[profession]: + match = profession_stat_match[profession][stat] + description = f"{level} - {match} 스탯" + else: + description = f"{level}" + + return color, description + + +# 개선된 스토리 응답 생성 함수 +def generate_story_response(action, dice_result, theme, location, character_info, success=None, ability=None, total=None, difficulty=None): + """행동 결과에 따른 스토리 응답 생성 - 개선된 버전""" + + # 아이템 관련 행동인지 확인 + item_acquisition = "[아이템 획득]" in action or "아이템" in action.lower() or "주워" in action or "발견" in action + item_usage = "[아이템 사용]" in action or "사용" in action.lower() + + # 캐릭터 정보 안전하게 가져오기 + stats = character_info.get('stats', {}) + profession = character_info.get('profession', '모험가') + race = character_info.get('race', '인간') + inventory = character_info.get('inventory', []) + backstory = character_info.get('backstory', '') + special_trait = character_info.get('special_trait', '') + + # 결과 판정 요약 + result_status = success if success is not None else (dice_result >= 15) + result_text = "성공" if result_status else "실패" + + # 능력치 관련 정보 + ability_names = { + 'STR': '근력', 'INT': '지능', 'DEX': '민첩', + 'CON': '체력', 'WIS': '지혜', 'CHA': '매력' + } + ability_full_name = ability_names.get(ability, '능력치') + + # 안전한 인벤토리 문자열 변환 + inventory_text = ', '.join([ + item.name if hasattr(item, 'name') else str(item) + for item in inventory + ]) + + prompt = f""" + 당신은 TRPG 게임 마스터입니다. 플레이어의 행동 결과에 대한 스토리를 생성해주세요. + + ## 상황 정보 + - 테마: {theme} + - 현재 위치: {location} + - 플레이어 종족: {race} + - 플레이어 직업: {profession} + - 플레이어 능력치: {', '.join([f"{k}: {v}" for k, v in stats.items()]) if stats else "기본 능력치"} + - 특별한 특성: {special_trait} + - 인벤토리: {inventory_text} + - 캐릭터 배경: {backstory[:150]}... + + ## 행동 및 판정 결과 + - 행동: {action} + - 판정 능력: {ability if ability else '없음'} ({ability_full_name}) + - 주사위 결과: {dice_result} + - 총점: {total if total else dice_result} + - 난이도: {difficulty if difficulty else 15} + - 판정 결과: {result_text} + + ## 스토리텔링 지침 + 1. 감각적 몰입을 위해 시각, 청각, 후각, 촉각 등 다양한 감각적 묘사를 포함해주세요. + 2. 캐릭터의 감정과 내면 상태를 반영해주세요. + 3. 행동의 결과를 극적으로 표현하되, 성공과 실패에 따른 차별화된 결과를 묘사해주세요. + 4. 결과가 세계관에 영향을 미치는 느낌을 주세요. + 5. 모든 문장은 완결되어야 합니다. 중간에 끊기지 않도록 해주세요. + 6. '어떻게 할까요?', '무엇을 할까요?', '선택하세요' 등의 질문 형태는 포함하지 마세요. + 7. 일반적인 캐릭터 이름, 장소 이름 등은 아이템으로 굵게 표시하지 마세요. + 8. {profession}과 {race}의 특성을 반영한 묘사를 포함하세요. + """ + + # 아이템 관련 행동인 경우 추가 지시사항 (여기서 명확하게 수정) + if item_acquisition: + prompt += f""" + ## 아이템 획득 지침 + - 플레이어가 획득할 수 있는 아이템을 생성하고, 반드시 해당 아이템을 굵게(**아이템명**) 표시해주세요. + - 아이템에 대한 설명(용도, 품질, 특징)을 포함하세요. + - 주사위 결과가 좋을수록 더 가치 있는 아이템을 획득하게 해주세요. + - 소비성 아이템인 경우 수량을 명시하세요. (예: "**물약** 3개를 발견했다") + - 장비형 아이템인 경우 내구도를 언급하세요. (예: "내구도가 높은 **검**을 획득했다") + - 일반적인 서술 중에 아이템 이름을 언급할 때도 반드시 굵게 표시해주세요(예: "당신은 **검**을 들어올렸다"). + - 아이템 획득은 스토리의 중요한 부분이므로 명확하게 묘사해주세요. + - 획득한 아이템은 반드시 문장 중에 포함시키고, 굵게(**아이템명**) 표기해주세요. + """ + elif item_usage: + prompt += f""" + ## 아이템 사용 지침 + - 플레이어가 사용할 아이템을 굵게(**아이템명**) 표시해주세요. + - 사용 가능한 인벤토리 아이템: {inventory_text} + - 아이템 사용의 효과를 자세히 설명해주세요. + - 주사위 결과가 좋을수록 더 효과적으로 아이템을 사용하게 해주세요. + - 소비성 아이템은 사용 후 소모됨을 설명하세요. + - 장비형 아이템은 계속 사용 가능함을 설명하세요. + - 사용한 아이템은 반드시 문장 중에 포함시키고, 굵게(**아이템명**) 표기해주세요. + """ + + # 테마별 묘사 스타일 가이드 추가 + if theme == 'fantasy': + prompt += """ + ## 판타지 세계 묘사 가이드 + - 마법적 요소와, 신비로운 분위기를 강조하세요. + - 판타지 세계의 독특한 종족, 생물, 정신적 특성을 언급하세요. + - 고대의 힘, 예언, 운명과 같은 테마를 활용하세요. + """ + elif theme == 'sci-fi': + prompt += """ + ## SF 세계 묘사 가이드 + - 첨단 기술, 미래적 환경, 외계 존재를 강조하세요. + - 과학적 원리, 인공지능, 우주 탐험 등의 요소를 활용하세요. + - 인류의 미래, 기술 발전의 영향과 같은 테마를 반영하세요. + """ + else: # dystopia + prompt += """ + ## 디스토피아 세계 묘사 가이드 + - 암울한 미래, 억압적 사회, 환경 파괴의 흔적을 강조하세요. + - 생존을 위한 투쟁, 자원 부족, 사회적 긴장감을 묘사하세요. + - 희망과 절망의 대비, 저항의 불씨와 같은 테마를 활용하세요. + """ + + # 스토리 길이 및 스타일 지침 + prompt += """ + ## 스타일 및 형식 지침 + - 약 250-300단어 분량으로 생생하게 묘사해주세요. + - 단락을 적절히 나누어 가독성을 높이세요. + - 다양한 문장 구조를 사용하여 리듬감 있는 서술을 해주세요. + - 캐릭터와 환경의 상호작용을 강조하여 현장감을 높이세요. + - 아이템과 관련된 내용이 있다면 아이템 이름을 반드시 굵게(**아이템명**) 표시하세요. + """ + + try: + response = generate_gemini_text(prompt, 600) + + # 응답이 너무 짧거나 없는 경우 백업 응답 사용 + if not response or len(response.strip()) < 20: + success_text = "성공" if (success or dice_result >= 15) else "실패" + return f"당신은 {action}을(를) 시도했고, 주사위 결과 {dice_result}로 {success_text}했습니다. {success_text}한 결과로 상황이 변화했고, 이제 다음 행동을 결정할 수 있습니다." + + # 디버깅을 위한 로그 추가 + print(f"생성된 스토리 (처음 100자): {response[:100]}...") + print(f"스토리에 '**' 포함 여부: {'**' in response}") + + return response + + except Exception as e: + # 오류 발생 시 백업 응답 + print(f"스토리 생성 중 오류 발생: {e}") + success_text = "성공" if (success or dice_result >= 15) else "실패" + return f"당신은 {action}을(를) 시도했습니다. 주사위 결과 {dice_result}가 나왔고, {success_text}했습니다. 다음 행동을 선택할 수 있습니다." + + +# extract_items_from_story() 함수 수정 +def extract_items_from_story(story_text): + """스토리 텍스트에서 획득한 아이템을 자동 추출 - 개선된 버전""" + try: + print(f"스토리 텍스트 처리 시작 (전체 길이: {len(story_text)}자)") + + # 굵게 표시된 텍스트를 우선 추출 (** 사이의 내용) + import re + + # 굵게 표시된 아이템 이름 추출 - 더 엄격한 패턴으로 수정 + # \*\*([^*]+?)\*\* 패턴은 ** 사이에 있는 모든 텍스트를 추출 + bold_items = re.findall(r'\*\*([^*]+?)\*\*', story_text) + + print(f"추출된 굵게 표시된 텍스트 수: {len(bold_items)}") + print(f"추출된 텍스트: {bold_items}") + + # 아이템 관련 문구가 있는지 확인 + item_related_phrases = ["아이템", "획득", "발견", "주웠", "얻었", "찾았", "집어들었", "주워 담았"] + is_item_acquisition = any(phrase in story_text for phrase in item_related_phrases) + + # 굵게 표시된 아이템이 없을 경우 일반 텍스트에서 찾기 + if not bold_items and is_item_acquisition: + print("굵게 표시된 아이템이 없어 일반 텍스트에서 검색") + # 획득 관련 문구 주변 텍스트 추출 + acquire_phrases = ["발견했다", "얻었다", "주웠다", "획득했다", "찾았다", "집어들었다", "주워 담았다", "발견했습니다", "얻었습니다"] + for phrase in acquire_phrases: + if phrase in story_text: + print(f"획득 관련 문구 '{phrase}' 발견됨") + # 문구 앞뒤 50자 추출 + idx = story_text.find(phrase) + start = max(0, idx - 50) + end = min(len(story_text), idx + 50) + context = story_text[start:end] + + # 문맥에서 아이템 이름 추출 시도 + item_matches = re.findall(r'"([^"]+)"|\'([^\']+)\'', context) + if item_matches: + for match in item_matches: + item_name = match[0] if match[0] else match[1] + if item_name: + print(f"문맥에서 아이템 이름 추출됨: '{item_name}'") + bold_items.append(item_name) + + # 아이템 필터링 - 너무 긴 텍스트 제거 (문장인 경우) + bold_items = [item for item in bold_items if len(item) < 50 and "." not in item and "," not in item] + + # 아이템 필터링 - 일반적인 단어나 의성어 제거 + common_words = ["쿵", "탕", "쾅", "펑", "쿠당탕", "휙", "쉭", "쏴아"] + bold_items = [item for item in bold_items if item.lower() not in common_words] + + # 중복 제거 (대소문자 구분 없이) + unique_items = [] + unique_item_names_lower = set() + for item in bold_items: + if item.lower() not in unique_item_names_lower: + unique_items.append(item) + unique_item_names_lower.add(item.lower()) + + bold_items = unique_items + + # 아이템 객체 목록 생성 + items = [] + for item_name in bold_items: + # 아이템 설명 추출 시도 + item_desc = "발견한 아이템입니다." + item_idx = story_text.find(item_name) + if item_idx > 0: + after_item = story_text[item_idx + len(item_name):item_idx + len(item_name) + 100] + desc_match = re.search(r'[:.\s]([^.!?]+)[.!?]', after_item) + if desc_match: + item_desc = desc_match.group(1).strip() + + # 소비성 여부 추정 + consumable = any(word in item_name.lower() for word in ["물약", "음식", "포션", "음료", "소비", "약초", "약", "드링크", "책", "두루마리", "알약", "필터", "주사기"]) + + # 무기/방어구 여부 추정 + item_type = "일반" + if any(word in item_name.lower() for word in ["검", "도끼", "창", "활", "단검", "무기", "도", "총", "블래스터", "나이프"]): + item_type = "무기" + elif any(word in item_name.lower() for word in ["갑옷", "방패", "투구", "방어구", "부츠", "장갑", "로브", "마스크", "의류"]): + item_type = "방어구" + elif consumable: + item_type = "소비품" + elif any(word in item_name.lower() for word in ["도구", "키트", "장비", "부품", "카드", "칩", "지도"]): + item_type = "도구" + + # 수량 추출 시도 + quantity = 1 + quantity_match = re.search(r'(\d+)\s*개(?:의)?\s*' + re.escape(item_name), story_text) + if quantity_match: + try: + quantity = int(quantity_match.group(1)) + except ValueError: + pass + + # 아이템 중복 확��� (대소문자 구분 없이) + if not any(existing.name.lower() == item_name.lower() for existing in items): + print(f"최종 아이템 생성: {item_name} (타입: {item_type}, 소비성: {consumable}, 수량: {quantity})") + items.append(Item( + name=item_name, + description=item_desc, + type=item_type, + consumable=consumable, + quantity=quantity + )) + + return items + + except Exception as e: + # 문제 발생 시 로그 기록 + print(f"아이템 추출 오류: {e}") + # 최소한의 결과라도 반환 + return [Item(name=item, description="발견한 아이템입니다.") for item in bold_items] if bold_items else [] + +# 아이템 클래스 구조 정의 +class Item: + """게임 내 아이템 기본 클래스""" + def __init__(self, name, description, type="일반", consumable=False, durability=None, max_durability=None, quantity=1, rarity="일반"): + self.name = name # 아이템 이름 + self.description = description # 아이템 설명 + self.type = type # 아이템 유형 (무기, 방어구, 소비품, 도구, 일반) + self.consumable = consumable # 소비성 여부 (사용 후 사라짐) + self.durability = durability # 현재 내구도 (None이면 내구도 없음) + self.max_durability = max_durability or durability # 최대 내구도 + self.quantity = quantity # 수량 + self.rarity = rarity # 희귀도 (일반, 희귀, 영웅, 전설) + + def to_dict(self): + """아이템을 사전 형태로 변환""" + return { + 'name': self.name, + 'description': self.description, + 'type': self.type, + 'consumable': self.consumable, + 'durability': self.durability, + 'max_durability': self.max_durability, + 'quantity': self.quantity, + 'rarity': self.rarity + } + + @classmethod + def from_dict(cls, data): + """사전 형태에서 아이템 객체 생성""" + return cls( + name=data['name'], + description=data.get('description', ''), + type=data.get('type', '일반'), + consumable=data.get('consumable', False), + durability=data.get('durability', None), + max_durability=data.get('max_durability', None), + quantity=data.get('quantity', 1), + rarity=data.get('rarity', '일반') + ) + + def use(self): + """아이템 사용""" + if self.consumable: + if self.quantity > 1: + self.quantity -= 1 + return f"{self.name}을(를) 사용했습니다. 남은 수량: {self.quantity}" + else: + return f"{self.name}을(를) 사용했습니다. 모두 소진되었습니다." + elif self.durability is not None: + self.durability -= 1 + if self.durability <= 0: + return f"{self.name}의 내구도가 다 되어 사용할 수 없게 되었습니다." + else: + return f"{self.name}을(를) 사용했습니다. 남은 내구도: {self.durability}/{self.max_durability}" + else: + return f"{self.name}을(를) 사용했습니다." + + def get_icon(self): + """아이템 유형에 따른 아이콘 반환""" + icons = { + "무기": "⚔️", + "방어구": "🛡️", + "소비품": "🧪", + "도구": "🔧", + "마법": "✨", + "기술": "🔌", + "일반": "📦" + } + return icons.get(self.type, "📦") + + def get_rarity_color(self): + """아이템 희귀도에 따른 색상 코드 반환""" + colors = { + "일반": "#AAAAAA", # 회색 + "고급": "#4CAF50", # 녹색 + "희귀": "#2196F3", # 파란색 + "영웅": "#9C27B0", # 보라색 + "전설": "#FFC107" # 노란색 + } + return colors.get(self.rarity, "#AAAAAA") + + def get_durability_percentage(self): + """내구도 백분율 계산""" + if self.durability is None or self.max_durability is None or self.max_durability <= 0: + return 100 + return (self.durability / self.max_durability) * 100 + + +def initialize_inventory(theme): + """테마별 기본 인벤토리 초기화 - 개선된 버전""" + inventory = [] + + if theme == 'fantasy': + inventory = [ + Item("기본 의류", "일반적인 모험가 복장입니다.", type="방어구", consumable=False), + Item("여행용 가방", "다양한 물건을 담을 수 있는 가방입니다.", type="도구", consumable=False), + Item("횃불", "어두운 곳을 밝힐 수 있습니다. 약 1시��� 정도 사용 가능합니다.", type="소비품", consumable=True, quantity=3), + Item("단검", "기본적인 근접 무기입니다.", type="무기", consumable=False, durability=20, max_durability=20), + Item("물통", "물을 담아 갈 수 있습니다.", type="도구", consumable=False), + Item("식량", "하루치 식량입니다.", type="소비품", consumable=True, quantity=5), + Item("치유 물약", "체력을 회복시켜주는 물약입니다.", type="소비품", consumable=True, quantity=2, rarity="고급") + ] + elif theme == 'sci-fi': + inventory = [ + Item("기본 의류", "표준 우주 여행자 복장입니다.", type="방어구", consumable=False), + Item("휴대용 컴퓨터", "간단한 정보 검색과 해킹에 사용할 수 있습니다.", type="도구", consumable=False, durability=30, max_durability=30), + Item("에너지 셀", "장비 작동에 필요한 에너지 셀입니다.", type="소비품", consumable=True, quantity=3), + Item("레이저 포인터", "기본적인 레이저 도구입니다.", type="도구", consumable=False, durability=15, max_durability=15), + Item("통신 장치", "다른 사람과 통신할 수 있습니다.", type="도구", consumable=False, durability=25, max_durability=25), + Item("비상 식량", "우주 여행용 압축 식량입니다.", type="소비품", consumable=True, quantity=5), + Item("의료 키트", "부상을 치료할 수 있는 기본 의료 키트입니다.", type="소비품", consumable=True, quantity=2, rarity="고급") + ] + else: # dystopia + inventory = [ + Item("작업용 의류", "튼튼하고 방호력이 있는 작업복입니다.", type="방어구", consumable=False, durability=15, max_durability=15), + Item("가스 마스크", "유해 가스를 걸러냅니다.", type="방어구", consumable=False, durability=20, max_durability=20), + Item("필터", "가스 마스크에 사용하는 필터입니다.", type="소비품", consumable=True, quantity=3), + Item("생존 나이프", "다용도 생존 도구입니다.", type="무기", consumable=False, durability=25, max_durability=25), + Item("정수 알약", "오염된 물을 정화할 수 있습니다.", type="소비품", consumable=True, quantity=5), + Item("식량 배급 카드", "배급소에서 식량을 받을 수 있는 카드입니다.", type="도구", consumable=False), + Item("응급 주사기", "위급 상황에서 생명 유지에 도움이 됩니다.", type="소비품", consumable=True, quantity=1, rarity="희귀") + ] + + return inventory +# 인벤토리 표시 함수 +def display_inventory(inventory): + """인벤토리 아이템을 시각적으로 표시하는 함수 - 개선된 버전""" + # 인벤토리가 비어있는 경우 처리 + if not inventory: + st.markdown("
인벤토리가 비어있습니다.
", unsafe_allow_html=True) + return + + # 아이템 타입별 아이콘 정의 + type_icons = { + "무기": "⚔️", + "방어구": "🛡️", + "소비품": "🧪", + "도구": "🔧", + "일반": "📦" + } + + # 아이템 등급별 색상 정의 + rarity_colors = { + "일반": "#AAAAAA", + "고급": "#55AA55", + "희귀": "#5555FF", + "전설": "#AA55AA", + "유물": "#FFAA00" + } + + # 아이템 타입별로 분류 + categorized_items = { + "무기": [], + "방어구": [], + "소비품": [], + "도구": [], + "일반": [] + } + + # 아이템 분류 + for item in inventory: + try: + # 아이템 속성 가져오기 + item_name = item.name if hasattr(item, 'name') else str(item) + item_desc = getattr(item, 'description', '발견한 아이템입니다.') + item_consumable = getattr(item, 'consumable', False) + item_durability = getattr(item, 'durability', None) + item_max_durability = getattr(item, 'max_durability', item_durability) + item_quantity = getattr(item, 'quantity', 1) + item_type = getattr(item, 'type', None) + item_rarity = getattr(item, 'rarity', '일반') + + # 아이템 타입 결정 + if item_type and item_type in categorized_items: + category = item_type + elif item_consumable: + category = "소비품" + elif item_durability is not None: + category = "무기" if any(word in item_name.lower() for word in ["검", "도끼", "창", "활", "단검", "무기", "도", "총", "블래스터", "나이프"]) else "방어구" + else: + category = "일반" + + # 아이템 정보 저장 + categorized_items[category].append({ + "name": item_name, + "description": item_desc, + "consumable": item_consumable, + "durability": item_durability, + "max_durability": item_max_durability, + "quantity": item_quantity, + "rarity": item_rarity + }) + except Exception as e: + # 오류 발생 시 기본 정보로 저장 + categorized_items["일반"].append({ + "name": str(item), + "description": "정보를 불러올 수 없는 아이템입니다.", + "consumable": False, + "durability": None, + "max_durability": None, + "quantity": 1, + "rarity": "일반" + }) + + # 카테고리별로 아이템 표시 + for category, items in categorized_items.items(): + if items: # 해당 카테고리에 아이템이 있는 경우만 표시 + st.markdown(f"

{type_icons.get(category, '📦')} {category}

", unsafe_allow_html=True) + + for item in items: + # 아이템 이름 및 수량 표시 + item_name = item["name"] + if item["quantity"] > 1: + item_name = f"{item_name} x{item['quantity']}" + + # 아이템 등급에 따른 색상 설정 + rarity_color = rarity_colors.get(item["rarity"], "#AAAAAA") + + # 아이템 타입에 따른 테두리 색상 설정 + if category == "소비품": + border_color = "#FFC107" # 노란색 + elif category == "무기": + border_color = "#F44336" # 빨간색 + elif category == "방어구": + border_color = "#2196F3" # 파란색 + elif category == "도구": + border_color = "#FF9800" # 주황색 + else: + border_color = "#4CAF50" # 녹색 + + # 아이템 등급에 따른 배경 효과 + rarity_effect = "" + if item["rarity"] == "희귀": + rarity_effect = "box-shadow: 0 0 5px #5555FF;" + elif item["rarity"] == "전설": + rarity_effect = "box-shadow: 0 0 8px #AA55AA;" + elif item["rarity"] == "유물": + rarity_effect = "box-shadow: 0 0 12px #FFAA00;" + + # 내구도 정보 생성 - 별도의 HTML 삽입 대신 조건부 렌더링 + durability_html = "" + if item["durability"] is not None: + try: + max_durability = item["max_durability"] or item["durability"] + durability_percent = (item["durability"] / max_durability) * 100 if max_durability > 0 else 0 + + # 내구도에 따른 색상 + if durability_percent > 66: + durability_color = "#4CAF50" # 녹색 (양호) + elif durability_percent > 33: + durability_color = "#FFC107" # 노란색 (경고) + else: + durability_color = "#F44336" # 빨간색 (위험) + + # 직접 HTML에 내구도 정보 포함 + st.markdown(f""" +
+
+ {type_icons.get(category, '📦')} + {item_name} + {item["rarity"]} +
+
+ {item["description"]} +
+
+
+ 내구도: {item["durability"]}/{max_durability} +
+
+
+
+
+
+ """, unsafe_allow_html=True) + except Exception as e: + st.markdown(f""" +
+
+ {type_icons.get(category, '📦')} + {item_name} + {item["rarity"]} +
+
+ {item["description"]} +
+
내구도: {item['durability']}
+
+ """, unsafe_allow_html=True) + else: + # 내구도가 없는 아이템 + st.markdown(f""" +
+
+ {type_icons.get(category, '📦')} + {item_name} + {item["rarity"]} +
+
+ {item["description"]} +
+
+ """, unsafe_allow_html=True) + + +def get_durability_color(percentage): + """내구도 퍼센트에 따른 색상 반환""" + if percentage > 66: + return "#4CAF50" # 녹색 (양호) + elif percentage > 33: + return "#FFC107" # 노란색 (경고) + else: + return "#F44336" # 빨간색 (위험) + +# 스토리 응답에서 아이템 추출 함수 개선 +def extract_items_from_story(story_text): + """스토리 텍스트에서 획득한 아이템을 자동 추출 - 개선된 버전""" + try: + # 1. 획득 관련 문맥 확인 - 중요 개선점 + acquisition_context = False + acquisition_patterns = [ + r'(\w+|[가-힣]+)(?:을|를|을\(를\))?[\s]*(?:얻었|획득했|발견했|주웠|찾았|집어들었)', + r'새로운[\s]*(?:\w+|[가-힣]+)', + r'(\w+|[가-힣]+)(?:이|가)[\s]*추가되었', + r'(\w+|[가-힣]+)(?:을|를|을\(를\))[\s]*인벤토리에' + ] + + for pattern in acquisition_patterns: + if re.search(pattern, story_text): + acquisition_context = True + break + + # 2. 굵게 표시된 아이템 추출 (한글 지원 개선) + bold_items = re.findall(r'\*\*([\w\s가-힣]+)\*\*', story_text) + + # 3. 아이템이 없거나 획득 문맥이 아니면 빈 리스트 반환 + if not bold_items or not acquisition_context: + return [] + + # 4. 중복 제거 및 품질 필터링 + unique_items = [] + for item_name in bold_items: + # 이미 추출된 아이템이 아닌 경우에만 추가 + if item_name.strip() and item_name not in unique_items: + unique_items.append(item_name) + + # 5. 아이템 객체 생성 + items = [] + for item_name in unique_items: + # 아이템 설명 추출 로직... + items.append(Item(name=item_name, description="발견한 아이템입니다.")) + + return items + except Exception as e: + print(f"아이템 추출 오류: {e}") + return [] + +# 사용된 아이템 추출 함수 개선 +def extract_used_items_from_story(story_text, inventory): + """스토리 텍스트에서 사용한 아이템 추출""" + # 인벤토리 아이템 이름 목록 생성 + inventory_names = [item.name for item in inventory] + + prompt = f""" + 다음 TRPG 스토리 텍스트를 분석하여 플레이어가 사용한 아이템을 추출해주세요. + 특히 굵게 표시된 아이템(**, ** 사이의 텍스트)에 주목하세요. + + 인벤토리에 있는 아이템: {', '.join(inventory_names)} + + 스토리 텍스트: + {story_text} + + 다음 JSON 형식으로 반환해주세요: + [ + {{ + "name": "아이템 이름", + "quantity": 사용한 수량 (기본값 1) + }}, + ... + ] + + 아무 아이템도 사용하지 않았다면 빈 배열 []을 반환하세요. + """ + + try: + response = generate_gemini_text(prompt, 200) + + # 굵게 표시된 텍스트를 우선 추출 (** 사이의 내용) + import re + import json + + # 굵게 표시된 아이템 이름 추출 + bold_items = re.findall(r'\*\*(.*?)\*\*', story_text) + + # 응답에서 JSON 구조 추출 시도 + try: + # 응답 텍스트에서 JSON 부분만 추출 시도 + json_match = re.search(r'\[\s*\{.*\}\s*\]', response, re.DOTALL) + if json_match: + used_items_data = json.loads(json_match.group(0)) + else: + # 전체 응답을 JSON으로 파싱 시도 + used_items_data = json.loads(response) + except: + # JSON 파싱 실패 시 기본 데이터 생성 + used_items_data = [] + for item_name in bold_items: + if item_name in inventory_names: + used_items_data.append({ + "name": item_name, + "quantity": 1 + }) + + # 사용된 아이템 데이터 필터링 (인벤토리에 있는 아이템만) + filtered_items_data = [] + for item_data in used_items_data: + if item_data["name"] in inventory_names: + filtered_items_data.append(item_data) + + # 굵게 표시된 아이템이 있지만 JSON에 포함되지 않은 경우 추가 + existing_names = [item["name"] for item in filtered_items_data] + for bold_item in bold_items: + if bold_item in inventory_names and bold_item not in existing_names: + filtered_items_data.append({ + "name": bold_item, + "quantity": 1 + }) + + return filtered_items_data + + except: + # 오류 시 기본 데이터 생성 + used_items_data = [] + for item_name in bold_items: + if item_name in inventory_names: + used_items_data.append({ + "name": item_name, + "quantity": 1 + }) + return used_items_data + +# 인벤토리 업데이트 함수 +def update_inventory(action, item_data, inventory): + """인벤토리 아이템 추가/제거/사용 - 중복 처리 강화""" + if action == "add": + # 아이템 객체화 + if isinstance(item_data, Item): + item = item_data + else: + item = Item(name=str(item_data), description="획득한 아이템입니다.") + + # 중복 확인 강화 - 대소문자 무관, 유사 이름 고려 + for existing_item in inventory: + existing_name = existing_item.name if hasattr(existing_item, 'name') else str(existing_item) + new_name = item.name + + # 1. 정확히 같은 이름 + if existing_name.lower() == new_name.lower(): + # 수량 증가 + if hasattr(existing_item, 'quantity'): + existing_item.quantity += getattr(item, 'quantity', 1) + return f"**{existing_name}** 수량이 추가되었습니다. (총 {existing_item.quantity}개)" + return f"**{existing_name}**은(는) 이미 보유하고 있습니다." + + # 2. 유사 이름 검사 (선택적) + # similarity_ratio = difflib.SequenceMatcher(None, existing_name.lower(), new_name.lower()).ratio() + # if similarity_ratio > 0.8: # 80% 이상 유사 + # return f"**{new_name}**은(는) 이미 {existing_name}(으)로 보유하고 있습니다." + + # 새 아이템 추가 + inventory.append(item) + return f"새 아이템 **{item.name}**을(를) 획득했습니다!" + + elif action == "use": + # 아이템 사용 (소비성 아이템 소모 또는 내구도 감소) + if isinstance(item_data, dict): + item_name = item_data.get("name", "") + quantity = item_data.get("quantity", 1) + else: + item_name = str(item_data) + quantity = 1 + + for i, item in enumerate(inventory): + item_n = item.name if hasattr(item, 'name') else str(item) + if item_n == item_name: + # 소비성 아이템인지 확인 + if hasattr(item, 'consumable') and item.consumable: + # 소비성 아이템 수량 감소 + if item.quantity <= quantity: + # 모두 소모 + removed_item = inventory.pop(i) + return f"**{removed_item.name}**을(를) 모두 사용했습니다." + else: + # 일부 소모 + item.quantity -= quantity + return f"**{item.name}** {quantity}개를 사용했습니다. (남은 수량: {item.quantity})" + + # 내구도 있는 아이템인지 확인 + elif hasattr(item, 'durability') and item.durability is not None: + # 내구도 감소 + item.durability -= 1 + if item.durability <= 0: + # 내구도 소진으로 파괴 + removed_item = inventory.pop(i) + return f"**{removed_item.name}**의 내구도가 다 되어 사용할 수 없게 되었습니다." + else: + # 내구도 감소 + max_durability = getattr(item, 'max_durability', item.durability) + return f"**{item.name}**의 내구도가 감소했습니다. (남은 내구도: {item.durability}/{max_durability})" + else: + # 일반 아이템 사용 (변화 없음) + return f"**{item.name}**을(를) 사용했습니다." + + return f"**{item_name}**이(가) 인벤토리에 없습니다." + + elif action == "remove": + # 아이템 제거 + if isinstance(item_data, dict): + item_name = item_data.get("name", "") + else: + item_name = str(item_data) + + for i, item in enumerate(inventory): + item_n = item.name if hasattr(item, 'name') else str(item) + if item_n == item_name: + removed_item = inventory.pop(i) + item_name = removed_item.name if hasattr(removed_item, 'name') else str(removed_item) + return f"**{item_name}**을(를) 인벤토리에서 제거했습니다." + + return f"**{item_name}**이(가) 인벤토리에 없습니다." + + return "아이템 작업에 실패했습니다." + +def process_acquired_items(): + """스토리에서 획득한 아이템 처리 및 인벤토리 업데이트""" + if not hasattr(st.session_state, 'acquired_items') or not st.session_state.acquired_items: + return + + items_added = [] + + # 획득한 아이템을 인벤토리에 추가 + for item in st.session_state.acquired_items: + if isinstance(item, Item): + # Item 객체인 경우 + item_name = item.name + update_result = update_inventory("add", item, st.session_state.character['inventory']) + items_added.append(item_name) + else: + # 단순 문자열인 경우 + item_name = item.strip() + if item_name and item_name not in st.session_state.character['inventory']: + st.session_state.character['inventory'].append(item_name) + items_added.append(item_name) + + # 획득 알림 표시 설정 + if items_added: + items_text = ", ".join(items_added) + st.session_state.item_notification = f"🎁 획득한 아이템: {items_text}" + st.session_state.show_item_notification = True + + # 처리 완료 후 상태 초기화 + st.session_state.acquired_items = [] + +# 아이템 처리 및 스토리 생성 함수 개선 +def process_items_and_generate_story(action, dice_result, theme, location, character): + """행동에 따른 아이템 처리 및 스토리 생성 - 개선된 버전""" + # 아이템 관련 행동인지 확인 + item_acquisition = "[아이템 획득]" in action or "아이템" in action.lower() or "주워" in action or "발견" in action + item_usage = "[아이템 사용]" in action or "사용" in action.lower() + + # 스토리 생성 + prompt = f""" + 당신은 TRPG 게임 마스터입니다. 플레이어의 행동 결과에 대한 스토리를 생성해주세요. + + ## 상황 정보 + - 테마: {theme} + - 현재 위치: {location} + - 플레이어 직업: {character['profession']} + - 플레이어 종족: {character.get('race', '인간')} + - 주사위 결과: {dice_result} + + ## 행동 및 판정 결과 + - 행동: {action} + - 판정 성공 여부: {'성공' if dice_result >= 20 else '실패'} + """ + + # 아이템 관련 행동인 경우 추가 지시사항 + if item_acquisition: + prompt += f""" + ## 아이템 획득 지침 + - 플레이어가 획득할 수 있는 아이템을 생성하고, 해당 아이템을 굵게(**아이템명**) 표시해주세요. + - 아이템에 대한 설명(용도, 품질, 특징)을 포함하세요. + - 주사위 결과가 좋을수록 더 가치 있는 아이템을 획득하게 해주세요. + - 소비성 아이템인 경우 수량을 명시하세요. (예: "**물약** 3개") + - 장비형 아이템인 경우 내구도를 언급하세요. (예: "내구도가 높은 **검**") + + ## 아이템 희귀도 지침 + - 주사위 결과: {dice_result} + - 10 이하: 일반 아이템 (허름한, 낡은, 기본적인) + - 11-15: 고급 아이템 (좋은 품질의, 견고한, 정교한) + - 16-20: 희귀 아이템 (희귀한, 특별한, 특화된) + - 21-25: 영웅급 아이템 (강력한, 전설적인, 고대의) + - 26 이상: 전설급 아이템 (신화적인, 불가능한, 시대를 초월한) + """ + elif item_usage: + # 인벤토리에서 아이템 이름 추출 + inventory_items = [] + for item in character['inventory']: + if hasattr(item, 'name'): + inventory_name = item.name + item_type = getattr(item, 'type', '일반') + item_consumable = getattr(item, 'consumable', False) + inventory_items.append(f"{inventory_name} ({item_type}, {'소비성' if item_consumable else '장비'})") + else: + inventory_items.append(str(item)) + + prompt += f""" + ## 아이템 사용 지침 + - 플레이어가 사용할 아이템을 굵게(**아이템명**) 표시해주세요. + - 사용 가능한 인벤토리 아이템: {', '.join(inventory_items)} + - 아이템 사용의 효과를 자세히 설명해주세요. + - 주사위 결과가 좋을수록 더 효과적으로 아이템을 사용하게 해주세요. + - 소비성 아이템은 사용 후 소모됨을 설명하세요. + - 장비형 아이템은 계속 사용 가능함을 설명하세요. + """ + + prompt += """ + ## 중요 지시사항 + 1. 감각적 몰입을 위해 시각, 청각, 후각, 촉각 등 다양한 감각적 묘사를 포함해주세요. + 2. 캐릭터의 감정과 내면 상태를 반영해주세요. + 3. 행동 결과를 극적으로 표현하되, 성공과 실패에 따른 차별화된 결과를 묘사해주세요. + 4. 선택지나 다음 행동 제안을 포함하지 마세요. + 5. 모든 문장은 완결되어야 합니다. 중간에 끊기지 않도록 해주세요. + 6. '어떻게 할까요?', '무엇을 할까요?', '선택하세요' 등의 문구를 사용하지 마세요. + 7. 응답은 250단어 이내로 간결하게 작성해주세요. + 8. 아이템 획득이나 사용의 경우 반드시 아이템 이름을 **굵게** 표시하세요. + """ + + # 스토리 생성 + story = generate_gemini_text(prompt, 350) + + # 아이템 처리 + notification = "" + + # 디버깅을 위한 로그 추가 + print(f"생성된 스토리: {story[:100]}...") + + # 1. 아이템 획득 처리 + if item_acquisition and dice_result >= 10: # 10 이상이면 아이템 획득 성공 + # 스토리에서 아이템 추출 + acquired_items = extract_items_from_story(story) + print(f"추출된 아이템: {[item.name for item in acquired_items]}") + + # 추출된 아이템이 없으면 간단한 아이템 생성 (백업) + if not acquired_items and "**" in story: + bold_matches = re.findall(r'\*\*(.*?)\*\*', story) + if bold_matches: + acquired_items = [Item( + name=bold_matches[0], + description="발견한 아이템입니다.", + consumable=False, + quantity=1 + )] + print(f"백업으로 생성된 아이템: {bold_matches[0]}") + + # 인벤토리에 아이템 추가 + if acquired_items: + notifications = [] + for item in acquired_items: + result = update_inventory("add", item, character['inventory']) + notifications.append(result) + print(f"인벤토리 업데이트 결과: {result}") + + notification = "🎁 " + " / ".join(notifications) + else: + print("추출된 아이템이 없습니다.") + + # 2. 아이템 사용 처리 + elif item_usage: + # 스토리에서 사용된 아이템 추출 + used_items_data = extract_used_items_from_story(story, character['inventory']) + + # 인벤토리에서 아이템 사용/제거 + if used_items_data: + notifications = [] + for item_data in used_items_data: + result = update_inventory("use", item_data, character['inventory']) + notifications.append(result) + + notification = "🔄 " + " / ".join(notifications) + + # 세션에 중요 데이터 저장 + if 'acquired_items' not in st.session_state: + st.session_state.acquired_items = [] + + if item_acquisition and acquired_items: + st.session_state.acquired_items.extend(acquired_items) + + return story, notification + +# 캐릭터 생성 시 인벤토리 초기화 통합 +def initialize_character(profession, backstory, stats, theme): + """캐릭터 초기화 및 인벤토리 설정""" + # 아이템 객체 리스트로 인벤토리 초기화 + inventory = initialize_inventory(theme) + + character = { + 'profession': profession, + 'backstory': backstory, + 'stats': stats, + 'inventory': inventory, + 'special_trait': None + } + + return character + +# 왼쪽 패널에 캐릭터 정보 표시 함수 +def display_character_panel(character, location): + """캐릭터 정보를 왼쪽 패널에 표시""" + st.markdown("
", unsafe_allow_html=True) + st.write(f"## {character['profession']}") + + # 능력치 표시 + st.write("### 능력치") + for stat, value in character['stats'].items(): + # 직업 정보 가져오기 + prof = character['profession'] + color, description = get_stat_info(stat, value, prof) + + st.markdown(f""" +
+ {stat} + {value} +
{description}
+
+ """, unsafe_allow_html=True) + + # 인벤토리 표시 (개선된 버전) + st.write("### 인벤토리") + # 기존 인벤토리 표시 코드 대신 display_inventory 함수 호출 + display_inventory(character['inventory']) + + st.markdown("
", unsafe_allow_html=True) + + # 위치 정보 + st.markdown(f""" +
+

현재 위치

+
{location}
+
+ """, unsafe_allow_html=True) + +# 게임플레이 페이지에서 아이템 알림 표시 +def display_item_notification(notification): + """아이템 관련 알림 표시 - 더 눈에 띄게 개선""" + if notification: + # 아이템 이름 강조를 위한 정규식 처리 + import re + # 아이템 이름을 추출하여 강조 처리 + highlighted_notification = notification + + # "획득한 아이템: item1, item2" 형식에서 아이템 추출 + if "획득한 아이템:" in notification: + items_part = notification.split("획득한 아이템:")[1].strip() + items = [item.strip() for item in items_part.split(',')] + + for item in items: + # 아이템 이름에 강조 스타일 적용 (더 눈에 띄게 수정) + highlighted_notification = highlighted_notification.replace( + item, + f'{item}' + ) + + # 획득/사용 키워드에 더 눈에 띄는 스타일 적용 + highlighted_notification = highlighted_notification.replace( + "획득한 아이템", + '🆕 획득한 아이템' + ).replace( + "사용한 아이템", + '⚙️ 사용한 아이템' + ) + + st.markdown(f""" +
+
+
🎁
+
{highlighted_notification}
+
+
+ + """, unsafe_allow_html=True) + +# 행동 처리 및 스토리 진행 개선 함수 +def handle_action_and_story(action, dice_result, theme, location, character): + """행동 처리 및 스토리 진행""" + # 아이템 처리 및 스토리 생성 + story, notification = process_items_and_generate_story( + action, dice_result, theme, location, character + ) + + # 스토리 로그에 추가 + if story and len(story) > 10: # 유효한 응답인지 확인 + st.session_state.story_log.append(story) + else: + # 백업 응답 사용 + backup_response = f"당신은 {action}을(를) 시도했습니다. 주사위 결과 {dice_result}가 나왔습니다." + st.session_state.story_log.append(backup_response) + + # 알림 저장 + if notification: + st.session_state.item_notification = notification + st.session_state.show_item_notification = True + + # 행동 단계 초기화 + st.session_state.action_phase = 'suggestions' + st.session_state.suggestions_generated = False + st.session_state.dice_rolled = False + + # 임시 상태 초기화 + for key in ['suggested_ability', 'dice_result', 'current_action']: + if key in st.session_state: + del st.session_state[key] + + return story, notification + + + +def handle_ability_check(action_phase, current_action, character_info): + """능력치 판정 과정을 처리하는 함수 - 완전히 새로 작성""" + with st.spinner("주사위를 굴리고 있습니다..."): + # 로딩 표시 + loading_placeholder = st.empty() + loading_placeholder.info("주사위를 굴려 스토리의 진행을 판단하는 중... 잠시만 기다려주세요.") + + st.subheader("능력치 판정") + + # 행동 표시 + st.markdown(f""" +
+

선택한 행동:

+

{current_action}

+
+ """, unsafe_allow_html=True) + + # 마스터가 능력치와 난이도 제안 + if 'suggested_ability' not in st.session_state: + with st.spinner("마스터가 판정 방식을 결정 중..."): + # 행동 분석을 위한 프롬프트 + prompt = f""" + 당신은 TRPG 게임 마스터입니다. 플레이어의 다음 행동에 가장 적합한 능력치와 난이도를 결정해주세요. + + 플레이어 행동: {current_action} + 플레이어 직업: {character_info['profession']} + 현재 위치: {st.session_state.current_location} + + 다음 능력치 중 하나를 선택하세요: + - STR (근력): 물리적 힘이 필요한 행동 + - INT (지능): 지식, 분석, 추론이 필요한 행동 + - DEX (민첩): 손재주, 반사신경, 정확성이 필요한 행동 + - CON (체력): 지구력, 내구성이 필요한 행동 + - WIS (지혜): 직관, 통찰력, 인식이 필요한 행동 + - CHA (매력): 설득, 위협, 속임수가 필요한 행동 + + 난이도는 다음 기준으로 설정하세요: + - 쉬움(10): 일상적인 행동, 실패 가능성이 낮음 + - 보통(15): 약간의 전문성이 필요한 행동, 보통 수준의 도전 + - 어려움(20): 전문적 기술이 필요한 행동, 실패 가능성이 높음 + - 매우 어려움(25): 극도로 어려운 행동, 전문가도 실패할 확률이 높음 + - 거의 불가능(30): 역사적으로 몇 번 성공한 적 있는 수준의 행동 + + 다음 형식으로 응답해주세요: + 능력치: [능력치 코드] + 난이도: [숫자] + 이유: [간략한 설명] + 성공 결과: [성공했을 때 일어날 일에 대한 간략한 설명] + 실패 결과: [실패했을 때 일어날 일에 대한 간략한 설명] + 추천 주사위: [추천 주사위 표현식, 예: 1d20+능력치] + """ + + # 마스터의 판정 제안 생성 + response = generate_gemini_text(prompt, 300) + + # 응답에서 능력치와 난이도 추출 + ability_code = "STR" # 기본값 + difficulty = 15 # 기본값 + reason = "이 행동에는 근력이 필요합니다." # 기본값 + success_outcome = "행동에 성공합니다." # 기본값 + failure_outcome = "행동에 실패합니다." # 기본값 + recommended_dice = "1d20" # 기본값 + + for line in response.split('\n'): + if '능력치:' in line.lower(): + for code in ['STR', 'INT', 'DEX', 'CON', 'WIS', 'CHA']: + if code in line: + ability_code = code + break + elif '난이도:' in line.lower(): + try: + difficulty_str = line.split(':')[1].strip() + difficulty = int(''.join(filter(str.isdigit, difficulty_str))) + # 범위 제한 + difficulty = max(5, min(30, difficulty)) + except: + pass + elif '이유:' in line.lower(): + reason = line.split(':', 1)[1].strip() + elif '성공 결과:' in line.lower(): + success_outcome = line.split(':', 1)[1].strip() + elif '실패 결과:' in line.lower(): + failure_outcome = line.split(':', 1)[1].strip() + elif '추천 주사위:' in line.lower(): + recommended_dice = line.split(':', 1)[1].strip() + # 기본값이 없는 경우 기본값 설정 + if not recommended_dice or 'd' not in recommended_dice.lower(): + recommended_dice = "1d20" + + # 능력치 전체 이름 매핑 + ability_names = { + 'STR': '근력', 'INT': '지능', 'DEX': '민첩', + 'CON': '체력', 'WIS': '지혜', 'CHA': '매력' + } + + # 세션에 저장 + st.session_state.suggested_ability = { + 'code': ability_code, + 'name': ability_names.get(ability_code, ''), + 'difficulty': difficulty, + 'reason': reason, + 'success_outcome': success_outcome, + 'failure_outcome': failure_outcome, + 'recommended_dice': recommended_dice + } + + st.rerun() + + # 마스터의 제안 표시 - 향상된 UI + ability = st.session_state.suggested_ability + st.markdown(f""" +
+

🎲 마스터의 판정 제안

+
+
+
능력치
+
{ability['code']} ({ability['name']})
+
+
+
난이도
+
{ability['difficulty']}
+
+
+
+
이유
+
{ability['reason']}
+
+
+
+
성공 시
+
{ability['success_outcome']}
+
+
+
실패 시
+
{ability['failure_outcome']}
+
+
+
+ 추천 주사위: {ability['recommended_dice']} +
+
+ 판정을 위해 주사위를 굴려주세요 +
+
+ """, unsafe_allow_html=True) + + # 주사위 굴리기 자동 실행 + if not st.session_state.get('dice_rolled', False): + # 주사위 애니메이션을 위한 플레이스홀더 + dice_placeholder = st.empty() + + # 주사위 표현식 결정 + dice_expression = ability.get('recommended_dice', "1d20") + + # 능력치 수정자 적용 (표현식에 이미 능력치가 포함되어 있지 않은 경우) + ability_code = ability['code'] + ability_value = character_info['stats'][ability_code] + + if "+" not in dice_expression and "-" not in dice_expression: + # 능력치 수정자 적용 + dice_expression = f"{dice_expression}+{ability_value}" + + with st.spinner("주사위 굴리는 중..."): + # 주사위 굴리기 애니메이션 및 결과 표시 + dice_result = display_dice_animation(dice_placeholder, dice_expression, 1.0) + + st.session_state.dice_rolled = True + st.session_state.dice_result = dice_result + else: + # 이미 굴린 주사위 결과 표시 + dice_placeholder = st.empty() + dice_result = st.session_state.dice_result + + # 주사위 결과 재표시 로직... + + # 판정 결과 계산 + difficulty = ability['difficulty'] + success = dice_result['total'] >= difficulty + + # 결과 표시 (더 풍부하게 개선) + result_color = "#1e3a23" if success else "#3a1e1e" + result_border = "#4CAF50" if success else "#F44336" + result_text = "성공" if success else "실패" + outcome_text = ability['success_outcome'] if success else ability['failure_outcome'] + + st.markdown(f""" +
+

판정 결과: {result_text}

+
+
+ 주사위 + 능력치 +
{dice_result['total']}
+
+
VS
+
+ 난이도 +
{difficulty}
+
+
+
+

결과: {outcome_text}

+
+
+ """, unsafe_allow_html=True) + + # 스토리 진행 버튼 - 더 매력적인 UI + if st.button("스토리 진행", key="continue_story_button", use_container_width=True): + handle_story_progression(current_action, dice_result['total'], success, ability['code'], dice_result['total'], difficulty) + + return success, dice_result['total'], ability['code'], dice_result['total'], difficulty + +def handle_story_progression(action, dice_result, success, ability, total, difficulty): + """주사위 결과에 따른 스토리 진행을 처리하는 함수 - 개선된 버전""" + with st.spinner("마스터가 결과를 계산 중..."): + # 로딩 표시 + loading_placeholder = st.empty() + loading_placeholder.info("마스터가 스토리를 생성하는 중... 잠시만 기다려주세요.") + + # 아이템 관련 행동인지 확인 + item_acquisition = "[아이템 획득]" in action or "아이템" in action.lower() or "주워" in action or "발견" in action + + # 능력치 판정 결과에 따른 스토리 응답 생성 + response = generate_story_response( + action, + dice_result, + st.session_state.theme, + st.session_state.current_location, + st.session_state.character, + success=success, + ability=ability, + total=total, + difficulty=difficulty + ) + + # 디버깅용 로그 + print(f"생성된 스토리 응답: {response[:100]}...") + + # 스토리 로그에 추가 + if response and len(response) > 10: # 유효한 응답인지 확인 + st.session_state.story_log.append(response) + + # 아이템 획득 행동인 경우 스토리에서 아이템 추출 + if item_acquisition: + print("아이템 획득 행동 감지됨, 아이템 추출 시작") + acquired_items = extract_items_from_story(response) + + # 아이템이 추출되었는지 확인 + if acquired_items: + print(f"추출된 아이템 수: {len(acquired_items)}") + # 세션에 아이템 저장 + if 'acquired_items' not in st.session_state: + st.session_state.acquired_items = [] + + st.session_state.acquired_items.extend(acquired_items) + + # 인벤토리에 아이템 직접 추가 + for item in acquired_items: + result = update_inventory("add", item, st.session_state.character['inventory']) + print(f"인벤토리 업데이트 결과: {result}") + + # 알림 메시지 생성 + item_names = [item.name for item in acquired_items] + notification = f"🎁 획득한 아이템: {', '.join(item_names)}" + st.session_state.item_notification = notification + st.session_state.show_item_notification = True + print(f"아이템 알림 설정됨: {notification}") + else: + print("추출된 아이템이 없음") + + # 백업 - 스토리에서 굵게 표시된 텍스트가 있으면 아이템으로 추가 + if '**' in response: + import re + bold_matches = re.findall(r'\*\*([^*]+?)\*\*', response) + if bold_matches: + backup_item = Item( + name=bold_matches[0], + description="발견한 아이템입니다.", + consumable=False, + quantity=1 + ) + update_inventory("add", backup_item, st.session_state.character['inventory']) + + # 알림 메시지 생성 + notification = f"🎁 획득한 아이템: {bold_matches[0]}" + st.session_state.item_notification = notification + st.session_state.show_item_notification = True + print(f"백업 방식으로 아이템 추가됨: {bold_matches[0]}") + else: + # 백업 응답 사용 + backup_response = f"{'성공적으로' if success else '아쉽게도'} {action}을(를) {'완료했습니다' if success else '실패했습니다'}. 다음 행동을 선택할 수 있습니다." + st.session_state.story_log.append(backup_response) + + # 다음 행동 제안으로 바로 전환 (인벤토리 관리 단계 제거) + st.session_state.action_phase = 'suggestions' + st.session_state.suggestions_generated = False + + # 임시 상태 초기화 + if 'suggested_ability' in st.session_state: + del st.session_state.suggested_ability + if 'dice_result' in st.session_state: + del st.session_state.dice_result + st.session_state.dice_rolled = False + + # 로딩 메시지 제거 + loading_placeholder.empty() + + st.rerun() + +def generate_action_suggestions(location, theme, context): + """현재 상황에 맞는 행동 제안 생성 - 개선된 버전""" + + # 플레이어 인벤토리 확인 + inventory_items = [] + character_info = {} + if 'character' in st.session_state: + if 'inventory' in st.session_state.character: + inventory_items = st.session_state.character['inventory'] + character_info = st.session_state.character + + prompt = f""" + 당신은 TRPG 게임 마스터입니다. 플레이어에게 현재 상황에서 취할 수 있는 5가지 행동을 제안해주세요. + + ## 상황 정보 + - 테마: {theme} + - 현재 위치: {location} + - 최근 상황: {context} + - 플레이어 직업: {character_info.get('profession', '모험가')} + - 플레이어 인벤토리: {', '.join([item.name if hasattr(item, 'name') else str(item) for item in inventory_items])} + + ## 제안 지침 + 1. 각 행동은 매력적이고 흥미로운 결과로 이어질 수 있어야 합니다. + 2. 다양한 플레이 스타일(탐험, 전투, 사회적 상호작용, 수집 등)을 고려해주세요. + 3. 위험과 보상의 균형을 고려하세요. + 4. "어떻게 하시겠습니까?", "무엇을 선택하시겠습니까?" 등의 질문은 포함하지 마세요. + 5. 각 행동은 간결하고 명확한 서술로 작성하세요. + + 반드시 다음 형식으로 5가지 행동을 제안해주세요: + 1. [일반] 일반적인 행동 제안 (환경 탐색 등) + 2. [위험] 위험하지만 보상이 큰 행동 + 3. [상호작용] NPC나 환경과 상호작용하는 행동 + 4. [아이템 획득] 새로운 아이템을 얻을 수 있는 행동 (어떤 아이템을 얻을 수 있는지 암시) + 5. [아이템 사용] 인벤토리의 아이템을 사용하는 행동 (사용할 아이템 명시) + + [아이템 사용]의 경우, 플레이어 인벤토리에 있는 아이템 중 하나를 사용하는 행동을 제안하세요. + 인벤토리가 비어있다면 다른 유형의 행동을 제안하세요. + """ + + response = generate_gemini_text(prompt, 400) + + # 응답 파싱 + suggestions = [] + temp_suggestions = [] + + for line in response.split('\n'): + line = line.strip() + if not line: + continue + + # 카테고리 태그가 있는 행동 찾기 + for tag in ['[일반]', '[위험]', '[상호작용]', '[아이템 획득]', '[아이템 사용]']: + if tag in line: + # 행에서 번호와 점(.)을 제거하여 깔끔하게 만듦 + temp_line = re.sub(r'^\d+\.\s*', '', line) + temp_suggestions.append(temp_line) + break + + # 카테고리별 기본 행동 + default_actions = { + '[일반]': "주변을 자세히 살펴본다.", + '[위험]': "수상한 소리가 나는 방향으로 탐색한다.", + '[상호작용]': "근처에 있는 인물에게 말을 건다.", + '[아이템 획득]': "근처에서 빛나는 물체를 발견하고 주워든다.", + '[아이템 사용]': "가방에서 유용한 도구를 꺼내 사용한다." + } + + # 각 카테고리별로 제안이 있는지 확인 + categories = ['[일반]', '[위험]', '[상호작용]', '[아이템 획득]', '[아이템 사용]'] + for i, category in enumerate(categories): + found = False + for suggestion in temp_suggestions: + if category in suggestion: + suggestions.append(f"{i+1}. {suggestion}") + found = True + break + + if not found: + # 기본 행동 추가 + action = f"{i+1}. {category} {default_actions[category]}" + suggestions.append(action) + + return suggestions[:5] # 최대 5개까지 반환 + + + +# 개선된 주사위 굴리기 함수 +# 주사위 굴리기 기본 함수 +def roll_dice(dice_type=20, num_dice=1): + """주사위 굴리기 함수 - 개선된 버전""" + results = [random.randint(1, dice_type) for _ in range(num_dice)] + return results + +# 주사위 결과 계산 함수 +def calculate_dice_result(dice_expression): + """주사위 표현식 계산 (예: '2d6+3', '1d20-2', '3d8' 등)""" + import re + + # 표현식 분석 + pattern = r'(\d+)d(\d+)([+-]\d+)?' + match = re.match(pattern, dice_expression.lower().replace(' ', '')) + + if not match: + raise ValueError(f"유효하지 않은 주사위 표현식: {dice_expression}") + + num_dice = int(match.group(1)) + dice_type = int(match.group(2)) + modifier = match.group(3) + + # 주사위 굴리기 + rolls = roll_dice(dice_type, num_dice) + + # 보정값 적용 + total = sum(rolls) + modifier_value = 0 + + if modifier: + modifier_value = int(modifier) + total += modifier_value + + return { + 'rolls': rolls, + 'total': total, + 'modifier': modifier_value, + 'num_dice': num_dice, + 'dice_type': dice_type + } + +def handle_action_phase(): + """행동 선택 및 처리 부분을 관리하는 함수 - 개선된 버전""" + # 행동 단계 관리 + action_phase = st.session_state.get('action_phase', 'suggestions') + + # 1. 이동 처리 + if action_phase == "moving": + with st.spinner(f"{st.session_state.move_destination}(으)로 이동 중..."): + # 로딩 표시 + loading_placeholder = st.empty() + loading_placeholder.info(f"{st.session_state.move_destination}(으)로 이동하는 중... 잠시만 기다려주세요.") + + # 이동 스토리 생성 + movement_story = generate_movement_story( + st.session_state.current_location, + st.session_state.move_destination, + st.session_state.theme + ) + + # 스토리 로그에 추가 + st.session_state.story_log.append(movement_story) + + # 현재 위치 업데이트 + st.session_state.current_location = st.session_state.move_destination + + # 이동 상태 초기화 + st.session_state.move_destination = "" + st.session_state.action_phase = 'suggestions' + st.session_state.suggestions_generated = False + + # 로딩 메시지 제거 + loading_placeholder.empty() + + st.rerun() + + # 2. 능력치 판정 단계 + elif action_phase == "ability_check": + st.subheader("능력치 판정") + + # 행동 표시 - 가독성 개선 + st.info(f"선택한 행동: {st.session_state.current_action}") + + # 마스터가 능력치와 난이도 제안 + if 'suggested_ability' not in st.session_state: + with st.spinner("마스터가 판정 방식을 결정 중..."): + # 로딩 표시 + loading_placeholder = st.empty() + loading_placeholder.info("마스터가 판정 방식을 결정하는 중... 잠시만 기다려주세요.") + + # 행동 분석을 위한 프롬프트 + prompt = f""" + 당신은 TRPG 게임 마스터입니다. 플레이어의 다음 행동에 가장 적합한 능력치와 난이도를 결정해주세요. + + 플레이어 행동: {st.session_state.current_action} + 플레이어 직업: {st.session_state.character['profession']} + 현재 위치: {st.session_state.current_location} + + 다음 능력치 중 하나를 선택하세요: + - STR (근력): 물리적 힘이 필요한 행동 + - INT (지능): 지식, 분석, 추론이 필요한 행동 + - DEX (민첩): 손재주, 반사신경, 정확성이 필요한 행동 + - CON (체력): 지구력, 내구성이 필요한 행동 + - WIS (지혜): 직관, 통찰력, 인식이 필요한 행동 + - CHA (매력): 설득, 위협, 속임수가 필요한 행동 + + 난이도는 다음 기준으로 설정하세요: + - 쉬움(10): 일상적인 행동, 실패 가능성이 낮음 + - 보통(15): 약간의 전문성이 필요한 행동, 보통 수준의 도전 + - 어려움(20): 전문적 기술이 필요한 행동, 실패 가능성이 높음 + - 매우 어려움(25): 극도로 어려운 행동, 전문가도 실패할 확률이 높음 + - 거의 불가능(30): 역사적으로 몇 번 성공한 적 있는 수준의 행동 + + 다음 형식으로 응답해주세요: + 능력치: [능력치 코드] + 난이도: [숫자] + 이유: [간략한 설명] + 성공 결과: [성공했을 때 일어날 일에 대한 간략한 설명] + 실패 결과: [실패했을 때 일어날 일에 대한 간략한 설명] + """ + + # 마스터의 판정 제안 생성 + response = generate_gemini_text(prompt, 250) + + # 응답에서 능력치와 난이도 추출 + ability_code = "STR" # 기본값 + difficulty = 15 # 기본값 + reason = "이 행동에는 근력이 필요합니다." # 기본값 + success_outcome = "행동에 성공합니다." # 기본값 + failure_outcome = "행동에 실패합니다." # 기본값 + + for line in response.split('\n'): + if '능력치:' in line.lower(): + for code in ['STR', 'INT', 'DEX', 'CON', 'WIS', 'CHA']: + if code in line: + ability_code = code + break + elif '난이도:' in line.lower(): + try: + difficulty_str = line.split(':')[1].strip() + difficulty = int(''.join(filter(str.isdigit, difficulty_str))) + # 범위 제한 + difficulty = max(5, min(30, difficulty)) + except: + pass + elif '이유:' in line.lower(): + reason = line.split(':')[1].strip() + elif '성공 결과:' in line.lower(): + success_outcome = line.split(':', 1)[1].strip() + elif '실패 결과:' in line.lower(): + failure_outcome = line.split(':', 1)[1].strip() + + # 능력치 전체 이름 매핑 + ability_names = { + 'STR': '근력', 'INT': '지능', 'DEX': '민첩', + 'CON': '체력', 'WIS': '지혜', 'CHA': '매력' + } + + # 세션에 저장 + st.session_state.suggested_ability = { + 'code': ability_code, + 'name': ability_names.get(ability_code, ''), + 'difficulty': difficulty, + 'reason': reason, + 'success_outcome': success_outcome, + 'failure_outcome': failure_outcome + } + + # 로딩 메시지 제거 + loading_placeholder.empty() + + st.rerun() + + # 마스터의 제안 표시 - 간소화된 UI + ability = st.session_state.suggested_ability + + # 레이아웃 분리 + col1, col2 = st.columns(2) + with col1: + st.write(f"**사용 능력치:** {ability['code']} ({ability['name']})") + st.write(f"**난이도:** {ability['difficulty']}") + with col2: + st.write(f"**이유:** {ability['reason']}") + + # 성공/실패 결과 표시 + st.success(f"**성공 시:** {ability['success_outcome']}") + st.error(f"**실패 시:** {ability['failure_outcome']}") + + # 주사위 굴리기 자동 실행 + if not st.session_state.get('dice_rolled', False): + # 주사위 애니메이션을 위한 플레이스홀더 + dice_placeholder = st.empty() + + # 주사위 굴리기 전 로딩 메시지 표시 + loading_dice_placeholder = st.empty() + loading_dice_placeholder.info("주사위를 준비하는 중... 잠시만 기다려주세요.") + + with st.spinner("주사위 굴리는 중..."): + # 주사위 굴리기 + dice_result = random.randint(1, 20) + + # 로딩 메시지 제거 + loading_dice_placeholder.empty() + + dice_placeholder.markdown(f"
🎲 {dice_result}
", unsafe_allow_html=True) + + st.session_state.dice_rolled = True + st.session_state.dice_result = dice_result + else: + # 주사위 결과 표시 + dice_result = st.session_state.dice_result + st.markdown(f"
🎲 {dice_result}
", unsafe_allow_html=True) + + # 능력치 값 가져오기 + ability_code = st.session_state.suggested_ability['code'] + ability_value = st.session_state.character['stats'][ability_code] + difficulty = st.session_state.suggested_ability['difficulty'] + + # 판정 결과 계산 + total_result = dice_result + ability_value + success = total_result >= difficulty + + # 결과 표시 (간소화된 버전) + result_color = "green" if success else "red" + result_text = "성공" if success else "실패" + + st.write(f"### 판정 결과: {result_text}") + st.write(f"주사위 결과: {dice_result}") + st.write(f"능력치 보너스: +{ability_value} ({ability_code})") + st.write(f"총합: {total_result} vs 난이도: {difficulty}") + + # 결과 설명 + if success: + st.success(ability['success_outcome']) + else: + st.error(ability['failure_outcome']) + + # 스토리 진행 버튼 + if st.button("스토리 진행", key="continue_story_button", use_container_width=True): + handle_story_progression(st.session_state.current_action, dice_result, success, ability_code, total_result, difficulty) + + # 3. 행동 제안 및 선택 단계 + elif action_phase == 'suggestions': + st.subheader("행동 선택") + + # 위치 이동 옵션 + if 'available_locations' in st.session_state and len(st.session_state.available_locations) > 1: + with st.expander("다른 장소로 이동", expanded=False): + st.write("이동할 장소를 선택하세요:") + + # 현재 위치를 제외한 장소 목록 생성 + other_locations = [loc for loc in st.session_state.available_locations + if loc != st.session_state.current_location] + + # 장소 버튼 표시 + location_cols = st.columns(2) + for i, location in enumerate(other_locations): + with location_cols[i % 2]: + if st.button(f"{location}로 이동", key=f"move_to_{i}", use_container_width=True): + st.session_state.move_destination = location + st.session_state.action_phase = 'moving' + st.rerun() + + # 행동 제안 표시 + if st.session_state.get('suggestions_generated', False): + # 행동 제안 표시 (간소화된 방식) + st.write("### 제안된 행동") + for i, action in enumerate(st.session_state.action_suggestions): + # 행동 유형 아이콘 결정 + if "[아이템 획득]" in action: + icon = "🔍" + elif "[아이템 사용]" in action: + icon = "🧰" + elif "[위험]" in action: + icon = "⚠️" + elif "[상호작용]" in action: + icon = "💬" + else: # [일반] + icon = "🔎" + + # 선택지 표시 + expander = st.expander(f"{icon} {action}") + with expander: + if st.button(f"이 행동 선택", key=f"action_{i}", use_container_width=True): + st.session_state.current_action = action + st.session_state.action_phase = 'ability_check' + # 초기화 + st.session_state.dice_rolled = False + if 'dice_result' in st.session_state: + del st.session_state.dice_result + if 'suggested_ability' in st.session_state: + del st.session_state.suggested_ability + st.rerun() + + # 직접 행동 입력 옵션 + st.markdown("---") + st.write("### 직접 행동 입력") + custom_action = st.text_input("행동 설명:", key="custom_action_input") + if st.button("실행", key="custom_action_button") and custom_action: + # 행동 선택 시 주사위 굴림 상태 초기화 + st.session_state.current_action = custom_action + st.session_state.action_phase = 'ability_check' + # 초기화 + st.session_state.dice_rolled = False + if 'dice_result' in st.session_state: + del st.session_state.dice_result + if 'suggested_ability' in st.session_state: + del st.session_state.suggested_ability + st.rerun() + + # 행동 제안 생성 + else: + with st.spinner("마스터가 행동을 제안 중..."): + # 로딩 표시 + loading_placeholder = st.empty() + loading_placeholder.info("마스터가 행동을 제안하는 중... 잠시만 기다려주세요.") + + if st.session_state.story_log: + last_entry = st.session_state.story_log[-1] + else: + last_entry = "모험의 시작" + + st.session_state.action_suggestions = generate_action_suggestions( + st.session_state.current_location, + st.session_state.theme, + last_entry + ) + st.session_state.suggestions_generated = True + + # 로딩 메시지 제거 + loading_placeholder.empty() + + st.rerun() + +def master_answer_game_question(question, theme, location, world_description): + """게임 중 질문에 마스터가 답변 - 개선된 버전""" + prompt = f""" + 당신은 TRPG 게임 마스터입니다. 플레이어가 게임 중에 다음과 같은 질문을 했습니다: + + {question} + + ## 게임 정보 + 세계 테마: {theme} + 현재 위치: {location} + 세계 설명: {world_description[:300]}... + + ## 응답 지침 + 1. 게임의 흐름을 유지하되, 플레이어에게 유용한 정보를 제공하세요. + 2. 세계관의 신비함과 일관성을 유지하세요. + 3. 필요하다면 플레이어의 캐릭터가 알지 못하는 정보는 "소문에 따르면..." 또는 "전설에 의하면..."과 같은 형식으로 제공하세요. + 4. 직접적인 답변보다는 플레이어가 스스로 발견하고 탐험할 수 있는 힌트를 제공하세요. + 5. 150단어 이내로 답변하세요. + 6. 모든 문장은 완결된 형태로 작성하세요. + """ + + return generate_gemini_text(prompt, 400) + +# 이동 스토리 생성 함수 +def generate_movement_story(current_location, destination, theme): + """장소 이동 시 스토리 생성 - 개선된 버전""" + prompt = f""" + 당신은 TRPG 게임 마스터입니다. 플레이어가 {current_location}에서 {destination}으로 이동하려고 합니다. + + ## 이동 스토리 지침 + 1. 이동 과정과 새로운 장소에 도착했을 때의 상황을 생생하게 묘사해주세요. + 2. 이동 중 발생하는 작은 사건이나 만남을 포함하세요. + 3. 출발지와 목적지의 대비되는 분위기나 환경적 차이를 강조하세요. + 4. 다양한 감각적 묘사(시각, 청각, 후각, 촉각)를 포함하세요. + 5. 도착 장소에서 플레이어가 볼 수 있는 주요 랜드마크나 특징적 요소를 설명하세요. + 6. 현지 주민이나 생물의 반응이나 활동을 포함하세요. + + ## 정보 + 세계 테마: {theme} + 출발 위치: {current_location} + 목적지: {destination} + + 약 200단어 내외로 작성해주세요. + 모든 문장은 완결된 형태로 작성하세요. + """ + + return generate_gemini_text(prompt, 500) + +def get_theme_description(theme): + """테마에 대한 상세 설명 제공""" + theme_descriptions = { + "fantasy": """ +

판타지 세계는 마법, 신화적 생물, 영웅적 모험이 가득한 세계입니다.

+

중세 시대를 연상시키는 배경에 마법과 신비로운 존재들이 공존하며, + 고대의 유물, 잊혀진 주문서, 드래곤과 같은 전설적 생물들이 있습니다.

+

당신은 이 세계에서 마법사, 전사, 도적, 성직자 등 다양한 직업을 가진 모험가가 될 수 있습니다.

+ """, + + "sci-fi": """ +

SF(공상과학) 세계는 미래 기술, 우주 탐험, 외계 생명체가 존재하는 세계입니다.

+

첨단 기술, 우주선, 인공지능, 외계 행성 등이 배경이 되며, + 인류의 미래 또는 다른 행성계의 이야기를 다룹니다.

+

당신은 우주 파일럿, 사이버 해커, 외계종족 전문가 등 미래 지향적인 직업을 가진 캐릭터가 될 수 있습니다.

+ """, + + "dystopia": """ +

디스토피아 세계는 암울한 미래, 억압적인 사회 체제, 환경 재앙 이후의 세계를 그립니다.

+

종종 파괴된 문명의 폐허, 독재 정권, 자원 부족, 계급 사회 등을 배경으로 하며, + 생존과 자유를 위한 투쟁이 중심 주제입니다.

+

당신은 저항군 요원, 밀수업자, 정보 브로커 등 어두운 세계에서 살아남기 위한 직업을 가진 캐릭터가 될 수 있습니다.

+ """ + } + + return theme_descriptions.get(theme, "") + +def world_description_page(): + st.header("2️⃣ 세계관 설명") + + # 마스터 메시지 표시 + st.markdown(f"
{st.session_state.master_message}
", unsafe_allow_html=True) + + # 세계관 설명 표시 - 단락 구분 개선 + world_desc_paragraphs = st.session_state.world_description.split("\n\n") + formatted_desc = "" + for para in world_desc_paragraphs: + formatted_desc += f"

{para}

\n" + + st.markdown(f"
{formatted_desc}
", unsafe_allow_html=True) + + # "다른 세계 탐험하기" 버튼 추가 - 새로운 기능 + if st.button("🌍 다른 세계 탐험하기", key="explore_other_world", use_container_width=True): + # 세션 상태 초기화 (일부만) + for key in ['theme', 'world_description', 'world_generated', 'world_accepted', + 'question_answers', 'question_count', 'current_location']: + if key in st.session_state: + del st.session_state[key] + + # 테마 선택 화면으로 돌아가기 + st.session_state.stage = 'theme_selection' + st.session_state.master_message = "새로운 세계를 탐험해보세요!" + st.rerun() + + # 탭 기반 UI로 변경 - 더 매끄러운 사용자 경험 + tabs = st.tabs(["세계관 확장", "질문하기", "탐험 시작"]) + + # 세계관 확장 탭 + with tabs[0]: + st.subheader("세계관 이어서 작성") + + # 설명 추가 - 가독성 개선 + st.markdown(""" +
+

세계관을 더 풍부하게 만들어보세요. AI 마스터에게 특정 부분을 확장해달라고 요청하거나, 직접 내용을 추가할 수 있습니다.

+

추가된 내용은 기존 세계관과 자연스럽게 통합되어 더 깊이 있는 세계를 만들어갑니다.

+
+ """, unsafe_allow_html=True) + + # 직접 입력 옵션 추가 + expand_method = st.radio( + "확장 방법 선택:", + ["AI 마스터에게 맡기기", "직접 작성하기"], + horizontal=True + ) + + # AI 확장 선택 시 + if expand_method == "AI 마스터에게 맡기기": + # 확장할 주제 선택 (더 구체적인 세계관 생성 유도) + expansion_topics = { + "역사와 전설": "세계의 역사적 사건, 신화, 전설적 영웅 등에 대한 이야기를 확장합니다.", + "마법/기술 체계": "세계의 마법 시스템이나 기술 체계의 작동 방식과 한계를 자세히 설명합니다.", + "종족과 문화": "세계에 존재하는 다양한 종족들과 그들의 문화, 관습, 생활 방식을 확장합니다.", + "정치 체계와 세력": "권력 구조, 주요 세력 간의 관계, 정치적 갈등 등을 더 자세히 설명합니다.", + "지리와 환경": "세계의 지리적 특성, 주요 지역, 기후, 자연환경에 대해 확장합니다.", + "현재 갈등과 위기": "세계에서 진행 중인 갈등, 위기, 중요한 문제에 대해 자세히 설명합니다." + } + + topic_options = list(expansion_topics.keys()) + topic_descriptions = list(expansion_topics.values()) + + # 설명과 함께 확장 주제 선택 + expansion_topic_idx = st.selectbox( + "확장할 세계관 요소를 선택하세요:", + range(len(topic_options)), + format_func=lambda i: topic_options[i] + ) + + expansion_topic = topic_options[expansion_topic_idx] + + # 선택한 주제에 대한 설명 표시 + st.markdown(f""" +
+

{topic_descriptions[expansion_topic_idx]}

+
+ """, unsafe_allow_html=True) + + # 이전 세계관 설명의 마지막 부분만 표시 + last_paragraph = st.session_state.world_description.split("\n\n")[-1] + + # ���장 버튼 누르기 전과 후의 상태 관리 + if 'continuation_generated' not in st.session_state: + st.session_state.continuation_generated = False + + if not st.session_state.continuation_generated: + if st.button("세계관 확장하기", key="expand_world"): + with st.spinner("이어질 내용을 생성 중..."): + try: + continuation_prompt = f""" + 당신은 TRPG 게임 마스터입니다. 다음 세계관 설명을 이어서 작성해주세요. + 이전 세계관 내용을 기반으로 "{expansion_topic}" 측면을 더 상세히 확장해주세요. + + 테마: {st.session_state.theme} + 현재 세계관 설명의 일부: + {st.session_state.world_description[:500]}... + + ## 확장 지침: + 1. 선택한 주제({expansion_topic})에 초점을 맞추어 세계관을 확장하세요. + 2. 플레이어가 탐험하거나 상호작용할 수 있는 구체적인 요소를 추가하세요. + 3. 이전 내용과 일관성을 유지하면서 세계를 더 풍부하게 만드세요. + 4. 비밀, 갈등, 또는 미스터리 요소를 하나 이상 포함하세요. + 5. 200-300단어 내외로 작성하세요. + 6. 단락을 나누어 가독성을 높이세요. + + 모든 문장은 완결된 형태로 작성하세요. + """ + + # 로딩 표시 확실히 하기 + loading_placeholder = st.empty() + loading_placeholder.info("AI 마스터가 세계관을 확장하고 있습니다... 잠시만 기다려주세요.") + + # 확장 내용 생성 + st.session_state.continuation_text = generate_gemini_text(continuation_prompt, 500) + st.session_state.continuation_generated = True + + # 로딩 메시지 제거 + loading_placeholder.empty() + except Exception as e: + st.error(f"내용 생성 중 오류 발생: {e}") + # 오류 발생 시 백업 응답 + st.session_state.continuation_text = "이 세계는 더 많은 비밀과 모험으로 가득 차 있습니다. 숨겨진 장소와 만날 수 있는 흥미로운 캐릭터들이 여러분을 기다리고 있습니다." + st.session_state.continuation_generated = True + st.rerun() + + # 생성된 내용이 있으면 표시 + if st.session_state.continuation_generated: + # 사용성 개선: 생성된 내용과 어떻게 반영되는지 시각적으로 표시 + st.subheader("확장된 세계관 내용:") + st.info("다음 내용이 세계관에 추가됩니다. '이 내용으로 적용하기'를 클릭하면 세계관에 반영됩니다.") + + # 단락 나누기 - 가독성 개선 + continuation_paragraphs = st.session_state.continuation_text.split("\n\n") + formatted_continuation = "" + for para in continuation_paragraphs: + formatted_continuation += f"

{para}

\n" + + st.markdown(f"
{formatted_continuation}
", unsafe_allow_html=True) + + # 적용 버튼과 다시 생성 버튼 병렬 배치 + col1, col2 = st.columns(2) + with col1: + if st.button("이 내용으로 적용하기", key="apply_expansion"): + # 세계 설명에 추가 + st.session_state.world_description += "\n\n## " + expansion_topic + "\n" + st.session_state.continuation_text + + # 상태 초기화 + st.session_state.continuation_generated = False + if "continuation_text" in st.session_state: + del st.session_state.continuation_text + + st.session_state.master_message = "세계관이 더욱 풍부해졌습니다! 이 세계에 대해 더 궁금한 점이 있으신가요?" + st.success("세계관이 성공적으로 확장되었습니다!") + st.rerun() + + with col2: + if st.button("다시 생성하기", key="regenerate_expansion"): + # 내용 다시 생성하도록 상태 초기화 + st.session_state.continuation_generated = False + if "continuation_text" in st.session_state: + del st.session_state.continuation_text + st.rerun() + + # 직접 작성 선택 시 + else: # "직접 작성하기" + st.write("세계관에 추가하고 싶은 내용을 직접 작성해보세요:") + user_continuation = st.text_area("세계관 추가 내용:", height=200) + + # 사용성 개선: 무한 추가 방지를 위한 확인 메시지 + if user_continuation and st.button("내용 추가하기", key="add_user_content"): + # 미리보기 표시 + st.subheader("추가될 내용:") + st.info("다음 내용이 세계관에 추가됩니다. 내용이 올바른지 확인하세요.") + + # 단락 나누기 - 가독성 개선 + user_paragraphs = user_continuation.split("\n\n") + formatted_user_content = "" + for para in user_paragraphs: + formatted_user_content += f"

{para}

\n" + + st.markdown(f"
{formatted_user_content}
", unsafe_allow_html=True) + + # 확인 후 추가 (한 번만 추가되도록 확인) + confirm = st.checkbox("위 내용을 세계관에 추가하시겠습니까?", key="confirm_add_content") + if confirm and st.button("확인 후 추가하기", key="confirm_add_user_content"): + # 작성한 내용 추가 + st.session_state.world_description += "\n\n## 직접 추가한 세계관 내용\n" + user_continuation + st.session_state.master_message = "직접 작성하신 내용이 세계관에 추가되었습니다! 이 세계가 더욱 풍부해졌습니다." + st.success("세계관에 내용이 성공적으로 추가되었습니다!") + st.rerun() + + # 질문하기 탭 - 개선된 선택 시각화 + with tabs[1]: + st.subheader("세계관에 대한 질문") + + # 설명 추가 - 가독성 개선 + st.markdown(""" +
+

세계에 대해 궁금한 점을 마스터에게 질문해보세요. 세계의 역사, 문화, 종족, 마법/기술 체계 등에 대한 질문을 할 수 있습니다.

+

마스터의 답변은 세계관에 추가되어 더 풍부한 배경을 만들어갑니다.

+
+ """, unsafe_allow_html=True) + + # 질문 제안 목록 + suggested_questions = [ + "이 세계의 마법/기술 체계는 어떻게 작동하나요?", + "가장 위험한 지역은 어디이며 어떤 위협이 있나요?", + "주요 세력들 간의 관계는 어떻게 되나요?", + "일반적인 사람들의 생활 방식은 어떠한가요?", + "이 세계에서 가장 귀중한 자원은 무엇인가요?", + "최근에 일어난 중요한 사건은 무엇인가요?", + "전설적인 인물이나 영웅은 누구인가요?", + ] + + # 질문 처리 상태 관리 + if 'question_processing' not in st.session_state: + st.session_state.question_processing = False + + if 'selected_suggested_question' not in st.session_state: + st.session_state.selected_suggested_question = None + + if 'world_questions_history' not in st.session_state: + st.session_state.world_questions_history = [] + + # 제안된 질문 표시 - 토글 방식으로 개선 + st.write("제안된 질문:") + question_cols = st.columns(2) + + for i, q in enumerate(suggested_questions): + with question_cols[i % 2]: + # 토글 버튼으로 질문 선택 + is_selected = st.checkbox(q, key=f"toggle_q_{i}", value=(st.session_state.selected_suggested_question == q)) + + if is_selected: + st.session_state.selected_suggested_question = q + elif st.session_state.selected_suggested_question == q: + st.session_state.selected_suggested_question = None + + # 선택된 질문이 있으면 질문하기 버튼 표시 + if st.session_state.selected_suggested_question: + st.markdown("
", unsafe_allow_html=True) + st.success(f"'{st.session_state.selected_suggested_question}' 질문이 선택되었습니다.") + + # 직접 질문 입력 섹션 + st.markdown("
", unsafe_allow_html=True) + st.write("### 직접 질문 입력") + + # 기본값 설정 (선택된 질문이 있으면 해당 질문 표시) + default_question = st.session_state.get('custom_question_value', st.session_state.get('selected_suggested_question', '')) + + # 폼 사용으로 무한 생성 방지 + with st.form(key="world_question_form"): + custom_question = st.text_input("질문 내용:", value=default_question, key="custom_world_question") + submit_question = st.form_submit_button("질문하기", use_container_width=True, disabled=st.session_state.question_processing) + + # 질문이 제출되었을 때 + if submit_question and (custom_question or st.session_state.selected_suggested_question): + question_to_ask = custom_question or st.session_state.selected_suggested_question + + # 이미 처리 중이 아닐 때만 실행 + if not st.session_state.question_processing: + st.session_state.question_processing = True + + # 응답 표시할 플레이스홀더 생성 + response_placeholder = st.empty() + response_placeholder.info("마스터가 답변을 작성 중입니다... 잠시만 기다려주세요.") + + # 질문 처리 및 답변 생성 + try: + prompt = f""" + 당신은 TRPG 마스터입니다. 플레이어가 당신이 만든 세계에 대해 질문했습니다. + 세계관 설명: {st.session_state.world_description} + + 플레이어의 질문: {question_to_ask} + + 이 질문에 대한 답변을 세계관에 맞게 상세하게 제공해주세요. + 답변은 마크다운 형식으로 작성하고, 일반적인 캐릭터이름, 장소 이름 등은 아이템으로 굵게 표시하지 마세요요 + 모든 문장은 완결된 형태로 작성하세요. + """ + + # 답변 생성 + with st.spinner("마스터가 질문에 대한 답변을 생각하고 있습니다..."): + answer = generate_gemini_text(prompt, 800) + + # 질문과 답변을 세션 상태에 저장 + qa_pair = { + "question": question_to_ask, + "answer": answer, + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + st.session_state.world_questions_history.append(qa_pair) + + # 세계관에 질문과 답변 추가 + st.session_state.world_description += f"\n\n## 질문: {question_to_ask}\n{answer}" + + # 단락 구분 적용 + answer_paragraphs = answer.split("\n\n") + formatted_answer = "" + for para in answer_paragraphs: + formatted_answer += f"

{para}

\n" + + # 응답 표시 - 페이지 새로고침 없이 표시 + response_placeholder.markdown(f""" +
+
질문: {question_to_ask}
+
{formatted_answer}
+
+ """, unsafe_allow_html=True) + + # 상태 초기화 + st.session_state.master_message = "질문에 답변했습니다. 더 궁금한 점이 있으신가요?" + + except Exception as e: + st.error(f"응답 생성 중 오류가 발생했습니다: {e}") + response_placeholder.error("질문 처리 중 오류가 발생했습니다. 다시 시도해주세요.") + + finally: + # 처리 완료 상태로 변경 + st.session_state.question_processing = False + st.session_state.selected_suggested_question = None + st.session_state.custom_question_value = '' + + # 이전 질문 및 답변 표시 + if st.session_state.world_questions_history: + st.markdown("
", unsafe_allow_html=True) + st.write("### 이전 질문 및 답변") + + for i, qa in enumerate(reversed(st.session_state.world_questions_history)): + with st.expander(f"Q: {qa['question']} ({qa['timestamp']})"): + st.markdown(qa['answer']) + # 탐험 시작 탭 + with tabs[2]: + st.subheader("탐험 시작하기") + + # 설명 추가 - 가독성 개선 + st.markdown(""" +
+

모험을 시작할 지역을 선택하고 캐릭터 생성으로 진행하세요.

+

선택한 지역은 캐릭터가 모험을 시작하는 첫 장소가 됩니다.

+
+ """, unsafe_allow_html=True) + + # 시작 지점 선택 + if 'available_locations' in st.session_state and st.session_state.available_locations: + st.write("#### 시작 지점 선택") + st.write("모험을 시작할 위치를 선택하세요:") + + # 사용성 개선: 선택된 위치를 표시 + selected_location = st.session_state.get('current_location', '') + + # 시작 지점 그리드 표시 + location_cols = st.columns(3) + for i, location in enumerate(st.session_state.available_locations): + with location_cols[i % 3]: + # 현재 선택된 위치인 경우 다른 스타일로 표시 + if location == selected_location: + st.markdown(f""" +
+ ✓ {location} (선택됨) +
+ """, unsafe_allow_html=True) + # 선택 취소 버튼 + if st.button("선택 취소", key=f"unselect_loc_{i}"): + st.session_state.current_location = "" + st.rerun() + else: + if st.button(location, key=f"start_loc_{i}", use_container_width=True): + st.session_state.current_location = location + st.session_state.master_message = f"{location}에서 모험을 시작합니다. 이제 캐릭터를 생성할 차례입니다." + st.rerun() + + # 캐릭터 생성으로 이동 버튼 + st.write("#### 캐릭터 생성") + st.write("세계를 충분히 탐색했다면, 이제 당신의 캐릭터를 만들어 모험을 시작할 수 있습니다.") + + # 선택된 시작 위치 없으면 경고 + if not st.session_state.get('current_location'): + st.warning("캐릭터 생성으로 진행하기 전에 시작 지점을 선택해주세요!") + proceed_button = st.button("캐릭터 생성으로 진행", key="to_character_creation", + use_container_width=True, disabled=True) + else: + proceed_button = st.button("캐릭터 생성으로 진행", key="to_character_creation", + use_container_width=True) + if proceed_button: + st.session_state.stage = 'character_creation' + st.session_state.master_message = "이제 이 세계에서 모험을 떠날 당신의 캐릭터를 만들어 볼까요?" + st.rerun() + +def reset_game_session(): + """게임 세션을 완전히 초기화하고 첫 화면으로 돌아가는 함수""" + # 세션 상태의 모든 키 리스트 가져오기 + all_keys = list(st.session_state.keys()) + + # 'initialized'를 제외한 모든 키 삭제 + for key in all_keys: + if key != 'initialized': + if key in st.session_state: + del st.session_state[key] + + # 기본 상태 다시 설정 + st.session_state.stage = 'theme_selection' + st.session_state.master_message = "어서 오세요, 모험가님. 어떤 세계를 탐험하고 싶으신가요?" + + # 이 함수가 호출된 후에는 반드시 st.rerun()을 호출해야 함 + +def set_stage_to_character_creation(): + st.session_state.stage = 'character_creation' + st.session_state.master_message = "이제 이 세계에서 모험을 떠날 당신의 캐릭터를 만들어 볼까요?" + + + + + +def is_mobile(): + """현재 기기가 모바일인지 확인""" + # 간단한 추정 - Streamlit에서 직접 기기 타입을 얻기 어려움 + # 실제로는 브라우저 window.innerWidth를 체크하는 JavaScript가 필요할 수 있음 + # 여기서는 세션 상태에 설정된 값을 사용 + return st.session_state.get('is_mobile', False) + +# 개선된 반응형 레이아웃 - 모바일 지원 +def setup_responsive_layout(): + """반응형 레이아웃 설정""" + # 이 함수는 실제로는 JavaScript를 통해 화면 너비를 감지하고 + # 모바일 여부를 설정할 수 있지만, 여기서는 간단히 버튼으로 전환 + + # 디스플레이 모드 토글 버튼 + display_mode = st.sidebar.radio( + "디스플레이 모드:", + ["데스크톱", "모바일"], + horizontal=True + ) + + # 모바일 모드 설정 + st.session_state.is_mobile = (display_mode == "모바일") + + # 모바일 모드일 때 사이드바에 추가 메뉴 + if st.session_state.is_mobile: + st.sidebar.markdown("### 모바일 네비게이션") + + # 게임 플레이 단계에서만 패널 선택 옵션 표시 + if st.session_state.get('stage') == 'game_play': + panel_options = ["스토리", "캐릭터 정보", "게임 도구"] + current_panel = st.session_state.get('mobile_panel', "스토리") + + selected_panel = st.sidebar.radio( + "표시할 패널:", + panel_options, + index=panel_options.index(current_panel) + ) + + if selected_panel != current_panel: + st.session_state.mobile_panel = selected_panel + st.rerun() + +def extract_background_tags(background_text): + """배경 텍스트에서 태그를 추출하는 함수""" + tags = [] + keyword_map = { + "영웅": "영웅적", "구원": "영웅적", "정의": "영웅적", + "비극": "비극적", "상실": "비극적", "슬픔": "비극적", "고통": "비극적", + "신비": "신비로운", "마법": "신비로운", "초자연": "신비로운", + "학자": "학자", "연구": "학자", "지식": "학자", "서적": "학자", + "범죄": "범죄자", "도둑": "범죄자", "불법": "범죄자", "암흑가": "범죄자", + "전사": "전사", "전투": "전사", "군인": "전사", "검술": "전사", + "귀족": "귀족", "왕족": "귀족", "부유": "귀족", "상류층": "귀족", + "서민": "서민", "평민": "서민", "일반인": "서민", "농부": "서민", + "이방인": "이방인", "외지인": "이방인", "여행자": "이방인", "이주민": "이방인", + "운명": "운명적", "예언": "운명적", "선택받은": "운명적" + } + + for keyword, tag in keyword_map.items(): + if keyword.lower() in background_text.lower() and tag not in tags: + tags.append(tag) + + # 최대 3개 태그 제한 + return tags[:3] if tags else ["신비로운"] # 기본 태그 추가 + +def generate_races(theme): + """테마에 따른 종족 목록 반환""" + races = { + 'fantasy': ['인간', '엘프', '드워프', '하플링', '오크', '고블린', '드라코니안'], + 'sci-fi': ['인간', '안드로이드', '외계인 하이브리드', '변형 인류', '네뷸런', '크로노스피어', '우주 유목민'], + 'dystopia': ['인간', '변이체', '강화인류', '생체기계', '숙주', '정신감응자', '저항자'] + } + return races.get(theme, ['인간', '비인간', '신비종족']) + +def character_creation_page(): + st.header("2️⃣ 캐릭터 생성") + + # 마스터 메시지 표시 + st.markdown(f"
{st.session_state.master_message}
", unsafe_allow_html=True) + + if 'character_creation_step' not in st.session_state: + st.session_state.character_creation_step = 'race' # 이제 종족 선택이 첫 단계 + + # 종족 선택 단계 + if st.session_state.character_creation_step == 'race': + st.subheader("종족 선택") + + # 종족 선택 설명 추가 + st.markdown(""" +
+

캐릭터의 종족은 당신의 모험에 큰 영향을 미칩니다. 각 종족은 고유한 특성과 문화적 배경을 가지고 있습니다.

+

종족에 따라 특정 능력치에 보너스가 부여될 수 있으며, 스토리텔링에도 영향을 줍니다.

+
+ """, unsafe_allow_html=True) + + # 종족 목록 + races = generate_races(st.session_state.theme) + + # 종족별 아이콘 매핑 + race_icons = { + '인간': '👨‍🦰', '엘프': '🧝', '드워프': '🧔', '하플링': '🧒', '오크': '👹', + '고블린': '👺', '드라코니안': '🐉', '안드로이드': '🤖', '외계인 하이브리드': '👽', + '변형 인류': '🧬', '네뷸런': '✨', '크로노스피어': '⏱️', '우주 유목민': '🚀', + '변이체': '☢️', '강화인류': '🦾', '생체기계': '🔌', '숙주': '🦠', + '정신감응자': '🔮', '저항자': '⚔️', '비인간': '❓', '신비종족': '🌟' + } + + # 종족 능력치 보너스 매핑 + race_bonuses = { + '인간': {'모든 능력치': '+1'}, + '엘프': {'DEX': '+2', 'INT': '+1'}, + '드워프': {'CON': '+2', 'STR': '+1'}, + '하플링': {'DEX': '+2', 'CHA': '+1'}, + '오크': {'STR': '+2', 'CON': '+1'}, + '고블린': {'DEX': '+2', 'INT': '+1'}, + '드라코니안': {'STR': '+2', 'CHA': '+1'}, + '안드로이드': {'INT': '+2', 'STR': '+1'}, + '외계인 하이브리드': {'WIS': '+2', 'CHA': '+1'}, + '변형 인류': {'DEX': '+2', 'CON': '+1'}, + '네뷸런': {'INT': '+2', 'WIS': '+1'}, + '크로노스피어': {'INT': '+2', 'DEX': '+1'}, + '우주 유목민': {'WIS': '+2', 'INT': '+1'}, + '변이체': {'CON': '+2', 'STR': '+1'}, + '강화인류': {'STR': '+2', 'INT': '+1'}, + '생체기계': {'CON': '+2', 'INT': '+1'}, + '숙주': {'CON': '+2', 'WIS': '+1'}, + '정신감응자': {'WIS': '+2', 'CHA': '+1'}, + '저항자': {'WIS': '+2', 'DEX': '+1'}, + '비인간': {'CHA': '+2', 'DEX': '+1'}, + '신비종족': {'WIS': '+2', 'CHA': '+1'} + } + + # 종족별 특수 능력 매핑 + race_abilities = { + '인간': '적응력: 모든 기술 판정에 +1 보너스', + '엘프': '암시야: 어두운 곳에서도 시각적 판정에 불이익 없음', + '드워프': '내구력: 독성 및 질병 저항에 +2 보너스', + '하플링': '행운: 하루에 한 번 주사위를 다시 굴릴 수 있음', + '오크': '위협: 협박 관련 판정에 +2 보너스', + '고블린': '교활함: 함정 및 장치 관련 판정에 +2 보너스', + '드라코니안': '용의 숨결: 하루에 한 번 약한 화염 공격 가능', + '안드로이드': '기계 저항: 전기 및 해킹 공격에 +2 방어', + '외계인 하이브리드': '텔레파시: 간단한 감정을 마음으로 전달 가능', + '변형 인류': '환경 적응: 극단적 환경에서 생존 판정에 +2 보너스', + '네뷸런': '에너지 조작: 작은 전자 장치를 맨손으로 작동 가능', + '크로노스피어': '시간 감각: 선제 행동 판정에 +2 보너스', + '우주 유목민': '우주 적응: 무중력 및 저산소 환경에서 유리함', + '변이체': '돌연변이 능력: 스트레스 상황에서 무작위 능력 발현', + '강화인류': '기계 장착: 특정 도구를 체내에 내장 가능', + '생체기계': '자가 수리: 휴식 중 추가 체력 회복', + '숙주': '공생체 감지: 숨겨진 생명체 감지에 +2 보너스', + '정신감응자': '사고 읽기: 단순한 생각을 감지할 확률 25%', + '저항자': '시스템 면역: 모든 정신 제어에 저항 가능', + '비인간': '이질적 존재감: 처음 만나는 NPC에게 강한 인상 남김', + '신비종족': '고대의 지식: 역사 및 마법 관련 지식에 +2 보너스' + } + + # 종족 선택 버튼 표시 (개선된 카드 형식) + race_cols = st.columns(3) + for i, race in enumerate(races): + with race_cols[i % 3]: + icon = race_icons.get(race, '👤') # 기본 아이콘 + bonus = race_bonuses.get(race, {'??': '+?'}) # 기본 보너스 + ability = race_abilities.get(race, '특수 능력 없음') # 기본 특수 능력 + + # 종족 카드 생성 (개선된 UI) + st.markdown(f""" +
+
{icon}
+

{race}

+
+ 능력치 보너스:
+ {"
".join([f"{k}: {v}" for k, v in bonus.items()])} +
+
+ 특수 능력:
+ {ability} +
+ """, unsafe_allow_html=True) + + # 종족별 간단한 설명 + race_descriptions = { + '인간': "적응력이 뛰어나고 다재다능한 종족", + '엘프': "장수하며 마법적 친화력과 우아함을 지님", + '드워프': "강인한 체력과 대장장이 기술을 가진 산악 거주민", + '하플링': "작지만 민첩하고 운이 좋은 종족", + '오크': "강력한 근력과 전투 기술을 지닌 전사 종족", + '고블린': "꾀가 많고 기계에 능통한 작은 종족", + '드라코니안': "용의 피를 이어받은 강력한 혼혈 종족", + '안드로이드': "인공지능과 합성 신체를 가진 인조 생명체", + '외계인 하이브리드': "인간과 외계 종족의 유전적 결합체", + '변형 인류': "유전적 개조를 통해 진화된 인류", + '네뷸런': "성운에서 태어난 에너지 기반 존재", + '크로노스피어': "시간 감각이 다른 차원의 존재", + '우주 유목민': "세대를 넘어 우주선에서 살아온 인류", + '변이체': "환경 오염으로 변이된 인류", + '강화인류': "기계적 향상을 받은 인류", + '생체기계': "기계와 유기체의 완전한 결합체", + '숙주': "외계 공생체와 결합한 인류", + '정신감응자': "초능력을 가진 인류의 새로운 진화", + '저항자': "통제 시스템에 영향받지 않는 희귀 유전자 보유자", + '비인간': "인간이 아닌 다양한 존재들", + '신비종족': "기원이 불분명한 신비로운 능력을 가진 종족" + } + + if race in race_descriptions: + st.markdown(f""" +
+ {race_descriptions[race]} +
+ """, unsafe_allow_html=True) + + st.markdown("
", unsafe_allow_html=True) + + if st.button(f"선택", key=f"race_{race}"): + st.session_state.selected_race = race + st.session_state.race_bonus = bonus + st.session_state.race_ability = ability + st.session_state.race_icon = icon + st.session_state.character_creation_step = 'profession' + st.session_state.master_message = f"{race} 종족을 선택하셨군요! 이제 당신의 직업을 선택해보세요." + st.rerun() + + # 직접 입력 옵션 + st.markdown("
", unsafe_allow_html=True) + st.write("### 다른 종족 직접 입력") + st.write("원하는 종족이 목록에 없다면, 직접 입력할 수 있습니다.") + custom_race = st.text_input("종족 이름:") + custom_icon = st.selectbox("아이콘 선택:", ['👤', '🧙', '🧝', '🧟', '👻', '👽', '🤖', '🦊', '🐲', '🌟']) + + # 능력치 보너스 선택 (최대 2개) + st.write("능력치 보너스 선택 (최대 2개):") + bonus_cols = st.columns(3) + + all_stats = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'] + custom_bonuses = {} + + for i, stat in enumerate(all_stats): + with bonus_cols[i % 3]: + bonus_value = st.selectbox(f"{stat} 보너스:", ['+0', '+1', '+2'], key=f"custom_bonus_{stat}") + if bonus_value != '+0': + custom_bonuses[stat] = bonus_value + + # 특수 능력 입력 + custom_ability = st.text_area("특수 능력 (선택사항):", + placeholder="예: 어둠 속에서도 잘 볼 수 있는 능력") + + if custom_race and st.button("이 종족으로 선택"): + st.session_state.selected_race = custom_race + st.session_state.race_bonus = custom_bonuses if custom_bonuses else {'없음': '+0'} + st.session_state.race_ability = custom_ability if custom_ability else "특수 능력 없음" + st.session_state.race_icon = custom_icon + st.session_state.character_creation_step = 'profession' + st.session_state.master_message = f"{custom_race} 종족을 선택하셨군요! 이제 당신의 직업을 선택해보세요." + st.rerun() + st.markdown("
", unsafe_allow_html=True) + # 추가 단계는 이어서 작성... +# 직업 선택 단계 + elif st.session_state.character_creation_step == 'profession': + st.subheader("직업 선택") + + # 직업 선택 설명 추가 + st.markdown(""" +
+

직업은 캐릭터가 세계에서 수행하는 역할과 전문 기술을 결정합니다.

+

각 직업마다 중요한 능력치가 다르며, 독특한 기술과 성장 경로를 가집니다.

+
+ """, unsafe_allow_html=True) + + # 선택된 종족 표시 (개선된 UI) + race_icon = st.session_state.get('race_icon', '👤') + race_bonuses = st.session_state.get('race_bonus', {}) + race_ability = st.session_state.get('race_ability', "특수 능력 없음") + + st.markdown(f""" +
+
{race_icon}
+
+

선택한 종족: {st.session_state.selected_race}

+
+ 능력치 보너스: {', '.join([f"{k} {v}" for k, v in race_bonuses.items()])} +
+
+ 특수 능력: {race_ability} +
+
+
+ """, unsafe_allow_html=True) + + # 직업 선택 방식 + profession_method = st.radio( + "직업 선택 방식:", + ["기본 직업 선택", "직접 직업 만들기"], + horizontal=True + ) + + if profession_method == "기본 직업 선택": + # 직업 목록 + professions = generate_professions(st.session_state.theme) + + # 직업별 아이콘 매핑 + profession_icons = { + # 판타지 직업 + '마법사': '🧙', '전사': '⚔️', '도적': '🗡️', '성직자': '✝️', + '음유시인': '🎭', '연금술사': '⚗️', + # SF 직업 + '우주 파일럿': '🚀', '사이버 해커': '💻', '생체공학자': '🧬', + '보안 요원': '🛡️', '외계종족 전문가': '👽', '기계공학자': '⚙️', + # 디스토피아 직업 + '정보 브로커': '📡', '밀수업자': '📦', '저항군 요원': '⚔️', + '엘리트 경비원': '👮', '스카운터': '🔭', '의료 기술자': '💉' + } + + # 직업별 주요 능력치 매핑 + profession_stats = { + # 판타지 직업 + '마법사': ['INT', 'WIS'], '전사': ['STR', 'CON'], '도적': ['DEX', 'CHA'], + '성직자': ['WIS', 'CHA'], '음유시인': ['CHA', 'DEX'], '연금술사': ['INT', 'DEX'], + # SF 직업 + '우주 파일럿': ['DEX', 'INT'], '사이버 해커': ['INT', 'DEX'], + '생체공학자': ['INT', 'WIS'], '보안 요원': ['STR', 'DEX'], + '외계종족 전문가': ['INT', 'CHA'], '기계공학자': ['INT', 'DEX'], + # 디스토피아 직업 + '정보 브로커': ['INT', 'CHA'], '밀수업자': ['DEX', 'CHA'], + '저항군 요원': ['DEX', 'CON'], '엘리트 경비원': ['STR', 'CON'], + '스카운터': ['DEX', 'WIS'], '의료 기술자': ['INT', 'DEX'] + } + + # 직업별 시작 장비 및 특수 기술 + profession_equipment = { + # 판타지 직업 + '마법사': ['마법서', '마법 지팡이', '마법 주머니', '초보자용 주문 2개'], + '전사': ['검 또는 도끼', '갑옷', '방패', '생존 도구 세트'], + '도적': ['단검 2개', '도둑 도구 세트', '후드 망토', '독약 제조 키트'], + '성직자': ['신성한 상징', '치유 물약 3개', '의식용 로브', '기도서'], + '음유시인': ['악기', '화려한 옷', '매력 향수', '이야기 모음집'], + '연금술사': ['연금술 키트', '약초 가방', '실험 도구', '공식 노트'], + # SF 직업 + '우주 파일럿': ['개인 통신기', '비상 우주복', '항법 장치', '우주선 접근 키'], + '사이버 해커': ['고급 컴퓨터', '해킹 장치', '신경 연결 케이블', '데이터 칩'], + '생체공학자': ['생체 스캐너', '미니 실험실', '표본 수집 키트', '의학 참고서'], + '보안 요원': ['에너지 무기', '방어 슈트', '감시 장치', '신분 위조 키트'], + '외계종족 전문가': ['번역기', '종족 백과사전', '접촉 프로토콜 가이드', '외계 유물'], + '기계공학자': ['다용도 공구 세트', '소형 드론', '수리 매뉴얼', '예비 부품'], + # 디스토피아 직업 + '정보 브로커': ['암호화된 단말기', '신원 위장 키트', '비밀 금고', '정보 데이터베이스'], + '밀수업자': ['은닉 가방', '위조 서류', '지도 컬렉션', '거래 연락망'], + '저항군 요원': ['숨겨진 무기', '위장 도구', '암호화 통신기', '안전가옥 접근권'], + '엘리트 경비원': ['최신형 방호구', '감시 장비', '접근 배지', '진압 무기'], + '스카운터': ['원거리 스캐너', '야간 투시경', '생존 키트', '지형 기록기'], + '의료 기���자': ['응급 의료 키트', '진단 장비', '약물 합성기', '의학 데이터뱅크'] + } + + # 직업별 특수 기술 + profession_skills = { + # 판타지 직업 + '마법사': '마법 감지: 주변의 마법적 현상을 감지할 수 있음', + '전사': '전투 기술: 모든 무기 사용에 +1 보너스', + '도적': '그림자 이동: 은신 및 잠입 판정에 +2 보너스', + '성직자': '신성한 보호: 하루에 한 번 약한 치유 마법 사용 가능', + '음유시인': '매혹: 설득 및 교섭 판정에 +2 보너스', + '연금술사': '물약 식별: 알 수 없는 물약의 효과를 판별 가능', + # SF 직업 + '우주 파일럿': '회피 기동: 위험한 상황에서의 회피 판정에 +2 보너스', + '사이버 해커': '시스템 침투: 전자 장치 해킹 시도에 +2 보너스', + '생체공학자': '생명체 분석: 생물학적 특성을 빠르게 파악 가능', + '보안 요원': '위협 감지: 잠재적 위험을 사전에 감지할 확률 +25%', + '외계종족 전문가': '외계어 이해: 처음 접하는 언어라도 기본 의사소통 가능', + '기계공학자': '즉석 수리: 손상된 장비를 임시로 빠르게 수리 가능', + # 디스토피아 직업 + '정보 브로커': '정보망: 지역 정보를 얻는 판정에 +2 보너스', + '밀수업자': '은밀한 거래: 불법 물품 거래 및 운송에 +2 보너스', + '저항군 요원': '생존 본능: 생명을 위협하는 상황에서 반사 판정 +2', + '엘리트 경비원': '경계: 잠복 중 적 발견 확률 +25%', + '스카운터': '지형 파악: 새로운 지역 탐색 시 +2 보너스', + '의료 기술자': '응급 처치: 중상을 입은 대상을 안정시키는 능력' + } + + # 직업 선택 버튼 표시 (개선된 카드 형식) + profession_cols = st.columns(3) + for i, profession in enumerate(professions): + with profession_cols[i % 3]: + icon = profession_icons.get(profession, '👤') # 기본 아이콘 + key_stats = profession_stats.get(profession, ['??', '??']) # 주요 능력치 + equipment = profession_equipment.get(profession, ['기본 장비']) # 시작 장비 + skill = profession_skills.get(profession, '특수 기술 없음') # 특수 기술 + + # 직업 카드 생성 (개선된 UI) + st.markdown(f""" +
+
{icon}
+

{profession}

+
+ 주요 능력치: {' & '.join(key_stats)} +
+
+ 시작 장비: + +
+
+ 특수 기술:
+ {skill} +
+
+ """, unsafe_allow_html=True) + + if st.button(f"선택", key=f"prof_{profession}"): + st.session_state.selected_profession = profession + st.session_state.profession_icon = icon + st.session_state.profession_stats = key_stats + st.session_state.profession_equipment = equipment + st.session_state.profession_skill = skill + + # 배경 옵션 생성 상태 확인 + if not st.session_state.background_options_generated: + with st.spinner("캐릭터 배경 옵션을 생성 중..."): + st.session_state.character_backgrounds = generate_character_options( + profession, st.session_state.theme + ) + st.session_state.background_options_generated = True + + st.session_state.character_creation_step = 'background' + st.session_state.master_message = f"{profession} 직업을 선택하셨군요! 이제 캐릭터의 배경 이야기를 선택해보세요." + st.rerun() + else: # 직접 직업 만들기 + st.markdown("
", unsafe_allow_html=True) + st.write("### 나만의 직업 만들기") + st.write("세계관에 맞는 독특한 직업을 직접 만들어보세요") + custom_profession = st.text_input("직업 이름:") + custom_icon = st.selectbox("아이콘 선택:", ['🧙', '⚔️', '🗡️', '🧪', '📚', '🔮', '🎭', '⚗️', '🛡️', '🚀', '💻', '🧬', '👽', '⚙️', '📡', '📦', '💉', '🔭']) + + # 주요 능력치 선택 (최대 2개) + st.write("주요 능력치 선택 (최대 2개):") + stat_cols = st.columns(3) + + all_stats = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'] + selected_stats = [] + + for i, stat in enumerate(all_stats): + with stat_cols[i % 3]: + if st.checkbox(f"{stat}", key=f"custom_prof_stat_{stat}"): + selected_stats.append(stat) + + # 3개 이상 선택 시 경고 + if len(selected_stats) > 2: + st.warning("주요 능력치는 최대 2개까지만 선택할 수 있습니다. 처음 2개만 적용됩니다.") + selected_stats = selected_stats[:2] + elif len(selected_stats) == 0: + st.info("주요 능력치를 1~2개 선택하세요.") + + # 시작 장비 입력 + st.write("시작 장비 (콤마로 구분):") + equipment_input = st.text_area("예: 검, 방패, 물약 3개", height=100) + + # 특수 기술 입력 + special_skill = st.text_input("특수 기술 (예: 숨기: 은신 판정에 +2 보너스):") + + # 직업 설명 + profession_desc = st.text_area("직업 설명:", + placeholder="이 직업의 역할, 행동 방식, 세계관에서의 위치 등을 설명해주세요.", + height=100) + + if st.button("이 직업으로 선택", use_container_width=True): + if custom_profession and len(selected_stats) > 0 and special_skill: + # 사용자 정의 직업 정보 저장 + st.session_state.selected_profession = custom_profession + st.session_state.profession_icon = custom_icon + st.session_state.profession_stats = selected_stats + + # 장비 파싱 + equipment_list = [item.strip() for item in equipment_input.split(',') if item.strip()] + if not equipment_list: + equipment_list = ["기본 장비"] + st.session_state.profession_equipment = equipment_list + + st.session_state.profession_skill = special_skill + st.session_state.profession_description = profession_desc + + # 배경 옵션 생성 상태 확인 + if not st.session_state.background_options_generated: + with st.spinner("캐릭터 배경 옵션을 생성 중..."): + st.session_state.character_backgrounds = generate_character_options( + custom_profession, st.session_state.theme + ) + st.session_state.background_options_generated = True + + st.session_state.character_creation_step = 'background' + st.session_state.master_message = f"{custom_profession} 직업을 선택하셨군요! 이제 캐릭터의 배경 이야기를 선택해보세요." + st.rerun() + else: + st.error("직업 이름, 최소 1개의 주요 능력치, 특수 기술은 필수 입력사항입니다.") + st.markdown("
", unsafe_allow_html=True) +# 배경 선택 단계 + elif st.session_state.character_creation_step == 'background': + st.subheader("캐릭터 배경 선택") + + # 배경 선택 설명 추가 + st.markdown(""" +
+

캐릭터의 배경 스토리는 당신이 누구이고, 어떻게 모험을 시작하게 되었는지를 결정합니다.

+

세계관 속에서 당신의 위치와 동기, 인간관계를 형성하는 중요한 요소입니다.

+
+ """, unsafe_allow_html=True) + + # 선택된 종족과 직업 표시 (개선된 UI) + race_icon = st.session_state.get('race_icon', '👤') + profession_icon = st.session_state.get('profession_icon', '👤') + key_stats = st.session_state.get('profession_stats', ['??', '??']) + special_skill = st.session_state.get('profession_skill', '특수 기술 없음') + + st.markdown(f""" +
+
{race_icon}
+
+

선택한 종족: {st.session_state.selected_race}

+
+ 특수 능력: {st.session_state.get('race_ability', '특수 능력 없음')} +
+
+
+
{profession_icon}
+
+

선택한 직업: {st.session_state.selected_profession}

+
+ 주요 능력치: {' & '.join(key_stats)} +
+
+ 특수 기술: {special_skill} +
+
+
+ """, unsafe_allow_html=True) + + # 배경 태그 색상 + background_tags = { + "영웅적": "#4CAF50", # 녹색 + "비극적": "#F44336", # 빨간색 + "신비로운": "#9C27B0", # 보라색 + "학자": "#2196F3", # 파란색 + "범죄자": "#FF9800", # 주황색 + "전사": "#795548", # 갈색 + "귀족": "#FFC107", # 노란색 + "서민": "#607D8B", # 회색 + "이방인": "#009688", # 청록색 + "운명적": "#E91E63" # 분홍색 + } + + # 배경 옵션 표시 + for i, background in enumerate(st.session_state.character_backgrounds): + # 배경에서 태그 추출 (간단한 키워드 기반) + bg_tags = [] + for tag, _ in background_tags.items(): + if tag.lower() in background.lower(): + bg_tags.append(tag) + + # 태그가 없으면 기본 태그 설정 + if not bg_tags: + bg_tags = ["신비로운"] # 기본 태그 + + # 최대 3개 태그만 사용 + bg_tags = bg_tags[:3] + + # 태그 HTML 생성 (한 줄로 변경) + tags_html = "" + for tag in bg_tags: + tag_color = background_tags.get(tag, "#607D8B") # 기본값은 회색 + tag_html = f"{tag}" + tags_html += tag_html + + # 옵션 카드 시작 + st.markdown("
", unsafe_allow_html=True) + + # 태그 섹션 렌더링 + tags_section = f""" +
+ {tags_html} +
+

배경 옵션 {i+1}

+ """ + st.markdown(tags_section, unsafe_allow_html=True) + + # 배경 내용 표시 - 단락별로 나누어 표시 + paragraphs = background.split('\n\n') + for para in paragraphs: + if para.strip(): + st.markdown(f"

{para}

", unsafe_allow_html=True) + + if st.button(f"이 배경 선택", key=f"bg_{i}", + use_container_width=True, + help="이 배경 스토리로 캐릭터를 생성합니다"): + st.session_state.selected_background = background + st.session_state.background_tags = bg_tags + st.session_state.character_creation_step = 'abilities' + st.session_state.master_message = "좋은 선택입니다! 이제 캐릭터의 능력치를 결정해 봅시다." + st.rerun() + st.markdown("
", unsafe_allow_html=True) + + # 직접 작성 옵션 + st.markdown("
", unsafe_allow_html=True) + st.write("### 직접 작성") + st.write("자신만의 독특한 배경 스토리를 작성하고 싶다면 직접 입력할 수 있습니다.") + + # 태그 선택 + st.write("배경 태그 선택 (최대 3개):") + tag_cols = st.columns(3) + selected_tags = [] + i = 0 + for tag, color in background_tags.items(): + with tag_cols[i % 3]: + if st.checkbox(tag, key=f"tag_{tag}"): + selected_tags.append(tag) + i += 1 + + # 선택된 태그가 3개 초과면 경고 + if len(selected_tags) > 3: + st.warning("태그는 최대 3개까지만 선택할 수 있습니다. 초과된 태그는 무시됩니다.") + selected_tags = selected_tags[:3] + + # 직접 입력 필드 + custom_background = st.text_area("나만의 배경 스토리:", height=200, + placeholder="당신의 캐릭터는 어떤 사람인가요? 어떤 경험을 했나요? 무엇을 위해 모험을 떠나게 되었나요?") + + if custom_background and st.button("직접 작성한 배경 사용", use_container_width=True): + st.session_state.selected_background = custom_background + st.session_state.background_tags = selected_tags if selected_tags else ["신비로운"] + st.session_state.character_creation_step = 'abilities' + st.session_state.master_message = "창의적인 배경 스토리군요! 이제 캐릭터의 능력치를 결정해 봅시다." + st.rerun() + st.markdown("
", unsafe_allow_html=True) + + # 뒤로 가기 옵션 + if st.button("← 직업 선택으로 돌아가기", use_container_width=True): + st.session_state.character_creation_step = 'profession' + st.session_state.background_options_generated = False + st.session_state.master_message = "직업을 다시 선택해 보세요!" + st.rerun() +# 능력치 설정 단계 + elif st.session_state.character_creation_step == 'abilities': + st.subheader("능력치 설정") + + # 능력치 설정 설명 추가 + st.markdown(""" +
+

능력치는 캐릭터의 신체적, 정신적 역량을 수치화한 것입니다.

+

주사위를 굴려 결정하거나, 기본값을 사용할 수 있습니다.

+
+ """, unsafe_allow_html=True) + + # 선택된 종족, 직업, 배경 태그 표시 (개선된 UI) + race_icon = st.session_state.get('race_icon', '👤') + profession_icon = st.session_state.get('profession_icon', '👤') + key_stats = st.session_state.get('profession_stats', ['??', '??']) + race_bonuses = st.session_state.get('race_bonus', {}) + bg_tags = st.session_state.get('background_tags', ["신비로운"]) + + # 태그 표시용 HTML 생성 + tags_html = "" + background_tags_colors = { + "영웅적": "#4CAF50", "비극적": "#F44336", "신비로운": "#9C27B0", + "학자": "#2196F3", "범죄자": "#FF9800", "전사": "#795548", + "귀족": "#FFC107", "서민": "#607D8B", "이방인": "#009688", + "운명적": "#E91E63" + } + + # 태그 HTML 별도 생성 + for tag in bg_tags: + # 태그 색상 가져오기 + tag_color = background_tags_colors.get(tag, "#607D8B") # 기본값은 회색 + + # 한 줄로 HTML 생성 + tag_html = f"{tag}" + + # 전체 HTML에 추가 + tags_html += tag_html + + # 종족 및 직업 정보 가져오기 + selected_race = st.session_state.get('selected_race', '알 수 없음') + selected_profession = st.session_state.get('selected_profession', '알 수 없음') + + # 종족 보너스 문자열 생성 + race_bonus_items = [] + for k, v in race_bonuses.items(): + race_bonus_items.append(f"{k} {v}") + race_bonus_text = "・".join(race_bonus_items) + + # 핵심 능력치 문자열 생성 + key_stats_text = "・".join(key_stats) + + # 캐릭터 요약 상단 부분 HTML + character_header_html = f""" +
+
{race_icon}
+
+

{selected_race} {selected_profession}

+
+ {tags_html} +
+
+
{profession_icon}
+
+ """ + + # 능력치 및 보너스 정보 HTML + character_stats_html = f""" +
+
+
핵심 능력치
+
{key_stats_text}
+
+
+
종족 보너스
+
{race_bonus_text}
+
+
+ """ + + # 최종 캐릭터 요약 HTML 조합 + character_summary_html = f""" +
+ {character_header_html} + {character_stats_html} +
+ """ + + # 최종 HTML 렌더링 + st.markdown(character_summary_html, unsafe_allow_html=True) + + ability_col1, ability_col2 = st.columns([3, 1]) + + with ability_col1: + # 능력치 설정 방법 선택 + ability_method = st.radio( + "능력치 설정 방법:", + ["3D6 주사위 굴리기", "기본 능력치 사용"], + horizontal=True + ) + + if ability_method == "3D6 주사위 굴리기": + # 주사위 굴리기 관련 상태 초기화 + if 'dice_rolled' not in st.session_state: + st.session_state.dice_rolled = False + + if 'reroll_used' not in st.session_state: + st.session_state.reroll_used = False + + # 주사위 굴리기 설명 추가 + st.markdown(""" +
+

능력치는 각각 3D6(6면체 주사위 3개) 방식으로 결정됩니다.

+

각 능력치는 3~18 사이의 값을 가지며, 평균값은 10-11입니다.

+

14 이상은 뛰어난 능력, 16 이상은 탁월한 능력입니다.

+

다시 굴리기는 1번만 가능합니다.

+
+ """, unsafe_allow_html=True) + + # 주사위 굴리기 버튼 + if not st.session_state.dice_rolled and st.button("주사위 굴리기", use_container_width=True, key="roll_ability_dice"): + st.session_state.dice_rolled = True + + # 능력치 목록 + ability_names = ['STR', 'INT', 'DEX', 'CON', 'WIS', 'CHA'] + rolled_abilities = {} + + # 각 능력치별 주사위 굴리기 결과 애니메이션으로 표시 + ability_placeholders = {} + for ability in ability_names: + ability_placeholders[ability] = st.empty() + + # 순차적으로 각 능력치 굴리기 + for ability in ability_names: + with st.spinner(f"{ability} 굴리는 중..."): + # 3D6 주사위 결과 계산 + dice_rolls = [random.randint(1, 6) for _ in range(3)] + total = sum(dice_rolls) + + # 결과 표시 + ability_placeholders[ability].markdown(f""" +
+
+ {ability} + 🎲 {dice_rolls[0]} + {dice_rolls[1]} + {dice_rolls[2]} = {total} +
+
+ """, unsafe_allow_html=True) + rolled_abilities[ability] = total + time.sleep(0.3) # 약간의 딜레이 + + # 세션에 저장 + st.session_state.rolled_abilities = rolled_abilities + st.rerun() + + # 굴린 결과 표시 + if st.session_state.dice_rolled and 'rolled_abilities' in st.session_state: + st.write("#### 주사위 결과:") + cols = st.columns(3) + i = 0 + + # 직업 정보를 미리 가져옴 + prof = st.session_state.selected_profession if 'selected_profession' in st.session_state else "" + + # 직업별 중요 능력치 정보 + profession_key_stats = st.session_state.get('profession_stats', []) + + # 능력치 총점 계산 (나중에 보여주기 위함) + total_points = sum(st.session_state.rolled_abilities.values()) + + # 결과를 정렬하여 먼저 중요 능력치를 표시 + sorted_abilities = sorted( + st.session_state.rolled_abilities.items(), + key=lambda x: (x[0] not in profession_key_stats, profession_key_stats.index(x[0]) if x[0] in profession_key_stats else 999) + ) + + for ability, value in sorted_abilities: + with cols[i % 3]: + # 직업에 중요한 능력치인지 확인 + is_key_stat = ability in profession_key_stats + + # 색상 및 설명 가져오기 + color, description = get_stat_info(ability, value, prof) + + # 중요 능력치 강조 스타일 + highlight = "border: 2px solid gold; background-color: rgba(255, 215, 0, 0.1);" if is_key_stat else "" + key_badge = "핵심" if is_key_stat else "" + + # 능력치 값에 따른 바 그래프 너비 계산 (백분율, 최대 18 기준) + bar_width = min(100, (value / 18) * 100) + + # 개선된 능력치 표시 + st.markdown(f""" +
+
+ {ability}{key_badge} + {value} +
+
+
+
+
{description}
+
+ """, unsafe_allow_html=True) + i += 1 + + # 능력치 총점 표시 + avg_total = 63 # 3D6 6개의 평균 + + # 총점 평가 (낮음, 평균, 높음) + if total_points < avg_total - 5: + total_rating = "낮음" + total_color = "#F44336" # 빨간색 + elif total_points > avg_total + 5: + total_rating = "높음" + total_color = "#4CAF50" # 녹색 + else: + total_rating = "평균" + total_color = "#FFC107" # 노란색 + + st.markdown(f""" +
+
능력치 총점:
+
+ {total_points} + {total_rating} +
+
(평균 63, 70+ 우수, 80+ 탁월)
+
+ """, unsafe_allow_html=True) + + # 버튼 열 생성 + col1, col2 = st.columns(2) + with col1: + if st.button("이 능력치로 진행하기", use_container_width=True, key="use_these_stats"): + st.session_state.character['stats'] = st.session_state.rolled_abilities + st.session_state.character['profession'] = st.session_state.selected_profession + st.session_state.character['race'] = st.session_state.selected_race + st.session_state.character['backstory'] = st.session_state.selected_background + st.session_state.character_creation_step = 'review' + st.session_state.master_message = "좋습니다! 캐릭터가 거의 완성되었습니다. 최종 확인을 해 볼까요?" + + # 다시 굴리기 관련 상태 초기화 + st.session_state.dice_rolled = False + st.session_state.reroll_used = False + st.rerun() + + with col2: + # 다시 굴리기 버튼 - 한번만 사용 가능하도록 제한 + if st.button("다시 굴리기", + use_container_width=True, + key="reroll_ability_dice", + disabled=st.session_state.reroll_used): + if not st.session_state.reroll_used: + # 다시 굴리기 사용 표시 + st.session_state.reroll_used = True + + # 능력치 목록 + ability_names = ['STR', 'INT', 'DEX', 'CON', 'WIS', 'CHA'] + rerolled_abilities = {} + + # 각 능력치별 재굴림 결과 표시 + reroll_placeholders = {} + for ability in ability_names: + reroll_placeholders[ability] = st.empty() + + # 순차적으로 각 능력치 다시 굴리기 + for ability in ability_names: + # 3D6 주사위 결과 계산 + dice_rolls = [random.randint(1, 6) for _ in range(3)] + total = sum(dice_rolls) + rerolled_abilities[ability] = total + + # 결과 저장 및 상태 업데이트 + st.session_state.rolled_abilities = rerolled_abilities + st.session_state.reroll_message = "다시 굴리기 기회를 사용했습니다." + st.rerun() + + # 다시 굴리기 사용 여부 표시 + if st.session_state.reroll_used: + st.info("다시 굴리기 기회를 이미 사용했습니다.") + + else: # 기본 능력치 사용 + st.write("#### 기본 능력치:") + base_abilities = {'STR': 10, 'INT': 10, 'DEX': 10, 'CON': 10, 'WIS': 10, 'CHA': 10} + + # 직업에 따른 추천 능력치 조정 + if 'selected_profession' in st.session_state: + profession = st.session_state.selected_profession + profession_key_stats = st.session_state.get('profession_stats', []) + + # 주요 능력치에 보너스 부여 + for stat in profession_key_stats: + if stat in base_abilities: + base_abilities[stat] = 14 # 주요 능력치는 14로 설정 + + # 종족에 따른 능력치 보너스 적용 + if 'race_bonus' in st.session_state: + for stat, bonus in st.session_state.race_bonus.items(): + if stat in base_abilities: + # 보너스값에서 '+'를 제거하고 정수로 변환 + bonus_value = int(bonus.replace('+', '')) + base_abilities[stat] += bonus_value + elif stat == "모든 능력치": + # 모든 능력치에 보너스 적용 + bonus_value = int(bonus.replace('+', '')) + for ability in base_abilities: + base_abilities[ability] += bonus_value + + # 결과 표시 (향상된 시각적 표현) + cols = st.columns(3) + i = 0 + + # 직업 정보 가져오기 + prof = st.session_state.selected_profession if 'selected_profession' in st.session_state else "" + key_stats = st.session_state.get('profession_stats', []) + + # 정렬: 주요 능력치 먼저 + sorted_abilities = sorted( + base_abilities.items(), + key=lambda x: (x[0] not in key_stats, key_stats.index(x[0]) if x[0] in key_stats else 999) + ) + + for ability, value in sorted_abilities: + with cols[i % 3]: + color, description = get_stat_info(ability, value, prof) + is_key_stat = ability in key_stats + + # 중요 능력치 강조 스타일 + highlight = "border: 2px solid gold; background-color: rgba(255, 215, 0, 0.1);" if is_key_stat else "" + key_badge = "핵심" if is_key_stat else "" + + # 종족 보너스 표시 + race_bonus_badge = "" + for stat, bonus in st.session_state.race_bonus.items(): + if stat == ability or stat == "모든 능력치": + race_bonus_badge = f"{bonus}" + + # 개선된 능력치 표시 + st.markdown(f""" +
+
+ {ability}{key_badge}{race_bonus_badge} + {value} +
+
+
+
+
+
+
{description}
+
+ """, unsafe_allow_html=True) + i += 1 + + # 능력치 총점 표시 + total_points = sum(base_abilities.values()) + avg_total = 60 # 평균 총점 + + # 총점 평가 (낮음, 평균, 높음) + if total_points < avg_total - 5: + total_rating = "낮음" + total_color = "#F44336" # 빨간색 + elif total_points > avg_total + 5: + total_rating = "높음" + total_color = "#4CAF50" # 녹색 + else: + total_rating = "평균" + total_color = "#FFC107" # 노란색 + + st.markdown(f""" +
+ 능력치 총점: + {total_points} + {total_rating} +
(평균 60-65, 70+ 우수, 80+ 탁월)
+
+ """, unsafe_allow_html=True) + + if st.button("기본 능력치로 진행하기", use_container_width=True): + st.session_state.character['stats'] = base_abilities + st.session_state.character['profession'] = st.session_state.selected_profession + st.session_state.character['race'] = st.session_state.selected_race + st.session_state.character['backstory'] = st.session_state.selected_background + st.session_state.character_creation_step = 'review' + st.session_state.master_message = "좋습니다! 캐릭터가 거의 완성되었습니다. 최종 확인을 해 볼까요?" + st.rerun() + + with ability_col2: + # 능력치 설명 및 정보 표시 + st.markdown(""" +
+

능력치 정보

+ + + + + + + +
STR근력, 물리적 공격력
DEX민첩성, 회피/정확도
CON체력, 생존력
INT지능, 마법/기술 이해력
WIS지혜, 직관/인식력
CHA매력, 설득력/교섭력
+
+ """, unsafe_allow_html=True) + + # 능력치 점수 해석 + st.markdown(""" +
+

능력치 점수 해석

+ + + + + + + + +
1-3심각한 약점
4-6약함
7-9평균 이하
10-12평균적
13-15평균 이상
16-17매우 뛰어남
18+전설적 수준
+
+ """, unsafe_allow_html=True) + + # 배경 요약 + st.markdown(""" +
+

배경 요약

+
+ """, unsafe_allow_html=True) + + # 배경 텍스트에서 중요 부분만 추출 (첫 200자) + bg_summary = st.session_state.selected_background[:200] + if len(st.session_state.selected_background) > 200: + bg_summary += "..." + + st.markdown(f"{bg_summary}", unsafe_allow_html=True) + st.markdown("
", unsafe_allow_html=True) + + # 뒤로 가기 옵션 + if st.button("← 배경 선택으로 돌아가기", use_container_width=True): + st.session_state.character_creation_step = 'background' + + # 주사위 굴리기 관련 상태 초기화 + if 'dice_rolled' in st.session_state: + del st.session_state.dice_rolled + if 'reroll_used' in st.session_state: + del st.session_state.reroll_used + if 'rolled_abilities' in st.session_state: + del st.session_state.rolled_abilities + + st.session_state.master_message = "배경을 다시 선택해 보세요!" + st.rerun() +# 캐릭터 최종 확인 단계 + elif st.session_state.character_creation_step == 'review': + st.subheader("캐릭터 최종 확인") + + # 마지막 설명 추가 + st.markdown(""" +
+

당신의 캐릭터가 완성되었습니다! 최종 정보를 확인하고 모험을 시작하세요.

+

능력치, 장비, 특수 능력을 확인하고 필요하다면 수정할 수 있습니다.

+
+ """, unsafe_allow_html=True) + + review_col1, review_col2 = st.columns([2, 1]) + + with review_col1: + # 종족 및 직업 아이콘 가져오기 + race_icon = st.session_state.get('race_icon', '👤') + profession_icon = st.session_state.get('profession_icon', '👤') + bg_tags = st.session_state.get('background_tags', ["신비로운"]) + + # 캐릭터 기본 정보 안전하게 가져오기 + character_race = st.session_state.character.get('race', '알 수 없음') + character_profession = st.session_state.character.get('profession', '알 수 없음') + character_backstory = st.session_state.character.get('backstory', '배경 스토리가 없습니다.') + race_ability = st.session_state.get('race_ability', '종족 특성 없음') + profession_skill = st.session_state.get('profession_skill', '직업 특성 없음') + + # 태그 표시용 HTML 생성 + # 태그 표시용 색상 정의 + background_tags = { + "영웅적": "#4CAF50", "비극적": "#F44336", "신비로운": "#9C27B0", + "학자": "#2196F3", "범죄자": "#FF9800", "전사": "#795548", + "귀족": "#FFC107", "서민": "#607D8B", "이방인": "#009688", + "운명적": "#E91E63" + } + + # 태그 HTML 생성 (한 줄로 변경) + tags_html = "" + for tag in bg_tags: + tag_color = background_tags.get(tag, "#607D8B") # 기본값은 회색 + tag_html = f"{tag}" + tags_html += tag_html + + # 캐릭터 카드 상단 부분 HTML (한 줄씩 추가) + card_header_html = "
" + card_header_html += f"
{race_icon}
" + card_header_html += "
" + card_header_html += f"

{character_race} {character_profession}

" + card_header_html += f"
{tags_html}
" + card_header_html += "
" + card_header_html += f"
{profession_icon}
" + card_header_html += "
" + + # 캐릭터 특성 부분 HTML + character_traits_html = f""" +
+
캐릭터 특성
+
+ {race_ability} +
+
+ {profession_skill} +
+
+ """ + + # 배경 스토리 부분 HTML + backstory_html = f""" +
배경 스토리
+
+ {character_backstory} +
+ """ + + # 최종 캐릭터 카드 HTML 조합 + character_card_html = f""" +
+ {card_header_html} + {character_traits_html} + {backstory_html} +
+ """ + + # 최종 HTML 렌더링 + st.markdown(character_card_html, unsafe_allow_html=True) + + # 인벤토리 표시 (개선된 버전) + st.markdown(""" +
+

인벤토리

+ """, unsafe_allow_html=True) + + # 인벤토리 아이템 정렬 + inventory_items = st.session_state.character['inventory'] + + # 아이템 카테고리 정의 + categories = { + "무기": [], + "방어구": [], + "소비품": [], + "도구": [], + "기타": [] + } + + # 아이템을 카테고리별로 분류 + for item in inventory_items: + item_name = item.name if hasattr(item, 'name') else str(item) + item_desc = getattr(item, 'description', '설명 없음') + item_consumable = getattr(item, 'consumable', False) + item_durability = getattr(item, 'durability', None) + item_quantity = getattr(item, 'quantity', 1) + + # 아이템 아이콘 결정 + if hasattr(item, 'type'): + item_type = item.type + category = item_type if item_type in categories else "기타" + if item_type == "무기": + icon = "⚔️" + elif item_type == "방어구": + icon = "🛡️" + elif item_type == "소비품": + icon = "🧪" + elif item_type == "도구": + icon = "🔧" + else: + icon = "📦" + else: + # 아이템 이름으로 유추 + if "검" in item_name or "도끼" in item_name or "단검" in item_name or "활" in item_name or "무기" in item_name: + icon = "⚔️" + category = "무기" + elif "갑옷" in item_name or "방패" in item_name or "투구" in item_name or "방어" in item_name: + icon = "🛡️" + category = "방어구" + elif item_consumable or "물약" in item_name or "음식" in item_name or "포션" in item_name: + icon = "🧪" + category = "소비품" + elif "도구" in item_name or "키트" in item_name or "세트" in item_name: + icon = "🔧" + category = "도구" + else: + icon = "📦" + category = "기타" + + # 아이템 정보 저장 + categories[category].append({ + "name": item_name, + "icon": icon, + "desc": item_desc, + "consumable": item_consumable, + "durability": item_durability, + "quantity": item_quantity + }) + + # 카테고리별로 아이템 표시 + for category, items in categories.items(): + if items: # 해당 카테고리에 아이템이 있는 경우에만 표시 + st.markdown(f""" +
+
{category}
+
+ """, unsafe_allow_html=True) + + for item in items: + # 아이템 정보 준비 + item_name = item.get('name', '알 수 없는 아이템') + item_desc = item.get('desc', '') + item_icon = item.get('icon', '📦') + item_quantity = item.get('quantity', 1) + item_durability = item.get('durability', None) + item_consumable = item.get('consumable', False) + + # 소비성 아이템인 경우 + if item_consumable: + # 소비성 아이템 + 수량 표시 + if item_quantity > 1: + st.markdown(f""" +
+
{item_icon}
+
+
+ {item_name} + 소비 + ×{item_quantity} +
+
{item_desc}
+
+
+ """, unsafe_allow_html=True) + else: + # 소비성 아이템 + 수량 1개 + st.markdown(f""" +
+
{item_icon}
+
+
+ {item_name} + 소비 +
+
{item_desc}
+
+
+ """, unsafe_allow_html=True) + # 내구도가 있는 아이템인 경우 + elif item_durability is not None: + st.markdown(f""" +
+
{item_icon}
+
+
+ {item_name} + 내구도: {item_durability} +
+
{item_desc}
+
+
+ """, unsafe_allow_html=True) + # 수량이 여러 개인 일반 아이템 + elif item_quantity > 1: + st.markdown(f""" +
+
{item_icon}
+
+
+ {item_name} + ×{item_quantity} +
+
{item_desc}
+
+
+ """, unsafe_allow_html=True) + # 일반 아이템 (수량 1개, 내구도 없음, 소비성 아님) + else: + st.markdown(f""" +
+
{item_icon}
+
+
+ {item_name} +
+
{item_desc}
+
+
+ """, unsafe_allow_html=True) + + st.markdown("
", unsafe_allow_html=True) + + # 특별한 특성 추가 + if 'special_trait' not in st.session_state: + # 테마와 배경 태그에 따른 특성 선택 + theme = st.session_state.theme + bg_tags = st.session_state.get('background_tags', ["신비로운"]) + + fantasy_traits = [ + "마법에 대한 직관: 마법 관련 판정에 +1 보너스", + "언어 재능: 하나의 추가 언어를 이해할 수 있음", + "생존 본능: 위험 감지 판정에 +2 보너스", + "전투 감각: 선제력 판정에 +1 보너스", + "비밀 감지: 숨겨진 문이나 함정 찾기에 +2 보너스" + ] + + scifi_traits = [ + "기계 친화력: 장치 조작 판정에 +1 보너스", + "우주 적응: 저중력 환경 적응에 +2 보너스", + "전술적 사고: 전투 전략 판정에 +1 보너스", + "네트워크 감각: 정보 검색에 +2 보너스", + "생체 회복: 휴식 시 추가 체력 회복" + ] + + dystopia_traits = [ + "생존자 본능: 위험한 상황 탈출에 +1 보너스", + "자원 절약: 소비품 사용 효율 +25%", + "야간 시력: 어두운 곳에서 시각 판정에 불이익 없음", + "불굴의 의지: 정신적 충격 저항에 +2 보너스", + "전술적 직감: 교전 시 선제 행동 확률 +15%" + ] + + # 태그에 따른 특성 선택 확률 조정 + has_hero = "영웅적" in bg_tags + has_scholarly = "학자" in bg_tags + has_tragic = "비극적" in bg_tags + has_criminal = "범죄자" in bg_tags + has_mysterious = "신비로운" in bg_tags + + if theme == "fantasy": + traits = fantasy_traits + if has_hero: + traits.append("운명의 보호: 하루에 한 번 치명적 공격을 일반 공격으로 낮출 수 있음") + if has_scholarly: + traits.append("비전학자: 마법 관련 지식 판정에 +2 보너스") + if has_tragic: + traits.append("고통의 힘: 체력이 절반 이하일 때 공격력 +1") + if has_criminal: + traits.append("그림자 걷기: 은신 판정에 +2 보너스") + if has_mysterious: + traits.append("신비한 직감: 하루에 한 번 주사위를 ��시 굴릴 수 있음") + elif theme == "sci-fi": + traits = scifi_traits + if has_hero: + traits.append("영웅적 리더십: 아군 NPC 의사 결정에 영향력 +25%") + if has_scholarly: + traits.append("데이터 분석: 기술 장치 판독에 +2 보너스") + if has_tragic: + traits.append("역경의 경험: 위기 상황에서 판단력 +1") + if has_criminal: + traits.append("시스템 침투: 보안 해제 시도에 +2 보너스") + if has_mysterious: + traits.append("양자 직감: 확률적 사건 예측에 +15% 정확도") + else: # dystopia + traits = dystopia_traits + if has_hero: + traits.append("불굴의 영웅: 동료를 보호하는 행동에 +2 보너스") + if has_scholarly: + traits.append("생존 지식: 자원 활용 효율 +20%") + if has_tragic: + traits.append("상실의 분노: 개인적 원한에 관련된 행동에 +2 보너스") + if has_criminal: + traits.append("암시장 연결망: 희귀 물품 거래 시 15% 할인") + if has_mysterious: + traits.append("통제 면역: 정신 조작 시도에 대한 저항 +25%") + + # 무작위 특성 선택 + st.session_state.special_trait = random.choice(traits) + + # 특수 특성 표시 + special_trait = st.session_state.get('special_trait', '특별한 특성이 아직 없습니다.') + + # 특성 이름과 설명 분리 + if ':' in special_trait: + trait_name = special_trait.split(':')[0] + trait_description = ':'.join(special_trait.split(':')[1:]) + else: + trait_name = special_trait + trait_description = '' + + # HTML 문자열 생성 + special_trait_html = f""" +
+

특별한 특성

+
+
🌟 {trait_name}
+
{trait_description}
+
+
+ """ + + st.markdown(special_trait_html, unsafe_allow_html=True) + + with review_col2: + # 능력치 표시 + st.markdown(""" +
+

능력치

+ """, unsafe_allow_html=True) + + # 직업 정보 가져오기 + prof = st.session_state.character.get('profession', '') + key_stats = st.session_state.get('profession_stats', []) + + # 능력치 값 총합 계산 + stats_dict = st.session_state.character.get('stats', {'STR': 0, 'INT': 0, 'DEX': 0, 'CON': 0, 'WIS': 0, 'CHA': 0}) + total_points = sum(stats_dict.values()) + + # 능력치 설정 + for stat, value in stats_dict.items(): + # 색상 및 설명 가져오기 + color, description = get_stat_info(stat, value, prof) + + # 바 그래프 너비 계산 (백분율, 최대 18 기준) + bar_width = min(100, (value / 18) * 100) + + # 핵심 스탯 여부 확인 + if stat in key_stats: + # 핵심 스탯인 경우 + st.markdown(f""" +
+
+
+ {stat} + 핵심 +
+ {value} +
+
+
+
+
{description}
+
+ """, unsafe_allow_html=True) + else: + # 일반 스탯인 경우 - 핵심 배지 없음 + st.markdown(f""" +
+
+
+ {stat} +
+ {value} +
+
+
+
+
{description}
+
+ """, unsafe_allow_html=True) + + # 능력치 총점 표시 + avg_total = 60 # 평균 총점 + + # 총점 평가 (낮음, 평균, 높음) + if total_points < avg_total - 5: + total_rating = "낮음" + total_color = "#F44336" # 빨간색 + elif total_points > avg_total + 5: + total_rating = "높음" + total_color = "#4CAF50" # 녹색 + else: + total_rating = "평균" + total_color = "#FFC107" # 노란색 + + st.markdown(f""" +
+ 능력치 총점: + {total_points} + {total_rating} +
+ """, unsafe_allow_html=True) + + st.markdown("
", unsafe_allow_html=True) + + # 시작 위치 정보 + st.markdown(f""" +
+

시작 위치

+
+
{st.session_state.current_location}
+
+
+ """, unsafe_allow_html=True) + + # 캐릭터 플레이 팁 + st.markdown(f""" +
+

플레이 팁

+ +
+ """, unsafe_allow_html=True) + + # 최종 선택 버튼 + col1, col2 = st.columns(2) + with col1: + if st.button("이 캐릭터로 게임 시작", use_container_width=True): + # 특별한 특성 저장 + if 'special_trait' in st.session_state: + st.session_state.character['special_trait'] = st.session_state.special_trait + + # 테마에 따른 인벤토리 초기화 확인 + if not st.session_state.character['inventory'] or len(st.session_state.character['inventory']) <= 2: + st.session_state.character['inventory'] = initialize_inventory(st.session_state.theme) + + # 게��� 시작 준비 + with st.spinner("게임을 준비하는 중..."): + # 시작 메시지 생성 + start_prompt = f""" + 당신은 TRPG 게임 마스터입니다. 플레이어 캐릭터의 게임 시작 장면을 묘사해주세요. + + 세계: {st.session_state.world_description[:200]}... + 캐릭터: {st.session_state.character['race']} {st.session_state.character['profession']} + 배경: {st.session_state.character['backstory'][:200]}... + 현재 위치: {st.session_state.current_location} + 특별한 특성: {st.session_state.character.get('special_trait', '특별한 특성 없음')} + + 게임을 시작하는 첫 장면을 생생하게 묘사해주세요. 플레이어가 마주한 상황을 설명하되, + 다양한 감각적 묘사(시각, 청각, 후각, 촉각)를 포함하세요. + 플레이어의 특별한 특성이나 배경과 연결된 요소를 포함하면 좋습니다. + '당신은 어떻게 할 것인가요?' 등의 질문으로 끝내지 마세요. + + 약 200단어 내외로 작성해주세요. + """ + intro = generate_gemini_text(start_prompt, 500) + st.session_state.story_log.append(intro) + + # 행동 제안 생성 상태 설정 + st.session_state.suggestions_generated = False + + # 게임 시작 + st.session_state.stage = 'game_play' + st.session_state.master_message = f"모험이 시작되었습니다! {st.session_state.character['race']} {st.session_state.character['profession']}으로서의 여정이 펼쳐집니다." + + # 행동 단계 초기화 + st.session_state.action_phase = 'suggestions' + st.rerun() + + with col2: + if st.button("처음부터 다시 만들기", use_container_width=True): + # 캐릭터 생성 단계 초기화 + st.session_state.character_creation_step = 'race' + st.session_state.background_options_generated = False + + # 임시 데이터 삭제 + for key in ['selected_race', 'selected_profession', 'character_backgrounds', 'selected_background', + 'rolled_abilities', 'special_trait', 'race_bonus', 'race_ability', 'race_icon', + 'profession_icon', 'profession_stats', 'profession_equipment', 'profession_skill', + 'background_tags', 'dice_rolled', 'reroll_used']: + if key in st.session_state: + del st.session_state[key] + + # 캐릭터 정보 초기화 + st.session_state.character = { + 'profession': '', + 'stats': {'STR': 0, 'INT': 0, 'DEX': 0, 'CON': 0, 'WIS': 0, 'CHA': 0}, + 'backstory': '', + 'inventory': ['기본 의류', '작은 주머니 (5 골드)'] + } + + st.session_state.master_message = "다시 시작해봅시다! 어떤 종족을 선택하시겠어요?" + st.rerun() + +# API 호출 재시도 버튼 및 오류 처리 기능 +def add_retry_button(): + col1, col2 = st.columns([3, 1]) + + with col1: + st.error("🚫 AI 응답을 받아오는 중 오류가 발생했습니다.") + + with col2: + if st.button("다시 시도", key="retry_api_call"): + # 마지막 메시지 상태 저장 + last_message = None + if st.session_state.messages and len(st.session_state.messages) > 0: + last_message = st.session_state.messages[-1] + + # 마지막 메시지가 사용자 메시지인 경우 (AI 응답을 받지 못한 경우) + if last_message and last_message["role"] == "user": + # 마지막 사용자 메시지 내용 가져오기 + user_input = last_message["content"] + + # 마지막 메시지 제거 (중복 방지) + st.session_state.messages.pop() + + # 메시지 다시 처리 + st.session_state.retry_in_progress = True + st.rerun() # 페이지 새로고침하여 메시지 다시 처리 + else: + st.warning("다시 시도할 메시지가 없습니다.") + +# 게임 도구 영역 표시 함수 +def display_game_tools(): + """게임 도구 및 옵션 UI 표시""" + # 게임 정보 및 도구 + st.markdown(""" +
+

게임 도구

+
+ """, unsafe_allow_html=True) + + # 세계관 요약 표시 - 수정 (st.popover 오류 해결) + with st.expander("세계관 요약", expanded=False): + # 세계관에서 주요 부분만 추출해서 요약 표시 + world_desc = st.session_state.world_description + # 200자 내외로 잘라내기 + summary = world_desc[:200] + "..." if len(world_desc) > 200 else world_desc + + # 단락 구분 적용 + summary_paragraphs = summary.split("\n\n") + formatted_summary = "" + for para in summary_paragraphs: + formatted_summary += f"

{para}

\n" + + st.markdown(f"
{formatted_summary}
", unsafe_allow_html=True) + + # 전체 보기 버튼 (popover 대신 확장 가능한 영역으로 변경) + if st.button("세계관 전체 보기", key="view_full_world"): + st.markdown("
", unsafe_allow_html=True) + + # 단락 구분 적용 + world_paragraphs = world_desc.split("\n\n") + formatted_world = "" + for para in world_paragraphs: + formatted_world += f"

{para}

\n" + + st.markdown(f"
{formatted_world}
", unsafe_allow_html=True) + st.markdown("
", unsafe_allow_html=True) + + # 마스터에게 질문 (개선됨) + st.markdown(""" +
+

마스터에게 질문

+
+ """, unsafe_allow_html=True) + + # 질문 제안 목록 + suggested_questions = [ + "이 지역의 위험 요소는 무엇인가요?", + "주변에 어떤 중요한 인물이 있나요?", + "이 장소에서 찾을 수 있는 가치 있는 것은?", + "이 지역의 역사는 어떻게 되나요?", + "현재 상황에서 가장 좋은 선택은?", + ] + + # 질문 처리 상태 관리 + if 'master_question_processing' not in st.session_state: + st.session_state.master_question_processing = False + + # 현재 선택된 질문 상태 관리 + if 'selected_master_question' not in st.session_state: + st.session_state.selected_master_question = None + + # 제안된 질문 버튼 - 선택 시 시각적 피드백 개선 + with st.expander("제안된 질문", expanded=False): + for i, q in enumerate(suggested_questions): + # 선택된 질문인지 확인하고 스타일 변경 + is_selected = st.session_state.selected_master_question == q + + st.markdown(f""" +
+

+ {q} {" ✓" if is_selected else ""} +

+
+ """, unsafe_allow_html=True) + + if st.button(f"{'이 질문 선택됨 ✓' if is_selected else '선택'}", + key=f"master_q_{i}", + use_container_width=True, + disabled=is_selected): + st.session_state.selected_master_question = q + st.session_state.master_question_input = q # 입력 필드에 자동 입력 + st.rerun() + + # 질문 입력 폼 - 상태 유지를 위해 form 사용 + with st.form(key="master_question_form"): + # 선택된 질문이 있으면 입력 필드에 표시 + default_question = st.session_state.get('selected_master_question', '') + master_question = st.text_input("질문:", value=default_question, key="master_question_input") + + # 로딩 중이면 버튼 비활성화 + submit_question = st.form_submit_button( + "질문하기", + disabled=st.session_state.master_question_processing + ) + + # 질문이 제출되었을 때 + if submit_question and master_question: + st.session_state.master_question_processing = True + + # 플레이스홀더 생성 - 응답을 표시할 위치 + response_placeholder = st.empty() + response_placeholder.info("마스터가 답변을 작성 중입니다... 잠시만 기다려주세요.") + + with st.spinner("마스터가 응답 중..."): + try: + # 질문에 대한 답변 생성 + answer = master_answer_game_question( + master_question, + st.session_state.theme, + st.session_state.current_location, + st.session_state.world_description + ) + + # 마스터 응답을 세계관에 반영하되, 별도의 상태로 저장 + if 'master_question_history' not in st.session_state: + st.session_state.master_question_history = [] + + st.session_state.master_question_history.append({ + "question": master_question, + "answer": answer + }) + + # 세계관에 반영 (나중에 참조 가능) + st.session_state.world_description += f"\n\n질문-{master_question}: {answer}" + + # 단락 구분 적용 + answer_paragraphs = answer.split("\n\n") + formatted_answer = "" + for para in answer_paragraphs: + formatted_answer += f"

{para}

\n" + + # 응답 표시 - 페이지 새로고침 없이 표시 + response_placeholder.markdown(f""" +
+
질문: {master_question}
+
{formatted_answer}
+
+ """, unsafe_allow_html=True) + + # 선택된 질문 초기화 + st.session_state.selected_master_question = None + + except Exception as e: + st.error(f"응답 생성 중 오류가 발생했습니다: {e}") + response_placeholder.error("질문 처리 중 오류가 발생했습니다. 다시 시도해주세요.") + + finally: + # 처리 완료 상태로 변경 + st.session_state.master_question_processing = False + + # 질문 기록 표시 + if 'master_question_history' in st.session_state and st.session_state.master_question_history: + with st.expander("이전 질문 기록"): + for i, qa in enumerate(st.session_state.master_question_history): + st.markdown(f"**Q{i+1}:** {qa['question']}") + + # 단락 구분 적용 + answer_paragraphs = qa['answer'].split("\n\n") + formatted_answer = "" + for para in answer_paragraphs: + formatted_answer += f"

{para}

\n" + + st.markdown(f"**A:**
{formatted_answer}
", unsafe_allow_html=True) + st.markdown("---") + + # 주사위 직접 굴리기 기능 + with st.expander("주사위 굴리기", expanded=False): + dice_cols = st.columns(3) + + with dice_cols[0]: + d6 = st.button("D6", use_container_width=True) + with dice_cols[1]: + d20 = st.button("D20", use_container_width=True) + with dice_cols[2]: + custom_dice = st.selectbox("커스텀", options=[4, 8, 10, 12, 100]) + roll_custom = st.button("굴리기", key="roll_custom") + + dice_result_placeholder = st.empty() + + if d6: + result = random.randint(1, 6) + dice_result_placeholder.markdown(f"
🎲 {result}
", unsafe_allow_html=True) + elif d20: + result = random.randint(1, 20) + dice_result_placeholder.markdown(f"
🎲 {result}
", unsafe_allow_html=True) + elif roll_custom: + result = random.randint(1, custom_dice) + dice_result_placeholder.markdown(f"
🎲 {result}
", unsafe_allow_html=True) + + # 게임 관리 기능 - 수정 (첫 화면 돌아가기 문제 해결) + st.markdown(""" +
+

게임 관리

+
+ """, unsafe_allow_html=True) + + + + # 완전히 개선된 게임 초기화 및 첫 화면 돌아가기 + if st.button("세계관 설정화면으로 돌아가기", use_container_width=True): + st.warning("⚠️ 주의: 모든 게임 진행 상황이 초기화됩니다!") + restart_confirm = st.radio( + "정말 세계관 설정화면으로 돌아가시겠습니까? 모든 진행사항과 세계관이 초기화됩니다.", + ["아니오", "예"] + ) + + if restart_confirm == "예": + # 확인 버튼 + if st.button("확인 - 처음부터 다시 시작", key="final_restart_confirm"): + # 게임 세션 완전 초기화 + reset_game_session() + st.success("첫 화면으로 돌아갑니다...") + st.experimental_rerun() # 강제 새로고침 + + add_retry_button() + +# 스토리와 행동 표시 함수 수정 +# 모든 가능한 색상 강조 패턴을 처리하는 함수 추가 +def process_color_highlights(text): + """텍스트에서 다양한 색상 강조 패턴을 HTML 태그로 변환""" + # 스타일 코드가 노출되는 문제 해결 + # 'color: #FFD700;>' 패턴 제거 + text = re.sub(r'color:\s*#[0-9A-Fa-f]{6};\s*(?:font-weight:\s*bold;)?>([^<]+?)(?=\s|$)', + r'\1', text) + + # 이미 처리된 HTML 태그는 건너뛰기 + if '\1', text) + + return text + +def display_story_and_actions(): + """스토리 로그와 플레이어 행동 관련 UI를 표시하는 함수""" + st.header("모험의 이야기") + + # 마스터 메시지 표시 + st.markdown(f"
{st.session_state.master_message}
", unsafe_allow_html=True) + + # 스토리 로그가 있으면 표시 + if st.session_state.story_log: + # 가장 최근 이야기는 강조하여 표시 + latest_story = st.session_state.story_log[-1] + + # 단락 구분 개선 + story_paragraphs = latest_story.split("\n\n") + formatted_story = "" + for para in story_paragraphs: + if not para.strip(): + continue + + # 스타일 코드 노출 문제 해결 - 먼저 처리 + para = re.sub(r'color:\s*#[0-9A-Fa-f]{6};\s*(?:font-weight:\s*bold;)?>([^<\n]+)', + r'\1', para) + + # HTML 이스케이프 처리 + para = para.replace("<", "<").replace(">", ">") + + # 굵게 표시 처리 (**텍스트** -> 텍스트) + para = re.sub(r'\*\*([^*]+?)\*\*', r'\1', para) + + # 아이템 이름 강조 처리 추가 + para = re.sub(r'"([^"]+)"', r'\1', para) + para = re.sub(r'\'([^\']+)\'', r'\1', para) + + # 중요 키워드 강조 처리 + para = re.sub(r'\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)\b', r'\1', para) + + # 아이템 처리 강조 (색상만 다르게) + para = re.sub(r'\b(아이템|획득|사용|장비|무기|방어구|소비품|재료)\b', r'\1', para) + + formatted_story += f"

{para}

\n" + + st.markdown(f"
{formatted_story}
", unsafe_allow_html=True) + + # 이전 이야기 표시 (접을 수 있는 형태) + if len(st.session_state.story_log) > 1: + with st.expander("이전 이야기", expanded=False): + # 최신 것부터 역순으로 표시 (가장 최근 것 제외) + for story in reversed(st.session_state.story_log[:-1]): + # 단락 구분 개선 + prev_paragraphs = story.split("\n\n") + formatted_prev = "" + for para in prev_paragraphs: + if not para.strip(): + continue + + # 스타일 코드 노출 문제 해결 - 먼저 처리 + para = re.sub(r'color:\s*#[0-9A-Fa-f]{6};\s*(?:font-weight:\s*bold;)?>([^<\n]+)', + r'\1', para) + + # HTML 이스케이프 처리 + para = para.replace("<", "<").replace(">", ">") + + # 굵게 표시 처리 + para = re.sub(r'\*\*([^*]+?)\*\*', r'\1', para) + + # 아이템 이름 강조 처리 추가 + para = re.sub(r"'([^']+)'", r"\1", para) + para = re.sub(r'"([^"]+)"', r"\1", para) + + # 중요 키워드 강조 처리 추가 + para = re.sub(r'\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)\b', r"\1", para) + + # 아이템 처리 강조 (색상만 다르게) + para = re.sub(r'\b(아이템|획득|사용|장비|무기|방어구|소비품|재료)\b', r'\1', para) + + formatted_prev += f"

{para}

\n" + + st.markdown(f"
{formatted_prev}
", unsafe_allow_html=True) + + # 아이템 알림 표시 (있을 경우) + if st.session_state.get('show_item_notification', False) and st.session_state.get('item_notification', ''): + # 아이템 알림 텍스트 가져오기 + item_notification = st.session_state.item_notification + + # 스타일 코드 노출 문제 해결 - 먼저 처리 + item_notification = re.sub(r'color:\s*#[0-9A-Fa-f]{6};\s*(?:font-weight:\s*bold;)?>([^<\n]+)', + r'\1', item_notification) + + # HTML 이스케이프 처리 + item_notification = item_notification.replace("<", "<").replace(">", ">") + + # 아이템 이름 강조 처리 + item_notification = re.sub(r"'([^']+)'", r"\1", item_notification) + item_notification = re.sub(r'"([^"]+)"', r"\1", item_notification) + + # 아이템 관련 키워드 강조 + item_notification = re.sub(r'\b(아이템|획득|사용|장비|무기|방어구|소비품|재료)\b', r'\1', item_notification) + + st.markdown(f"
{item_notification}
", unsafe_allow_html=True) + # 알림을 표시한 후 초기화 (다음 번에 사라지게) + st.session_state.show_item_notification = False + + # 행동 단계 처리 + st.subheader("당신의 행동") + + # 행동 처리 함수 호출 + handle_action_phase() + +# 개선된 게임 플레이 페이지 (세계관 요약 및 게임 관리 문제 해결 + 반응형 UI) +def game_play_page(): + """개선된 게임 플레이 페이지""" + # 모바일 모드 확인 + mobile_mode = is_mobile() + + # 모바일 패널 상태 초기화 + if mobile_mode and 'mobile_panel' not in st.session_state: + st.session_state.mobile_panel = "스토리" + + # 레이아웃 설정 - 모바일/데스크톱 모드에 따라 다르게 + if mobile_mode: + # 모바일: 선택된 패널만 표시 + current_panel = st.session_state.mobile_panel + + if current_panel == "캐릭터 정보": + # 캐릭터 정보 패널 + display_character_panel(st.session_state.character, st.session_state.current_location) + + # 아이템 알림 표시 (있을 경우) + if st.session_state.get('show_item_notification', False) and st.session_state.get('item_notification', ''): + # 스타일 코드 노출 문제 해결 + item_notification = st.session_state.item_notification + item_notification = re.sub(r'color:\s*#[0-9A-Fa-f]{6};\s*(?:font-weight:\s*bold;)?>([^<\n]+)', + r'\1', item_notification) + + # HTML 이스케이프 처리 + item_notification = item_notification.replace("<", "<").replace(">", ">") + + # 아이템 이름 강조 처리 + item_notification = re.sub(r"'([^']+)'", r"\1", item_notification) + item_notification = re.sub(r'"([^"]+)"', r"\1", item_notification) + + st.markdown(f"
{item_notification}
", unsafe_allow_html=True) + # 알림을 표시한 후 초기화 + st.session_state.show_item_notification = False + + elif current_panel == "게임 도구": + # 게임 도구 패널 + display_game_tools() + + else: # "스토리" (기본) + # 스토리 영역 + display_story_and_actions() + + else: + # 데스크톱: 3열 레이아웃 + game_col1, game_col2, game_col3 = st.columns([1, 2, 1]) + + # 왼쪽 열 - 캐릭터 정보 + with game_col1: + # 캐릭터 정보 패널 + display_character_panel(st.session_state.character, st.session_state.current_location) + + # 아이템 알림 표시 (있을 경우) + if st.session_state.get('show_item_notification', False) and st.session_state.get('item_notification', ''): + # 스타일 코드 노출 문제 해결 + item_notification = st.session_state.item_notification + item_notification = re.sub(r'color:\s*#[0-9A-Fa-f]{6};\s*(?:font-weight:\s*bold;)?>([^<\n]+)', + r'\1', item_notification) + + # HTML 이스케이프 처리 + item_notification = item_notification.replace("<", "<").replace(">", ">") + + # 아이템 이름 강조 처리 + item_notification = re.sub(r"'([^']+)'", r"\1", item_notification) + item_notification = re.sub(r'"([^"]+)"', r"\1", item_notification) + + st.markdown(f"
{item_notification}
", unsafe_allow_html=True) + # 알림을 표시한 후 초기화 + st.session_state.show_item_notification = False + + # 중앙 열 - 스토리 및 행동 + with game_col2: + display_story_and_actions() + + # 오른쪽 열 - 게임 도구 + with game_col3: + display_game_tools() + + +# 메인 애플리케이션 타이틀과 컨셉 변경 +def main(): + # 반응형 레이아웃 설정 (모바일/데스크톱 모드 설정) + setup_responsive_layout() + + st.title("유니버스 원: 세상에서 하나뿐인 TRPG") + + # 컨셉 설명 추가 + if st.session_state.stage == 'theme_selection': + st.markdown(""" +
+

🌟 유니버스 원은 AI가 만들어내는 유일무이한 세계와 이야기를 경험하는 TRPG 플랫폼입니다.

+

🎲 당신이 내리는 모든 선택과 행동이 세계를 형성하고, 이야기를 만들어갑니다.

+

✨ 누구도 똑같은 이야기를 경험할 수 없습니다. 오직 당신만의 단 하나뿐인 모험이 시작됩니다.

+
+ """, unsafe_allow_html=True) + + # 테마 선택 단계 + if st.session_state.stage == 'theme_selection': + st.header("1️⃣ 세계관 선택") + + # 마스터 메시지 표시 + st.markdown(f"
{st.session_state.master_message}
", unsafe_allow_html=True) + + # 테마 설명 추가 + st.markdown(""" +
+

모험을 시작할 세계의 테마를 선택하세요. 각 테마는 독특한 분위기와 가능성을 제공합니다.

+
+ """, unsafe_allow_html=True) + + col1, col2, col3 = st.columns(3) + + with col1: + st.markdown("
", unsafe_allow_html=True) + # HTML로 색상 박스 생성 + st.markdown(create_theme_image("fantasy"), unsafe_allow_html=True) + + # 테마 설명 추가 + st.markdown(get_theme_description("fantasy"), unsafe_allow_html=True) + + if st.button("판타지", key="fantasy"): + with st.spinner("AI 마스터가 세계를 생성 중입니다..."): + loading_placeholder = st.empty() + loading_placeholder.info("판타지 세계를 생성하는 중... 잠시만 기다려주세요.") + + st.session_state.theme = "fantasy" + st.session_state.world_description = generate_world_description("fantasy") + st.session_state.current_location = "왕국의 수도" + st.session_state.available_locations = generate_locations("fantasy") + st.session_state.master_message = "판타지 세계에 오신 것을 환영합니다! 아래 세계 설명을 읽어보시고, 질문이 있으시면 언제든지 물어보세요." + st.session_state.world_generated = True + st.session_state.stage = 'world_description' + + loading_placeholder.empty() + st.rerun() + st.markdown("
", unsafe_allow_html=True) + + with col2: + st.markdown("
", unsafe_allow_html=True) + st.markdown(create_theme_image("sci-fi"), unsafe_allow_html=True) + + # 테마 설명 추가 + st.markdown(get_theme_description("sci-fi"), unsafe_allow_html=True) + + if st.button("SF", key="scifi"): + with st.spinner("AI 마스터가 세계를 생성 중입니다..."): + loading_placeholder = st.empty() + loading_placeholder.info("SF 세계를 생성하는 중... 잠시만 기다려주세요.") + + st.session_state.theme = "sci-fi" + st.session_state.world_description = generate_world_description("sci-fi") + st.session_state.current_location = "중앙 우주 정거장" + st.session_state.available_locations = generate_locations("sci-fi") + st.session_state.master_message = "SF 세계에 오신 것을 환영합니다! 아래 세계 설명을 읽어보시고, 질문이 있으시면 언제든지 물어보세요." + st.session_state.world_generated = True + st.session_state.stage = 'world_description' + + loading_placeholder.empty() + st.rerun() + st.markdown("
", unsafe_allow_html=True) + + with col3: + st.markdown("
", unsafe_allow_html=True) + st.markdown(create_theme_image("dystopia"), unsafe_allow_html=True) + + # 테마 설명 추가 + st.markdown(get_theme_description("dystopia"), unsafe_allow_html=True) + + if st.button("디스토피아", key="dystopia"): + with st.spinner("AI 마스터가 세계를 생성 중입니다..."): + loading_placeholder = st.empty() + loading_placeholder.info("디스토피아 세계를 생성하는 중... 잠시만 기다려주세요.") + + st.session_state.theme = "dystopia" + st.session_state.world_description = generate_world_description("dystopia") + st.session_state.current_location = "지하 피난처" + st.session_state.available_locations = generate_locations("dystopia") + st.session_state.master_message = "디스토피아 세계에 오신 것을 환영합니다! 아래 세계 설명을 읽어보시고, 질문이 있으시면 언제든지 물어보세요." + st.session_state.world_generated = True + st.session_state.stage = 'world_description' + + loading_placeholder.empty() + st.rerun() + st.markdown("
", unsafe_allow_html=True) + + # 세계관 설명 단계 + elif st.session_state.stage == 'world_description': + world_description_page() + + # 캐릭터 생성 단계 + elif st.session_state.stage == 'character_creation': + character_creation_page() + + # 게임 플레이 단계 + elif st.session_state.stage == 'game_play': + game_play_page() + +# 애플리케이션 실행 +if __name__ == "__main__": + main() \ No newline at end of file