import streamlit as st import random import time import re from typing import Dict, List, Any, Tuple, Optional from utils.dice_roller import roll_dice, display_dice_animation, calculate_dice_result from utils.theme_manager import create_theme_image from utils.location_manager import generate_locations, generate_movement_story from modules.ai_service import ( generate_action_suggestions, master_answer_game_question, generate_story_response ) from modules.item_manager import ( display_inventory, extract_items_from_story, extract_used_items_from_story, update_inventory ) def initialize_game_state(): """게임 관련 상태 초기화""" # 게임 플레이 상태 초기화 if 'story_log' not in st.session_state: st.session_state.story_log = [] if 'action_phase' not in st.session_state: st.session_state.action_phase = 'suggestions' if 'suggestions_generated' not in st.session_state: st.session_state.suggestions_generated = False if 'dice_rolled' not in st.session_state: st.session_state.dice_rolled = False if 'action_submitted' not in st.session_state: st.session_state.action_submitted = False if 'action_processed' not in st.session_state: st.session_state.action_processed = False if 'ability_check_done' not in st.session_state: st.session_state.ability_check_done = False # 이동 관련 상태 if 'move_submitted' not in st.session_state: st.session_state.move_submitted = False if 'move_processed' not in st.session_state: st.session_state.move_processed = False if 'move_destination' not in st.session_state: st.session_state.move_destination = "" # 아이템 알림 관련 상태 if 'item_notification' not in st.session_state: st.session_state.item_notification = "" if 'show_item_notification' not in st.session_state: st.session_state.show_item_notification = False # 마스터 질문 상태 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 if 'master_question_history' not in st.session_state: st.session_state.master_question_history = [] def display_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) # 아이템 알림 표시 (있을 경우) display_item_notification() 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) # 아이템 알림 표시 (있을 경우) display_item_notification() # 중앙 열 - 스토리 및 행동 with game_col2: display_story_and_actions() # 오른쪽 열 - 게임 도구 with game_col3: display_game_tools() def is_mobile() -> bool: """현재 기기가 모바일인지 확인""" return st.session_state.get('is_mobile', False) def display_character_panel(character: Dict[str, Any], location: str): """캐릭터 정보를 왼쪽 패널에 표시""" st.markdown("
", unsafe_allow_html=True) st.write(f"## {character['profession']}") # 능력치 표시 st.write("### 능력치") for stat, value in character['stats'].items(): # 직업 정보 가져오기 prof = character['profession'] from modules.character_utils import get_stat_info color, description = get_stat_info(stat, value, prof) st.markdown(f"""
{stat} {value}
{description}
""", unsafe_allow_html=True) # 인벤토리 표시 st.write("### 인벤토리") display_inventory(character['inventory']) st.markdown("
", unsafe_allow_html=True) # 위치 정보 st.markdown(f"""

현재 위치

{location}
""", unsafe_allow_html=True) def display_item_notification(): """아이템 관련 알림 표시""" if st.session_state.get('show_item_notification', False) and st.session_state.get('item_notification', ''): # 아이템 이름 강조를 위한 정규식 처리 import re # 아이템 이름을 추출하여 강조 처리 notification = st.session_state.item_notification # 아이템 이름 강조 처리 추가 notification = re.sub(r"'([^']+)'", r"\1", notification) notification = re.sub(r'"([^"]+)"', r"\1", notification) notification = re.sub(r'\*\*([^*]+)\*\*', r"\1", notification) st.markdown(f"""
🎁
{notification}
""", unsafe_allow_html=True) # 알림을 표시한 후 초기화 (다음 번에 사라지게) st.session_state.show_item_notification = False 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: # 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) 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: # 아이템 이름 강조 처리 추가 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) formatted_prev += f"

{para}

\n" st.markdown(f"
{formatted_prev}
", unsafe_allow_html=True) # 아이템 알림 표시 (있을 경우) display_item_notification() # 행동 단계 처리 st.subheader("당신의 행동") # 행동 처리 함수 호출 handle_action_phase() def handle_action_phase(): """행동 선택 및 처리 부분을 관리하는 함수""" # 행동 단계 관리 action_phase = st.session_state.get('action_phase', 'suggestions') # 1. 이동 처리 if action_phase == "moving": handle_movement() # 2. 능력치 판정 단계 elif action_phase == "ability_check": handle_ability_check() # 3. 행동 제안 및 선택 단계 elif action_phase == 'suggestions': handle_action_suggestions() def handle_movement(): """위치 이동 처리""" 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() def handle_ability_check(): """능력치 판정 과정을 처리하는 함수""" with st.spinner("주사위를 굴리고 있습니다..."): # 로딩 표시 loading_placeholder = st.empty() loading_placeholder.info("주사위를 굴려 스토리의 진행을 판단하는 중... 잠시만 기다려주세요.") st.subheader("능력치 판정") # 행동 표시 st.markdown(f"""

