feat: port scenario 903 select-pool flow
This commit is contained in:
@@ -206,6 +206,21 @@ const officerLevelOptions = [
|
||||
<span>사기</span>
|
||||
<input v-model.number="general.atmos" type="number" min="40" :max="options.config.maxAtmosByWar" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>내정특기</span>
|
||||
<select v-model="general.special">
|
||||
<option :value="null">-</option>
|
||||
<option
|
||||
v-for="trait in options.eventDomesticTraits"
|
||||
:key="trait.key"
|
||||
:value="trait.key"
|
||||
>
|
||||
{{ trait.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="field">
|
||||
<span>전특</span>
|
||||
<select v-model="general.special2">
|
||||
|
||||
@@ -3,6 +3,7 @@ import MainView from '../views/MainView.vue';
|
||||
import PublicView from '../views/PublicView.vue';
|
||||
import LoginView from '../views/LoginView.vue';
|
||||
import JoinView from '../views/JoinView.vue';
|
||||
import SelectGeneralView from '../views/SelectGeneralView.vue';
|
||||
import InheritView from '../views/InheritView.vue';
|
||||
import AuctionView from '../views/AuctionView.vue';
|
||||
import NationCitiesView from '../views/NationCitiesView.vue';
|
||||
@@ -92,6 +93,14 @@ const routes = [
|
||||
requiresNoGeneral: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/select-general',
|
||||
name: 'select-general',
|
||||
component: SelectGeneralView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/inherit',
|
||||
name: 'inherit',
|
||||
|
||||
@@ -101,7 +101,7 @@ export const useSessionStore = defineStore('session', {
|
||||
},
|
||||
async refreshGeneralStatus() {
|
||||
if (!this.gameToken) {
|
||||
this.status = 'authed';
|
||||
this.status = this.sessionToken ? 'authed' : 'public';
|
||||
return;
|
||||
}
|
||||
if (!isAccessToken(this.gameToken)) {
|
||||
@@ -111,11 +111,44 @@ export const useSessionStore = defineStore('session', {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await gameTrpc.auth.status.query();
|
||||
} catch {
|
||||
this.setGameToken(null);
|
||||
if (this.sessionToken && this.profile) {
|
||||
try {
|
||||
const issued = await gatewayTrpc.auth.issueGameSession.mutate({
|
||||
sessionToken: this.sessionToken,
|
||||
profile: this.profile,
|
||||
});
|
||||
this.setGameToken(issued.gameToken);
|
||||
if (await this.exchangeGatewayToken()) {
|
||||
await gameTrpc.auth.status.query();
|
||||
} else {
|
||||
throw new Error('Game token exchange failed.');
|
||||
}
|
||||
} catch {
|
||||
this.setGameToken(null);
|
||||
this.error = 'game_session_invalid';
|
||||
this.status = 'public';
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
this.error = 'game_session_invalid';
|
||||
this.status = 'public';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const lobby = await gameTrpc.lobby.info.query();
|
||||
this.status = lobby.myGeneral ? 'general' : 'authed';
|
||||
} catch {
|
||||
this.error = 'game_status_unavailable';
|
||||
this.error = 'game_lobby_unavailable';
|
||||
if (this.status === 'unknown' || this.status === 'public') {
|
||||
this.status = 'authed';
|
||||
}
|
||||
}
|
||||
},
|
||||
async exchangeGatewayToken(): Promise<boolean> {
|
||||
|
||||
@@ -23,6 +23,7 @@ export type GeneralDraft = {
|
||||
injury: number;
|
||||
rice: number;
|
||||
personal: string | null;
|
||||
special: string | null;
|
||||
special2: string | null;
|
||||
crew: number;
|
||||
crewtype: number;
|
||||
@@ -57,6 +58,7 @@ export type BattleSimOptions = {
|
||||
crewTypes: Array<{ id: number; name: string; armType: number }>;
|
||||
};
|
||||
nationTypes: Array<{ key: string; name: string; info: string }>;
|
||||
eventDomesticTraits: Array<{ key: string; name: string; info: string }>;
|
||||
warTraits: Array<{ key: string; name: string; info: string }>;
|
||||
personalities: Array<{ key: string; name: string; info: string }>;
|
||||
items: {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
const KOREA_TIME_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
|
||||
const pad = (value: number): string => String(value).padStart(2, '0');
|
||||
|
||||
export const formatSeoulDateTime = (value: string | Date): string => {
|
||||
if (
|
||||
typeof value === 'string' &&
|
||||
!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(value.trim())
|
||||
) {
|
||||
return value.trim().replace('T', ' ').slice(0, 19);
|
||||
}
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return typeof value === 'string' ? value.slice(0, 19) : '';
|
||||
}
|
||||
const koreaTime = new Date(date.getTime() + KOREA_TIME_OFFSET_MS);
|
||||
return `${koreaTime.getUTCFullYear()}-${pad(koreaTime.getUTCMonth() + 1)}-${pad(
|
||||
koreaTime.getUTCDate()
|
||||
)} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad(
|
||||
koreaTime.getUTCSeconds()
|
||||
)}`;
|
||||
};
|
||||
@@ -134,6 +134,7 @@ const createGeneralDraft = (overrides?: Partial<GeneralDraft>): GeneralDraft =>
|
||||
injury: 0,
|
||||
rice: 5000,
|
||||
personal: null,
|
||||
special: null,
|
||||
special2: null,
|
||||
crew: 7000,
|
||||
crewtype: baseCrew,
|
||||
@@ -193,6 +194,7 @@ const applyGeneralExport = (target: GeneralDraft, data: GeneralExport) => {
|
||||
target.injury = data.injury;
|
||||
target.rice = data.rice;
|
||||
target.personal = data.personal;
|
||||
target.special = data.special;
|
||||
target.special2 = data.special2;
|
||||
target.crew = data.crew;
|
||||
target.crewtype = data.crewtype;
|
||||
@@ -228,6 +230,7 @@ const toExportedGeneral = (general: GeneralDraft): GeneralExport => ({
|
||||
injury: general.injury,
|
||||
rice: general.rice,
|
||||
personal: general.personal,
|
||||
special: general.special,
|
||||
special2: general.special2,
|
||||
crew: general.crew,
|
||||
crewtype: general.crewtype,
|
||||
@@ -401,6 +404,7 @@ const normalizeGeneralExport = (raw: Record<string, unknown>): GeneralExport =>
|
||||
injury: readNumberValue(raw.injury, 0),
|
||||
rice: readNumberValue(raw.rice, 0),
|
||||
personal: readOptionalString(raw.personal),
|
||||
special: readOptionalString(raw.special),
|
||||
special2: readOptionalString(raw.special2),
|
||||
crew: readNumberValue(raw.crew, 0),
|
||||
crewtype: readNumberValue(raw.crewtype, 0),
|
||||
@@ -438,6 +442,7 @@ const buildGeneralPayload = (
|
||||
nation: nationId,
|
||||
turntime: timestamp,
|
||||
personal: general.personal,
|
||||
special: general.special,
|
||||
special2: general.special2,
|
||||
crew: general.crew,
|
||||
crewtype: general.crewtype,
|
||||
@@ -872,6 +877,7 @@ const applyServerGeneral = async (target: GeneralDraft, generalId: number) => {
|
||||
injury: response.general.injury,
|
||||
rice: response.general.rice,
|
||||
personal: response.general.personal,
|
||||
special: response.general.special,
|
||||
special2: response.general.special2,
|
||||
crew: response.general.crew,
|
||||
crewtype: response.general.crewtype,
|
||||
|
||||
@@ -194,6 +194,10 @@ const loadConfig = async () => {
|
||||
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;
|
||||
form.value.name = config.user.displayName || '';
|
||||
applyBalancedStats();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { formatLog } from '../utils/formatLog';
|
||||
import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
||||
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic';
|
||||
|
||||
const SCREEN_MODE_KEY = 'sam.screenMode';
|
||||
@@ -10,6 +11,7 @@ type ScreenMode = 'auto' | '500px' | '1000px';
|
||||
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
|
||||
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
|
||||
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
|
||||
type SelectionPoolStatus = Awaited<ReturnType<typeof trpc.join.getConfig.query>>['selectionPool'];
|
||||
|
||||
type WorldSnapshot = {
|
||||
currentYear: number;
|
||||
@@ -28,6 +30,7 @@ type SettingForm = {
|
||||
|
||||
const data = ref<MyGeneralResponse | null>(null);
|
||||
const world = ref<WorldSnapshot>(null);
|
||||
const selectionPoolStatus = ref<SelectionPoolStatus | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const screenMode = ref<ScreenMode>('auto');
|
||||
@@ -130,6 +133,11 @@ const actionAvailability = computed(() => {
|
||||
selectOtherGeneral: Boolean(npcMode === 2 && general?.npcState === 0),
|
||||
};
|
||||
});
|
||||
const formatSelectionAvailableAt = computed(() => {
|
||||
const value = selectionPoolStatus.value?.nextChangeAt;
|
||||
if (!value) return '';
|
||||
return formatSeoulDateTime(value);
|
||||
});
|
||||
|
||||
const applyCustomCss = (text: string) => {
|
||||
let style = document.getElementById('sammo-custom-css') as HTMLStyleElement | null;
|
||||
@@ -161,12 +169,14 @@ const loadPage = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const [general, state] = await Promise.all([
|
||||
const [general, state, joinConfig] = await Promise.all([
|
||||
trpc.general.me.query(),
|
||||
trpc.world.getState.query() as Promise<WorldSnapshot>,
|
||||
trpc.join.getConfig.query(),
|
||||
]);
|
||||
data.value = general;
|
||||
world.value = state;
|
||||
selectionPoolStatus.value = joinConfig.selectionPool;
|
||||
if (general) {
|
||||
Object.assign(form, general.settings);
|
||||
}
|
||||
@@ -399,6 +409,20 @@ onMounted(() => {
|
||||
접경 귀환
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="actionAvailability.selectOtherGeneral && selectionPoolStatus?.enabled"
|
||||
class="action-line"
|
||||
>
|
||||
다른 장수 선택
|
||||
<template v-if="formatSelectionAvailableAt">
|
||||
({{ formatSelectionAvailableAt }} 부터)
|
||||
</template>
|
||||
<br />
|
||||
<RouterLink class="action-button select-general-link" to="/select-general">
|
||||
다른 장수 선택
|
||||
</RouterLink>
|
||||
<br /><br />
|
||||
</div>
|
||||
|
||||
<div class="screen-mode-row">
|
||||
<span>500px/1000px 모드<br />(모바일 전용, 즉시 설정)</span>
|
||||
@@ -612,6 +636,14 @@ dt {
|
||||
margin: 4px 0;
|
||||
background: #225500;
|
||||
}
|
||||
.select-general-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.action-line {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,728 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { useSessionStore } from '../stores/session';
|
||||
import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type JoinConfig = Awaited<ReturnType<typeof trpc.join.getConfig.query>>;
|
||||
type Reservation = Awaited<ReturnType<typeof trpc.join.getSelectionPool.mutate>>;
|
||||
type Candidate = Reservation['candidates'][number];
|
||||
type Nation = JoinConfig['nations'][number];
|
||||
type PendingSelectionAction = {
|
||||
operation: 'create' | 'reselect';
|
||||
uniqueName: string;
|
||||
personality?: string;
|
||||
clientRequestId: string;
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const session = useSessionStore();
|
||||
|
||||
const config = ref<JoinConfig | null>(null);
|
||||
const reservation = ref<Reservation | null>(null);
|
||||
const selectedUniqueName = ref<string | null>(null);
|
||||
const nations = ref<Nation[]>([]);
|
||||
const personality = ref('Random');
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const error = ref('');
|
||||
const now = ref(Date.now());
|
||||
let timer: number | null = null;
|
||||
const pendingActionStorageKey = 'sammo-select-pool-pending-action';
|
||||
|
||||
const candidates = computed(() => reservation.value?.candidates ?? []);
|
||||
const selectedCandidate = computed(
|
||||
() => candidates.value.find((candidate) => candidate.uniqueName === selectedUniqueName.value) ?? null
|
||||
);
|
||||
const hasGeneral = computed(() => reservation.value?.hasGeneral ?? config.value?.selectionPool.hasGeneral ?? false);
|
||||
const allowPersonality = computed(() => config.value?.selectionPool.allowOptions.includes('ego') ?? false);
|
||||
const personalities = computed(() => config.value?.personalities ?? []);
|
||||
const serverInfo = computed(() => config.value?.serverInfo ?? null);
|
||||
const validUntil = computed(() => {
|
||||
const value = reservation.value?.validUntil;
|
||||
return value ? new Date(value).getTime() : 0;
|
||||
});
|
||||
const expired = computed(() => validUntil.value > 0 && now.value > validUntil.value);
|
||||
const validUntilColor = computed(() => {
|
||||
const remaining = validUntil.value - now.value;
|
||||
if (remaining <= 0 || remaining > 30_000) {
|
||||
return '#fff';
|
||||
}
|
||||
const channel = Math.max(0, Math.round((255 * remaining) / 30_000));
|
||||
return `rgb(255, ${channel}, ${channel})`;
|
||||
});
|
||||
|
||||
const errorText = (value: unknown): string =>
|
||||
value instanceof Error ? value.message : typeof value === 'string' ? value : 'unknown_error';
|
||||
|
||||
const readPendingAction = (): PendingSelectionAction | null => {
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(pendingActionStorageKey);
|
||||
if (!raw) return null;
|
||||
const value = JSON.parse(raw) as Partial<PendingSelectionAction>;
|
||||
if (
|
||||
(value.operation !== 'create' && value.operation !== 'reselect') ||
|
||||
typeof value.uniqueName !== 'string' ||
|
||||
typeof value.clientRequestId !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return value as PendingSelectionAction;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getPendingAction = (
|
||||
operation: PendingSelectionAction['operation'],
|
||||
uniqueName: string,
|
||||
requestedPersonality?: string
|
||||
): PendingSelectionAction => {
|
||||
const current = readPendingAction();
|
||||
if (
|
||||
current?.operation === operation &&
|
||||
current.uniqueName === uniqueName &&
|
||||
current.personality === requestedPersonality
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
const next: PendingSelectionAction = {
|
||||
operation,
|
||||
uniqueName,
|
||||
...(requestedPersonality ? { personality: requestedPersonality } : {}),
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
};
|
||||
window.sessionStorage.setItem(pendingActionStorageKey, JSON.stringify(next));
|
||||
return next;
|
||||
};
|
||||
|
||||
const clearPendingAction = (action: PendingSelectionAction): void => {
|
||||
if (readPendingAction()?.clientRequestId === action.clientRequestId) {
|
||||
window.sessionStorage.removeItem(pendingActionStorageKey);
|
||||
}
|
||||
};
|
||||
|
||||
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 formatDateTime = (value: string | null | undefined): string => {
|
||||
if (!value) return '';
|
||||
return formatSeoulDateTime(value);
|
||||
};
|
||||
|
||||
const shuffleNations = (source: Nation[]): Nation[] => {
|
||||
const shuffled = [...source];
|
||||
for (let index = shuffled.length - 1; index > 0; index -= 1) {
|
||||
const random = new Uint32Array(1);
|
||||
crypto.getRandomValues(random);
|
||||
const swapIndex = random[0]! % (index + 1);
|
||||
[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex]!, shuffled[index]!];
|
||||
}
|
||||
return shuffled;
|
||||
};
|
||||
|
||||
const userIconBaseUrl =
|
||||
import.meta.env.VITE_GATEWAY_USER_ICON_BASE_URL ?? '/gateway/api/user-icons';
|
||||
const imageUrl = (candidate: Candidate): string =>
|
||||
candidate.imageServer
|
||||
? `${userIconBaseUrl.replace(/\/$/, '')}/${candidate.picture}`
|
||||
: `/image/icons/${candidate.picture}`;
|
||||
const useFallbackImage = (event: Event): void => {
|
||||
const image = event.currentTarget as HTMLImageElement;
|
||||
if (image.dataset.fallbackApplied === 'true') return;
|
||||
image.dataset.fallbackApplied = 'true';
|
||||
image.src = '/image/icons/default.jpg';
|
||||
};
|
||||
|
||||
const personalityName = (key: string | null): string | null => {
|
||||
if (!key) return null;
|
||||
return personalities.value.find((entry) => entry.key === key)?.name ?? key;
|
||||
};
|
||||
const personalityInfo = (key: string | null): string =>
|
||||
key ? personalities.value.find((entry) => entry.key === key)?.info ?? '' : '';
|
||||
|
||||
const lightTextNationColors = new Set([
|
||||
'',
|
||||
'#330000',
|
||||
'#FF0000',
|
||||
'#800000',
|
||||
'#A0522D',
|
||||
'#FF6347',
|
||||
'#808000',
|
||||
'#008000',
|
||||
'#2E8B57',
|
||||
'#008080',
|
||||
'#6495ED',
|
||||
'#0000FF',
|
||||
'#000080',
|
||||
'#483D8B',
|
||||
'#7B68EE',
|
||||
'#800080',
|
||||
'#A9A9A9',
|
||||
'#000000',
|
||||
]);
|
||||
const nationTextColor = (color: string): string =>
|
||||
lightTextNationColors.has(color.toUpperCase()) ? '#FFFFFF' : '#000000';
|
||||
|
||||
const selectCandidate = async (candidate: Candidate): Promise<void> => {
|
||||
if (!hasGeneral.value) {
|
||||
selectedUniqueName.value = candidate.uniqueName;
|
||||
return;
|
||||
}
|
||||
if (!confirm(`이 장수를 선택할까요? : ${candidate.generalName}`)) {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
const pending = getPendingAction('reselect', candidate.uniqueName);
|
||||
try {
|
||||
await trpc.join.reselectPoolGeneral.mutate({
|
||||
uniqueName: candidate.uniqueName,
|
||||
clientRequestId: pending.clientRequestId,
|
||||
});
|
||||
clearPendingAction(pending);
|
||||
alert('선택한 장수로 변경했습니다.');
|
||||
await session.refreshGeneralStatus();
|
||||
await router.push('/');
|
||||
} catch (cause) {
|
||||
console.error(cause);
|
||||
if (!isIndeterminateTimeout(cause)) {
|
||||
clearPendingAction(pending);
|
||||
}
|
||||
alert(`실패했습니다: ${errorText(cause)}`);
|
||||
await loadPage();
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const createGeneral = async (): Promise<void> => {
|
||||
const candidate = selectedCandidate.value;
|
||||
if (!candidate) {
|
||||
alert('장수를 선택해주세요!');
|
||||
return;
|
||||
}
|
||||
if (!confirm('이 장수로 생성할까요?')) {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
const pending = getPendingAction('create', candidate.uniqueName, personality.value);
|
||||
try {
|
||||
await trpc.join.selectPoolGeneral.mutate({
|
||||
uniqueName: candidate.uniqueName,
|
||||
personality: personality.value,
|
||||
clientRequestId: pending.clientRequestId,
|
||||
});
|
||||
clearPendingAction(pending);
|
||||
alert('선택한 장수로 생성했습니다.');
|
||||
await session.refreshGeneralStatus();
|
||||
await router.push('/');
|
||||
} catch (cause) {
|
||||
console.error(cause);
|
||||
if (!isIndeterminateTimeout(cause)) {
|
||||
clearPendingAction(pending);
|
||||
}
|
||||
alert(`실패했습니다: ${errorText(cause)}`);
|
||||
await loadPage();
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
async function loadPage(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
selectedUniqueName.value = null;
|
||||
try {
|
||||
const nextConfig = await trpc.join.getConfig.query();
|
||||
config.value = nextConfig;
|
||||
nations.value = shuffleNations(nextConfig.nations);
|
||||
if (!nextConfig.selectionPool.enabled) {
|
||||
await router.replace(nextConfig.selectionPool.hasGeneral ? '/' : '/join');
|
||||
return;
|
||||
}
|
||||
reservation.value = await trpc.join.getSelectionPool.mutate();
|
||||
now.value = Date.now();
|
||||
} catch (cause) {
|
||||
console.error(cause);
|
||||
error.value = errorText(cause);
|
||||
alert(`실패했습니다: ${error.value}`);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const goBack = (): void => {
|
||||
if (window.history.length > 1) {
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
void router.push(hasGeneral.value ? '/' : '/join');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
timer = window.setInterval(() => {
|
||||
now.value = Date.now();
|
||||
}, 1_000);
|
||||
void loadPage();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer !== null) {
|
||||
window.clearInterval(timer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="select-pool-page legacy-bg0">
|
||||
<header class="page-title with-border">
|
||||
장 수 선 택<br />
|
||||
<button class="legacy-button" type="button" @click="goBack">돌아가기</button>
|
||||
</header>
|
||||
|
||||
<table v-if="serverInfo" class="server-info-table legacy-bg0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
현재 : {{ serverInfo.currentYear }}年 {{ serverInfo.currentMonth }}月
|
||||
(<span class="cyan">{{ serverInfo.tickMinutes }}분 턴</span> 서버)<br />
|
||||
등록 장수 : 유저 {{ serverInfo.userGeneralCount }} / {{ serverInfo.maxGeneral }} 명 +
|
||||
<span class="cyan">NPC {{ serverInfo.npcGeneralCount }} 명</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table class="invitation-table legacy-bg0">
|
||||
<thead>
|
||||
<tr>
|
||||
<td colspan="2" class="legacy-bg1">임관 권유 메시지</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="nation in nations"
|
||||
:key="nation.id"
|
||||
:style="{
|
||||
color: nationTextColor(nation.color),
|
||||
backgroundColor: nation.color,
|
||||
}"
|
||||
>
|
||||
<td class="invitation-nation">{{ nation.name }}</td>
|
||||
<td><div class="invitation-message">{{ nation.scoutMessage ?? '-' }}</div></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<section class="selection-section">
|
||||
<h1 class="section-title legacy-bg1 with-border">장수 선택</h1>
|
||||
<div class="selection-body with-border">
|
||||
<div v-if="loading">불러오는 중...</div>
|
||||
<div v-else-if="error" class="error-text">{{ error }}</div>
|
||||
<template v-else-if="reservation">
|
||||
<small v-if="!expired">
|
||||
(<span :style="{ color: validUntilColor }">{{ formatDateTime(reservation.validUntil) }}</span
|
||||
>까지 유효)
|
||||
</small>
|
||||
<small v-else class="expired-text">- 만료 -</small>
|
||||
<br />
|
||||
<div class="card-holder">
|
||||
<article
|
||||
v-for="candidate in candidates"
|
||||
:key="candidate.uniqueName"
|
||||
class="general-card"
|
||||
>
|
||||
<h4 class="legacy-bg1 with-border">{{ candidate.generalName }}</h4>
|
||||
<h4 class="portrait">
|
||||
<img
|
||||
:src="imageUrl(candidate)"
|
||||
:alt="candidate.generalName"
|
||||
width="64"
|
||||
height="64"
|
||||
@error="useFallbackImage"
|
||||
/>
|
||||
</h4>
|
||||
<p>
|
||||
{{ candidate.leadership }} / {{ candidate.strength }} / {{ candidate.intel }}<br />
|
||||
<span v-if="candidate.ego" class="trait-tooltip" tabindex="0">
|
||||
{{ personalityName(candidate.ego) }}
|
||||
<span role="tooltip">{{ personalityInfo(candidate.ego) }}</span>
|
||||
</span>
|
||||
<br v-if="candidate.ego" />
|
||||
<span class="trait-tooltip" tabindex="0">
|
||||
{{ candidate.specialDomesticName }}
|
||||
<span role="tooltip">{{ candidate.specialDomesticInfo }}</span>
|
||||
</span>
|
||||
/
|
||||
<span>{{ candidate.specialWar ?? '-' }}</span
|
||||
><br /><br />
|
||||
보병: {{ Math.trunc(candidate.dex[0] / 1000) }}K<br />
|
||||
궁병: {{ Math.trunc(candidate.dex[1] / 1000) }}K<br />
|
||||
기병: {{ Math.trunc(candidate.dex[2] / 1000) }}K<br />
|
||||
귀병: {{ Math.trunc(candidate.dex[3] / 1000) }}K<br />
|
||||
차병: {{ Math.trunc(candidate.dex[4] / 1000) }}K<br />
|
||||
</p>
|
||||
<button
|
||||
class="select-button with-border"
|
||||
type="button"
|
||||
:disabled="submitting"
|
||||
@click="selectCandidate(candidate)"
|
||||
>
|
||||
선택하기
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="reservation && !hasGeneral" class="create-section">
|
||||
<h1 class="section-title legacy-bg1 with-border">장수 생성</h1>
|
||||
<div class="create-body with-border">
|
||||
<div id="left-pad">
|
||||
<article v-if="selectedCandidate" class="general-card selected-card">
|
||||
<h4 class="legacy-bg1 with-border">{{ selectedCandidate.generalName }}</h4>
|
||||
<h4 class="portrait">
|
||||
<img
|
||||
:src="imageUrl(selectedCandidate)"
|
||||
:alt="selectedCandidate.generalName"
|
||||
width="64"
|
||||
height="64"
|
||||
@error="useFallbackImage"
|
||||
/>
|
||||
</h4>
|
||||
<p>
|
||||
{{ selectedCandidate.leadership }} / {{ selectedCandidate.strength }} /
|
||||
{{ selectedCandidate.intel }}<br />
|
||||
<span v-if="selectedCandidate.ego" class="trait-tooltip" tabindex="0">
|
||||
{{ personalityName(selectedCandidate.ego) }}
|
||||
<span role="tooltip">{{ personalityInfo(selectedCandidate.ego) }}</span>
|
||||
</span>
|
||||
<br v-if="selectedCandidate.ego" />
|
||||
<span class="trait-tooltip" tabindex="0">
|
||||
{{ selectedCandidate.specialDomesticName }}
|
||||
<span role="tooltip">{{ selectedCandidate.specialDomesticInfo }}</span>
|
||||
</span>
|
||||
/
|
||||
<span>{{ selectedCandidate.specialWar ?? '-' }}</span
|
||||
><br /><br />
|
||||
보병: {{ Math.trunc(selectedCandidate.dex[0] / 1000) }}K<br />
|
||||
궁병: {{ Math.trunc(selectedCandidate.dex[1] / 1000) }}K<br />
|
||||
기병: {{ Math.trunc(selectedCandidate.dex[2] / 1000) }}K<br />
|
||||
귀병: {{ Math.trunc(selectedCandidate.dex[3] / 1000) }}K<br />
|
||||
차병: {{ Math.trunc(selectedCandidate.dex[4] / 1000) }}K<br />
|
||||
</p>
|
||||
<button class="select-button with-border" type="button">선택하기</button>
|
||||
</article>
|
||||
<template v-else>장수를<br />선택해주세요!</template>
|
||||
</div>
|
||||
<form class="custom-form" @submit.prevent="createGeneral">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr v-if="allowPersonality">
|
||||
<th class="legacy-bg1">성격</th>
|
||||
<td>
|
||||
<select v-model="personality">
|
||||
<option value="Random">????</option>
|
||||
<option
|
||||
v-for="entry in personalities.filter((item) => item.key !== 'Random')"
|
||||
:key="entry.key"
|
||||
:value="entry.key"
|
||||
>
|
||||
{{ entry.name }}
|
||||
</option>
|
||||
</select>
|
||||
<span>
|
||||
{{ personalities.find((entry) => entry.key === personality)?.info ?? '' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" class="join-guidance">
|
||||
임의의 도시에서 재야로 시작하며 건국과 임관은 게임 내에서 실행합니다.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="create-action">
|
||||
<button
|
||||
id="build-general"
|
||||
class="legacy-button"
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
>
|
||||
장수생성
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
class="legacy-button"
|
||||
type="reset"
|
||||
:disabled="submitting"
|
||||
@click="personality = 'Random'"
|
||||
>
|
||||
다시입력
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="page-footer">
|
||||
<div class="footer-back with-border">
|
||||
<button class="legacy-button" type="button" @click="goBack">돌아가기</button>
|
||||
</div>
|
||||
<div class="footer-banner with-border">
|
||||
<small>
|
||||
삼국지 모의전투 HiDCHe core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
||||
HideD /
|
||||
<a
|
||||
href="https://sam.hided.net/wiki/hidche/credit"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>Credit</a
|
||||
>
|
||||
</small>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css');
|
||||
|
||||
.select-pool-page {
|
||||
width: 1000px;
|
||||
min-width: 1000px;
|
||||
margin: 8px auto 0;
|
||||
color: #fff;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
overflow: visible;
|
||||
}
|
||||
.with-border {
|
||||
border: solid 1px;
|
||||
border-top-color: gray;
|
||||
border-left-color: gray;
|
||||
border-right-color: #000;
|
||||
border-bottom-color: #000;
|
||||
}
|
||||
.page-title {
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
.page-title .legacy-button,
|
||||
.footer-back .legacy-button {
|
||||
padding: 1px 6px;
|
||||
font-weight: 400;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.server-info-table,
|
||||
.invitation-table {
|
||||
border: 1px solid;
|
||||
border-collapse: collapse;
|
||||
border-top-color: gray;
|
||||
border-left-color: gray;
|
||||
border-right-color: #000;
|
||||
border-bottom-color: #000;
|
||||
border-spacing: 0;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
}
|
||||
.server-info-table {
|
||||
width: 100%;
|
||||
}
|
||||
.server-info-table td,
|
||||
.invitation-table td {
|
||||
border: 1px solid gray;
|
||||
padding: 0;
|
||||
}
|
||||
.server-info-table td {
|
||||
padding: 1px 0;
|
||||
}
|
||||
.invitation-table {
|
||||
margin: 0 auto;
|
||||
}
|
||||
.invitation-nation {
|
||||
width: 130px;
|
||||
}
|
||||
.invitation-message {
|
||||
width: 870px;
|
||||
max-width: 870px;
|
||||
max-height: 200px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cyan {
|
||||
color: cyan;
|
||||
}
|
||||
.selection-section,
|
||||
.create-section {
|
||||
margin: 0;
|
||||
}
|
||||
.section-title {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 18.2px;
|
||||
}
|
||||
.selection-body {
|
||||
padding: 0;
|
||||
}
|
||||
.card-holder {
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
}
|
||||
.general-card {
|
||||
width: 125px;
|
||||
display: inline-block;
|
||||
box-sizing: content-box;
|
||||
border: solid 1px;
|
||||
border-top-color: gray;
|
||||
border-left-color: gray;
|
||||
border-right-color: #000;
|
||||
border-bottom-color: #000;
|
||||
vertical-align: top;
|
||||
}
|
||||
.general-card h4,
|
||||
.general-card p {
|
||||
margin: 0;
|
||||
}
|
||||
.general-card h4 {
|
||||
padding: 0;
|
||||
}
|
||||
.portrait {
|
||||
text-align: center;
|
||||
}
|
||||
.portrait img {
|
||||
display: inline;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
vertical-align: baseline;
|
||||
object-fit: fill;
|
||||
}
|
||||
.select-button {
|
||||
width: 100%;
|
||||
height: 19px;
|
||||
padding: 0 4px;
|
||||
border-radius: 0;
|
||||
background: #191919;
|
||||
color: #fff;
|
||||
line-height: normal;
|
||||
}
|
||||
.expired-text,
|
||||
.error-text {
|
||||
color: red;
|
||||
}
|
||||
.create-section {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.create-body {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
}
|
||||
#left-pad {
|
||||
flex: 1;
|
||||
padding-top: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.selected-card .select-button {
|
||||
display: none;
|
||||
}
|
||||
.custom-form {
|
||||
flex: 4;
|
||||
}
|
||||
.custom-form table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.custom-form th,
|
||||
.custom-form td {
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
.custom-form th {
|
||||
width: 200px;
|
||||
text-align: right;
|
||||
}
|
||||
.custom-form select {
|
||||
color: #fff;
|
||||
background: #000;
|
||||
}
|
||||
.custom-form .legacy-button {
|
||||
padding: 3px 6px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.join-guidance {
|
||||
text-align: center;
|
||||
}
|
||||
.create-action {
|
||||
width: 200px;
|
||||
text-align: right;
|
||||
}
|
||||
.footer-back,
|
||||
.footer-banner {
|
||||
text-align: left;
|
||||
}
|
||||
.footer-banner a {
|
||||
color: #fff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.select-button:disabled {
|
||||
background: #333;
|
||||
}
|
||||
.select-button:focus-visible,
|
||||
.custom-form select:focus-visible,
|
||||
.trait-tooltip:focus-visible {
|
||||
outline: auto 1px;
|
||||
outline-offset: 0;
|
||||
}
|
||||
.trait-tooltip {
|
||||
position: relative;
|
||||
cursor: help;
|
||||
}
|
||||
.trait-tooltip [role='tooltip'] {
|
||||
display: none;
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
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;
|
||||
white-space: normal;
|
||||
word-break: keep-all;
|
||||
}
|
||||
.trait-tooltip:hover [role='tooltip'],
|
||||
.trait-tooltip:focus [role='tooltip'] {
|
||||
display: block;
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.select-pool-page {
|
||||
margin-left: 8px;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user