Merge remote-tracking branch 'origin/main' into feature/game-action-feedback-20260812

This commit is contained in:
2026-08-12 15:57:47 +00:00
20 changed files with 920 additions and 148 deletions
@@ -50,6 +50,7 @@ const commandArgsValid = ref(false);
const expanded = ref(false);
const menuRevision = ref(0);
const pendingReservation = ref<CommandPatternEntry | null>(null);
const collapsedRowCount = 15;
const loadStorage = (key: string) => {
storage.value = new CommandStorage(key);
@@ -108,7 +109,11 @@ const labelMap = computed(() => {
for (const group of groups) for (const command of group.values) map.set(command.key, command.name);
return map;
});
const displayRows = computed(() => props.rows.slice(0, expanded.value || props.compact ? props.rows.length : 14));
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 rowLabel = (row: ReservedCommandRow): string => 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);
@@ -396,6 +401,87 @@ const clickOutsideMenu = (event: Event) => {
</details>
</aside>
<div v-if="editMode" class="advanced-actions">
<details class="legacy-menu selected-menu">
<summary>선택한 턴을</summary>
<div class="menu-items">
<button
@click="
cut();
clickOutsideMenu($event);
"
>
잘라내기
</button>
<button
@click="
copy();
clickOutsideMenu($event);
"
>
복사하기
</button>
<button
@click="
paste();
clickOutsideMenu($event);
"
>
붙여넣기
</button>
<button
@click="
textCopy();
clickOutsideMenu($event);
"
>
텍스트 복사
</button>
<button
@click="
saveTemplate();
clickOutsideMenu($event);
"
>
보관하기
</button>
<button
@click="
repeatPattern();
clickOutsideMenu($event);
"
>
반복하기
</button>
<button
@click="
clearSelection();
clickOutsideMenu($event);
"
>
비우기
</button>
<button
@click="
rearrange('pull');
clickOutsideMenu($event);
"
>
지우고 당기기
</button>
<button
@click="
rearrange('push');
clickOutsideMenu($event);
"
>
뒤로 밀기
</button>
</div>
</details>
<button type="button" class="select-command" @click="openPicker()">명령 선택 </button>
</div>
<div class="queue-area">
<div class="queue-grid" :class="{ advanced: editMode }">
<DragSelect
@@ -443,9 +529,11 @@ const clickOutsideMenu = (event: Event) => {
<div
v-for="row in displayRows"
:key="row.index"
:title="row.label ?? labelMap.get(row.action) ?? row.action"
:title="row.autonomous ? `${rowLabel(row)} · 자율 행동` : rowLabel(row)"
:class="{ autonomous: row.autonomous }"
>
{{ row.label ?? labelMap.get(row.action) ?? row.action }}
<span>{{ rowLabel(row) }}</span>
<small v-if="row.autonomous && row.action === '휴식'">(자율 행동)</small>
</div>
</div>
<div v-if="!editMode" class="edit-column">
@@ -461,87 +549,6 @@ const clickOutsideMenu = (event: Event) => {
</div>
</div>
<div v-if="editMode" class="advanced-actions">
<details class="legacy-menu selected-menu">
<summary>선택한 턴을</summary>
<div class="menu-items">
<button
@click="
cut();
clickOutsideMenu($event);
"
>
잘라내기
</button>
<button
@click="
copy();
clickOutsideMenu($event);
"
>
복사하기
</button>
<button
@click="
paste();
clickOutsideMenu($event);
"
>
붙여넣기
</button>
<button
@click="
textCopy();
clickOutsideMenu($event);
"
>
텍스트 복사
</button>
<button
@click="
saveTemplate();
clickOutsideMenu($event);
"
>
보관하기
</button>
<button
@click="
repeatPattern();
clickOutsideMenu($event);
"
>
반복하기
</button>
<button
@click="
clearSelection();
clickOutsideMenu($event);
"
>
비우기
</button>
<button
@click="
rearrange('pull');
clickOutsideMenu($event);
"
>
지우고 당기기
</button>
<button
@click="
rearrange('push');
clickOutsideMenu($event);
"
>
뒤로 밀기
</button>
</div>
</details>
<button type="button" class="select-command" @click="openPicker()">명령 선택 </button>
</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>
@@ -550,7 +557,12 @@ const clickOutsideMenu = (event: Event) => {
</div>
</div>
<div v-if="pickerOpen" class="command-picker" data-testid="command-picker">
<div
v-if="pickerOpen"
class="command-picker"
data-testid="command-picker"
:style="quickTarget === null || props.compact ? undefined : { top: quickPickerTop }"
>
<header>
<strong>{{ quickTarget === null ? '선택한 턴' : `${quickTarget + 1}` }} 명령 입력</strong
><button type="button" aria-label="명령 입력 닫기" @click="closePicker">×</button>
@@ -628,6 +640,9 @@ const clickOutsideMenu = (event: Event) => {
gap: 4px;
padding: 3px 0;
}
.queue-area {
order: 2;
}
.control-pad > button,
.clock,
.legacy-menu > summary,
@@ -779,6 +794,13 @@ const clickOutsideMenu = (event: Event) => {
.action-column > div:nth-child(even) {
background: #071638;
}
.action-column > div.autonomous {
color: #aaffff;
}
.action-column small {
font-size: 0.72em;
line-height: 1;
}
.edit-column button {
background: #444;
cursor: pointer;
@@ -786,6 +808,7 @@ const clickOutsideMenu = (event: Event) => {
.advanced-actions {
display: grid;
grid-template-columns: 5fr 7fr;
order: 1;
}
.advanced-actions > * {
border-radius: 0 !important;
@@ -808,6 +831,10 @@ const clickOutsideMenu = (event: Event) => {
background: #303030;
box-shadow: 0 6px 16px #000;
}
.reserved-command-editor:not(.compact) .command-picker {
max-height: none;
overflow: visible;
}
.command-picker > header {
display: flex;
align-items: center;
@@ -49,6 +49,7 @@ export type ReservedCommandRow = {
time?: string;
year?: number;
month?: number;
autonomous?: boolean;
};
export type CommandPatternEntry = {
@@ -12,6 +12,7 @@ const props = defineProps<{
currentYear?: number;
currentMonth?: number;
turnTermMinutes?: number;
autorunLimit?: number | null;
storageKey?: string;
}>();
@@ -43,6 +44,7 @@ const rows = computed<ReservedCommandRow[]>(() => {
label: labelMap.value.get(turn.action) ?? turn.action,
year: Math.floor(absoluteMonth / 12),
month: (absoluteMonth % 12) + 1,
autonomous: props.autorunLimit != null && absoluteMonth <= props.autorunLimit - 1,
time: date
? term >= 5
? `${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}`
@@ -115,6 +115,33 @@ 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%;
@@ -13,6 +13,11 @@ interface MapSummary {
year: number;
month: number;
startYear: number;
techLevelLimit?: {
maxLevel: number;
initialLevel: number;
increaseYears: number;
};
cityList: [number, number, number, number, number, number][];
nationList: [number, string, string, number][];
myCity?: number | null;
@@ -198,9 +203,77 @@ const mapSummary = computed(() => {
if (!props.mapData) {
return '';
}
return `${props.mapData.year} ${props.mapData.month}`;
return `${props.mapData.year} ${props.mapData.month}`;
});
const titleColor = computed(() => {
if (!props.mapData) {
return undefined;
}
const { startYear, year } = props.mapData;
if (year < startYear + 1) {
return 'magenta';
}
if (year < startYear + 2) {
return 'orange';
}
if (year < startYear + 3) {
return 'yellow';
}
return undefined;
});
const titleTooltipLines = computed(() => {
if (!props.mapData) {
return [];
}
const { startYear, year, month } = props.mapData;
const lines: string[] = [];
if (year <= startYear + 3) {
// Ref uses joinYearMonth(startYear + 3, 0) as the limit boundary.
const remainingMonths = (startYear + 3) * 12 - 1 - (year * 12 + month - 1);
const remainYear = Math.trunc(remainingMonths / 12);
const remainMonth = (remainingMonths % 12) + 1;
lines.push(
`초반제한 기간 : ${remainYear}${remainMonth > 0 ? ` ${remainMonth}개월` : ''} (${startYear + 3}년)`
);
}
const limit = props.mapData.techLevelLimit ?? {
maxLevel: 12,
initialLevel: 1,
increaseYears: 5,
};
const currentLevel = Math.min(
limit.maxLevel,
Math.max(1, Math.floor((year - startYear) / limit.increaseYears) + limit.initialLevel)
);
if (currentLevel === limit.maxLevel) {
lines.push(`기술등급 제한 : ${currentLevel}등급 (최종)`);
} else {
lines.push(`기술등급 제한 : ${currentLevel}등급 (${currentLevel * limit.increaseYears + startYear}년 해제)`);
}
return lines;
});
const titleBandStyle = computed(() =>
detailMode.value
? {
backgroundImage: `url('${resolveAsset('ltitle.jpg')}'), url('${resolveAsset('rtitle.jpg')}')`,
}
: {}
);
const titleTextStyle = computed(() =>
detailMode.value
? {
color: titleColor.value,
backgroundImage: `url('${resolveAsset('ad.gif')}'), url('${resolveAsset(`${mapSeason.value}.gif`)}')`,
}
: { color: titleColor.value }
);
const mapThemeClass = computed(() => {
return `map-theme-${mapTheme.value}`;
});
@@ -269,6 +342,24 @@ const hoveredCity = computed(() => {
return cityViews.value.find((city) => city.id === hoveredCityId.value) ?? null;
});
const hoveredCityTitle = computed(() => {
if (!hoveredCity.value) {
return '';
}
return `${hoveredCity.value.regionName}|${hoveredCity.value.levelName}${hoveredCity.value.name}`;
});
const tooltipPosition = computed(() => {
const width = 120;
const offset = 10;
const mapPixelWidth = BASE_MAP_WIDTH * mapScale.value;
const left = elementX.value + width + offset > mapPixelWidth ? elementX.value - width - 5 : elementX.value + offset;
return {
left: `${Math.max(0, left)}px`,
top: `${elementY.value + 30}px`,
};
});
const setHoveredCity = (cityId: number | null) => {
mapStore.setHoveredCity(cityId);
};
@@ -280,8 +371,13 @@ const selectCity = (cityId: number) => {
<template>
<div class="map-viewer">
<div class="map-top">
<div class="map-title">{{ mapSummary }}</div>
<div class="map-top" :style="titleBandStyle">
<div class="map-title" tabindex="0" :style="titleTextStyle">
{{ mapSummary }}
<div class="map-title-tooltip" role="tooltip">
<div v-for="line in titleTooltipLines" :key="line">{{ line }}</div>
</div>
</div>
</div>
<div v-if="props.loading">
<SkeletonLines :lines="4" />
@@ -309,15 +405,9 @@ const selectCity = (cityId: number) => {
@leave="setHoveredCity(null)"
@select="selectCity"
/>
<div
v-if="hoveredCity"
class="map-tooltip"
:style="{ left: `${elementX + 16}px`, top: `${elementY + 16}px` }"
>
<div class="tooltip-title">{{ hoveredCity.name }}</div>
<div class="tooltip-body">
{{ hoveredCity.nationName }} · {{ hoveredCity.regionName }} · {{ hoveredCity.levelName }}
</div>
<div v-if="hoveredCity" class="map-tooltip" :style="tooltipPosition">
<div class="tooltip-title">{{ hoveredCityTitle }}</div>
<div class="tooltip-body">{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}</div>
</div>
<div class="map-controls">
<button class="map-toggle" :class="{ active: showCityName }" @click="mapStore.toggleCityName">
@@ -338,17 +428,69 @@ const selectCity = (cityId: number) => {
}
.map-top {
position: relative;
display: flex;
height: 20px;
align-items: center;
justify-content: center;
background: #111;
background-position:
left top,
right top;
background-repeat: no-repeat;
line-height: 20px;
}
.map-title {
font-size: 0.95rem;
font-weight: 600;
position: relative;
display: block;
width: 160px;
height: 20px;
margin: auto;
background-position:
left top,
right top;
background-repeat: no-repeat;
font-size: 14px;
font-weight: 700;
line-height: 20px;
text-align: center;
}
.map-title-tooltip {
position: absolute;
z-index: 20;
bottom: calc(100% + 7px);
left: 50%;
display: none;
box-sizing: border-box;
width: 220px;
border-radius: 4px;
padding: 5px 8px;
background: #000;
color: #fff;
font-size: 12px;
font-weight: 400;
line-height: 18px;
text-align: left;
transform: translateX(-50%);
white-space: nowrap;
}
.map-title-tooltip::after {
position: absolute;
top: 100%;
left: 50%;
border: 5px solid transparent;
border-top-color: #000;
content: '';
transform: translateX(-50%);
}
.map-title:hover .map-title-tooltip,
.map-title:focus .map-title-tooltip,
.map-title:focus-within .map-title-tooltip {
display: block;
}
.map-controls {
@@ -400,19 +542,28 @@ const selectCity = (cityId: number) => {
.map-tooltip {
position: absolute;
z-index: 16;
box-sizing: border-box;
min-width: 120px;
pointer-events: none;
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(16, 16, 16, 0.9);
padding: 4px 6px;
font-size: 0.65rem;
border: 1px solid gray;
padding: 0;
background: rgb(30, 164, 255);
color: #fff;
font-size: 14px;
line-height: 15px;
white-space: nowrap;
}
.tooltip-title {
font-weight: 600;
height: 15px;
}
.tooltip-body {
color: rgba(232, 221, 196, 0.6);
height: 15px;
border-top: 1px solid gray;
color: #fff;
text-align: right;
}
.map-empty {
@@ -87,6 +87,13 @@ const bucket = (type: MessageType): MessageEntry[] => props.messages?.[type] ??
const visibleMessages = (type: MessageType): MessageEntry[] => bucket(type).slice(0, visibleLimits[type]);
const permission = computed(() => props.messages?.permission ?? -1);
const replyableGeneralIds = computed(() =>
props.mailboxGroups.flatMap((group) =>
group.options
.filter((option) => !option.disabled && option.value > 0 && option.value < 9000)
.map((option) => option.value)
)
);
const setMailbox = (value: string) => {
const parsed = Number(value);
@@ -239,6 +246,7 @@ const forwardResponse = (messageId: number, response: boolean) => {
:nation-id="nationId"
:permission="permission"
:can-respond-diplomacy="canRespondDiplomacy"
:replyable-general-ids="replyableGeneralIds"
@set-target="setReplyTarget"
@delete="emit('delete', $event)"
@respond="forwardResponse"
@@ -29,6 +29,7 @@ const props = defineProps<{
nationId: number;
permission: number;
canRespondDiplomacy: boolean;
replyableGeneralIds: number[];
}>();
const emit = defineEmits<{
@@ -107,6 +108,7 @@ const isBright = (color: string): boolean => {
};
const iconUrl = computed(() => resolveMessageGeneralIconUrl(props.message.src.icon));
const canReplyToGeneral = (target: MessageTarget): boolean => props.replyableGeneralIds.includes(target.generalId);
const targetClass = (target: MessageTarget) => ({
'msg-target': true,
@@ -167,6 +169,7 @@ onBeforeUnmount(() => {
>
<span class="msg-from-to"></span>
<button
v-if="canReplyToGeneral(destination)"
:class="targetClass(destination)"
:style="{ backgroundColor: destination.color }"
type="button"
@@ -174,9 +177,13 @@ onBeforeUnmount(() => {
>
{{ destination.generalName }}:{{ destination.nationName }} |
</button>
<span v-else :class="targetClass(destination)" :style="{ backgroundColor: destination.color }">
{{ destination.generalName }}:{{ destination.nationName }}
</span>
</template>
<template v-else>
<button
v-if="canReplyToGeneral(message.src)"
:class="targetClass(message.src)"
:style="{ backgroundColor: message.src.color }"
type="button"
@@ -184,6 +191,9 @@ onBeforeUnmount(() => {
>
{{ message.src.generalName }}:{{ message.src.nationName }} |
</button>
<span v-else :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
{{ message.src.generalName }}:{{ message.src.nationName }}
</span>
<span class="msg-from-to"></span>
<span :class="targetClass(destination)" :style="{ backgroundColor: destination.color }"
></span
@@ -241,7 +251,7 @@ onBeforeUnmount(() => {
</template>
<button
v-else-if="message.src.generalId !== generalId"
v-else-if="message.src.generalId !== generalId && canReplyToGeneral(message.src)"
:class="targetClass(message.src)"
:style="{ backgroundColor: message.src.color }"
type="button"
@@ -249,6 +259,13 @@ onBeforeUnmount(() => {
>
{{ message.src.generalName }}:{{ message.src.nationName }} |
</button>
<span
v-else-if="message.src.generalId !== generalId"
:class="targetClass(message.src)"
:style="{ backgroundColor: message.src.color }"
>
{{ message.src.generalName }}:{{ message.src.nationName }}
</span>
<span v-else :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
{{ message.src.generalName }}
</span>