Files
ai-code-app/src/views/play/apps/the-quest/TheQuestAppView.tsx

2386 lines
93 KiB
TypeScript

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: <UserOutlined /> },
inventory: { label: '가방', title: '가방/장비', icon: <AppstoreOutlined /> },
quests: { label: '퀘', title: '퀘스트', icon: <EnvironmentOutlined /> },
npcs: { label: '상점', title: 'NPC/상점', icon: <ShopOutlined /> },
} as const;
const NPC_ROLE_META: Record<NpcDefinition['role'], { label: string; badge: string; className: string }> = {
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<EquipmentSlot, { label: string; hint: string }> = {
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<InventoryItem['rarity'], { label: string; className: string }> = {
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<GestureSkillDirection, { icon: string; label: string }> = {
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<string, 'character' | 'inventory' | 'quests' | 'npcs' | 'settings'>;
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<typeof useTheQuestSnapshot>['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<HTMLDivElement | null>(null);
const baseRef = useRef<HTMLDivElement | null>(null);
const activePointerIdRef = useRef<number | null>(null);
const activeTouchIdRef = useRef<number | null>(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<HTMLDivElement> = (event) => {
if (event.pointerType === 'touch') {
return;
}
activePointerIdRef.current = event.pointerId;
event.currentTarget.setPointerCapture(event.pointerId);
updateFromPointer(event.clientX, event.clientY);
};
const handlePointerMove: React.PointerEventHandler<HTMLDivElement> = (event) => {
if (event.pointerType === 'touch') {
return;
}
if (activePointerIdRef.current !== event.pointerId) {
return;
}
updateFromPointer(event.clientX, event.clientY);
};
const handlePointerUp: React.PointerEventHandler<HTMLDivElement> = (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<HTMLDivElement> = (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<HTMLDivElement> = (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<HTMLDivElement> = (event) => {
if (activeTouchIdRef.current === null) {
return;
}
const touch = findTouchByIdentifier(event.changedTouches, activeTouchIdRef.current);
if (!touch) {
return;
}
resetTouchJoystick();
event.preventDefault();
};
const handleTouchCancel: React.TouchEventHandler<HTMLDivElement> = (event) => {
if (activeTouchIdRef.current === null) {
return;
}
resetTouchJoystick();
event.preventDefault();
};
return (
<div
ref={baseRef}
className={`the-quest__joystick${joystick.active ? ' the-quest__joystick--active' : ''}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchCancel}
>
<div className="the-quest__joystick-ring" />
<div ref={knobRef} className="the-quest__joystick-knob" />
</div>
);
}
function TheQuestGesturePad({
now,
skills,
gesturePage,
skillCooldowns,
consumableEffects,
emitSkillCast,
activeNpc,
activePortal,
prioritizeCombat,
portalUnlocked,
onInteractNpc,
onInteractPortal,
}: {
now: number;
skills: ReturnType<typeof useTheQuestSnapshot>['skills'];
gesturePage: number;
skillCooldowns: ReturnType<typeof useTheQuestSnapshot>['skillCooldowns'];
consumableEffects: ReturnType<typeof useTheQuestSnapshot>['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<number | null>(null);
const [gestureDirection, setGestureDirection] = useState<GestureSkillDirection | null>(null);
const [isPressed, setIsPressed] = useState(false);
const [castEffectTarget, setCastEffectTarget] = useState<GestureSkillDirection | 'core' | null>(null);
const gestureSkills = useMemo(
() => getPagedGestureSkills(skills, gesturePage),
[gesturePage, skills],
);
const skillDirectionMap = useMemo(
() =>
gestureSkills.reduce<Partial<Record<GestureSkillDirection, (typeof gestureSkills)[number]>>>((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<HTMLDivElement>) => {
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<HTMLDivElement> = (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<HTMLDivElement> = (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<HTMLDivElement> = (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 (
<div
className={`the-quest__gesture-pad${isPressed ? ' the-quest__gesture-pad--active' : ''}${
gestureDirection ? ` the-quest__gesture-pad--dir-${gestureDirection}` : ''
}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={finishGesture}
onPointerCancel={finishGesture}
role="group"
aria-label="전투 제스처 패드"
>
<div className="the-quest__gesture-grid">
{GESTURE_DIRECTION_ORDER.map((direction) => {
const skill = skillDirectionMap[direction];
const meta = GESTURE_DIRECTION_META[direction];
if (!skill) {
return (
<div key={direction} className={`the-quest__gesture-node the-quest__gesture-node--${direction} the-quest__gesture-node--empty`}>
<span className="the-quest__gesture-node-icon" aria-hidden="true">
{meta.icon}
</span>
</div>
);
}
const readyAt = skillCooldowns[skill.id] ?? 0;
const remaining = Math.max(0, readyAt - now);
const effectState = resolveSkillEffectState(skill.id, consumableEffects, now);
return (
<div
key={skill.id}
className={`the-quest__gesture-node the-quest__gesture-node--${direction}${
remaining > 0 ? ' the-quest__gesture-node--cooldown' : ''
}${gestureDirection === direction ? ' the-quest__gesture-node--selected' : ''}${
castEffectTarget === direction ? ' the-quest__gesture-node--cast' : ''
}`}
>
<span className="the-quest__gesture-node-icon" aria-hidden="true">
{meta.icon}
</span>
<strong>{skill.icon}</strong>
<small>
{effectState.remainingMs > 0
? `${effectState.label} · ${formatSecondsLabel(effectState.remainingMs)}`
: remaining > 0
? formatCooldownBadge(remaining)
: skill.name}
</small>
</div>
);
})}
<button
type="button"
className={`the-quest__gesture-core${isPressed && !gestureDirection ? ' the-quest__gesture-core--pressed' : ''}${
castEffectTarget === 'core' ? ' the-quest__gesture-core--cast' : ''
}`}
onClick={(event) => {
if (event.detail !== 0) {
return;
}
event.stopPropagation();
triggerPrimaryAction();
}}
>
<strong>{prioritizeCombat ? 'ATK' : activePortal ? (portalUnlocked ? 'GO' : 'LOCK') : activeNpc ? 'TALK' : 'ATK'}</strong>
</button>
</div>
</div>
);
}
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<string | null>(null);
const [dragPointer, setDragPointer] = useState<{ x: number; y: number } | null>(null);
const [inventoryTab, setInventoryTab] = useState<'equipment' | 'inventory'>('inventory');
const [inventoryFilter, setInventoryFilter] = useState<InventoryCategoryFilter>('all');
const [detailItemId, setDetailItemId] = useState<string | null>(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<HTMLButtonElement> => (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<HTMLButtonElement> = (event) => {
if (!touchDragRef.current || touchDragRef.current.pointerId !== event.pointerId) {
return;
}
setDragPointer({ x: event.clientX, y: event.clientY });
};
const finishTouchDrag = (event: React.PointerEvent<HTMLButtonElement>) => {
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<HTMLElement>('[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 (
<div className="the-quest__panel-sheet">
<header className="the-quest__panel-header">
<strong>
{overlayPanel === 'character'
? '캐릭터 상세'
: overlayPanel === 'inventory' || overlayPanel === 'equipment'
? '가방/장비'
: overlayPanel === 'quests'
? '퀘스트'
: overlayPanel === 'npcs'
? 'NPC'
: '설정'}
</strong>
<button
type="button"
className="the-quest__panel-close"
aria-label="패널 닫기"
title="닫기"
onClick={() => setOverlayPanel(null)}
>
<CloseOutlined />
</button>
</header>
{overlayPanel === 'character' ? (
<div className="the-quest__panel-content the-quest__panel-content--character">
<div className="the-quest__character-layout">
<section className="the-quest__character-hero">
<button
type="button"
className="the-quest__character-portrait-button"
onClick={() => setOverlayPanel('equipment')}
aria-label="장비 패널 열기"
title="장비 관리"
>
<div className="the-quest__portrait-card the-quest__portrait-card--player" aria-hidden="true">
<span className="the-quest__portrait-glow" />
<span className="the-quest__portrait-frame" />
<span className="the-quest__portrait-hair" />
<span className="the-quest__portrait-face" />
<span className="the-quest__portrait-cloak" />
<span className="the-quest__portrait-armor" />
<span className="the-quest__portrait-weapon" />
</div>
</button>
<div className="the-quest__character-hero-copy">
<span>Moon Guard / Wizard</span>
<strong> </strong>
<div className="the-quest__character-hero-meta">
<span>Lv.{vitals.level}</span>
<span>Stage {stage.current}</span>
<span>{playerLocation.zoneLabel}</span>
<span>{playerLocation.placeLabel}</span>
</div>
<div className="the-quest__character-hero-actions">
<button type="button" onClick={() => setOverlayPanel('equipment')}>
</button>
<button type="button" onClick={() => setOverlayPanel('inventory')}>
</button>
</div>
</div>
</section>
<section className="the-quest__character-section">
<span className="the-quest__sidebar-label">Status</span>
<strong> </strong>
<div className="the-quest__character-stat-grid">
<div className="the-quest__sidebar-stat-box">
<span>EXP</span>
<strong>
{vitals.exp}/{vitals.expToNext}
</strong>
</div>
<div className="the-quest__sidebar-stat-box">
<span></span>
<strong>{EQUIPMENT_SLOT_ORDER.filter((slot) => equipment[slot]).length}/11</strong>
</div>
<div className="the-quest__sidebar-stat-box">
<span>HP</span>
<strong>{formatRatio(vitals.hp, vitals.maxHp)}</strong>
</div>
<div className="the-quest__sidebar-stat-box">
<span>MP</span>
<strong>{formatRatio(vitals.mp, vitals.maxMp)}</strong>
</div>
<div className="the-quest__sidebar-stat-box">
<span></span>
<strong>{vitals.attack}</strong>
</div>
<div className="the-quest__sidebar-stat-box">
<span></span>
<strong>{vitals.spellPower}</strong>
</div>
<div className="the-quest__sidebar-stat-box">
<span></span>
<strong>{vitals.defense}</strong>
</div>
<div className="the-quest__sidebar-stat-box">
<span></span>
<strong>{formatPercent(vitals.critRate)}</strong>
</div>
<div className="the-quest__sidebar-stat-box">
<span> </span>
<strong>{formatPercent(vitals.manaAbsorbChance)}</strong>
</div>
<div className="the-quest__sidebar-stat-box">
<span></span>
<strong>{vitals.gold.toLocaleString('ko-KR')}</strong>
</div>
</div>
</section>
<section className="the-quest__character-section">
<span className="the-quest__sidebar-label">Buff</span>
<strong> </strong>
{buildActiveBuffEntries(consumableEffects, now).length ? (
<div className="the-quest__effect-list">
{buildActiveBuffEntries(consumableEffects, now).map((entry) => (
<div key={entry.id} className="the-quest__effect-chip">
<strong>{entry.label}</strong>
<span>
{entry.detail} · {formatSecondsLabel(entry.remainingMs)}
</span>
</div>
))}
</div>
) : (
<p className="the-quest__character-empty-copy"> . .</p>
)}
</section>
<section className="the-quest__character-section">
<span className="the-quest__sidebar-label">Skills</span>
<strong> </strong>
<div className="the-quest__sidebar-skill-list">
{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 (
<article key={skill.id} className="the-quest__sidebar-skill-card">
<div className="the-quest__sidebar-skill-head">
<div className="the-quest__sidebar-skill-icon" aria-hidden="true">
{skill.icon}
</div>
<div className="the-quest__sidebar-skill-copy">
<div className="the-quest__sidebar-skill-title-row">
<strong>{skill.name}</strong>
<span className="the-quest__sidebar-skill-tag">{index === 0 ? '기본' : `${index}`}</span>
</div>
<small>{skill.description}</small>
</div>
</div>
<div className="the-quest__sidebar-skill-meta">
<span>MP {skill.manaCost}</span>
<span> {formatCooldownBadge(skill.cooldownMs)}</span>
<span>{skill.durationMs ? `지속 ${Math.round(skill.durationMs / 1000)}` : `사거리 ${Math.round(skill.range)}`}</span>
</div>
<div className="the-quest__sidebar-skill-state">
<em
className={
isEffectActive
? 'the-quest__sidebar-skill-state--active'
: remainingMs > 0
? 'the-quest__sidebar-skill-state--cooldown'
: ''
}
>
{isEffectActive
? `${effectState.label ?? '효과 적용'} · ${formatSecondsLabel(effectState.remainingMs)}`
: remainingMs > 0
? `재사용 ${formatCooldownBadge(remainingMs)}`
: '사용 가능'}
</em>
<span>{skill.accentLabel}</span>
</div>
</article>
);
})}
</div>
</section>
</div>
</div>
) : null}
{overlayPanel === 'inventory' || overlayPanel === 'equipment' ? (
<div className="the-quest__panel-content the-quest__panel-content--inventory">
<div className="the-quest__inventory-shell">
<div className="the-quest__inventory-layout">
<section className="the-quest__inventory-main">
<div className="the-quest__inventory-topbar">
<div>
<span className="the-quest__inventory-caption">Royal Vault</span>
<strong>{inventoryTab === 'equipment' ? '장비 관리' : '모바일 인벤토리'}</strong>
</div>
<div className="the-quest__inventory-gold">
<GoldOutlined />
<span>{vitals.gold.toLocaleString('ko-KR')}</span>
<small>Gold</small>
</div>
</div>
<div className="the-quest__inventory-tabs" role="tablist" aria-label="인벤토리 보기 탭">
<button
type="button"
role="tab"
aria-selected={inventoryTab === 'equipment'}
className={`the-quest__inventory-tab${inventoryTab === 'equipment' ? ' the-quest__inventory-tab--active' : ''}`}
onClick={() => setInventoryTab('equipment')}
>
</button>
<button
type="button"
role="tab"
aria-selected={inventoryTab === 'inventory'}
className={`the-quest__inventory-tab${inventoryTab === 'inventory' ? ' the-quest__inventory-tab--active' : ''}`}
onClick={() => setInventoryTab('inventory')}
>
</button>
</div>
{inventoryTab === 'equipment' ? (
<div className="the-quest__equipment-panel">
<div className="the-quest__equipment-hero">
<div className="the-quest__portrait-card the-quest__portrait-card--player" aria-hidden="true">
<span className="the-quest__portrait-glow" />
<span className="the-quest__portrait-frame" />
<span className="the-quest__portrait-hair" />
<span className="the-quest__portrait-face" />
<span className="the-quest__portrait-cloak" />
<span className="the-quest__portrait-armor" />
<span className="the-quest__portrait-weapon" />
</div>
<div className="the-quest__equipment-hero-copy">
<span>Character Render</span>
<strong> </strong>
</div>
</div>
<div className="the-quest__equipment-column">
{EQUIPMENT_SLOT_ORDER.map((slot) => {
const equippedItem = inventory.find((candidate) => candidate.id === equipment[slot]) ?? null;
return (
<div
key={slot}
className={`the-quest__equipment-slot the-quest__equipment-slot--${slot}${draggedItem?.slot === slot ? ' the-quest__equipment-slot--drop-ready' : ''}`}
data-equip-slot={slot}
onDragOver={(event) => {
if (draggedItem?.slot === slot) {
event.preventDefault();
}
}}
onDrop={(event) => {
event.preventDefault();
const itemId = event.dataTransfer.getData('text/plain');
handleEquipDrop(itemId, slot);
}}
>
<div className="the-quest__equipment-slot-head">
<span>{formatEquipmentSlot(slot)}</span>
</div>
<button
type="button"
className={`the-quest__equipment-slot-card${equippedItem ? '' : ' the-quest__equipment-slot-card--empty'}${
equippedItem?.id === openDetailItem?.id ? ' the-quest__equipment-slot-card--active' : ''
}`}
onClick={() => {
if (equippedItem) {
setSelectedInventoryId(equippedItem.id);
setDetailItemId(equippedItem.id);
}
}}
aria-label={equippedItem ? `${equippedItem.name} 상세 보기` : `${formatEquipmentSlot(slot)} 빈 슬롯`}
>
<span className="the-quest__item-icon">{equippedItem?.icon ?? '✧'}</span>
<div className="the-quest__equipment-slot-copy">
<strong>{equippedItem?.name ?? '빈 슬롯'}</strong>
{equippedItem ? (
<span className={`the-quest__item-rarity-chip the-quest__item-rarity-chip--${equippedItem.rarity}`}>
{ITEM_RARITY_META[equippedItem.rarity].label}
</span>
) : null}
</div>
</button>
<button type="button" onClick={() => unequipItem(slot)} disabled={!equippedItem}>
</button>
</div>
);
})}
</div>
</div>
) : (
<div className="the-quest__inventory-grid-panel">
<div className="the-quest__inventory-grid-head">
<div>
<strong></strong>
<span>
{filteredInventory.length}/{inventory.length}
</span>
</div>
<div className="the-quest__inventory-grid-actions">
<button
type="button"
onClick={() => {
if (selectedItem) {
setDetailItemId(selectedItem.id);
}
}}
disabled={!selectedItem}
>
</button>
</div>
</div>
<div className="the-quest__inventory-filters" role="tablist" aria-label="아이템 분류 필터">
{INVENTORY_CATEGORY_FILTERS.map((filter) => {
const filterCount =
filter.key === 'all' ? inventory.length : inventory.filter((item) => item.category === filter.key).length;
return (
<button
key={filter.key}
type="button"
role="tab"
aria-selected={inventoryFilter === filter.key}
className={`the-quest__inventory-filter${inventoryFilter === filter.key ? ' the-quest__inventory-filter--active' : ''}`}
onClick={() => setInventoryFilter(filter.key)}
>
<span>{filter.label}</span>
<em>{filterCount}</em>
</button>
);
})}
</div>
<div className="the-quest__item-grid">
{filteredInventory.map((item) => (
<button
key={item.id}
type="button"
draggable
className={[
'the-quest__item-card',
ITEM_RARITY_META[item.rarity].className,
item.id === selectedItem?.id ? 'the-quest__item-card--active' : '',
draggedItemId === item.id ? 'the-quest__item-card--dragging' : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => {
setSelectedInventoryId(item.id);
}}
aria-label={`${item.name}${item.quantity ? ` ${item.quantity}` : ''}`}
title={item.name}
onPointerDown={handleItemPointerDown(item.id)}
onPointerMove={handleItemPointerMove}
onPointerUp={finishTouchDrag}
onPointerCancel={finishTouchDrag}
onDragStart={(event) => {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', item.id);
setDraggedItemId(item.id);
}}
onDragEnd={() => {
setDraggedItemId(null);
setDragPointer(null);
}}
>
<span className="the-quest__item-rarity-frame" />
<span className={`the-quest__item-rarity-chip the-quest__item-rarity-chip--${item.rarity}`}>
{ITEM_RARITY_META[item.rarity].label}
</span>
<span className="the-quest__item-icon">{item.icon}</span>
<span className="the-quest__sr-only">{item.name}</span>
{item.slot && equipment[item.slot] === item.id ? (
<span className="the-quest__item-equipped-mark" aria-hidden="true">
E
</span>
) : null}
{item.quantity ? <em className="the-quest__item-stack">{item.quantity}</em> : null}
</button>
))}
</div>
</div>
)}
</section>
</div>
</div>
{openDetailItem
? createPortal(
<div
className="the-quest__item-detail-modal-backdrop"
role="presentation"
onClick={() => setDetailItemId(null)}
>
<section
className="the-quest__item-detail-modal"
aria-label={`${openDetailItem.name} 상세 정보`}
role="dialog"
aria-modal="true"
onClick={(event) => event.stopPropagation()}
>
<div className="the-quest__item-detail-modal-head">
<div className="the-quest__item-tooltip-headline">
<strong> </strong>
<span>{inventoryTab === 'equipment' ? '착용 슬롯 기준' : '선택한 아이템 기준'}</span>
</div>
<button
type="button"
className="the-quest__item-detail-close"
aria-label="상세 정보 닫기"
onClick={() => setDetailItemId(null)}
>
<CloseOutlined />
</button>
</div>
<div className={`the-quest__item-tooltip-badge the-quest__item-tooltip-badge--${openDetailItem.rarity}`}>
{ITEM_RARITY_META[openDetailItem.rarity].label}
</div>
<div className="the-quest__item-tooltip-header">
<span className="the-quest__item-icon">{openDetailItem.icon}</span>
<div className="the-quest__item-tooltip-copy">
<strong>{openDetailItem.name}</strong>
<span>{openDetailItem.slot ? formatEquipmentSlot(openDetailItem.slot) : buildItemCategoryLabel(openDetailItem.category)}</span>
</div>
</div>
{isDetailItemEquipped ? <div className="the-quest__item-equipped-chip"> </div> : null}
<div className="the-quest__item-stats">
{buildItemStats(openDetailItem).map((stat) => (
<span key={stat}>{stat}</span>
))}
</div>
{equippedComparisonItem && comparisonRows.length ? (
<div className="the-quest__item-compare">
<div className="the-quest__item-compare-head">
<strong> </strong>
<small>
: {equippedComparisonItem.icon} {equippedComparisonItem.name}
</small>
</div>
<div className="the-quest__item-compare-list">
{comparisonRows.map((row) => (
<div key={row.key} className="the-quest__item-compare-row">
<span>{row.label}</span>
<strong>
{row.currentValue}
{row.unit ?? ''} {row.nextValue}
{row.unit ?? ''}
</strong>
<em className={row.delta >= 0 ? 'the-quest__item-compare-delta--up' : 'the-quest__item-compare-delta--down'}>
{row.delta >= 0 ? '+' : ''}
{row.delta}
{row.unit ?? ''}
</em>
</div>
))}
</div>
</div>
) : null}
<div className="the-quest__item-actions the-quest__item-actions--detail">
{openDetailItem.slot && !isDetailItemEquipped ? (
<button type="button" onClick={() => equipItem(openDetailItem.id)}>
</button>
) : null}
{isDetailItemEquipped && openDetailItem.slot ? (
<button type="button" onClick={() => unequipItem(openDetailItem.slot!)}>
</button>
) : null}
{openDetailItem.category === 'consumable' ? (
<button type="button" onClick={() => useConsumable(openDetailItem.id)}>
</button>
) : null}
</div>
</section>
</div>,
document.body,
)
: null}
{draggedItem && dragPointer ? (
<div
className="the-quest__drag-preview"
style={{
left: dragPointer.x,
top: dragPointer.y,
}}
>
<span className="the-quest__item-icon">{draggedItem.icon}</span>
<strong>{draggedItem.name}</strong>
</div>
) : null}
</div>
) : null}
{overlayPanel === 'quests' ? (
<div className="the-quest__panel-content the-quest__panel-content--quests">
<div className="the-quest__quest-list">
{quests.map((quest) => (
<button
key={quest.id}
type="button"
className={`the-quest__quest-card${quest.id === selectedQuest?.id ? ' the-quest__quest-card--active' : ''}`}
onClick={() => setSelectedQuestId(quest.id)}
>
<div className="the-quest__quest-card-head">
<span className={`the-quest__quest-tag${buildQuestStatusClassName(quest)}`}>
{buildQuestStatusLabel(quest)}
</span>
<small>
{quest.currentCount}/{quest.requiredCount}
</small>
</div>
<strong>{quest.title}</strong>
<p>{quest.description}</p>
<span className="the-quest__quest-next-step">{buildQuestNextStep(quest)}</span>
<div className="the-quest__quest-card-foot">
<span>EXP {quest.rewardExp}</span>
<span>Gold {quest.rewardGold}</span>
{findRewardItemLabel(quest.rewardItemId) ? <span>{findRewardItemLabel(quest.rewardItemId)}</span> : null}
</div>
</button>
))}
</div>
{selectedQuest ? (
<div className="the-quest__quest-detail">
<div className="the-quest__quest-detail-head">
<span className={`the-quest__quest-tag${buildQuestStatusClassName(selectedQuest)}`}>
{buildQuestStatusLabel(selectedQuest)}
</span>
<strong>{selectedQuest.title}</strong>
</div>
<p>{selectedQuest.description}</p>
<div className="the-quest__quest-guide">
<span>{buildQuestObjectiveLabel(selectedQuest)}</span>
<strong>{buildQuestNextStep(selectedQuest)}</strong>
</div>
<div className="the-quest__quest-progress">
<div style={{ width: `${(selectedQuest.currentCount / selectedQuest.requiredCount) * 100}%` }} />
</div>
<div className="the-quest__quest-detail-metrics">
<span> {selectedQuest.currentCount}/{selectedQuest.requiredCount}</span>
<span> NPC {selectedQuestNpcName}</span>
<span> {selectedQuest.rewardExp} EXP</span>
<span>{selectedQuest.rewardGold} </span>
{findRewardItemLabel(selectedQuest.rewardItemId) ? <span>{findRewardItemLabel(selectedQuest.rewardItemId)}</span> : null}
</div>
<div className="the-quest__quest-actions">
{canTurnInSelectedQuest ? (
<button type="button" className="the-quest__quest-action-button" onClick={() => turnInQuest(selectedQuest.id)}>
</button>
) : (
<button type="button" className="the-quest__quest-action-button" onClick={() => openQuestTurnInNpc(selectedQuest)}>
{selectedQuest.completed ? `${selectedQuestNpcName} 보기` : `${selectedQuestNpcName}에게 가기`}
</button>
)}
</div>
</div>
) : null}
</div>
) : null}
{overlayPanel === 'npcs' && activeNpc ? (
<div className="the-quest__panel-content the-quest__panel-content--npc">
<div className="the-quest__npc-layout">
<div className="the-quest__npc-list">
{npcs.map((npc) => (
<button
key={npc.id}
type="button"
className={`the-quest__npc-list-button${npc.id === activeNpc.id ? ' the-quest__npc-list-button--active' : ''}`}
onClick={() => setActiveNpc(npc.id)}
>
<span className={`the-quest__role-badge ${NPC_ROLE_META[npc.role].className}`}>{NPC_ROLE_META[npc.role].badge}</span>
<strong>{npc.name}</strong>
<small>{NPC_ROLE_META[npc.role].label}</small>
</button>
))}
</div>
<div className="the-quest__npc-stage">
<div className="the-quest__npc-card the-quest__npc-card--hero">
<div className="the-quest__npc-hero-head">
<span className={`the-quest__role-badge ${NPC_ROLE_META[activeNpc.role].className}`}>
{NPC_ROLE_META[activeNpc.role].badge}
</span>
<strong>{activeNpc.name}</strong>
</div>
<span>{activeNpc.description}</span>
<div className="the-quest__npc-meta">
<span>{NPC_ROLE_META[activeNpc.role].label}</span>
<span>{buildNpcSupportLabel(activeNpc)}</span>
</div>
</div>
<div className="the-quest__npc-card">
<strong> </strong>
<p>{buildNpcDialogue(activeNpc)}</p>
</div>
{reportableLinkedQuests.length ? (
<div className="the-quest__npc-card the-quest__npc-card--report">
<strong> </strong>
<div className="the-quest__npc-report-list">
{reportableLinkedQuests.map((quest) => (
<div key={quest.id} className="the-quest__npc-report-row">
<div className="the-quest__npc-report-copy">
<span className={`the-quest__quest-tag${buildQuestStatusClassName(quest)}`}>{buildQuestStatusLabel(quest)}</span>
<strong>{quest.title}</strong>
</div>
<button type="button" className="the-quest__quest-action-button" onClick={() => turnInQuest(quest.id)}>
</button>
</div>
))}
</div>
</div>
) : null}
{linkedQuests.length ? (
<div className="the-quest__npc-card">
<strong> </strong>
<div className="the-quest__npc-quest-list">
{linkedQuests.map((quest) => (
<button
key={quest.id}
type="button"
className={`the-quest__npc-quest-card${quest.id === selectedQuest?.id ? ' the-quest__npc-quest-card--active' : ''}`}
onClick={() => {
setSelectedQuestId(quest.id);
setOverlayPanel('quests');
}}
>
<div className="the-quest__npc-quest-head">
<span className={`the-quest__quest-tag${buildQuestStatusClassName(quest)}`}>
{buildQuestStatusLabel(quest)}
</span>
<small>
{quest.currentCount}/{quest.requiredCount}
</small>
</div>
<strong>{quest.title}</strong>
<p>{quest.description}</p>
<span className="the-quest__quest-next-step">{buildQuestNextStep(quest)}</span>
{quest.readyToTurnIn && !quest.completed && activeNpc.id === quest.turnInNpcId ? (
<span className="the-quest__npc-quest-cta"> NPC에게 </span>
) : null}
</button>
))}
</div>
</div>
) : null}
</div>
</div>
</div>
) : null}
{overlayPanel === 'settings' ? (
<div className="the-quest__panel-content">
<section className="the-quest__settings-card">
<span className="the-quest__sidebar-label"> </span>
<strong>The Quest </strong>
<p> .</p>
<button type="button" className="the-quest__settings-exit" onClick={onBack}>
</button>
</section>
</div>
) : null}
</div>
);
}
export function TheQuestAppView({ onBack, launchContext = 'direct' }: TheQuestAppViewProps) {
const mountRef = useRef<HTMLDivElement | null>(null);
const gameHandleRef = useRef<{ destroy: () => void; requestManualSkillCast: (skillId: string) => void } | null>(null);
const pressedMovementKeysRef = useRef<Set<string>>(new Set());
const [isReady, setIsReady] = useState(false);
const [initError, setInitError] = useState<string | null>(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<string>) => {
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<string>) => {
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<string>) => {
if (document.visibilityState !== 'visible') {
resetKeyboardJoystick(pressedMovementKeys);
}
});
const handleKeyboardWindowBlur = useEffectEvent((pressedMovementKeys: Set<string>) => {
resetKeyboardJoystick(pressedMovementKeys);
});
const handleKeyboardKeyDown = useEffectEvent((event: KeyboardEvent, pressedMovementKeys: Set<string>) => {
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<string>) => {
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 (
<section
className={`the-quest${performance.isMobile ? ' the-quest--mobile-optimized' : ''}${performance.lowEffects ? ' the-quest--low-effects' : ''}`}
data-testid="apps-library-open-the-quest"
>
<div className="the-quest__shell">
<div className="the-quest__stage">
<div ref={mountRef} className="the-quest__game-surface" />
{!isReady ? (
<div className={`the-quest__loading${initError ? ' the-quest__loading--error' : ''}`}>
<div className="the-quest__loading-card">
<strong>{initError ? 'The Quest 실행에 실패했습니다.' : '모험 지도를 불러오는 중...'}</strong>
<p>
{initError
? '렌더러 초기화에 실패해 계속 멈춘 것처럼 보이지 않도록 즉시 실패 상태를 표시합니다. 새로고침 후 다시 열어 주세요.'
: '전투 맵과 조작 HUD를 초기화하고 있습니다.'}
</p>
</div>
</div>
) : null}
<div className="the-quest__stage-vignette" />
<div className="the-quest__stage-glow the-quest__stage-glow--left" />
<div className="the-quest__stage-glow the-quest__stage-glow--right" />
<div className="the-quest__hud">
<div className="the-quest__hud-table-wrap">
<table className="the-quest__hud-table" aria-label="캐릭터 HUD">
<tbody>
<tr>
<td colSpan={4} className="the-quest__hud-toolbar-cell">
<div className="the-quest__hud-toolbar" role="toolbar" aria-label="퀘스트 상단 메뉴">
<button
type="button"
className="the-quest__hud-toolbar-button the-quest__hud-toolbar-button--menu"
onClick={onBack}
aria-label="앱 목록으로 돌아가기"
title="앱 목록"
>
<span className="the-quest__hud-toolbar-icon" aria-hidden="true">
<MenuOutlined />
</span>
</button>
{isEmbeddedLaunch ? (
<button
type="button"
className="the-quest__hud-toolbar-button the-quest__hud-toolbar-button--exit"
onClick={onBack}
aria-label="부모 앱으로 종료"
title="부모 앱 종료"
>
<span className="the-quest__hud-toolbar-icon" aria-hidden="true">
<CloseOutlined />
</span>
</button>
) : null}
<div className="the-quest__hud-toolbar-group">
{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 (
<button
key={key}
type="button"
className={`the-quest__hud-toolbar-button${isActive ? ' the-quest__hud-toolbar-button--active' : ''}`}
onClick={() => setOverlayPanel(targetPanel)}
aria-label={`${meta.title} 열기`}
title={meta.title}
>
<span className="the-quest__hud-toolbar-icon" aria-hidden="true">
{meta.icon}
</span>
<span className="the-quest__hud-toolbar-label">{meta.label}</span>
{count ? <span className="the-quest__hud-toolbar-badge">{count}</span> : null}
</button>
);
})}
</div>
</div>
</td>
</tr>
<tr>
<th scope="row" className="the-quest__hud-stack-label">
HP
</th>
<td className="the-quest__hud-gauge-cell the-quest__hud-gauge-cell--stack">
<div className="the-quest__bar the-quest__bar--compact the-quest__hud-gauge" aria-label={`HP ${formatRatio(vitals.hp, vitals.maxHp)}`}>
<div className="the-quest__bar-fill the-quest__bar-fill--hp" style={{ width: `${hpGaugePercent}%` }} />
<span>{formatRatio(vitals.hp, vitals.maxHp)}</span>
</div>
</td>
<th scope="row" className="the-quest__hud-stack-label">
MP
</th>
<td className="the-quest__hud-gauge-cell the-quest__hud-gauge-cell--stack">
<div className="the-quest__bar the-quest__bar--compact the-quest__hud-gauge" aria-label={`MP ${formatRatio(vitals.mp, vitals.maxMp)}`}>
<div className="the-quest__bar-fill the-quest__bar-fill--mp" style={{ width: `${mpGaugePercent}%` }} />
<span>{formatRatio(vitals.mp, vitals.maxMp)}</span>
</div>
</td>
</tr>
<tr>
<th scope="row">LV</th>
<td>{vitals.level}</td>
<th scope="row">STAGE</th>
<td>
{stage.current}
{portalUnlocked ? ' / 포탈 개방' : ''}
</td>
</tr>
<tr>
<th scope="row">EXP</th>
<td>
{vitals.level} / EXP {vitals.exp}/{vitals.expToNext}
</td>
<th scope="row">BUFF</th>
<td className="the-quest__hud-buff-cell">
{hudBuffEntries.length ? (
<div className="the-quest__hud-buff-list" aria-label="활성 버프 상태">
{hudBuffEntries.map((entry) => (
<div key={entry.id} className="the-quest__hud-buff-pill">
<strong>{entry.label}</strong>
<span>{entry.detail}</span>
<em>{formatSecondsLabel(entry.remainingMs)}</em>
</div>
))}
{hiddenHudBuffCount > 0 ? <span className="the-quest__hud-buff-more">+{hiddenHudBuffCount}</span> : null}
</div>
) : (
<span className="the-quest__hud-buff-empty"> </span>
)}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div className="the-quest__bottom-ui">
<div className="the-quest__left-controls">
<div className="the-quest__movement-dock">
<div className="the-quest__dock-head">
<span>MOVE</span>
<small className="the-quest__dock-hotkey">WASD / </small>
<div className="the-quest__auto-toggle">
<span className="the-quest__auto-toggle-icon" aria-hidden="true">
<ThunderboltOutlined />
</span>
<small>AUTO</small>
<Switch size="small" checked={autoCombat} onChange={setAutoCombat} />
</div>
</div>
<TheQuestJoystick />
</div>
</div>
<div className="the-quest__right-controls">
<div className="the-quest__consumable-strip" role="group" aria-label="전투 소모품">
<div className="the-quest__consumable-strip-rail">
<button
type="button"
className="the-quest__consumable-nav"
onClick={() => setConsumablePage((page) => Math.max(0, page - 1))}
disabled={consumablePage <= 0}
aria-label="이전 소모품 페이지"
title="이전 소모품"
>
<LeftOutlined />
</button>
<div className="the-quest__consumable-strip-list">
{visibleConsumableSlots.map((item, index) => {
if (!item) {
return (
<div
key={`empty-slot-${index}`}
className="the-quest__consumable-slot the-quest__consumable-slot--empty"
aria-hidden="true"
/>
);
}
const quantity = item.quantity ?? 0;
const variantClass =
item.id === 'hp-potion' ? 'hp' : item.id === 'auto-heal-elixir' ? 'special' : 'speed';
return (
<button
key={item.id}
type="button"
className={`the-quest__consumable-slot the-quest__consumable-slot--${variantClass}`}
onClick={() => useConsumable(item.id)}
disabled={quantity <= 0}
aria-label={`${item.name} 사용`}
title={item.name}
>
<span className="the-quest__consumable-slot-icon" aria-hidden="true">
{item.icon}
</span>
<em>{quantity}</em>
</button>
);
})}
</div>
<button
type="button"
className="the-quest__consumable-nav"
onClick={() => setConsumablePage((page) => Math.min(consumablePageCount - 1, page + 1))}
disabled={consumablePage >= consumablePageCount - 1}
aria-label="다음 소모품 페이지"
title="다음 소모품"
>
<RightOutlined />
</button>
</div>
</div>
<div className="the-quest__combat-stack">
<div className="the-quest__dock-head">
<span>ARCANE / SPELL</span>
<small className="the-quest__dock-hotkey">Space·Enter / 1-8 / ZXC / B</small>
<div className="the-quest__page-nav" role="group" aria-label="스킬 페이지 전환">
<button
type="button"
className="the-quest__page-nav-button"
onClick={() => setGestureSkillPage((page) => Math.max(0, page - 1))}
disabled={gestureSkillPage <= 0}
aria-label="이전 스킬 페이지"
title="이전 스킬"
>
<LeftOutlined />
</button>
<strong className="the-quest__page-nav-label">
SKILL {gestureSkillPage + 1}/{gestureSkillPageCount}
</strong>
<button
type="button"
className="the-quest__page-nav-button"
onClick={() => setGestureSkillPage((page) => Math.min(gestureSkillPageCount - 1, page + 1))}
disabled={gestureSkillPage >= gestureSkillPageCount - 1}
aria-label="다음 스킬 페이지"
title="다음 스킬"
>
<RightOutlined />
</button>
</div>
</div>
<div className="the-quest__combat-cluster">
<TheQuestGesturePad
now={now}
skills={skills}
gesturePage={gestureSkillPage}
skillCooldowns={skillCooldowns}
consumableEffects={consumableEffects}
emitSkillCast={emitSkillCast}
activeNpc={activeNpc}
activePortal={activePortal}
prioritizeCombat={shouldPrioritizeCombat}
portalUnlocked={portalUnlocked}
onInteractNpc={handleNpcInteraction}
onInteractPortal={handlePortalInteraction}
/>
</div>
</div>
</div>
</div>
</div>
</div>
{createPortal(<TheQuestPanel onBack={onBack} />, document.body)}
</section>
);
}