1385 lines
48 KiB
Vue
1385 lines
48 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
|
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
|
import PanelCard from '../components/ui/PanelCard.vue';
|
|
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
|
import { trpc } from '../utils/trpc';
|
|
import { useSessionStore } from '../stores/session';
|
|
import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat';
|
|
import { getNpcColor } from '../utils/npcColor';
|
|
import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
|
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
|
|
|
type JoinConfig = Awaited<ReturnType<typeof trpc.join.getConfig.query>>;
|
|
type JoinInput = Parameters<typeof trpc.join.createGeneral.mutate>[0];
|
|
type PossessReservation = Awaited<ReturnType<typeof trpc.join.listPossessCandidates.mutate>>;
|
|
type PossessCandidate = PossessReservation['candidates'][number];
|
|
type NpcGeneralList = Awaited<ReturnType<typeof trpc.public.getNpcList.query>>;
|
|
type NpcGeneralRow = NpcGeneralList['generals'][number] & {
|
|
reservationState: 0 | 1 | 2;
|
|
keepCount: number | null;
|
|
};
|
|
type JoinForm = Omit<JoinInput, 'inheritBonusStat' | 'clientRequestId'> & {
|
|
inheritBonusStat: [number, number, number];
|
|
};
|
|
type PendingJoinAction = {
|
|
ownerUserId: string;
|
|
input: JoinForm;
|
|
clientRequestId: string;
|
|
};
|
|
type PendingPossessAction = {
|
|
ownerUserId: string;
|
|
generalId: number;
|
|
tokenNonce: number;
|
|
clientRequestId: string;
|
|
};
|
|
|
|
const router = useRouter();
|
|
const route = useRoute();
|
|
const session = useSessionStore();
|
|
|
|
const loading = ref(true);
|
|
const error = ref<string | null>(null);
|
|
const submitting = ref(false);
|
|
|
|
const joinConfig = ref<JoinConfig | null>(null);
|
|
const activeTab = ref<'create' | 'possess'>('create');
|
|
const pendingJoinStorageKey = 'sammo-join-create-pending-action';
|
|
const pendingPossessStorageKey = 'sammo-npc-possess-pending-action';
|
|
|
|
const form = ref<JoinForm>({
|
|
name: '',
|
|
leadership: 0,
|
|
strength: 0,
|
|
intel: 0,
|
|
character: 'Random',
|
|
pic: true,
|
|
inheritBonusStat: [0, 0, 0],
|
|
});
|
|
|
|
const readPendingJoin = (): PendingJoinAction | null => {
|
|
try {
|
|
const raw = window.sessionStorage.getItem(pendingJoinStorageKey);
|
|
if (!raw) return null;
|
|
const value = JSON.parse(raw) as Partial<PendingJoinAction>;
|
|
if (
|
|
!value.input ||
|
|
typeof value.input !== 'object' ||
|
|
typeof value.ownerUserId !== 'string' ||
|
|
typeof value.clientRequestId !== 'string'
|
|
) {
|
|
return null;
|
|
}
|
|
return value as PendingJoinAction;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const cloneJoinInput = (): JoinForm => JSON.parse(JSON.stringify(form.value)) as JoinForm;
|
|
|
|
const getPendingJoin = (): PendingJoinAction => {
|
|
const input = cloneJoinInput();
|
|
const current = readPendingJoin();
|
|
const ownerUserId = joinConfig.value?.user.id ?? '';
|
|
if (current && current.ownerUserId === ownerUserId && JSON.stringify(current.input) === JSON.stringify(input)) {
|
|
return current;
|
|
}
|
|
const pending: PendingJoinAction = {
|
|
ownerUserId,
|
|
input,
|
|
clientRequestId: crypto.randomUUID(),
|
|
};
|
|
window.sessionStorage.setItem(pendingJoinStorageKey, JSON.stringify(pending));
|
|
return pending;
|
|
};
|
|
|
|
const clearPendingJoin = (pending: PendingJoinAction): void => {
|
|
if (readPendingJoin()?.clientRequestId === pending.clientRequestId) {
|
|
window.sessionStorage.removeItem(pendingJoinStorageKey);
|
|
}
|
|
};
|
|
|
|
const readPendingPossess = (): PendingPossessAction | null => {
|
|
try {
|
|
const raw = window.sessionStorage.getItem(pendingPossessStorageKey);
|
|
if (!raw) return null;
|
|
const value = JSON.parse(raw) as Partial<PendingPossessAction>;
|
|
if (
|
|
typeof value.ownerUserId !== 'string' ||
|
|
typeof value.generalId !== 'number' ||
|
|
typeof value.tokenNonce !== 'number' ||
|
|
typeof value.clientRequestId !== 'string'
|
|
) {
|
|
return null;
|
|
}
|
|
return value as PendingPossessAction;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const getPendingPossess = (generalId: number, tokenNonce: number): PendingPossessAction => {
|
|
const ownerUserId = joinConfig.value?.user.id ?? '';
|
|
const current = pendingPossessAction.value ?? readPendingPossess();
|
|
if (
|
|
current &&
|
|
current.ownerUserId === ownerUserId &&
|
|
current.generalId === generalId &&
|
|
current.tokenNonce === tokenNonce
|
|
) {
|
|
return current;
|
|
}
|
|
const pending: PendingPossessAction = {
|
|
ownerUserId,
|
|
generalId,
|
|
tokenNonce,
|
|
clientRequestId: crypto.randomUUID(),
|
|
};
|
|
window.sessionStorage.setItem(pendingPossessStorageKey, JSON.stringify(pending));
|
|
pendingPossessAction.value = pending;
|
|
return pending;
|
|
};
|
|
|
|
const clearPendingPossess = (pending: PendingPossessAction): void => {
|
|
if (readPendingPossess()?.clientRequestId === pending.clientRequestId) {
|
|
window.sessionStorage.removeItem(pendingPossessStorageKey);
|
|
}
|
|
if (pendingPossessAction.value?.clientRequestId === pending.clientRequestId) {
|
|
pendingPossessAction.value = null;
|
|
}
|
|
};
|
|
|
|
const isIndeterminateTimeout = (value: unknown): boolean => {
|
|
if (!value || typeof value !== 'object' || !('data' in value)) return false;
|
|
const data = value.data;
|
|
return Boolean(data && typeof data === 'object' && 'code' in data && data.code === 'TIMEOUT');
|
|
};
|
|
|
|
const isTrpcBusinessError = (value: unknown): boolean => {
|
|
if (!value || typeof value !== 'object' || !('data' in value)) return false;
|
|
const data = value.data;
|
|
return Boolean(data && typeof data === 'object' && 'code' in data && typeof data.code === 'string');
|
|
};
|
|
|
|
const npcImageUrl = (candidate: { picture: string | null; imageServer: number }): string =>
|
|
resolveGeneralIconUrl(candidate);
|
|
|
|
const npcReservation = ref<PossessReservation | null>(null);
|
|
const npcLoading = ref(false);
|
|
const npcError = ref<string | null>(null);
|
|
const keptNpcIds = ref<number[]>([]);
|
|
const nowMs = ref(Date.now());
|
|
const npcPickMoreAvailableAtMs = ref(0);
|
|
const pendingPossessAction = ref<PendingPossessAction | null>(null);
|
|
const npcGeneralList = ref<NpcGeneralList | null>(null);
|
|
const npcGeneralListLoading = ref(false);
|
|
const npcGeneralListError = ref('');
|
|
const npcGeneralListVisibleCount = ref(50);
|
|
let npcTimer: number | null = null;
|
|
|
|
const npcCandidates = computed<PossessCandidate[]>(() => npcReservation.value?.candidates ?? []);
|
|
const npcValidUntilMs = computed(() => {
|
|
const value = npcReservation.value?.validUntil;
|
|
return value ? new Date(value).getTime() : 0;
|
|
});
|
|
const npcExpired = computed(() => npcValidUntilMs.value > 0 && npcValidUntilMs.value < nowMs.value);
|
|
const npcPickMoreSeconds = computed(() =>
|
|
Math.max(0, Math.ceil((npcPickMoreAvailableAtMs.value - nowMs.value) / 1000))
|
|
);
|
|
const hasPendingPossession = computed(
|
|
() => pendingPossessAction.value !== null && pendingPossessAction.value.ownerUserId === joinConfig.value?.user.id
|
|
);
|
|
const isPendingPossessCandidate = (candidate: PossessCandidate): boolean => {
|
|
const pending = pendingPossessAction.value;
|
|
return Boolean(
|
|
pending &&
|
|
pending.ownerUserId === joinConfig.value?.user.id &&
|
|
pending.generalId === candidate.id &&
|
|
pending.tokenNonce === npcReservation.value?.tokenNonce
|
|
);
|
|
};
|
|
const npcGeneralRows = computed<NpcGeneralRow[]>(() => {
|
|
const list = npcGeneralList.value;
|
|
if (!list) {
|
|
return [];
|
|
}
|
|
return list.generals
|
|
.map((general) => {
|
|
const keepCount = list.tokenKeepCounts[String(general.id)];
|
|
return {
|
|
...general,
|
|
reservationState: general.npcState < 2 ? 2 : keepCount !== undefined ? 1 : 0,
|
|
keepCount: keepCount ?? null,
|
|
} as NpcGeneralRow;
|
|
})
|
|
.sort(
|
|
(left, right) =>
|
|
right.reservationState - left.reservationState ||
|
|
right.statTotal - left.statTotal ||
|
|
// Ref select_npc.ts has this asymmetric comparator. Keep it because the visible order is contractual.
|
|
right.leadership - left.statTotal ||
|
|
(left.name < right.name ? -1 : left.name > right.name ? 1 : 0)
|
|
);
|
|
});
|
|
const visibleNpcGeneralRows = computed(() => npcGeneralRows.value.slice(0, npcGeneralListVisibleCount.value));
|
|
const npcValidColor = computed(() => {
|
|
const remaining = npcValidUntilMs.value - nowMs.value;
|
|
if (remaining > 30_000) return '#ffffff';
|
|
const channel = Math.max(0, Math.min(255, Math.round((remaining / 30_000) * 255)));
|
|
return `rgb(255, ${channel}, ${channel})`;
|
|
});
|
|
|
|
const statRules = computed(() => joinConfig.value?.rules.stat ?? null);
|
|
const statTotal = computed(() => form.value.leadership + form.value.strength + form.value.intel);
|
|
const statErrors = computed(() => {
|
|
const rules = statRules.value;
|
|
if (!rules) {
|
|
return [] as string[];
|
|
}
|
|
const errors: string[] = [];
|
|
const values = [form.value.leadership, form.value.strength, form.value.intel];
|
|
if (values.some((value) => value < rules.min || value > rules.max)) {
|
|
errors.push(`능력치는 ${rules.min} ~ ${rules.max} 범위여야 합니다.`);
|
|
}
|
|
if (statTotal.value > rules.total) {
|
|
errors.push(`능력치 합이 ${rules.total}을 넘을 수 없습니다.`);
|
|
}
|
|
return errors;
|
|
});
|
|
|
|
const canSubmit = computed(() => {
|
|
if (!statRules.value) {
|
|
return false;
|
|
}
|
|
if (!form.value.name.trim()) {
|
|
return false;
|
|
}
|
|
if (statErrors.value.length > 0) {
|
|
return false;
|
|
}
|
|
if (inheritErrors.value.length > 0) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const nationList = computed(() => joinConfig.value?.nations ?? []);
|
|
const personalities = computed(() => joinConfig.value?.personalities ?? []);
|
|
const inheritConfig = computed(() => joinConfig.value?.inherit ?? null);
|
|
|
|
const inheritTotalPoint = computed(() => inheritConfig.value?.totalPoint ?? 0);
|
|
const inheritCosts = computed(
|
|
() =>
|
|
inheritConfig.value?.costs ?? {
|
|
inheritBornSpecialPoint: 0,
|
|
inheritBornTurntimePoint: 0,
|
|
inheritBornCityPoint: 0,
|
|
inheritBornStatPoint: 0,
|
|
}
|
|
);
|
|
const inheritRequiredPoint = computed(() => {
|
|
let total = 0;
|
|
if (form.value.inheritSpecial) {
|
|
total += inheritCosts.value.inheritBornSpecialPoint;
|
|
}
|
|
if (form.value.inheritCity !== undefined) {
|
|
total += inheritCosts.value.inheritBornCityPoint;
|
|
}
|
|
if (form.value.inheritTurntimeZone !== undefined) {
|
|
total += inheritCosts.value.inheritBornTurntimePoint;
|
|
}
|
|
const bonus = form.value.inheritBonusStat ?? [0, 0, 0];
|
|
const bonusSum = bonus.reduce((acc, value) => acc + value, 0);
|
|
if (bonusSum > 0) {
|
|
total += inheritCosts.value.inheritBornStatPoint;
|
|
}
|
|
return total;
|
|
});
|
|
|
|
const inheritBonusSum = computed(() => {
|
|
const bonus = form.value.inheritBonusStat ?? [0, 0, 0];
|
|
return bonus.reduce((acc, value) => acc + value, 0);
|
|
});
|
|
|
|
const inheritErrors = computed(() => {
|
|
const errors: string[] = [];
|
|
const bonus = form.value.inheritBonusStat ?? [0, 0, 0];
|
|
const bonusSum = bonus.reduce((acc, value) => acc + value, 0);
|
|
if (bonusSum !== 0 && (bonusSum < 3 || bonusSum > 5)) {
|
|
errors.push('보너스 능력치는 합 3~5 사이여야 합니다.');
|
|
}
|
|
if (inheritRequiredPoint.value > inheritTotalPoint.value) {
|
|
errors.push('보유한 유산 포인트가 부족합니다.');
|
|
}
|
|
return errors;
|
|
});
|
|
|
|
const inheritSpecialChoice = computed<string>({
|
|
get: () => form.value.inheritSpecial ?? '',
|
|
set: (value) => {
|
|
form.value.inheritSpecial = value ? value : undefined;
|
|
},
|
|
});
|
|
|
|
const inheritCityChoice = computed<string>({
|
|
get: () => (form.value.inheritCity !== undefined ? String(form.value.inheritCity) : ''),
|
|
set: (value) => {
|
|
form.value.inheritCity = value ? Number(value) : undefined;
|
|
},
|
|
});
|
|
|
|
const inheritTurntimeChoice = computed<string>({
|
|
get: () => (form.value.inheritTurntimeZone !== undefined ? String(form.value.inheritTurntimeZone) : ''),
|
|
set: (value) => {
|
|
form.value.inheritTurntimeZone = value ? Number(value) : undefined;
|
|
},
|
|
});
|
|
|
|
const randomInt = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min;
|
|
|
|
const applyBalancedStats = () => {
|
|
const rules = statRules.value;
|
|
if (!rules) {
|
|
return;
|
|
}
|
|
const base = Math.floor(rules.total / 3);
|
|
form.value.leadership = rules.total - base * 2;
|
|
form.value.strength = base;
|
|
form.value.intel = base;
|
|
};
|
|
|
|
const applyRandomStats = () => {
|
|
const rules = statRules.value;
|
|
if (!rules) {
|
|
return;
|
|
}
|
|
for (let i = 0; i < 40; i += 1) {
|
|
const leadership = randomInt(rules.min, rules.max);
|
|
const strength = randomInt(rules.min, rules.max);
|
|
const intel = rules.total - leadership - strength;
|
|
if (intel >= rules.min && intel <= rules.max) {
|
|
form.value.leadership = leadership;
|
|
form.value.strength = strength;
|
|
form.value.intel = intel;
|
|
return;
|
|
}
|
|
}
|
|
applyBalancedStats();
|
|
};
|
|
|
|
const applyFocusedStats = (focus: 'leadership' | 'strength' | 'intel') => {
|
|
const rules = statRules.value;
|
|
if (!rules) {
|
|
return;
|
|
}
|
|
const focusValue = Math.min(rules.max, rules.min + Math.floor(rules.total * 0.45));
|
|
const remain = rules.total - focusValue;
|
|
const side = Math.floor(remain / 2);
|
|
form.value.leadership = focus === 'leadership' ? focusValue : side;
|
|
form.value.strength = focus === 'strength' ? focusValue : side;
|
|
form.value.intel = focus === 'intel' ? focusValue : remain - side;
|
|
};
|
|
|
|
const loadConfig = async () => {
|
|
loading.value = true;
|
|
error.value = null;
|
|
try {
|
|
const config = await trpc.join.getConfig.query();
|
|
if (config.selectionPool.enabled) {
|
|
await router.replace({ name: 'select-general' });
|
|
return;
|
|
}
|
|
joinConfig.value = config;
|
|
const storedPossession = readPendingPossess();
|
|
pendingPossessAction.value = storedPossession?.ownerUserId === config.user.id ? storedPossession : null;
|
|
if (route.query.tab === 'possess' && config.npcPossession.enabled && config.user.canCreateGeneral) {
|
|
activeTab.value = 'possess';
|
|
}
|
|
const pending = readPendingJoin();
|
|
if (pending?.ownerUserId === config.user.id) {
|
|
form.value = pending.input;
|
|
} else {
|
|
form.value.name = config.rules.allowCustomName ? config.user.displayName || '' : '무작위';
|
|
applyBalancedStats();
|
|
}
|
|
} catch (err) {
|
|
error.value = err instanceof Error ? err.message : 'join_config_failed';
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const loadNpcCandidates = async (refresh = false) => {
|
|
npcLoading.value = true;
|
|
npcError.value = null;
|
|
try {
|
|
const reservation = await trpc.join.listPossessCandidates.mutate({
|
|
refresh,
|
|
...(refresh ? { keepIds: keptNpcIds.value } : {}),
|
|
});
|
|
npcReservation.value = reservation;
|
|
keptNpcIds.value = [];
|
|
const receivedAt = Date.now();
|
|
nowMs.value = receivedAt;
|
|
npcPickMoreAvailableAtMs.value = receivedAt + reservation.pickMoreSeconds * 1000;
|
|
} catch (err) {
|
|
npcError.value = err instanceof Error ? err.message : 'npc_list_failed';
|
|
if (refresh) {
|
|
window.alert(npcError.value);
|
|
if (isTrpcBusinessError(err)) {
|
|
window.location.reload();
|
|
}
|
|
} else if (isTrpcBusinessError(err)) {
|
|
window.alert(npcError.value);
|
|
} else {
|
|
window.alert(`알 수 없는 에러: ${npcError.value}`);
|
|
}
|
|
} finally {
|
|
npcLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const submitJoin = async () => {
|
|
if (!canSubmit.value || submitting.value) {
|
|
return;
|
|
}
|
|
submitting.value = true;
|
|
error.value = null;
|
|
const pending = getPendingJoin();
|
|
try {
|
|
await trpc.join.createGeneral.mutate({
|
|
...pending.input,
|
|
clientRequestId: pending.clientRequestId,
|
|
});
|
|
clearPendingJoin(pending);
|
|
await session.refreshGeneralStatus();
|
|
if (session.hasGeneral) {
|
|
await router.push({ name: 'home' });
|
|
}
|
|
} catch (err) {
|
|
if (!isIndeterminateTimeout(err)) {
|
|
clearPendingJoin(pending);
|
|
}
|
|
error.value = err instanceof Error ? err.message : 'join_failed';
|
|
} finally {
|
|
submitting.value = false;
|
|
}
|
|
};
|
|
|
|
const submitPossession = async (pending: PendingPossessAction) => {
|
|
if (submitting.value) {
|
|
return;
|
|
}
|
|
submitting.value = true;
|
|
error.value = null;
|
|
try {
|
|
await trpc.join.possessGeneral.mutate({
|
|
generalId: pending.generalId,
|
|
tokenNonce: pending.tokenNonce,
|
|
clientRequestId: pending.clientRequestId,
|
|
});
|
|
clearPendingPossess(pending);
|
|
window.alert('빙의에 성공했습니다.');
|
|
await session.refreshGeneralStatus();
|
|
if (session.hasGeneral) {
|
|
await router.push({ name: 'home' });
|
|
}
|
|
} catch (err) {
|
|
if (!isIndeterminateTimeout(err)) {
|
|
clearPendingPossess(pending);
|
|
}
|
|
error.value = err instanceof Error ? err.message : 'possess_failed';
|
|
if (isTrpcBusinessError(err) && !isIndeterminateTimeout(err)) {
|
|
window.alert(error.value);
|
|
window.location.reload();
|
|
} else if (!isIndeterminateTimeout(err)) {
|
|
window.alert(`알 수 없는 에러: ${error.value}`);
|
|
}
|
|
} finally {
|
|
submitting.value = false;
|
|
}
|
|
};
|
|
|
|
const possessGeneral = async (candidate: PossessCandidate) => {
|
|
const reservation = npcReservation.value;
|
|
if (
|
|
submitting.value ||
|
|
!reservation ||
|
|
(hasPendingPossession.value && !isPendingPossessCandidate(candidate)) ||
|
|
!window.confirm(`빙의할까요? : ${candidate.name}`)
|
|
) {
|
|
return;
|
|
}
|
|
await submitPossession(getPendingPossess(candidate.id, reservation.tokenNonce));
|
|
};
|
|
|
|
const retryPendingPossession = async () => {
|
|
const pending = pendingPossessAction.value;
|
|
if (!pending || pending.ownerUserId !== joinConfig.value?.user.id) {
|
|
return;
|
|
}
|
|
await submitPossession(pending);
|
|
};
|
|
|
|
const loadNpcGeneralList = async () => {
|
|
if (npcGeneralListLoading.value) {
|
|
return;
|
|
}
|
|
npcGeneralListLoading.value = true;
|
|
npcGeneralListError.value = '';
|
|
try {
|
|
npcGeneralList.value = await trpc.public.getNpcList.query({ sort: 1, includeAllWithToken: true });
|
|
npcGeneralListVisibleCount.value = 50;
|
|
} catch (err) {
|
|
npcGeneralListError.value = err instanceof Error ? err.message : 'npc_general_list_failed';
|
|
window.alert(`실패했습니다: ${npcGeneralListError.value}`);
|
|
} finally {
|
|
npcGeneralListLoading.value = false;
|
|
}
|
|
};
|
|
|
|
watch(activeTab, (value) => {
|
|
if (value === 'possess' && !npcReservation.value) {
|
|
void loadNpcCandidates(false);
|
|
}
|
|
});
|
|
|
|
onMounted(() => {
|
|
npcTimer = window.setInterval(() => {
|
|
nowMs.value = Date.now();
|
|
}, 250);
|
|
void loadConfig();
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
if (npcTimer !== null) {
|
|
window.clearInterval(npcTimer);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<main class="join-page">
|
|
<header class="join-header">
|
|
<div>
|
|
<h1 class="join-title">장수 생성/빙의</h1>
|
|
<p class="join-subtitle">로그인 완료, 아직 장수가 없는 상태입니다.</p>
|
|
</div>
|
|
<div class="join-tabs">
|
|
<RouterLink class="simulator-link" to="/past-plays">내 지난 플레이</RouterLink>
|
|
<RouterLink class="simulator-link" to="/battle-simulator">전투 시뮬레이터</RouterLink>
|
|
<button
|
|
:class="{ active: activeTab === 'create' }"
|
|
:disabled="joinConfig?.user.canCreateGeneral === false"
|
|
@click="activeTab = 'create'"
|
|
>
|
|
장수 생성
|
|
</button>
|
|
<button
|
|
v-if="joinConfig?.npcPossession.enabled"
|
|
:class="{ active: activeTab === 'possess' }"
|
|
:disabled="joinConfig?.user.canCreateGeneral === false"
|
|
@click="activeTab = 'possess'"
|
|
>
|
|
NPC 빙의
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div v-if="error" class="join-error">{{ error }}</div>
|
|
|
|
<div v-if="loading">
|
|
<SkeletonLines :lines="4" />
|
|
</div>
|
|
|
|
<section v-else-if="activeTab === 'create'" class="join-grid">
|
|
<PanelCard title="국가 임관 권유">
|
|
<div v-if="nationList.length === 0" class="muted">국가 정보가 아직 준비되지 않았습니다.</div>
|
|
<div v-else class="nation-list">
|
|
<div v-for="nation in nationList" :key="nation.id" class="nation-card">
|
|
<div class="nation-name" :style="{ backgroundColor: nation.color }">{{ nation.name }}</div>
|
|
<div class="nation-message">
|
|
{{ nation.scoutMessage ?? '권유문 없음' }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</PanelCard>
|
|
|
|
<PanelCard title="장수 기본 정보" subtitle="능력치와 성격을 지정합니다.">
|
|
<div class="form-grid">
|
|
<label class="form-field">
|
|
<span>장수명</span>
|
|
<input
|
|
v-if="joinConfig?.rules.allowCustomName"
|
|
v-model="form.name"
|
|
type="text"
|
|
class="form-input"
|
|
/>
|
|
<span v-else>무작위</span>
|
|
</label>
|
|
<label class="form-field">
|
|
<span>성격</span>
|
|
<select v-model="form.character" class="form-input">
|
|
<option v-for="option in personalities" :key="option.key" :value="option.key">
|
|
{{ option.name }}
|
|
</option>
|
|
</select>
|
|
<small class="muted">{{ personalities.find((p) => p.key === form.character)?.info }}</small>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="stat-grid">
|
|
<label class="form-field">
|
|
<span>통솔</span>
|
|
<input v-model.number="form.leadership" type="number" class="form-input" />
|
|
</label>
|
|
<label class="form-field">
|
|
<span>무력</span>
|
|
<input v-model.number="form.strength" type="number" class="form-input" />
|
|
</label>
|
|
<label class="form-field">
|
|
<span>지력</span>
|
|
<input v-model.number="form.intel" type="number" class="form-input" />
|
|
</label>
|
|
</div>
|
|
|
|
<div class="stat-actions">
|
|
<button @click="applyRandomStats">랜덤형</button>
|
|
<button @click="applyFocusedStats('leadership')">통솔형</button>
|
|
<button @click="applyFocusedStats('strength')">무력형</button>
|
|
<button @click="applyFocusedStats('intel')">지력형</button>
|
|
<button @click="applyBalancedStats">균형형</button>
|
|
</div>
|
|
|
|
<div class="stat-summary">
|
|
<div>능력치 합계: {{ statTotal }} / {{ statRules?.total ?? '-' }}</div>
|
|
<div v-if="statErrors.length" class="stat-errors">
|
|
<div v-for="item in statErrors" :key="item">{{ item }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-actions">
|
|
<button :disabled="!canSubmit || submitting" @click="submitJoin">장수 생성</button>
|
|
<button class="ghost" @click="applyBalancedStats">다시 입력</button>
|
|
</div>
|
|
</PanelCard>
|
|
|
|
<PanelCard title="유산 포인트 옵션" subtitle="보유 포인트를 사용해 시작 옵션을 지정합니다.">
|
|
<div v-if="!inheritConfig" class="muted">유산 포인트 정보를 불러오지 못했습니다.</div>
|
|
<div v-else class="inherit-panel">
|
|
<div class="inherit-summary">
|
|
<div>보유 포인트: {{ inheritTotalPoint }}</div>
|
|
<div>필요 포인트: {{ inheritRequiredPoint }}</div>
|
|
</div>
|
|
|
|
<div class="inherit-options">
|
|
<label class="form-field">
|
|
<span>전투 특기 선택</span>
|
|
<select v-model="inheritSpecialChoice" class="form-input">
|
|
<option value="">선택 안함</option>
|
|
<option
|
|
v-for="special in inheritConfig.availableSpecialWar"
|
|
:key="special.key"
|
|
:value="special.key"
|
|
>
|
|
{{ special.name }}
|
|
</option>
|
|
</select>
|
|
<small class="muted">비용 {{ inheritCosts.inheritBornSpecialPoint }} 포인트</small>
|
|
</label>
|
|
|
|
<label class="form-field">
|
|
<span>시작 도시 지정</span>
|
|
<select v-model="inheritCityChoice" class="form-input">
|
|
<option value="">랜덤 배치</option>
|
|
<option
|
|
v-for="city in inheritConfig.availableCities"
|
|
:key="city.id"
|
|
:value="String(city.id)"
|
|
>
|
|
{{ city.name }} · {{ cityLevelMap[city.level] ?? city.level }} ·
|
|
{{ regionMap[city.region] ?? city.region }}
|
|
</option>
|
|
</select>
|
|
<small class="muted">비용 {{ inheritCosts.inheritBornCityPoint }} 포인트</small>
|
|
</label>
|
|
|
|
<label class="form-field">
|
|
<span>턴 시간대 지정</span>
|
|
<select v-model="inheritTurntimeChoice" class="form-input">
|
|
<option value="">랜덤 배치</option>
|
|
<option
|
|
v-for="(zone, index) in inheritConfig.turnTimeZones"
|
|
:key="zone"
|
|
:value="String(index)"
|
|
>
|
|
{{ zone }}
|
|
</option>
|
|
</select>
|
|
<small class="muted">비용 {{ inheritCosts.inheritBornTurntimePoint }} 포인트</small>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="inherit-bonus">
|
|
<div class="bonus-title">보너스 능력치</div>
|
|
<div class="bonus-grid">
|
|
<label class="form-field">
|
|
<span>통솔</span>
|
|
<input
|
|
v-model.number="form.inheritBonusStat[0]"
|
|
type="number"
|
|
min="0"
|
|
max="5"
|
|
class="form-input"
|
|
/>
|
|
</label>
|
|
<label class="form-field">
|
|
<span>무력</span>
|
|
<input
|
|
v-model.number="form.inheritBonusStat[1]"
|
|
type="number"
|
|
min="0"
|
|
max="5"
|
|
class="form-input"
|
|
/>
|
|
</label>
|
|
<label class="form-field">
|
|
<span>지력</span>
|
|
<input
|
|
v-model.number="form.inheritBonusStat[2]"
|
|
type="number"
|
|
min="0"
|
|
max="5"
|
|
class="form-input"
|
|
/>
|
|
</label>
|
|
</div>
|
|
<small class="muted">
|
|
보너스 합 {{ inheritBonusSum }} (0 또는 3~5) · 비용
|
|
{{ inheritCosts.inheritBornStatPoint }} 포인트
|
|
</small>
|
|
</div>
|
|
|
|
<div v-if="inheritErrors.length" class="inherit-errors">
|
|
<div v-for="item in inheritErrors" :key="item">{{ item }}</div>
|
|
</div>
|
|
</div>
|
|
</PanelCard>
|
|
</section>
|
|
|
|
<section v-else class="npc-possession-section">
|
|
<PanelCard title="장수 빙의">
|
|
<div v-if="npcError" class="muted">{{ npcError }}</div>
|
|
<div v-if="npcLoading && npcCandidates.length === 0">
|
|
<SkeletonLines :lines="3" />
|
|
</div>
|
|
<div v-else-if="npcCandidates.length === 0" class="muted">빙의 가능한 NPC가 없습니다.</div>
|
|
<template v-else>
|
|
<div class="npc-token-status">
|
|
<span v-if="!npcExpired">
|
|
(<span :style="{ color: npcValidColor }">{{
|
|
npcReservation?.validUntil ? formatSeoulDateTime(npcReservation.validUntil) : ''
|
|
}}</span
|
|
>까지 유효)
|
|
</span>
|
|
<span v-else class="npc-token-expired">- 만료 -</span>
|
|
</div>
|
|
<form class="npc-card-holder" @submit.prevent>
|
|
<div v-for="npc in npcCandidates" :key="npc.id" class="npc-card">
|
|
<h4 class="npc-card-name">{{ npc.name }}</h4>
|
|
<h4>
|
|
<img
|
|
class="npc-card-image"
|
|
:src="npcImageUrl(npc)"
|
|
:alt="`${npc.name} 얼굴`"
|
|
width="64"
|
|
height="64"
|
|
@error="useDefaultGeneralIcon"
|
|
/>
|
|
</h4>
|
|
<p>
|
|
{{ npc.stats.leadership }} / {{ npc.stats.strength }} / {{ npc.stats.intelligence
|
|
}}<br />
|
|
<span :style="{ color: npc.nation.color }">{{ npc.nation.name }}</span
|
|
><br />
|
|
<span class="npc-tooltip" tabindex="0">
|
|
{{ npc.personality.name }}
|
|
<span role="tooltip">{{ npc.personality.info }}</span>
|
|
</span>
|
|
<br />
|
|
<span class="npc-tooltip" tabindex="0">
|
|
{{ npc.specialDomestic.name }}
|
|
<span role="tooltip">{{ npc.specialDomestic.info }}</span>
|
|
</span>
|
|
/
|
|
<span class="npc-tooltip" tabindex="0">
|
|
{{ npc.specialWar.name }}
|
|
<span role="tooltip">{{ npc.specialWar.info }}</span>
|
|
</span>
|
|
</p>
|
|
<button
|
|
class="npc-action"
|
|
type="button"
|
|
:disabled="submitting || (hasPendingPossession && !isPendingPossessCandidate(npc))"
|
|
@click="possessGeneral(npc)"
|
|
>
|
|
빙의하기
|
|
</button>
|
|
<label class="npc-keep">
|
|
<input
|
|
v-model="keptNpcIds"
|
|
type="checkbox"
|
|
:value="npc.id"
|
|
:disabled="npc.keepCount <= 0"
|
|
/>
|
|
보관({{ npc.keepCount }}회)
|
|
</label>
|
|
</div>
|
|
</form>
|
|
</template>
|
|
<div class="npc-footer">
|
|
<button
|
|
id="btn-pick-more"
|
|
class="ghost"
|
|
type="button"
|
|
:disabled="npcLoading || npcPickMoreSeconds > 0 || submitting || hasPendingPossession"
|
|
@click="loadNpcCandidates(true)"
|
|
>
|
|
다른 장수 보기<span v-if="npcPickMoreSeconds > 0">({{ npcPickMoreSeconds }}초)</span>
|
|
</button>
|
|
<button
|
|
v-if="hasPendingPossession"
|
|
id="btn-retry-possession"
|
|
class="ghost"
|
|
type="button"
|
|
:disabled="submitting"
|
|
@click="retryPendingPossession"
|
|
>
|
|
접수 결과 다시 확인
|
|
</button>
|
|
<button
|
|
id="btn-load-general-list"
|
|
class="ghost npc-list-link"
|
|
type="button"
|
|
:disabled="npcGeneralListLoading"
|
|
@click="loadNpcGeneralList"
|
|
>
|
|
{{ npcGeneralListLoading ? '불러오는 중...' : '장수 목록 보기' }}
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="npcGeneralListError" class="npc-general-list-error" role="alert">
|
|
{{ npcGeneralListError }}
|
|
</div>
|
|
<div v-if="npcGeneralList" class="npc-general-list-wrap">
|
|
<table id="tb-general-list" class="npc-general-table">
|
|
<colgroup>
|
|
<col style="width: 64px" />
|
|
<col style="width: 140px" />
|
|
<col style="width: 40px" />
|
|
<col style="width: 40px" />
|
|
<col style="width: 80px" />
|
|
<col style="width: 45px" />
|
|
<col style="width: 140px" />
|
|
<col style="width: 50px" />
|
|
<col style="width: 50px" />
|
|
<col style="width: 75px" />
|
|
<col style="width: 60px" />
|
|
<col style="width: 45px" />
|
|
<col style="width: 45px" />
|
|
<col style="width: 45px" />
|
|
<col style="width: 45px" />
|
|
</colgroup>
|
|
<thead>
|
|
<tr>
|
|
<th>얼 굴</th>
|
|
<th>이 름</th>
|
|
<th>연령</th>
|
|
<th>성격</th>
|
|
<th>특기</th>
|
|
<th>레 벨</th>
|
|
<th>국 가</th>
|
|
<th>명 성</th>
|
|
<th>계 급</th>
|
|
<th>관 직</th>
|
|
<th>종능</th>
|
|
<th>통솔</th>
|
|
<th>무력</th>
|
|
<th>지력</th>
|
|
<th>삭턴</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
v-for="general in visibleNpcGeneralRows"
|
|
:key="general.id"
|
|
:data-general-id="general.id"
|
|
:data-reservation-state="general.reservationState"
|
|
>
|
|
<td>
|
|
<img
|
|
class="npc-general-icon"
|
|
:src="npcImageUrl(general)"
|
|
:alt="`${general.name} 얼굴`"
|
|
width="64"
|
|
height="64"
|
|
@error="useDefaultGeneralIcon"
|
|
/>
|
|
</td>
|
|
<td
|
|
class="npc-general-name"
|
|
:style="{
|
|
color:
|
|
general.reservationState === 1
|
|
? 'violet'
|
|
: general.npcState > 0
|
|
? getNpcColor(general.npcState)
|
|
: '',
|
|
}"
|
|
>
|
|
{{ general.name }}
|
|
<template v-if="general.ownerName">
|
|
<br /><small>({{ general.ownerName }})</small>
|
|
</template>
|
|
<template v-if="general.reservationState === 1">
|
|
<br /><small>({{ general.keepCount }}회)</small>
|
|
</template>
|
|
</td>
|
|
<td>{{ general.age }}세</td>
|
|
<td>
|
|
<span v-if="general.personality" class="npc-tooltip" tabindex="0">
|
|
{{ general.personality.name }}
|
|
<span role="tooltip">{{ general.personality.info }}</span>
|
|
</span>
|
|
<span v-else>-</span>
|
|
</td>
|
|
<td>
|
|
<span v-if="general.specialDomestic" class="npc-tooltip" tabindex="0">
|
|
{{ general.specialDomestic.name }}
|
|
<span role="tooltip">{{ general.specialDomestic.info }}</span>
|
|
</span>
|
|
<span v-else>-</span>
|
|
/
|
|
<span v-if="general.specialWar" class="npc-tooltip" tabindex="0">
|
|
{{ general.specialWar.name }}
|
|
<span role="tooltip">{{ general.specialWar.info }}</span>
|
|
</span>
|
|
<span v-else>-</span>
|
|
</td>
|
|
<td>Lv {{ general.level }}</td>
|
|
<td>{{ general.nationName }}</td>
|
|
<td>{{ general.experienceText }}</td>
|
|
<td>{{ general.dedicationText }}</td>
|
|
<td>{{ formatOfficerLevelText(general.officerLevel, general.nationLevel) }}</td>
|
|
<td>{{ general.statTotal }}</td>
|
|
<td>{{ general.leadership }}</td>
|
|
<td>{{ general.strength }}</td>
|
|
<td>{{ general.intelligence }}</td>
|
|
<td>{{ general.killturn }}</td>
|
|
</tr>
|
|
</tbody>
|
|
<tfoot v-if="visibleNpcGeneralRows.length < npcGeneralRows.length">
|
|
<tr>
|
|
<td colspan="15">
|
|
<button
|
|
id="btn-print-more-generals"
|
|
type="button"
|
|
@click="npcGeneralListVisibleCount += 50"
|
|
>
|
|
더 보기
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
</PanelCard>
|
|
</section>
|
|
</main>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.join-page {
|
|
min-height: 100vh;
|
|
padding: 24px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.join-header {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
|
|
padding-bottom: 12px;
|
|
}
|
|
|
|
.join-title {
|
|
font-size: 1.6rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.join-subtitle {
|
|
font-size: 0.85rem;
|
|
color: rgba(232, 221, 196, 0.7);
|
|
}
|
|
|
|
.join-tabs {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
.join-tabs button {
|
|
border: 1px solid rgba(201, 164, 90, 0.4);
|
|
padding: 6px 10px;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.simulator-link {
|
|
border: 1px solid rgba(112, 170, 141, 0.55);
|
|
padding: 6px 10px;
|
|
color: #bfe2cd;
|
|
font-size: 0.8rem;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.join-tabs button.active {
|
|
background: rgba(201, 164, 90, 0.2);
|
|
}
|
|
|
|
.join-error {
|
|
border: 1px solid rgba(240, 90, 90, 0.6);
|
|
padding: 8px 10px;
|
|
color: rgba(240, 150, 150, 0.9);
|
|
}
|
|
|
|
.join-grid {
|
|
display: grid;
|
|
gap: 16px;
|
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
}
|
|
|
|
.nation-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.nation-card {
|
|
border: 1px solid rgba(201, 164, 90, 0.25);
|
|
padding: 8px;
|
|
display: grid;
|
|
grid-template-columns: 120px 1fr;
|
|
gap: 8px;
|
|
}
|
|
|
|
.nation-name {
|
|
font-weight: 600;
|
|
padding: 4px 6px;
|
|
color: #101010;
|
|
text-align: center;
|
|
}
|
|
|
|
.nation-message {
|
|
font-size: 0.75rem;
|
|
color: rgba(232, 221, 196, 0.7);
|
|
}
|
|
|
|
.form-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 12px;
|
|
}
|
|
|
|
.stat-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
|
gap: 12px;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.form-field {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.form-input {
|
|
border: 1px solid rgba(201, 164, 90, 0.4);
|
|
background: rgba(10, 10, 10, 0.8);
|
|
padding: 6px 8px;
|
|
color: inherit;
|
|
}
|
|
|
|
.stat-actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.stat-actions button {
|
|
border: 1px solid rgba(201, 164, 90, 0.3);
|
|
padding: 4px 8px;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.stat-summary {
|
|
margin-top: 10px;
|
|
font-size: 0.75rem;
|
|
color: rgba(232, 221, 196, 0.7);
|
|
}
|
|
|
|
.stat-errors {
|
|
margin-top: 4px;
|
|
color: rgba(240, 150, 150, 0.9);
|
|
}
|
|
|
|
.form-actions {
|
|
margin-top: 12px;
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
.form-actions button {
|
|
border: 1px solid rgba(201, 164, 90, 0.4);
|
|
padding: 6px 12px;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.inherit-panel {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.inherit-summary {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
font-size: 0.8rem;
|
|
color: rgba(232, 221, 196, 0.85);
|
|
}
|
|
|
|
.inherit-options {
|
|
display: grid;
|
|
gap: 12px;
|
|
}
|
|
|
|
.inherit-bonus {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.bonus-title {
|
|
font-size: 0.8rem;
|
|
color: rgba(232, 221, 196, 0.8);
|
|
}
|
|
|
|
.bonus-grid {
|
|
display: grid;
|
|
gap: 12px;
|
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
|
}
|
|
|
|
.inherit-errors {
|
|
color: rgba(240, 150, 150, 0.9);
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.form-actions .ghost,
|
|
.join-tabs .ghost,
|
|
.npc-footer .ghost {
|
|
background: transparent;
|
|
}
|
|
|
|
.npc-possession-section {
|
|
width: 1000px;
|
|
align-self: center;
|
|
}
|
|
|
|
.npc-token-status {
|
|
min-height: 22px;
|
|
text-align: center;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.npc-token-expired {
|
|
color: red;
|
|
}
|
|
|
|
.npc-card-holder {
|
|
text-align: center;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.npc-card {
|
|
width: 125px;
|
|
display: inline-flex;
|
|
flex-direction: column;
|
|
vertical-align: top;
|
|
white-space: normal;
|
|
}
|
|
|
|
.npc-card h4,
|
|
.npc-card p {
|
|
margin: 0;
|
|
}
|
|
|
|
.npc-card-name {
|
|
min-height: 25px;
|
|
border: 1px solid rgba(201, 164, 90, 0.3);
|
|
font-size: 1rem;
|
|
line-height: 23px;
|
|
}
|
|
|
|
.npc-card-image {
|
|
width: 64px;
|
|
height: 64px;
|
|
}
|
|
|
|
.npc-card p {
|
|
min-height: 78px;
|
|
font-size: 0.75rem;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.npc-tooltip {
|
|
position: relative;
|
|
cursor: help;
|
|
text-decoration: underline dotted;
|
|
}
|
|
|
|
.npc-tooltip [role='tooltip'] {
|
|
display: none;
|
|
position: absolute;
|
|
z-index: 10;
|
|
left: 50%;
|
|
bottom: calc(100% + 4px);
|
|
width: 220px;
|
|
padding: 5px 7px;
|
|
transform: translateX(-50%);
|
|
border: 1px solid #888;
|
|
background: #202020;
|
|
color: #fff;
|
|
text-align: left;
|
|
word-break: keep-all;
|
|
}
|
|
|
|
.npc-tooltip:hover [role='tooltip'],
|
|
.npc-tooltip:focus [role='tooltip'] {
|
|
display: block;
|
|
}
|
|
|
|
.npc-action {
|
|
width: 100%;
|
|
border: 1px solid rgba(201, 164, 90, 0.4);
|
|
padding: 4px 8px;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.npc-keep {
|
|
display: block;
|
|
padding-left: 15px;
|
|
text-indent: -15px;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.npc-keep input {
|
|
width: 13px;
|
|
height: 13px;
|
|
padding: 0;
|
|
margin: 0;
|
|
vertical-align: bottom;
|
|
position: relative;
|
|
top: -1px;
|
|
}
|
|
|
|
.npc-footer {
|
|
margin-top: 8px;
|
|
padding: 20px 0;
|
|
text-align: center;
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 2ch;
|
|
}
|
|
|
|
.npc-list-link {
|
|
display: inline-block;
|
|
border: 1px solid rgba(201, 164, 90, 0.4);
|
|
padding: 4px 8px;
|
|
color: inherit;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.npc-general-list-error {
|
|
margin-bottom: 8px;
|
|
color: rgba(240, 150, 150, 0.9);
|
|
text-align: center;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.npc-general-list-wrap {
|
|
width: 970px;
|
|
margin: 0 auto 20px;
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.npc-general-table {
|
|
width: 970px;
|
|
border-collapse: collapse;
|
|
table-layout: fixed;
|
|
font-size: 12px;
|
|
word-break: break-all;
|
|
}
|
|
|
|
.npc-general-table th,
|
|
.npc-general-table td {
|
|
border: 1px solid gray;
|
|
padding: 0;
|
|
text-align: center;
|
|
}
|
|
|
|
.npc-general-table th {
|
|
height: 24px;
|
|
background: rgba(201, 164, 90, 0.15);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.npc-general-table tbody tr {
|
|
height: 65px;
|
|
}
|
|
|
|
.npc-general-icon {
|
|
display: block;
|
|
width: 64px;
|
|
height: 64px;
|
|
max-width: none;
|
|
}
|
|
|
|
.npc-general-name small {
|
|
font-size: 10px;
|
|
}
|
|
|
|
#btn-print-more-generals {
|
|
width: 100%;
|
|
min-height: 28px;
|
|
border: 0;
|
|
}
|
|
|
|
.muted {
|
|
color: rgba(232, 221, 196, 0.6);
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.ghost {
|
|
background: transparent;
|
|
}
|
|
</style>
|