merge: 최신 main을 인게임 폰트 통일에 반영

This commit is contained in:
2026-08-17 10:53:27 +00:00
29 changed files with 1009 additions and 150 deletions
@@ -48,26 +48,6 @@
cursor: pointer;
}
/*
* Ref renders the dashboard reload control with the Lumen navigation family:
* the bottom edge shortens on hover and again while pressed.
*/
.game-shell__action--navigation {
border-color: var(--sammo-button-navigation-border);
border-width: 0 1px 4px;
background: var(--sammo-button-navigation-bg);
}
.game-shell__action--navigation:not(:disabled):hover {
margin-top: 1px;
border-bottom-width: 3px;
}
.game-shell__action--navigation:not(:disabled):active {
margin-top: 2px;
border-bottom-width: 2px;
}
.game-feedback--error {
color: var(--sammo-color-error);
font-size: 0.85rem;
@@ -56,7 +56,6 @@
--legacy-button-bg: var(--sammo-button-primary-bg);
--legacy-button-border: var(--sammo-button-primary-border);
--legacy-button-color: #fff;
min-height: 35.5px;
margin-top: 0;
border-color: var(--legacy-button-border);
border-style: solid;
@@ -65,6 +64,7 @@
padding: 5.25px 10.5px;
background: var(--legacy-button-bg);
color: var(--legacy-button-color);
filter: none;
line-height: 21px;
/* Ref's framework baseline for these controls. */
vertical-align: middle;
@@ -21,6 +21,7 @@ import type {
CommandTable,
ReservedCommandRow,
} from './types';
import { formatReservedCommandBrief } from './reservedCommandBrief';
const props = withDefaults(
defineProps<{
@@ -37,6 +38,7 @@ const props = withDefaults(
currentTime?: string;
mapData?: CommandMapData | null;
mapLayout?: CommandMapLayout | null;
autonomousUntil?: string | null;
}>(),
{
maxPushTurn: 6,
@@ -47,6 +49,7 @@ const props = withDefaults(
currentTime: '--:--:--',
mapData: null,
mapLayout: null,
autonomousUntil: null,
}
);
@@ -70,6 +73,7 @@ const commandArgsValid = ref(false);
const expanded = ref(false);
const menuRevision = ref(0);
const pendingReservation = ref<CommandPatternEntry | null>(null);
const editorElement = ref<HTMLElement | null>(null);
const pickerElement = ref<HTMLElement | null>(null);
const collapsedRowCount = 15;
@@ -133,12 +137,16 @@ const labelMap = computed(() => {
const displayRows = computed(() =>
props.rows.slice(0, expanded.value || props.compact ? props.rows.length : collapsedRowCount)
);
const quickPickerTop = computed(() => `${70 + (quickTarget.value ?? 0) * 34.4}px`);
const quickPickerTop = ref('38px');
const isRecruitmentCommand = computed(
() => selectedCommand.value?.key === 'che_징병' || selectedCommand.value?.key === 'che_모병'
);
const isRecruitmentOverlayOpen = computed(() => pickerOpen.value && isRecruitmentCommand.value);
const rowLabel = (row: ReservedCommandRow): string => row.label ?? labelMap.value.get(row.action) ?? row.action;
const rowLabel = (row: ReservedCommandRow): string =>
formatReservedCommandBrief(props.scope, row.action, row.args, props.commandTable) ||
row.label ||
labelMap.value.get(row.action) ||
row.action;
const selectedIndices = () => normalizedSelection(selected.value, previousSelected.value, props.rows.length);
const pattern = () => extractPattern(props.rows, selectedIndices());
const touchMenus = () => (menuRevision.value += 1);
@@ -163,6 +171,19 @@ const finishDrag = (next: Set<number>) => {
};
const openPicker = (turnIndex?: number) => {
if (!props.compact && editorElement.value) {
const editorRect = editorElement.value.getBoundingClientRect();
const anchor =
turnIndex === undefined
? editorElement.value.querySelector<HTMLElement>('.control-pad')
: editorElement.value.querySelector<HTMLElement>(`[data-turn-index="${turnIndex}"]`);
if (anchor) {
const anchorRect = anchor.getBoundingClientRect();
quickPickerTop.value = `${
turnIndex === undefined ? anchorRect.bottom - editorRect.top : anchorRect.top - editorRect.top + 30
}px`;
}
}
quickTarget.value = turnIndex ?? null;
pickerOpen.value = true;
selectedCommand.value = null;
@@ -281,7 +302,7 @@ const rearrange = (direction: 'pull' | 'push') => {
const textCopy = async () => {
const lines = selectedIndices().map((index) => {
const row = props.rows[index];
return `${index + 1}${row?.label ?? labelMap.value.get(row?.action ?? '') ?? row?.action ?? ''}`;
return `${index + 1}${row ? rowLabel(row) : ''}`;
});
await navigator.clipboard.writeText(lines.join('\n'));
releaseSelection();
@@ -310,6 +331,7 @@ const clickOutsideMenu = (event: Event) => {
<template>
<article
ref="editorElement"
class="reserved-command-editor"
:class="{
compact: props.compact,
@@ -326,6 +348,10 @@ const clickOutsideMenu = (event: Event) => {
<span>{{ props.title }} :</span><strong>{{ props.name ?? '-' }}</strong>
</header>
<div v-if="props.autonomousUntil" class="autorun-status" data-command-autorun-status role="status">
자율 행동: {{ props.autonomousUntil }}
</div>
<div class="editor-layout">
<aside class="control-pad">
<div v-if="props.mobile && props.compact" class="mobile-identity legacy-bg1">
@@ -614,7 +640,11 @@ const clickOutsideMenu = (event: Event) => {
<div
v-for="row in displayRows"
:key="row.index"
:title="row.autonomous ? `${rowLabel(row)} · 자율 행동` : rowLabel(row)"
:title="
row.autonomous
? `${rowLabel(row)} · 자율 행동${props.autonomousUntil ? ` (${props.autonomousUntil})` : ''}`
: rowLabel(row)
"
:class="{ autonomous: row.autonomous }"
>
<span>{{ rowLabel(row) }}</span>
@@ -635,9 +665,15 @@ const clickOutsideMenu = (event: Event) => {
</div>
<div v-if="!props.compact" class="bottom-actions">
<button type="button" @click="emit('shift', -1)">당기기</button>
<button type="button" @click="emit('shift', 1)">미루기</button>
<button type="button" @click="expanded = !expanded">{{ expanded ? '접기' : '펼치기' }}</button>
<button class="legacy-button legacy-button--secondary" type="button" @click="emit('shift', -1)">
당기기
</button>
<button class="legacy-button legacy-button--secondary" type="button" @click="emit('shift', 1)">
미루기
</button>
<button class="legacy-button legacy-button--secondary" type="button" @click="expanded = !expanded">
{{ expanded ? '접기' : '펼치기' }}
</button>
</div>
</div>
</div>
@@ -649,7 +685,7 @@ const clickOutsideMenu = (event: Event) => {
class="command-picker"
:class="{ 'recruitment-picker': isRecruitmentCommand }"
data-testid="command-picker"
:style="isRecruitmentCommand || quickTarget === null || props.compact ? undefined : { top: quickPickerTop }"
:style="isRecruitmentCommand || props.compact ? undefined : { top: quickPickerTop }"
:role="isRecruitmentCommand ? 'dialog' : undefined"
:aria-modal="isRecruitmentCommand ? 'true' : undefined"
:aria-label="
@@ -760,7 +796,6 @@ const clickOutsideMenu = (event: Event) => {
.control-pad > button,
.clock,
.legacy-menu > summary,
.bottom-actions button,
.select-command {
box-sizing: border-box;
min-height: 34px;
@@ -850,6 +885,15 @@ const clickOutsideMenu = (event: Event) => {
display: grid;
grid-template-columns: 75px 40px minmax(0, 1fr) 38px;
}
.autorun-status {
padding: 3px 6px;
border-bottom: 1px solid #2d6574;
background: #102c35;
color: #aaffff;
font-size: 0.78rem;
line-height: 1.35;
text-align: center;
}
.queue-grid.advanced {
grid-template-columns: 34px 75px 40px minmax(0, 1fr);
}
@@ -1149,5 +1193,4 @@ const clickOutsideMenu = (event: Event) => {
bottom: 0;
left: 109px;
}
</style>
@@ -0,0 +1,185 @@
import { JosaUtil } from '@sammo-ts/common';
import type { CommandOption, CommandTable } from './types';
type CommandScope = 'general' | 'nation';
type CommandArgs = Record<string, unknown>;
const CITY_TO_COMMANDS = new Set(['che_이동', 'che_강행', 'che_출병', 'che_천도']);
const CITY_AT_COMMANDS = new Set([
'che_첩보',
'che_화계',
'che_선동',
'che_탈취',
'che_파괴',
'che_백성동원',
'che_허보',
]);
const CITY_OBJECT_COMMANDS = new Set(['che_수몰', 'che_초토화']);
const NATION_AT_COMMANDS = new Set(['che_선전포고', 'che_급습', 'che_이호경식']);
const NATION_PROPOSAL_COMMANDS = new Set(['che_불가침파기제의', 'che_종전제의']);
const ITEM_TYPE_NAMES: Record<string, string> = {
horse: '명마',
weapon: '무기',
book: '서적',
item: '도구',
};
const asArgs = (value: unknown): CommandArgs =>
value && typeof value === 'object' && !Array.isArray(value) ? (value as CommandArgs) : {};
const firstValue = (args: CommandArgs, keys: readonly string[]): unknown => {
for (const key of keys) {
if (args[key] !== undefined) return args[key];
}
return undefined;
};
const optionLabel = (options: readonly CommandOption[], value: unknown): string | null => {
const option = options.find((entry) => entry.value === value || String(entry.value) === String(value));
if (!option) return null;
// 도시·장수 option에는 선택 보조용 소속 정보가 괄호로 붙지만 Ref brief에는 이름만 들어간다.
return option.label.replace(/\s+\([^)]*\)$/u, '');
};
const commandNameMap = (table: CommandTable | null, scope: CommandScope): Map<string, string> => {
const result = new Map<string, string>([['휴식', '휴식']]);
for (const group of table?.[scope] ?? []) {
for (const command of group.values) result.set(command.key, command.name);
}
return result;
};
const defaultCommandName = (action: string): string => action.replace(/^(?:che_|cr_|event_)/u, '');
const numberText = (value: unknown, grouped = false): string => {
if (typeof value !== 'number' || !Number.isFinite(value)) return String(value ?? '');
return grouped ? value.toLocaleString('en-US') : String(value);
};
const wrap = (value: string): string => `${value}`;
const withParticle = (value: string, particle: '을' | '으로'): string => `${value}${JosaUtil.pick(value, particle)}`;
const wrappedWithParticle = (value: string, particle: '을' | '으로'): string =>
`${wrap(value)}${JosaUtil.pick(value, particle)}`;
export const formatReservedCommandBrief = (
scope: CommandScope,
action: string,
rawArgs: unknown,
table: CommandTable | null
): string => {
const args = asArgs(rawArgs);
const input = table?.inputOptions;
const commandNames = commandNameMap(table, scope);
const commandName = commandNames.get(action) ?? defaultCommandName(action);
const cityName = optionLabel(input?.cities ?? [], firstValue(args, ['destCityId', 'destCityID']));
const nationName = optionLabel(input?.nations ?? [], firstValue(args, ['destNationId', 'destNationID']));
const generalName = optionLabel(input?.generals ?? [], firstValue(args, ['destGeneralId', 'destGeneralID']));
if (CITY_TO_COMMANDS.has(action) && cityName) {
return `${wrappedWithParticle(cityName, '으로')} ${commandName}`;
}
if (action === 'cr_인구이동' && cityName) {
return `${wrappedWithParticle(cityName, '으로')} ${numberText(args.amount, true)}${commandName}`;
}
if (CITY_AT_COMMANDS.has(action) && cityName) {
const suffix =
scope === 'nation' ? commandName : action === 'che_첩보' ? `${commandName} 실행` : `${commandName}실행`;
return `${wrap(cityName)}${suffix}`;
}
if (CITY_OBJECT_COMMANDS.has(action) && cityName) {
return `${wrappedWithParticle(cityName, '을')} ${commandName}`;
}
if (action === 'che_임관' && nationName) {
return `${wrappedWithParticle(nationName, '으로')} ${commandName}`;
}
if (NATION_AT_COMMANDS.has(action) && nationName) {
return `${wrap(nationName)}${commandName}`;
}
if (NATION_PROPOSAL_COMMANDS.has(action) && nationName) {
return `${wrap(nationName)}에게 ${commandName}`;
}
if (action === 'che_불가침제의' && nationName) {
return `${wrap(nationName)}에게 ${numberText(args.year)}${numberText(args.month)}월까지 ${commandName}`;
}
if (action === 'che_등용' && generalName) {
return `${wrappedWithParticle(generalName, '을')} ${commandName}`;
}
if (action === 'che_장수대상임관' && generalName) {
return `${wrappedWithParticle(generalName, '을')} 따라 임관`;
}
if (action === 'che_선양' && generalName) {
return `${wrap(generalName)}에게 ${commandName}`;
}
if (action === 'che_랜덤임관') {
return '무작위 국가로 임관';
}
if ((action === 'che_건국' || action === 'cr_건국') && typeof args.nationName === 'string') {
return `${wrappedWithParticle(args.nationName, '을')} 건국`;
}
if (action === 'che_무작위건국' && typeof args.nationName === 'string') {
return `${wrappedWithParticle(args.nationName, '을')} 무작위 도시에 건국`;
}
if (action === 'che_군량매매') {
return `군량 ${numberText(args.amount)}${args.buyRice ? '구입' : '판매'}`;
}
if (action === 'che_헌납') {
return `${args.isGold ? '금' : '쌀'} ${numberText(args.amount)}${commandName}`;
}
if (action === 'che_증여' && generalName) {
return `${wrap(generalName)}에게 ${args.isGold ? '금' : '쌀'} ${numberText(args.amount)}${commandName}`;
}
if (action === 'che_징병' || action === 'che_모병') {
const crewType = optionLabel(input?.crewTypes ?? [], args.crewType);
if (crewType) return `${wrap(crewType)} ${numberText(args.amount)}${commandName}`;
}
if (action === 'che_숙련전환') {
const srcArmType = optionLabel(input?.armTypes ?? [], args.srcArmType);
const destArmType = optionLabel(input?.armTypes ?? [], args.destArmType);
if (srcArmType && destArmType) return `${wrap(srcArmType)}숙련을 ${wrap(destArmType)}숙련으로 전환`;
}
if (action === 'che_장비매매') {
const itemType = typeof args.itemType === 'string' ? args.itemType : '';
if (args.itemCode === 'None') {
const itemTypeName = ITEM_TYPE_NAMES[itemType];
if (itemTypeName) return `${withParticle(itemTypeName, '을')} 판매`;
}
const itemName = optionLabel(input?.items[itemType] ?? [], args.itemCode);
if (itemName) {
const itemRawName = itemName.replace(/\([^)]*\)$/u, '');
return `${wrap(itemName)}${JosaUtil.pick(itemRawName, '을')} 구입`;
}
}
if (action === 'che_발령' && generalName && cityName) {
return `${wrap(generalName)}${wrappedWithParticle(cityName, '으로')} ${commandName}`;
}
if (action === 'che_부대탈퇴지시' && generalName) {
return `${wrap(generalName)}${commandName}`;
}
if ((action === 'che_포상' || action === 'che_몰수') && generalName) {
return `${wrap(generalName)} ${args.isGold ? '금' : '쌀'} ${numberText(args.amount, true)} ${commandName}`;
}
if (action === 'che_물자원조' && nationName && Array.isArray(args.amountList)) {
return `${wrap(nationName)}에게 국고 ${numberText(args.amountList[0], true)} 병량 ${numberText(
args.amountList[1],
true
)} ${commandName}`;
}
if (action === 'che_증축' || action === 'che_감축') {
return `수도를 ${commandName}`;
}
if (action === 'che_국기변경') {
return '【국기】를 변경';
}
if (action === 'che_국호변경' && typeof args.nationName === 'string') {
return `국호를 ${wrappedWithParticle(args.nationName, '으로')} 변경`;
}
if (action === 'che_피장파장' && nationName) {
const targetAction = typeof args.commandType === 'string' ? args.commandType : '';
const targetName = commandNames.get(targetAction) ?? defaultCommandName(targetAction);
return `${wrap(nationName)}${wrap(targetName)} ${commandName}`;
}
return commandName;
};
@@ -2,7 +2,7 @@
import { computed, onUnmounted, ref, watch } from 'vue';
import { addMinutes } from 'date-fns';
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
import { formatLocalTimeSeconds } from '../../utils/legacyDateTime';
import { formatLocalDateTime, formatLocalTimeSeconds } from '../../utils/legacyDateTime';
import type {
CommandMapData,
CommandMapLayout,
@@ -65,6 +65,26 @@ const rows = computed<ReservedCommandRow[]>(() => {
});
});
const autonomousUntil = computed(() => {
if (props.autorunLimit == null) return null;
const baseYear = props.currentYear ?? 0;
const baseMonth = props.currentMonth ?? 1;
const currentAbsoluteMonth = baseYear * 12 + baseMonth - 1;
const lastAutonomousMonth = props.autorunLimit - 1;
if (lastAutonomousMonth < currentAbsoluteMonth) return null;
const untilYear = Math.floor(lastAutonomousMonth / 12);
const untilMonth = (lastAutonomousMonth % 12) + 1;
const base = props.general?.turnTime ? new Date(props.general.turnTime) : null;
const term = props.turnTermMinutes ?? 0;
const expiresAt =
base && Number.isFinite(base.getTime())
? addMinutes(base, (lastAutonomousMonth - currentAbsoluteMonth) * term)
: null;
const currentTimeLabel = expiresAt ? formatLocalDateTime(expiresAt) : '현재시각 확인 불가';
return `${untilYear}${untilMonth}月 · ${currentTimeLabel}까지`;
});
const currentServerTime = ref('--:--:--');
let sampledServerTimeMs: number | null = null;
let sampledClientTimeMs = 0;
@@ -112,6 +132,7 @@ onUnmounted(() => {
:current-time="currentServerTime"
:map-data="props.mapData"
:map-layout="props.mapLayout"
:autonomous-until="autonomousUntil"
@reserve-bulk="emit('set-general-turns', $event)"
@shift="emit('shift-general-turns', $event)"
@repeat="emit('repeat-general-turns', $event)"
@@ -26,10 +26,11 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
:link="entry"
:enabled="isNavigationConfigured(entry)"
:active="isActive(entry)"
lumen-variant="navigation"
/>
<div v-else-if="entry.kind === 'group'" class="main-menu-popup">
<button
class="main-menu-button"
class="main-menu-button legacy-button legacy-button--navigation"
type="button"
:data-menu-id="entry.id"
:aria-expanded="openId === entry.id"
@@ -63,9 +64,10 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
:link="entry.main"
:enabled="isNavigationConfigured(entry.main)"
:active="isActive(entry.main)"
lumen-variant="navigation"
/>
<button
class="main-menu-button main-menu-split__toggle"
class="main-menu-button main-menu-split__toggle legacy-button legacy-button--navigation"
type="button"
:data-menu-id="entry.id"
:aria-label="`${entry.main.label} 하위 메뉴`"
@@ -115,33 +117,6 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
min-width: 0;
}
.main-global-menu > :deep(.main-menu-link),
.main-menu-popup > .main-menu-button,
.main-menu-split > :deep(.main-menu-link),
.main-menu-split > .main-menu-split__toggle {
border-color: var(--sammo-button-navigation-border);
background-color: var(--sammo-button-navigation-bg);
background-image: none;
}
.main-global-menu > :deep(.main-menu-link:hover),
.main-global-menu > :deep(.main-menu-link:focus-visible),
.main-global-menu > :deep(.main-menu-link:active),
.main-menu-popup > .main-menu-button:hover,
.main-menu-popup > .main-menu-button:focus-visible,
.main-menu-popup > .main-menu-button:active,
.main-menu-popup > .main-menu-button[aria-expanded='true'],
.main-menu-split > :deep(.main-menu-link:hover),
.main-menu-split > :deep(.main-menu-link:focus-visible),
.main-menu-split > :deep(.main-menu-link:active),
.main-menu-split > .main-menu-split__toggle:hover,
.main-menu-split > .main-menu-split__toggle:focus-visible,
.main-menu-split > .main-menu-split__toggle:active,
.main-menu-split > .main-menu-split__toggle[aria-expanded='true'] {
border-color: var(--sammo-button-navigation-border);
background-color: var(--sammo-button-navigation-bg);
}
.main-menu-popup > .main-menu-button,
.main-menu-split > :deep(.main-menu-link) {
width: 100%;
@@ -157,7 +132,11 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
width: 28px;
padding: 0;
border-left-width: 0;
border-radius: 0 3px 3px 0;
border-radius: 0 5.25px 5.25px 0;
}
.main-menu-split > :deep(.main-menu-link) {
border-radius: 5.25px 0 0 5.25px;
}
.menu-caret {
@@ -33,15 +33,17 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
:link="entry"
:enabled="isNationNavigationEnabled(entry, access)"
:active="isActive(entry)"
lumen-variant="lumen"
/>
<div v-else-if="entry.kind === 'split'" class="nation-menu-split">
<MainNavigationLink
:link="entry.main"
:enabled="isNationNavigationEnabled(entry.main, access)"
:active="isActive(entry.main)"
lumen-variant="lumen"
/>
<button
class="main-menu-button nation-menu-split__toggle"
class="main-menu-button nation-menu-split__toggle legacy-button legacy-button--lumen"
type="button"
:data-menu-id="entry.id"
:aria-label="`${entry.main.label} 하위 메뉴`"
@@ -81,13 +83,14 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
.main-nation-menu :deep(.main-menu-link),
.main-nation-menu .main-menu-button {
border-color: color-mix(in srgb, var(--nation-menu-color) 85%, #000);
background-color: var(--nation-menu-color);
--legacy-button-bg: var(--nation-menu-color);
--legacy-button-border: color-mix(in srgb, var(--nation-menu-color) 90%, #000);
--legacy-button-color: #fff;
background-image: none;
}
.main-nation-menu.dark-label :deep(.main-menu-link),
.main-nation-menu.dark-label .main-menu-button {
color: #000;
--legacy-button-color: #000;
}
.nation-menu-split {
@@ -106,7 +109,11 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
width: 28px;
padding: 0;
border-left-width: 0;
border-radius: 0 3px 3px 0;
border-radius: 0 5.25px 5.25px 0;
}
.nation-menu-split > :deep(.main-menu-link) {
border-radius: 5.25px 0 0 5.25px;
}
.menu-caret {
@@ -8,11 +8,13 @@ const props = withDefaults(
enabled?: boolean;
compact?: boolean;
active?: boolean;
lumenVariant?: 'navigation' | 'lumen';
}>(),
{
enabled: true,
compact: false,
active: false,
lumenVariant: undefined,
}
);
@@ -22,13 +24,16 @@ const emit = defineEmits<{
const label = computed(() => (props.compact ? (props.link.compactLabel ?? props.link.label) : props.link.label));
const rel = computed(() => (props.link.newTab ? 'noopener noreferrer' : undefined));
const lumenClasses = computed(() =>
props.lumenVariant ? ['legacy-button', `legacy-button--${props.lumenVariant}`] : []
);
</script>
<template>
<RouterLink
v-if="enabled && link.to"
class="main-menu-link"
:class="{ highlight: active }"
:class="[lumenClasses, { highlight: active }]"
:to="link.to"
:target="link.newTab ? '_blank' : undefined"
:rel="rel"
@@ -40,7 +45,7 @@ const rel = computed(() => (props.link.newTab ? 'noopener noreferrer' : undefine
<a
v-else-if="enabled && link.href"
class="main-menu-link"
:class="{ highlight: active }"
:class="[lumenClasses, { highlight: active }]"
:href="link.href"
:target="link.newTab ? '_blank' : undefined"
:rel="rel"
@@ -52,6 +57,7 @@ const rel = computed(() => (props.link.newTab ? 'noopener noreferrer' : undefine
<span
v-else
class="main-menu-link disabled"
:class="lumenClasses"
role="link"
aria-disabled="true"
:title="link.unavailableReason"
@@ -15,3 +15,12 @@ export const formatLocalTimeSeconds = (value: string | Date): string => {
.map((part) => String(part).padStart(2, '0'))
.join(':');
};
export const formatLocalDateTime = (value: string | Date): string => {
const parsed = value instanceof Date ? value : new Date(value);
if (!Number.isFinite(parsed.getTime())) return '-';
const date = [parsed.getFullYear(), parsed.getMonth() + 1, parsed.getDate()]
.map((part, index) => String(part).padStart(index === 0 ? 4 : 2, '0'))
.join('-');
return `${date} ${formatLocalTimeSeconds(parsed)}`;
};
@@ -256,6 +256,11 @@ const toExportedGeneral = (general: GeneralDraft): GeneralExport => ({
inheritBuff: { ...general.inheritBuff },
});
const resolveAvailableNationType = (candidate?: string | null): string => {
const available = options.value?.nationTypes ?? [];
return available.some((entry) => entry.key === candidate) ? (candidate as string) : (available[0]?.key ?? '');
};
const initializeDefaults = async () => {
loading.value = true;
error.value = null;
@@ -269,9 +274,9 @@ const initializeDefaults = async () => {
repeatCnt.value = 1;
seed.value = '';
const nationTypeDefault = context.nationTypes[0]?.key ?? 'che_중립';
attackerNation.type = me?.nation?.typeCode ?? nationTypeDefault;
defenderNation.type = me?.nation?.typeCode ?? nationTypeDefault;
const nationTypeDefault = resolveAvailableNationType(me?.nation?.typeCode);
attackerNation.type = nationTypeDefault;
defenderNation.type = nationTypeDefault;
attackerNation.level = me?.nation?.level ?? 0;
defenderNation.level = me?.nation?.level ?? 0;
@@ -304,10 +309,10 @@ const applyGameEnvironment = () => {
return;
}
const me = gameDefaults.value;
const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립';
const nationTypeDefault = resolveAvailableNationType(me?.nation?.typeCode);
year.value = options.value.world.currentYear;
month.value = options.value.world.currentMonth;
attackerNation.type = me?.nation?.typeCode ?? nationTypeDefault;
attackerNation.type = nationTypeDefault;
defenderNation.type = attackerNation.type;
attackerNation.level = me?.nation?.level ?? 0;
defenderNation.level = attackerNation.level;
@@ -326,7 +331,7 @@ const applyIndependentEnvironment = () => {
if (!options.value) {
return;
}
const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립';
const nationTypeDefault = resolveAvailableNationType();
year.value = options.value.world.startYear;
month.value = 1;
seed.value = '';
@@ -740,13 +745,13 @@ const importBattle = (data: BattleExport) => {
month.value = data.month;
repeatCnt.value = data.repeatCnt;
attackerNation.type = data.attackerNation.type;
attackerNation.type = resolveAvailableNationType(data.attackerNation.type);
attackerNation.level = data.attackerNation.level;
attackerNation.tech = Math.floor(data.attackerNation.tech / 1000);
attackerNation.isCapital = data.attackerNation.capital === 1;
attackerCity.level = data.attackerCity.level;
defenderNation.type = data.defenderNation.type;
defenderNation.type = resolveAvailableNationType(data.defenderNation.type);
defenderNation.level = data.defenderNation.level;
defenderNation.tech = Math.floor(data.defenderNation.tech / 1000);
defenderNation.isCapital = data.defenderNation.capital === 3;
+12 -31
View File
@@ -143,7 +143,7 @@ watch(
</h1>
<div class="game-shell__actions desktop-action-controls">
<button
class="game-shell__action toggle"
class="game-shell__action toggle legacy-button legacy-button--navigation"
:class="{ active: realtimeEnabled }"
type="button"
@click="dashboard.setRealtimeEnabled(!realtimeEnabled)"
@@ -151,7 +151,7 @@ watch(
실시간 동기화: {{ realtimeLabel }}
</button>
<button
class="game-shell__action game-shell__action--navigation"
class="game-shell__action legacy-button legacy-button--navigation"
type="button"
:disabled="refreshing"
:aria-busy="refreshing"
@@ -159,7 +159,13 @@ watch(
>
</button>
<button class="game-shell__action" type="button" @click="moveLobby">로비로</button>
<button
class="game-shell__action legacy-button legacy-button--navigation"
type="button"
@click="moveLobby"
>
로비로
</button>
</div>
</header>
@@ -475,10 +481,6 @@ button {
background-image: var(--sammo-texture-walnut);
}
.toggle.active {
background: rgba(201, 164, 90, 0.2);
}
.game-shell__action.highlight {
border-color: #f39c12;
background: #8a5b13;
@@ -750,29 +752,8 @@ button {
margin-top: 31px;
}
/*
* Ref renders these dashboard controls with the Lumen navigation family: no top
* border, 1px sides and a 4px bottom edge that shortens on hover and press
* while the control moves down.
*/
.desktop-action-controls .game-shell__action {
border-color: #004f28;
border-style: solid;
border-width: 0 1px 4px;
background: #006b36;
color: #fff;
}
.desktop-action-controls .game-shell__action:hover {
margin-top: 1px;
border-bottom-width: 3px;
background: #00582c;
}
.desktop-action-controls .game-shell__action:active {
margin-top: 2px;
border-bottom-width: 2px;
background: #005128;
font-weight: 400;
}
.placeholder {
@@ -793,8 +774,8 @@ button {
}
.desktop-action-controls .game-shell__action {
padding-right: 8px;
padding-left: 8px;
padding-right: 4px;
padding-left: 4px;
}
.main-page {