선택한 행동:

{st.session_state.current_action}

""", unsafe_allow_html=True) # 마스터가 능력치와 난이도 제안 if 'suggested_ability' not in st.session_state: with st.spinner("마스터가 판정 방식을 결정 중..."): # 행동 분석을 위한 프롬프트 suggested_ability = suggest_ability_for_action( st.session_state.current_action, st.session_state.character['profession'], st.session_state.current_location ) # 세션에 저장 st.session_state.suggested_ability = suggested_ability 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.get('recommended_dice', '1d20')}
""", 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 = st.session_state.character['stats'][ability_code] if "+" not in dice_expression and "-" not in dice_expression: # 능력치 수정자 적용 dice_expression = f"{dice_expression}+{ability_value}" with st.spinner("주사위 굴리는 중..."): # 주사위 굴리기 애니메이션 및 결과 표시 dice_result = display_dice_animation(dice_placeholder, dice_expression, 1.0) st.session_state.dice_rolled = True st.session_state.dice_result = dice_result else: # 이미 굴린 주사위 결과 표시 dice_placeholder = st.empty() dice_result = st.session_state.dice_result # 판정 결과 계산 difficulty = ability['difficulty'] success = dice_result['total'] >= difficulty # 결과 표시 (더 풍부하게 개선) result_color = "#1e3a23" if success else "#3a1e1e" result_border = "#4CAF50" if success else "#F44336" result_text = "성공" if success else "실패" outcome_text = ability['success_outcome'] if success else ability['failure_outcome'] st.markdown(f"""

판정 결과: {result_text}

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

결과: {outcome_text}

""", unsafe_allow_html=True) # 스토리 진행 버튼 - 더 매력적인 UI if st.button("스토리 진행", key="continue_story_button", use_container_width=True): handle_story_progression( st.session_state.current_action, dice_result['total'], success, ability['code'], difficulty ) return success, dice_result['total'], ability['code'], dice_result['total'], difficulty def suggest_ability_for_action(action: str, profession: str, location: str) -> Dict[str, Any]: """행동 분석 후 능력치 및 난이도 제안""" from modules.ai_service import get_ability_suggestion # AI 서비스에 능력치 제안 요청 suggestion = get_ability_suggestion(action, profession, location) # 능력치 전체 이름 매핑 ability_names = { 'STR': '근력', 'INT': '지능', 'DEX': '민첩', 'CON': '체력', 'WIS': '지혜', 'CHA': '매력' } # 기본값 설정 (오류 방지) ability_code = suggestion.get('ability_code', 'STR') difficulty = suggestion.get('difficulty', 15) reason = suggestion.get('reason', '이 행동에는 능력이 필요합니다.') success_outcome = suggestion.get('success_outcome', '행동에 성공합니다.') failure_outcome = suggestion.get('failure_outcome', '행동에 실패합니다.') recommended_dice = suggestion.get('recommended_dice', '1d20') return { '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 } def handle_action_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.character ) st.session_state.suggestions_generated = True # 로딩 메시지 제거 loading_placeholder.empty() st.rerun() def display_game_tools(): """게임 도구 및 옵션 UI 표시""" # 게임 정보 및 도구 st.markdown("""

게임 도구

""", unsafe_allow_html=True) # 세계관 요약 표시 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) # 전체 보기 버튼 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) display_master_question_ui() # 주사위 직접 굴리기 기능 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"): # 게임 세션 완전 초기화 from utils.session_manager import reset_game_session reset_game_session() st.success("첫 화면으로 돌아갑니다...") st.rerun() def display_master_question_ui(): """마스터에게 질문하는 UI 표시""" # 질문 제안 목록 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("---")