import { AppstoreOutlined, CloseOutlined, EnvironmentOutlined, GoldOutlined, LeftOutlined, MenuOutlined, RightOutlined, ShopOutlined, ThunderboltOutlined, UserOutlined, } from '@ant-design/icons'; import { Switch } from 'antd'; import { useEffect, useEffectEvent, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useShallow } from 'zustand/react/shallow'; import './TheQuestAppView.css'; import { createTheQuestGame } from './game/createTheQuestGame'; import { useTheQuestStore, type ConsumableEffectsState, type ManualSkillCastCommand, type QuestProgressState } from './game/theQuestStore'; import { THE_QUEST_ITEMS, THE_QUEST_NPCS, THE_QUEST_STAGE_PORTAL } from './game/theQuestData'; import type { EquipmentSlot, InventoryItem, NpcDefinition, SkillDefinition } from './game/theQuestData'; type TheQuestAppViewProps = { onBack: () => void; launchContext?: 'direct' | 'embedded'; }; type GestureSkillDirection = | 'up' | 'up-right' | 'right' | 'down-right' | 'down' | 'down-left' | 'left' | 'up-left'; type QuickConsumableId = 'hp-potion' | 'auto-heal-elixir' | 'celerity-potion'; type InventoryCategoryFilter = 'all' | InventoryItem['category']; const QUICK_ACTION_META = { character: { label: '정보', title: '캐릭터 상세', icon: }, inventory: { label: '가방', title: '가방/장비', icon: }, quests: { label: '퀘', title: '퀘스트', icon: }, npcs: { label: '상점', title: 'NPC/상점', icon: }, } as const; const NPC_ROLE_META: Record = { quest: { label: '퀘스트 담당', badge: 'QUEST', className: 'the-quest__role-badge--quest' }, shop: { label: '상점 담당', badge: 'SHOP', className: 'the-quest__role-badge--shop' }, guide: { label: '가이드 담당', badge: 'GUIDE', className: 'the-quest__role-badge--guide' }, }; const EQUIPMENT_SLOT_ORDER: EquipmentSlot[] = [ 'weapon', 'helmet', 'shoulders', 'chest', 'belt', 'gloves', 'boots', 'ring', 'necklace', 'bracelet', 'artifact', ]; const EQUIPMENT_SLOT_META: Record = { weapon: { label: '무기', hint: '검, 지팡이, 활 장착' }, helmet: { label: '투구', hint: '후드, 투구, 바이저' }, shoulders: { label: '견갑', hint: '어깨 보호구, 숄더 가드' }, chest: { label: '갑옷', hint: '메일, 경갑, 상의' }, belt: { label: '벨트', hint: '허리 장비, 전투 벨트' }, gloves: { label: '장갑', hint: '건틀릿, 랩스, 손보호구' }, boots: { label: '신발', hint: '부츠, 경화, 이동 장비' }, ring: { label: '반지', hint: '공격/치명 보조 반지' }, necklace: { label: '목걸이', hint: '부적, 펜던트, 목장식' }, bracelet: { label: '팔찌', hint: '주문/민첩 보조 팔찌' }, artifact: { label: '유물', hint: '핵심 버프형 특수 장비' }, }; const ITEM_RARITY_META: Record = { common: { label: '일반', className: 'the-quest__item-card--common' }, rare: { label: '희귀', className: 'the-quest__item-card--rare' }, epic: { label: '영웅', className: 'the-quest__item-card--epic' }, }; const INVENTORY_CATEGORY_FILTERS: Array<{ key: InventoryCategoryFilter; label: string }> = [ { key: 'all', label: '전체' }, { key: 'weapon', label: '무기' }, { key: 'armor', label: '방어구' }, { key: 'accessory', label: '장신구' }, { key: 'consumable', label: '소모품' }, { key: 'material', label: '재료' }, ]; const PRIMARY_ACTION_SKILL: ManualSkillCastCommand['skillId'] = 'energy-bolt'; const GESTURE_DIRECTION_ORDER: GestureSkillDirection[] = [ 'up', 'up-right', 'right', 'down-right', 'down', 'down-left', 'left', 'up-left', ]; const GESTURE_DIRECTION_META: Record = { up: { icon: '↑', label: '상단' }, 'up-right': { icon: '↗', label: '우상단' }, right: { icon: '→', label: '우측' }, 'down-right': { icon: '↘', label: '우하단' }, down: { icon: '↓', label: '하단' }, 'down-left': { icon: '↙', label: '좌하단' }, left: { icon: '←', label: '좌측' }, 'up-left': { icon: '↖', label: '좌상단' }, }; const QUICK_CONSUMABLE_ORDER: QuickConsumableId[] = ['hp-potion', 'auto-heal-elixir', 'celerity-potion']; const QUICK_CONSUMABLE_SLOTS = 4; const GESTURE_SKILL_PAGE_SIZE = GESTURE_DIRECTION_ORDER.length; const MOVEMENT_KEY_ALIASES = { ArrowUp: 'up', KeyW: 'up', ArrowDown: 'down', KeyS: 'down', ArrowLeft: 'left', KeyA: 'left', ArrowRight: 'right', KeyD: 'right', } as const; const SKILL_HOTKEY_ORDER = ['Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6', 'Digit7', 'Digit8'] as const; const PANEL_HOTKEYS = { KeyC: 'character', KeyI: 'inventory', KeyQ: 'quests', KeyN: 'npcs', Comma: 'settings', } as const satisfies Record; function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } function findTouchByIdentifier(touches: TouchList, identifier: number) { for (let index = 0; index < touches.length; index += 1) { const touch = touches.item(index); if (touch && touch.identifier === identifier) { return touch; } } return null; } function formatRatio(value: number, max: number) { return `${Math.max(0, Math.round(value))}/${Math.max(1, Math.round(max))}`; } function buildGaugePercent(value: number, max: number) { if (max <= 0) { return 0; } return clamp((value / max) * 100, 0, 100); } function formatEquipmentSlot(slot: EquipmentSlot) { return EQUIPMENT_SLOT_META[slot].label; } function formatCooldownBadge(remainingMs: number) { return `${(remainingMs / 1000).toFixed(1)}s`; } function formatSecondsLabel(remainingMs: number) { return `${Math.max(1, Math.ceil(remainingMs / 1000))}초`; } function formatPercent(value: number) { return `${Math.round(value * 100)}%`; } type ActiveBuffEntry = { id: string; label: string; detail: string; remainingMs: number; }; function resolveSkillEffectState(skillId: string, consumableEffects: ConsumableEffectsState, now: number) { switch (skillId) { case 'greater-haste': { const remainingMs = Math.max(0, consumableEffects.arcaneSurgeUntil - now); return { remainingMs, label: remainingMs > 0 ? `가속 x${consumableEffects.arcaneSurgeMultiplier}` : null, }; } case 'magic-shield': { const remainingMs = Math.max(0, consumableEffects.manaWardUntil - now); return { remainingMs, label: remainingMs > 0 ? `피감 ${Math.round(consumableEffects.manaWardReduction * 100)}%` : null, }; } case 'summon-skeleton': { const remainingMs = Math.max(0, consumableEffects.familiarUntil - now); return { remainingMs, label: remainingMs > 0 ? '언데드 소환 유지' : null, }; } default: return { remainingMs: 0, label: null, }; } } function buildActiveBuffEntries(consumableEffects: ConsumableEffectsState, now: number) { const entries: ActiveBuffEntry[] = []; if (consumableEffects.arcaneSurgeUntil > now) { entries.push({ id: 'greater-haste', label: '그레이터 헤이스트', detail: `가속 x${consumableEffects.arcaneSurgeMultiplier}`, remainingMs: consumableEffects.arcaneSurgeUntil - now, }); } if (consumableEffects.manaWardUntil > now) { entries.push({ id: 'magic-shield', label: '매직 실드', detail: `피감 ${Math.round(consumableEffects.manaWardReduction * 100)}%`, remainingMs: consumableEffects.manaWardUntil - now, }); } if (consumableEffects.familiarUntil > now) { entries.push({ id: 'summon-skeleton', label: '서먼 스켈레톤', detail: '언데드 소환 유지', remainingMs: consumableEffects.familiarUntil - now, }); } if (consumableEffects.autoHealBoostUntil > now) { entries.push({ id: 'auto-heal', label: '오토 힐', detail: `AUTO x${consumableEffects.autoHealMultiplier}`, remainingMs: consumableEffects.autoHealBoostUntil - now, }); } if (consumableEffects.moveSpeedBoostUntil > now) { entries.push({ id: 'celerity-potion', label: '속도 물약', detail: `SPD x${consumableEffects.moveSpeedMultiplier}`, remainingMs: consumableEffects.moveSpeedBoostUntil - now, }); } consumableEffects.queuedRecovery.forEach((effect, index) => { const lastTickAt = effect.nextTickAt + Math.max(0, effect.ticksRemaining - 1) * effect.tickIntervalMs; entries.push({ id: `${effect.itemId}-${index}`, label: effect.label, detail: `${effect.healPerTick} HP x${effect.ticksRemaining}`, remainingMs: Math.max(0, lastTickAt - now), }); }); return entries.sort((left, right) => left.remainingMs - right.remainingMs); } function getPagedGestureSkills( skills: ReturnType['skills'], page: number, ) { const gestureSkills = skills.filter((skill) => skill.id !== PRIMARY_ACTION_SKILL); const startIndex = page * GESTURE_SKILL_PAGE_SIZE; return gestureSkills.slice(startIndex, startIndex + GESTURE_SKILL_PAGE_SIZE); } function buildItemCategoryLabel(category: InventoryItem['category']) { switch (category) { case 'weapon': return '무기'; case 'armor': return '방어구'; case 'accessory': return '장신구'; case 'consumable': return '소모품'; case 'material': return '재료'; default: return '아이템'; } } function buildItemStats(item: InventoryItem) { return [ item.attack ? `공격 +${item.attack}` : null, item.defense ? `방어 +${item.defense}` : null, item.critRate ? `치명 +${Math.round(item.critRate * 100)}%` : null, item.spellPower ? `주문력 +${item.spellPower}` : null, item.maxMpBonus ? `최대 MP +${item.maxMpBonus}` : null, item.manaAbsorbChance ? `마나 흡수 ${Math.round(item.manaAbsorbChance * 100)}%` : null, item.heal ? `HP ${item.heal} 회복` : null, item.mpRestore ? `MP ${item.mpRestore} 회복` : null, item.healOverTimeTotal ? `HP ${item.healOverTimeTotal} 순차 회복` : null, item.autoHealMultiplier ? `자동회복 ${item.autoHealMultiplier}배` : null, item.moveSpeedMultiplier ? `이동속도 ${item.moveSpeedMultiplier}배` : null, item.effectDurationMs ? `${Math.round(item.effectDurationMs / 1000)}초 지속` : null, item.quantity ? `보유 ${item.quantity}` : null, ].filter(Boolean) as string[]; } function buildItemComparison(item: InventoryItem, equippedItem: InventoryItem | null) { const statRows = [ { key: 'attack', label: '공격', nextValue: item.attack ?? 0, currentValue: equippedItem?.attack ?? 0, }, { key: 'defense', label: '방어', nextValue: item.defense ?? 0, currentValue: equippedItem?.defense ?? 0, }, { key: 'critRate', label: '치명', nextValue: Math.round((item.critRate ?? 0) * 100), currentValue: Math.round((equippedItem?.critRate ?? 0) * 100), unit: '%', }, { key: 'spellPower', label: '주문력', nextValue: item.spellPower ?? 0, currentValue: equippedItem?.spellPower ?? 0, }, { key: 'maxMpBonus', label: '최대 MP', nextValue: item.maxMpBonus ?? 0, currentValue: equippedItem?.maxMpBonus ?? 0, }, ]; return statRows.filter((row) => row.nextValue > 0 || row.currentValue > 0).map((row) => { const delta = row.nextValue - row.currentValue; return { ...row, delta, }; }); } function buildQuestTag(title: string) { return title.includes('[메인]') ? '메인' : title.includes('[서브]') ? '서브' : '일반'; } function buildNpcDialogue(npc: NpcDefinition) { if (npc.role === 'shop') { return '슬라임 젤을 모았다면 상점 담당 미라에게 바로 보고해 보상을 받으세요. 완료 표시는 끝이 아니라 제출 가능 상태를 뜻합니다.'; } if (npc.role === 'quest') { return '포탈 근처 슬라임 정리가 끝나면 기사단장 브롬에게 다시 말을 걸어 메인 퀘스트 보상을 받으세요.'; } return '이동은 좌측 조이스틱, 공격과 스킬은 우측 제스처 패드 하나로 처리하세요. 중앙은 에너지 볼트, 방향 스와이프는 파이어 애로우, 아이스 대거, 라이트닝, 파이어볼, 그레이터 헤이스트, 매직 실드, 서먼 스켈레톤으로 이어집니다.'; } function buildNpcSupportLabel(npc: NpcDefinition) { if (npc.role === 'shop') { return '추천 업무: 슬라임 젤 전달, 포션 보상 수령'; } if (npc.role === 'quest') { return '추천 업무: 슬라임 토벌 보고, 메인 보상 수령'; } return '추천 확인: 에너지 볼트와 방향별 리니지풍 마법 배치'; } function buildNpcLinkedQuestIds(npc: NpcDefinition) { if (npc.role === 'quest') { return ['slime-hunt']; } if (npc.role === 'shop') { return ['slime-gel']; } if (npc.role === 'guide') { return ['slime-hunt']; } return []; } function findNpcName(npcId: string) { return THE_QUEST_NPCS.find((npc) => npc.id === npcId)?.name ?? '담당 NPC'; } function buildQuestStatusLabel(quest: QuestProgressState) { if (quest.completed) { return '완료'; } if (quest.readyToTurnIn) { return '보고 가능'; } return buildQuestTag(quest.title); } function buildQuestStatusClassName(quest: QuestProgressState) { if (quest.completed) { return ' the-quest__quest-tag--done'; } if (quest.readyToTurnIn) { return ' the-quest__quest-tag--ready'; } return ''; } function buildQuestObjectiveLabel(quest: QuestProgressState) { if (quest.progressType === 'collect') { const itemName = findRewardItemLabel(quest.turnInItemId) ?? '재료'; return `${itemName} ${quest.requiredCount}개 수집`; } return '슬라임 처치'; } function buildQuestNextStep(quest: QuestProgressState) { const npcName = findNpcName(quest.turnInNpcId); if (quest.completed) { return `${npcName} 보고와 보상 수령이 끝났습니다.`; } if (quest.readyToTurnIn) { return `${npcName}에게 가서 지금 보상을 받으세요.`; } if (quest.progressType === 'collect') { const remainingCount = Math.max(0, quest.requiredCount - quest.currentCount); const itemName = findRewardItemLabel(quest.turnInItemId) ?? '재료'; return `${itemName} ${remainingCount}개를 더 모은 뒤 ${npcName}에게 전달하세요.`; } const remainingCount = Math.max(0, quest.requiredCount - quest.currentCount); return `슬라임 ${remainingCount}마리를 더 처치한 뒤 ${npcName}에게 보고하세요.`; } function findRewardItemLabel(itemId?: string) { if (!itemId) { return null; } return THE_QUEST_ITEMS.find((item) => item.id === itemId)?.name ?? null; } function formatBossStageLabel(stageCurrent: number) { return `보스 Lv.${stageCurrent + 13}`; } function useViewportClock() { const [now, setNow] = useState(() => Date.now()); useEffect(() => { const interval = window.setInterval(() => setNow(Date.now()), 120); return () => window.clearInterval(interval); }, []); return now; } function findNearestAliveEnemyDistance( playerLocation: { x: number; y: number }, enemies: Array<{ x: number; y: number; alive: boolean }>, ) { let nearestDistance = Number.POSITIVE_INFINITY; enemies.forEach((enemy) => { if (!enemy.alive) { return; } const distance = Math.hypot(enemy.x - playerLocation.x, enemy.y - playerLocation.y); if (distance < nearestDistance) { nearestDistance = distance; } }); return nearestDistance; } function useTheQuestSnapshot() { return useTheQuestStore( useShallow((state) => ({ vitals: state.vitals, equipment: state.equipment, inventory: state.inventory, quests: state.quests, skills: state.skills, skillCooldowns: state.skillCooldowns, enemies: state.enemies, npcs: state.npcs, autoCombat: state.autoCombat, overlayPanel: state.overlayPanel, activeNpcId: state.activeNpcId, selectedInventoryId: state.selectedInventoryId, selectedQuestId: state.selectedQuestId, joystick: state.joystick, minimap: state.minimap, playerLocation: state.playerLocation, performance: state.performance, stage: state.stage, lastCombatLog: state.lastCombatLog, consumableEffects: state.consumableEffects, setOverlayPanel: state.setOverlayPanel, setJoystick: state.setJoystick, setAutoCombat: state.setAutoCombat, setActiveNpc: state.setActiveNpc, setSelectedInventoryId: state.setSelectedInventoryId, setSelectedQuestId: state.setSelectedQuestId, useConsumable: state.useConsumable, equipItem: state.equipItem, unequipItem: state.unequipItem, turnInQuest: state.turnInQuest, advanceStage: state.advanceStage, setLastCombatLog: state.setLastCombatLog, requestManualSkillCast: state.requestManualSkillCast, reset: state.reset, })), ); } function TheQuestJoystick() { const joystick = useTheQuestStore((state) => state.joystick); const setJoystick = useTheQuestStore((state) => state.setJoystick); const knobRef = useRef(null); const baseRef = useRef(null); const activePointerIdRef = useRef(null); const activeTouchIdRef = useRef(null); const setKnobTransform = (x: number, y: number) => { if (!knobRef.current) { return; } knobRef.current.style.transform = `translate(-50%, -50%) translate(${x}px, ${y}px)`; }; const updateFromPointer = (clientX: number, clientY: number) => { const rect = baseRef.current?.getBoundingClientRect(); if (!rect) { return; } const localX = clientX - rect.left - rect.width / 2; const localY = clientY - rect.top - rect.height / 2; const distance = Math.hypot(localX, localY); const maxDistance = rect.width * 0.28; const limitedDistance = Math.min(distance, maxDistance); const angle = Math.atan2(localY, localX); const knobX = Math.cos(angle) * limitedDistance; const knobY = Math.sin(angle) * limitedDistance; const normalizedX = distance === 0 ? 0 : knobX / maxDistance; const normalizedY = distance === 0 ? 0 : knobY / maxDistance; setJoystick({ active: true, x: clamp(normalizedX, -1, 1), y: clamp(normalizedY, -1, 1), }); setKnobTransform(knobX, knobY); }; const handlePointerDown: React.PointerEventHandler = (event) => { if (event.pointerType === 'touch') { return; } activePointerIdRef.current = event.pointerId; event.currentTarget.setPointerCapture(event.pointerId); updateFromPointer(event.clientX, event.clientY); }; const handlePointerMove: React.PointerEventHandler = (event) => { if (event.pointerType === 'touch') { return; } if (activePointerIdRef.current !== event.pointerId) { return; } updateFromPointer(event.clientX, event.clientY); }; const handlePointerUp: React.PointerEventHandler = (event) => { if (event.pointerType === 'touch') { return; } if (activePointerIdRef.current !== event.pointerId) { return; } activePointerIdRef.current = null; setJoystick({ active: false, x: 0, y: 0 }); setKnobTransform(0, 0); }; const resetTouchJoystick = () => { activeTouchIdRef.current = null; setJoystick({ active: false, x: 0, y: 0 }); setKnobTransform(0, 0); }; const handleTouchStart: React.TouchEventHandler = (event) => { if (activeTouchIdRef.current !== null) { return; } const touch = event.changedTouches.item(0); if (!touch) { return; } activeTouchIdRef.current = touch.identifier; updateFromPointer(touch.clientX, touch.clientY); event.preventDefault(); }; const handleTouchMove: React.TouchEventHandler = (event) => { if (activeTouchIdRef.current === null) { return; } const touch = findTouchByIdentifier(event.touches, activeTouchIdRef.current); if (!touch) { return; } updateFromPointer(touch.clientX, touch.clientY); event.preventDefault(); }; const handleTouchEnd: React.TouchEventHandler = (event) => { if (activeTouchIdRef.current === null) { return; } const touch = findTouchByIdentifier(event.changedTouches, activeTouchIdRef.current); if (!touch) { return; } resetTouchJoystick(); event.preventDefault(); }; const handleTouchCancel: React.TouchEventHandler = (event) => { if (activeTouchIdRef.current === null) { return; } resetTouchJoystick(); event.preventDefault(); }; return (
); } function TheQuestGesturePad({ now, skills, gesturePage, skillCooldowns, consumableEffects, emitSkillCast, activeNpc, activePortal, prioritizeCombat, portalUnlocked, onInteractNpc, onInteractPortal, }: { now: number; skills: ReturnType['skills']; gesturePage: number; skillCooldowns: ReturnType['skillCooldowns']; consumableEffects: ReturnType['consumableEffects']; emitSkillCast: (skillId: ManualSkillCastCommand['skillId']) => void; activeNpc: NpcDefinition | null; activePortal: boolean; prioritizeCombat: boolean; portalUnlocked: boolean; onInteractNpc: (npc: NpcDefinition) => void; onInteractPortal: () => void; }) { const pointerStateRef = useRef<{ pointerId: number; startX: number; startY: number; startedInCore: boolean; executedAction: GestureSkillDirection | 'core' | null; } | null>(null); const castEffectTimerRef = useRef(null); const [gestureDirection, setGestureDirection] = useState(null); const [isPressed, setIsPressed] = useState(false); const [castEffectTarget, setCastEffectTarget] = useState(null); const gestureSkills = useMemo( () => getPagedGestureSkills(skills, gesturePage), [gesturePage, skills], ); const skillDirectionMap = useMemo( () => gestureSkills.reduce>>((acc, skill, index) => { const direction = GESTURE_DIRECTION_ORDER[index]; if (direction) { acc[direction] = skill; } return acc; }, {}), [gestureSkills], ); const resolveDirection = (deltaX: number, deltaY: number) => { const distance = Math.hypot(deltaX, deltaY); if (distance < 22) { return null; } const angle = ((Math.atan2(-deltaY, deltaX) * 180) / Math.PI + 360) % 360; if (angle >= 67.5 && angle < 112.5) { return skillDirectionMap.up ? 'up' : null; } if (angle >= 22.5 && angle < 67.5) { return skillDirectionMap['up-right'] ? 'up-right' : null; } if (angle >= 337.5 || angle < 22.5) { return skillDirectionMap.right ? 'right' : null; } if (angle >= 292.5 && angle < 337.5) { return skillDirectionMap['down-right'] ? 'down-right' : null; } if (angle >= 247.5 && angle < 292.5) { return skillDirectionMap.down ? 'down' : null; } if (angle >= 202.5 && angle < 247.5) { return skillDirectionMap['down-left'] ? 'down-left' : null; } if (angle >= 157.5 && angle < 202.5) { return skillDirectionMap.left ? 'left' : null; } return skillDirectionMap['up-left'] ? 'up-left' : null; }; useEffect(() => { return () => { if (castEffectTimerRef.current !== null) { window.clearTimeout(castEffectTimerRef.current); } }; }, []); const triggerCastEffect = (target: GestureSkillDirection | 'core') => { if (castEffectTimerRef.current !== null) { window.clearTimeout(castEffectTimerRef.current); } setCastEffectTarget(target); castEffectTimerRef.current = window.setTimeout(() => { setCastEffectTarget((current) => (current === target ? null : current)); castEffectTimerRef.current = null; }, 280); }; const triggerGestureSkill = (direction: GestureSkillDirection | null) => { if (!direction) { return; } const skill = skillDirectionMap[direction]; if (!skill) { return; } emitSkillCast(skill.id); triggerCastEffect(direction); }; const triggerPrimaryAction = () => { if (prioritizeCombat) { emitSkillCast(PRIMARY_ACTION_SKILL); triggerCastEffect('core'); return; } if (activePortal) { onInteractPortal(); return; } if (activeNpc) { onInteractNpc(activeNpc); return; } emitSkillCast(PRIMARY_ACTION_SKILL); triggerCastEffect('core'); }; const isInsideGestureCore = (event: React.PointerEvent) => { const rect = event.currentTarget.getBoundingClientRect(); const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; const normalizedDistance = Math.hypot(event.clientX - centerX, event.clientY - centerY) / Math.min(rect.width, rect.height); return normalizedDistance <= 0.24; }; const handlePointerDown: React.PointerEventHandler = (event) => { const startedInCore = isInsideGestureCore(event); pointerStateRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, startedInCore, executedAction: null, }; setIsPressed(true); setGestureDirection(null); event.currentTarget.setPointerCapture(event.pointerId); }; const handlePointerMove: React.PointerEventHandler = (event) => { const pointerState = pointerStateRef.current; if (!pointerState || pointerState.pointerId !== event.pointerId) { return; } const direction = resolveDirection(event.clientX - pointerState.startX, event.clientY - pointerState.startY); setGestureDirection(direction); if (pointerState.startedInCore || !direction || pointerState.executedAction === direction) { return; } triggerGestureSkill(direction); pointerStateRef.current = { ...pointerState, executedAction: direction, }; }; const finishGesture: React.PointerEventHandler = (event) => { const pointerState = pointerStateRef.current; if (!pointerState || pointerState.pointerId !== event.pointerId) { return; } const direction = resolveDirection(event.clientX - pointerState.startX, event.clientY - pointerState.startY); pointerStateRef.current = null; setGestureDirection(null); setIsPressed(false); if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } if (pointerState.executedAction) { return; } if (!direction) { if (event.pointerType === 'touch' || event.target === event.currentTarget || pointerState.startedInCore) { triggerPrimaryAction(); } return; } triggerGestureSkill(direction); }; return (
{GESTURE_DIRECTION_ORDER.map((direction) => { const skill = skillDirectionMap[direction]; const meta = GESTURE_DIRECTION_META[direction]; if (!skill) { return (
); } const readyAt = skillCooldowns[skill.id] ?? 0; const remaining = Math.max(0, readyAt - now); const effectState = resolveSkillEffectState(skill.id, consumableEffects, now); return (
0 ? ' the-quest__gesture-node--cooldown' : '' }${gestureDirection === direction ? ' the-quest__gesture-node--selected' : ''}${ castEffectTarget === direction ? ' the-quest__gesture-node--cast' : '' }`} > {skill.icon} {effectState.remainingMs > 0 ? `${effectState.label} · ${formatSecondsLabel(effectState.remainingMs)}` : remaining > 0 ? formatCooldownBadge(remaining) : skill.name}
); })}
); } function TheQuestPanel({ onBack }: { onBack: () => void }) { const { overlayPanel, vitals, stage, inventory, equipment, quests, skills, skillCooldowns, npcs, activeNpcId, selectedInventoryId, selectedQuestId, playerLocation, consumableEffects, setOverlayPanel, setActiveNpc, setSelectedInventoryId, setSelectedQuestId, useConsumable, equipItem, unequipItem, turnInQuest, } = useTheQuestSnapshot(); const now = useViewportClock(); const [draggedItemId, setDraggedItemId] = useState(null); const [dragPointer, setDragPointer] = useState<{ x: number; y: number } | null>(null); const [inventoryTab, setInventoryTab] = useState<'equipment' | 'inventory'>('inventory'); const [inventoryFilter, setInventoryFilter] = useState('all'); const [detailItemId, setDetailItemId] = useState(null); const touchDragRef = useRef<{ pointerId: number; itemId: string } | null>(null); const filteredInventory = useMemo( () => inventory.filter((item) => inventoryFilter === 'all' || item.category === inventoryFilter), [inventory, inventoryFilter], ); const selectedItem = filteredInventory.find((item) => item.id === selectedInventoryId) ?? filteredInventory[0] ?? null; const draggedItem = inventory.find((item) => item.id === draggedItemId) ?? null; const selectedQuest = quests.find((quest) => quest.id === selectedQuestId) ?? quests[0] ?? null; const activeNpc = npcs.find((npc) => npc.id === activeNpcId) ?? npcs[0] ?? null; const firstEquippedItem = EQUIPMENT_SLOT_ORDER.map((slot) => inventory.find((candidate) => candidate.id === equipment[slot]) ?? null).find(Boolean) ?? null; useEffect(() => { if (overlayPanel === 'equipment') { setInventoryTab('equipment'); return; } if (overlayPanel === 'inventory') { setInventoryTab('inventory'); } setDetailItemId(null); }, [overlayPanel]); useEffect(() => { setDetailItemId(null); }, [inventoryTab]); useEffect(() => { if (inventoryTab !== 'inventory') { return; } if (selectedItem && selectedItem.id !== selectedInventoryId) { setSelectedInventoryId(selectedItem.id); return; } if (!selectedItem && selectedInventoryId) { setSelectedInventoryId(null); } }, [inventoryTab, inventoryFilter, selectedInventoryId, selectedItem, setSelectedInventoryId]); const openDetailItem = inventory.find((item) => item.id === detailItemId) ?? null; const detailItemSlot = openDetailItem?.slot; const equippedComparisonItem = detailItemSlot && equipment[detailItemSlot] && equipment[detailItemSlot] !== openDetailItem.id ? inventory.find((candidate) => candidate.id === equipment[detailItemSlot]) ?? null : null; const comparisonRows = detailItemSlot ? buildItemComparison(openDetailItem, equippedComparisonItem) : []; const isDetailItemEquipped = Boolean(detailItemSlot && equipment[detailItemSlot] === openDetailItem.id); const linkedQuestIds = activeNpc ? buildNpcLinkedQuestIds(activeNpc) : []; const linkedQuests = linkedQuestIds .map((questId) => quests.find((quest) => quest.id === questId) ?? null) .filter(Boolean); const reportableLinkedQuests = linkedQuests.filter((quest) => quest.readyToTurnIn && !quest.completed && quest.turnInNpcId === activeNpc?.id); const selectedQuestNpcName = selectedQuest ? findNpcName(selectedQuest.turnInNpcId) : null; const canTurnInSelectedQuest = Boolean(selectedQuest && !selectedQuest.completed && selectedQuest.readyToTurnIn && activeNpc?.id === selectedQuest.turnInNpcId); const openQuestTurnInNpc = (quest: QuestProgressState) => { setActiveNpc(quest.turnInNpcId); setSelectedQuestId(quest.id); setOverlayPanel('npcs'); }; const handleEquipDrop = (itemId: string, slot: EquipmentSlot) => { const item = inventory.find((candidate) => candidate.id === itemId); if (!item?.slot || item.slot !== slot) { return; } equipItem(itemId); setSelectedInventoryId(itemId); setDetailItemId(itemId); setDraggedItemId(null); setDragPointer(null); }; const handleItemPointerDown = (itemId: string): React.PointerEventHandler => (event) => { if (event.pointerType === 'mouse') { return; } touchDragRef.current = { pointerId: event.pointerId, itemId, }; setDraggedItemId(itemId); setDragPointer({ x: event.clientX, y: event.clientY }); event.currentTarget.setPointerCapture(event.pointerId); }; const handleItemPointerMove: React.PointerEventHandler = (event) => { if (!touchDragRef.current || touchDragRef.current.pointerId !== event.pointerId) { return; } setDragPointer({ x: event.clientX, y: event.clientY }); }; const finishTouchDrag = (event: React.PointerEvent) => { if (!touchDragRef.current || touchDragRef.current.pointerId !== event.pointerId) { return; } const currentDrag = touchDragRef.current; touchDragRef.current = null; if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } const dropTarget = document.elementFromPoint(event.clientX, event.clientY)?.closest('[data-equip-slot]'); const slot = dropTarget?.dataset.equipSlot as EquipmentSlot | undefined; if (slot) { handleEquipDrop(currentDrag.itemId, slot); return; } setDraggedItemId(null); setDragPointer(null); }; if (!overlayPanel) { return null; } return (
{overlayPanel === 'character' ? '캐릭터 상세' : overlayPanel === 'inventory' || overlayPanel === 'equipment' ? '가방/장비' : overlayPanel === 'quests' ? '퀘스트' : overlayPanel === 'npcs' ? 'NPC' : '설정'}
{overlayPanel === 'character' ? (
Moon Guard / Wizard 문가드 메이지
Lv.{vitals.level} Stage {stage.current} {playerLocation.zoneLabel} {playerLocation.placeLabel}
Status 기본 스탯
EXP {vitals.exp}/{vitals.expToNext}
장착 {EQUIPMENT_SLOT_ORDER.filter((slot) => equipment[slot]).length}/11
HP {formatRatio(vitals.hp, vitals.maxHp)}
MP {formatRatio(vitals.mp, vitals.maxMp)}
공격 {vitals.attack}
주문력 {vitals.spellPower}
방어 {vitals.defense}
치명 {formatPercent(vitals.critRate)}
마나 흡수 {formatPercent(vitals.manaAbsorbChance)}
골드 {vitals.gold.toLocaleString('ko-KR')}
Buff 적용 효과 {buildActiveBuffEntries(consumableEffects, now).length ? (
{buildActiveBuffEntries(consumableEffects, now).map((entry) => (
{entry.label} {entry.detail} · {formatSecondsLabel(entry.remainingMs)}
))}
) : (

현재 활성 버프가 없습니다. 버프 스킬이나 소모품을 사용하면 이곳에 표시됩니다.

)}
Skills 스킬 상세
{skills.map((skill, index) => { const readyAt = skillCooldowns[skill.id] ?? 0; const remainingMs = Math.max(0, readyAt - now); const effectState = resolveSkillEffectState(skill.id, consumableEffects, now); const isEffectActive = effectState.remainingMs > 0; return (
{skill.name} {index === 0 ? '기본' : `${index}`}
{skill.description}
MP {skill.manaCost} 쿨 {formatCooldownBadge(skill.cooldownMs)} {skill.durationMs ? `지속 ${Math.round(skill.durationMs / 1000)}초` : `사거리 ${Math.round(skill.range)}`}
0 ? 'the-quest__sidebar-skill-state--cooldown' : '' } > {isEffectActive ? `${effectState.label ?? '효과 적용'} · ${formatSecondsLabel(effectState.remainingMs)}` : remainingMs > 0 ? `재사용 ${formatCooldownBadge(remainingMs)}` : '사용 가능'} {skill.accentLabel}
); })}
) : null} {overlayPanel === 'inventory' || overlayPanel === 'equipment' ? (
Royal Vault {inventoryTab === 'equipment' ? '장비 관리' : '모바일 인벤토리'}
{vitals.gold.toLocaleString('ko-KR')} Gold
{inventoryTab === 'equipment' ? (
Character Render 문가드 메이지
{EQUIPMENT_SLOT_ORDER.map((slot) => { const equippedItem = inventory.find((candidate) => candidate.id === equipment[slot]) ?? null; return (
{ if (draggedItem?.slot === slot) { event.preventDefault(); } }} onDrop={(event) => { event.preventDefault(); const itemId = event.dataTransfer.getData('text/plain'); handleEquipDrop(itemId, slot); }} >
{formatEquipmentSlot(slot)}
); })}
) : (
소지품 {filteredInventory.length}/{inventory.length}칸
{INVENTORY_CATEGORY_FILTERS.map((filter) => { const filterCount = filter.key === 'all' ? inventory.length : inventory.filter((item) => item.category === filter.key).length; return ( ); })}
{filteredInventory.map((item) => ( ))}
)}
{openDetailItem ? createPortal(
setDetailItemId(null)} >
event.stopPropagation()} >
상세 정보 {inventoryTab === 'equipment' ? '착용 슬롯 기준' : '선택한 아이템 기준'}
{ITEM_RARITY_META[openDetailItem.rarity].label}
{openDetailItem.icon}
{openDetailItem.name} {openDetailItem.slot ? formatEquipmentSlot(openDetailItem.slot) : buildItemCategoryLabel(openDetailItem.category)}
{isDetailItemEquipped ?
현재 착용 중
: null}
{buildItemStats(openDetailItem).map((stat) => ( {stat} ))}
{equippedComparisonItem && comparisonRows.length ? (
현재 장착 비교 현재 착용: {equippedComparisonItem.icon} {equippedComparisonItem.name}
{comparisonRows.map((row) => (
{row.label} {row.currentValue} {row.unit ?? ''} → {row.nextValue} {row.unit ?? ''} = 0 ? 'the-quest__item-compare-delta--up' : 'the-quest__item-compare-delta--down'}> {row.delta >= 0 ? '+' : ''} {row.delta} {row.unit ?? ''}
))}
) : null}
{openDetailItem.slot && !isDetailItemEquipped ? ( ) : null} {isDetailItemEquipped && openDetailItem.slot ? ( ) : null} {openDetailItem.category === 'consumable' ? ( ) : null}
, document.body, ) : null} {draggedItem && dragPointer ? (
{draggedItem.icon} {draggedItem.name}
) : null}
) : null} {overlayPanel === 'quests' ? (
{quests.map((quest) => ( ))}
{selectedQuest ? (
{buildQuestStatusLabel(selectedQuest)} {selectedQuest.title}

{selectedQuest.description}

{buildQuestObjectiveLabel(selectedQuest)} {buildQuestNextStep(selectedQuest)}
진행 {selectedQuest.currentCount}/{selectedQuest.requiredCount} 보고 NPC {selectedQuestNpcName} 보상 {selectedQuest.rewardExp} EXP {selectedQuest.rewardGold} 골드 {findRewardItemLabel(selectedQuest.rewardItemId) ? {findRewardItemLabel(selectedQuest.rewardItemId)} : null}
{canTurnInSelectedQuest ? ( ) : ( )}
) : null}
) : null} {overlayPanel === 'npcs' && activeNpc ? (
{npcs.map((npc) => ( ))}
{NPC_ROLE_META[activeNpc.role].badge} {activeNpc.name}
{activeNpc.description}
{NPC_ROLE_META[activeNpc.role].label} {buildNpcSupportLabel(activeNpc)}
현재 안내

{buildNpcDialogue(activeNpc)}

{reportableLinkedQuests.length ? (
즉시 처리 가능
{reportableLinkedQuests.map((quest) => (
{buildQuestStatusLabel(quest)} {quest.title}
))}
) : null} {linkedQuests.length ? (
연결 퀘스트
{linkedQuests.map((quest) => ( ))}
) : null}
) : null} {overlayPanel === 'settings' ? (
앱 설정 The Quest 메뉴

상단 헤더를 제거한 대신 여기서 앱 종료를 처리합니다.

) : null}
); } export function TheQuestAppView({ onBack, launchContext = 'direct' }: TheQuestAppViewProps) { const mountRef = useRef(null); const gameHandleRef = useRef<{ destroy: () => void; requestManualSkillCast: (skillId: string) => void } | null>(null); const pressedMovementKeysRef = useRef>(new Set()); const [isReady, setIsReady] = useState(false); const [initError, setInitError] = useState(null); const [consumablePage, setConsumablePage] = useState(0); const [gestureSkillPage, setGestureSkillPage] = useState(0); const isEmbeddedLaunch = launchContext === 'embedded'; const now = useViewportClock(); const { vitals, equipment, inventory, quests, skills, skillCooldowns, autoCombat, enemies, npcs, overlayPanel, activeNpcId, performance, playerLocation, consumableEffects, setOverlayPanel, setAutoCombat, setActiveNpc, setJoystick, setSelectedQuestId, useConsumable, requestManualSkillCast, stage, advanceStage, setLastCombatLog, } = useTheQuestSnapshot(); useEffect(() => { let isMounted = true; let handle: { destroy: () => void; requestManualSkillCast: (skillId: string) => void } | null = null; if (!mountRef.current) { return undefined; } setInitError(null); void createTheQuestGame({ mountElement: mountRef.current }) .then((nextHandle) => { if (!isMounted) { nextHandle.destroy(); return; } handle = nextHandle; gameHandleRef.current = nextHandle; setIsReady(true); }) .catch((error) => { if (!isMounted) { return; } setInitError(error instanceof Error ? error.message : '게임 엔진 초기화에 실패했습니다.'); }); return () => { isMounted = false; setIsReady(false); gameHandleRef.current = null; handle?.destroy(); }; }, []); const activeNpc = useMemo( () => npcs.find((npc) => npc.id === activeNpcId) ?? null, [activeNpcId, npcs], ); const bossEnemy = useMemo( () => enemies.find((enemy) => enemy.role === 'boss') ?? null, [enemies], ); const portalUnlocked = bossEnemy ? !bossEnemy.alive : false; const activePortal = useMemo( () => Math.hypot(playerLocation.x - THE_QUEST_STAGE_PORTAL.x, playerLocation.y - THE_QUEST_STAGE_PORTAL.y) <= THE_QUEST_STAGE_PORTAL.radius, [playerLocation.x, playerLocation.y], ); const nearestEnemyDistance = useMemo( () => findNearestAliveEnemyDistance(playerLocation, enemies), [enemies, playerLocation], ); const shouldPrioritizeCombat = nearestEnemyDistance <= 244; const quickConsumables = useMemo( () => QUICK_CONSUMABLE_ORDER.map((itemId) => inventory.find((item) => item.id === itemId && item.category === 'consumable') ?? null), [inventory], ); const availableQuickConsumables = useMemo(() => quickConsumables.filter(Boolean), [quickConsumables]); const consumablePageCount = Math.max(1, Math.ceil(availableQuickConsumables.length / QUICK_CONSUMABLE_SLOTS)); const gestureSkillPageCount = Math.max( 1, Math.ceil(skills.filter((skill) => skill.id !== PRIMARY_ACTION_SKILL).length / GESTURE_SKILL_PAGE_SIZE), ); const visibleGestureSkills = useMemo(() => getPagedGestureSkills(skills, gestureSkillPage), [gestureSkillPage, skills]); const visibleConsumableSlots = useMemo(() => { const startIndex = consumablePage * QUICK_CONSUMABLE_SLOTS; const visibleItems = availableQuickConsumables.slice(startIndex, startIndex + QUICK_CONSUMABLE_SLOTS); return Array.from({ length: QUICK_CONSUMABLE_SLOTS }, (_, index) => visibleItems[index] ?? null); }, [availableQuickConsumables, consumablePage]); const hpGaugePercent = buildGaugePercent(vitals.hp, vitals.maxHp); const mpGaugePercent = buildGaugePercent(vitals.mp, vitals.maxMp); const activeQuestCount = quests.filter((quest) => !quest.completed).length; const activeBuffEntries = useMemo(() => buildActiveBuffEntries(consumableEffects, now), [consumableEffects, now]); const maxHudBuffEntries = performance.isMobile ? 1 : 2; const hudBuffEntries = activeBuffEntries.slice(0, maxHudBuffEntries); const hiddenHudBuffCount = Math.max(0, activeBuffEntries.length - hudBuffEntries.length); const toolbarButtons = [ { key: 'character' }, { key: 'inventory', panel: 'inventory', count: inventory.length > 0 ? inventory.length : null }, { key: 'quests', count: activeQuestCount > 0 ? activeQuestCount : null }, { key: 'npcs', count: npcs.length > 0 ? npcs.length : null }, ] as const; useEffect(() => { setConsumablePage((currentPage) => Math.min(currentPage, consumablePageCount - 1)); }, [consumablePageCount]); useEffect(() => { setGestureSkillPage((currentPage) => Math.min(currentPage, gestureSkillPageCount - 1)); }, [gestureSkillPageCount]); const emitSkillCast = useEffectEvent((skillId: ManualSkillCastCommand['skillId']) => { requestManualSkillCast(skillId); }); const handleNpcInteraction = (npc: NpcDefinition) => { setActiveNpc(npc.id); const linkedQuestId = buildNpcLinkedQuestIds(npc)[0] ?? null; if (linkedQuestId) { setSelectedQuestId(linkedQuestId); } setOverlayPanel('npcs'); }; const handlePortalInteraction = () => { if (!activePortal) { return; } if (!portalUnlocked) { setLastCombatLog('포탈이 봉인되어 있습니다. 스테이지 보스를 먼저 처치하세요.'); return; } advanceStage(); }; const syncKeyboardJoystick = useEffectEvent((pressedMovementKeys: Set) => { if (overlayPanel) { setJoystick({ active: false, x: 0, y: 0 }); return; } const hasLeft = pressedMovementKeys.has('left'); const hasRight = pressedMovementKeys.has('right'); const hasUp = pressedMovementKeys.has('up'); const hasDown = pressedMovementKeys.has('down'); const x = (hasRight ? 1 : 0) - (hasLeft ? 1 : 0); const y = (hasDown ? 1 : 0) - (hasUp ? 1 : 0); const length = Math.hypot(x, y); if (length <= 0) { setJoystick({ active: false, x: 0, y: 0 }); return; } setJoystick({ active: true, x: x / length, y: y / length, }); }); const resetKeyboardJoystick = useEffectEvent((pressedMovementKeys: Set) => { pressedMovementKeys.clear(); setJoystick({ active: false, x: 0, y: 0 }); }); const triggerKeyboardPrimaryAction = useEffectEvent(() => { if (shouldPrioritizeCombat) { emitSkillCast(PRIMARY_ACTION_SKILL); return; } if (activePortal) { handlePortalInteraction(); return; } if (activeNpc) { handleNpcInteraction(activeNpc); return; } emitSkillCast(PRIMARY_ACTION_SKILL); }); const handleKeyboardVisibilityChange = useEffectEvent((pressedMovementKeys: Set) => { if (document.visibilityState !== 'visible') { resetKeyboardJoystick(pressedMovementKeys); } }); const handleKeyboardWindowBlur = useEffectEvent((pressedMovementKeys: Set) => { resetKeyboardJoystick(pressedMovementKeys); }); const handleKeyboardKeyDown = useEffectEvent((event: KeyboardEvent, pressedMovementKeys: Set) => { const target = event.target; if (target instanceof HTMLElement) { const isEditable = target.isContentEditable || target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT'; if (isEditable) { return; } } const movementDirection = MOVEMENT_KEY_ALIASES[event.code as keyof typeof MOVEMENT_KEY_ALIASES]; if (movementDirection) { event.preventDefault(); pressedMovementKeys.add(movementDirection); syncKeyboardJoystick(pressedMovementKeys); return; } if (event.code === 'Escape') { if (overlayPanel) { event.preventDefault(); setOverlayPanel(null); } return; } const panel = PANEL_HOTKEYS[event.code as keyof typeof PANEL_HOTKEYS]; if (panel && !event.repeat) { event.preventDefault(); setOverlayPanel(overlayPanel === panel ? null : panel); return; } if (event.code === 'KeyB' && !event.repeat) { event.preventDefault(); setAutoCombat(!autoCombat); return; } if (overlayPanel) { return; } if ((event.code === 'Space' || event.code === 'Enter') && !event.repeat) { event.preventDefault(); triggerKeyboardPrimaryAction(); return; } const skillHotkeyIndex = SKILL_HOTKEY_ORDER.indexOf(event.code as (typeof SKILL_HOTKEY_ORDER)[number]); if (skillHotkeyIndex >= 0 && !event.repeat) { event.preventDefault(); const hotkeySkill = visibleGestureSkills[skillHotkeyIndex]; if (hotkeySkill) { emitSkillCast(hotkeySkill.id); } return; } if ((event.code === 'KeyZ' || event.code === 'KeyX' || event.code === 'KeyC') && !event.repeat) { event.preventDefault(); const hotkeyIndex = event.code === 'KeyZ' ? 0 : event.code === 'KeyX' ? 1 : 2; const consumable = quickConsumables[hotkeyIndex]; if (consumable?.quantity && consumable.quantity > 0) { useConsumable(consumable.id); } } }); const handleKeyboardKeyUp = useEffectEvent((event: KeyboardEvent, pressedMovementKeys: Set) => { const movementDirection = MOVEMENT_KEY_ALIASES[event.code as keyof typeof MOVEMENT_KEY_ALIASES]; if (!movementDirection) { return; } event.preventDefault(); pressedMovementKeys.delete(movementDirection); syncKeyboardJoystick(pressedMovementKeys); }); useEffect(() => { const pressedMovementKeys = pressedMovementKeysRef.current; const handleVisibilityChange = () => handleKeyboardVisibilityChange(pressedMovementKeys); const handleWindowBlur = () => handleKeyboardWindowBlur(pressedMovementKeys); const handleKeyDown = (event: KeyboardEvent) => handleKeyboardKeyDown(event, pressedMovementKeys); const handleKeyUp = (event: KeyboardEvent) => handleKeyboardKeyUp(event, pressedMovementKeys); window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); window.addEventListener('blur', handleWindowBlur); document.addEventListener('visibilitychange', handleVisibilityChange); return () => { window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); window.removeEventListener('blur', handleWindowBlur); document.removeEventListener('visibilitychange', handleVisibilityChange); resetKeyboardJoystick(pressedMovementKeys); }; }, []); return (
{!isReady ? (
{initError ? 'The Quest 실행에 실패했습니다.' : '모험 지도를 불러오는 중...'}

{initError ? '렌더러 초기화에 실패해 계속 멈춘 것처럼 보이지 않도록 즉시 실패 상태를 표시합니다. 새로고침 후 다시 열어 주세요.' : '전투 맵과 조작 HUD를 초기화하고 있습니다.'}

) : null}
{isEmbeddedLaunch ? ( ) : null}
{toolbarButtons.map(({ key, panel, count }) => { const meta = QUICK_ACTION_META[key]; const targetPanel = panel ?? key; const isActive = targetPanel === 'inventory' ? overlayPanel === 'inventory' || overlayPanel === 'equipment' : overlayPanel === targetPanel; return ( ); })}
HP
{formatRatio(vitals.hp, vitals.maxHp)}
MP
{formatRatio(vitals.mp, vitals.maxMp)}
LV {vitals.level} STAGE {stage.current} {portalUnlocked ? ' / 포탈 개방' : ''}
EXP {vitals.level} / EXP {vitals.exp}/{vitals.expToNext} BUFF {hudBuffEntries.length ? (
{hudBuffEntries.map((entry) => (
{entry.label} {entry.detail} {formatSecondsLabel(entry.remainingMs)}
))} {hiddenHudBuffCount > 0 ? +{hiddenHudBuffCount} : null}
) : ( 활성 효과 없음 )}
MOVE WASD / 방향키
AUTO
{visibleConsumableSlots.map((item, index) => { if (!item) { return (
ARCANE / SPELL Space·Enter / 1-8 / ZXC / B
SKILL {gestureSkillPage + 1}/{gestureSkillPageCount}
{createPortal(, document.body)}
); }