2386 lines
93 KiB
TypeScript
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>
|
|
);
|
|
}
|