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"""
+
+ """, 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
+
+
+
+
+ """, 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)}
+
+
+
시작 장비:
+
+ {"".join([f"- {item}
" for item in equipment[:3]])}
+ {"" if len(equipment) <= 3 else "- ...
"}
+
+
+
+ 특수 기술:
+ {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"""
+
+ """, 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"""
+
+ """, 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"""
+
+
플레이 팁
+
+ - 당신의 핵심 능력치({', '.join(key_stats)})를 활용하는 행동을 시도하세요.
+ - "{st.session_state.special_trait.split(':')[0]}" 특성을 중요한 순간에 활용하세요.
+ - 배경 스토리와 일관된 캐릭터 플레이를 하면 더 몰입감 있는 경험을 할 수 있습니다.
+ - 마스터에게 세계관에 대한 궁금한 점을 자유롭게 질문하세요.
+ - 창의적인 문제 해결 방법을 시도해보세요.
+
+
+ """, 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