Merge remote-tracking branch 'origin/main' into fix/command-map-detail-images-20260813

This commit is contained in:
2026-08-13 16:36:27 +00:00
16 changed files with 1455 additions and 49 deletions
@@ -0,0 +1,524 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { configuredGameAssetUrl } from '../../utils/imageAssets';
import type { RecruitmentCrewType, RecruitmentInfo } from './types';
const props = defineProps<{
commandKey: 'che_징병' | 'che_모병';
info: RecruitmentInfo;
}>();
const emit = defineEmits<{
(event: 'update:args', args: Record<string, unknown>): void;
(event: 'update:valid', valid: boolean): void;
(event: 'submit'): void;
}>();
const selectedCrewTypeId = ref(0);
const amount = ref(1);
const showUnavailable = ref<Record<number, boolean>>({});
const commandName = computed(() => (props.commandKey === 'che_모병' ? '모병' : '징병'));
const goldCoefficient = computed(() => (props.commandKey === 'che_모병' ? 2 : 1));
const crewTypes = computed(() => props.info.groups.flatMap((group) => group.values));
const selectedCrewType = computed(
() => crewTypes.value.find((crewType) => crewType.id === selectedCrewTypeId.value) ?? crewTypes.value[0] ?? null
);
const valid = computed(
() => Boolean(selectedCrewType.value?.available) && Number.isFinite(amount.value) && amount.value >= 1
);
const estimatedGold = computed(() =>
selectedCrewType.value ? Math.ceil(amount.value * selectedCrewType.value.baseCost * goldCoefficient.value) : 0
);
const filledAmount = (crewType: RecruitmentCrewType | null): number => {
if (crewType?.id === props.info.currentCrewTypeId) {
return Math.max(1, props.info.fullLeadership - Math.floor(props.info.crew / 100));
}
return Math.max(1, props.info.fullLeadership);
};
const initialize = () => {
showUnavailable.value = Object.fromEntries(props.info.groups.map((group) => [group.armType, false]));
const current = crewTypes.value.find((crewType) => crewType.id === props.info.currentCrewTypeId);
selectedCrewTypeId.value =
(current ?? crewTypes.value.find((crewType) => crewType.available) ?? crewTypes.value[0])?.id ?? 0;
amount.value = filledAmount(selectedCrewType.value);
};
const selectCrewType = (crewType: RecruitmentCrewType) => {
selectedCrewTypeId.value = crewType.id;
amount.value = filledAmount(crewType);
};
const setHalf = () => {
amount.value = Math.max(1, Math.ceil(props.info.fullLeadership * 0.5));
};
const setFilled = () => {
amount.value = filledAmount(selectedCrewType.value);
};
const setFull = () => {
amount.value = Math.max(1, Math.floor(props.info.fullLeadership * 1.2));
};
const updateAmount = (event: Event) => {
const value = Number((event.currentTarget as HTMLInputElement).value);
amount.value = Number.isFinite(value) ? value : 0;
};
const submit = async (crewType?: RecruitmentCrewType) => {
if (crewType) selectCrewType(crewType);
await nextTick();
if (valid.value) emit('submit');
};
const imageUrl = (crewTypeId: number): string => `${configuredGameAssetUrl()}/crewtype${crewTypeId}.png`;
const availabilityClass = (crewType: RecruitmentCrewType): string =>
crewType.available ? (crewType.special ? 'special' : 'available') : 'unavailable';
const displayDecimal = (value: number): string => value.toFixed(1);
watch(() => [props.commandKey, props.info] as const, initialize, { immediate: true, deep: true });
watch(
[selectedCrewTypeId, amount, valid],
() => {
emit('update:args', {
crewType: selectedCrewTypeId.value,
amount: Math.max(0, Math.trunc(amount.value * 100)),
});
emit('update:valid', valid.value);
},
{ immediate: true }
);
</script>
<template>
<section class="recruitment-command-form" data-testid="recruitment-command-form">
<div class="recruitment-intro legacy-bg0">
병사를 모집합니다.
<template v-if="props.commandKey === 'che_징병'"> 훈련과 사기치는 낮지만 가격이 저렴합니다. </template>
<template v-else>훈련과 사기치는 높지만 자금이 많이 듭니다.</template>
<br />가능한 수보다 많게 입력하면 가능한 최대 병사를 모집합니다.<br />
이미 병사가 있는 경우 추가 {{ commandName }}되며, 병종이 다르면 기존 병사는 소집해제됩니다.<br />
현재 {{ commandName }} 가능한 기본 병종은 <span class="legend available">녹색</span>, 특수 병종은
<span class="legend special">초록색</span>, 불가능한 병종은
<span class="legend unavailable">빨간색</span>으로 표시됩니다.
</div>
<div class="recruitment-list-front">
<p v-if="props.commandKey === 'che_모병'" class="mercenary-notice">모병은 가격 2배의 자금이 소요됩니다.</p>
<div class="recruitment-status legacy-bg2">
<span>현재 기술력 : {{ props.info.techLevel }}등급</span>
<span
>현재 통솔 :
<strong :class="{ injured: props.info.leadership < props.info.fullLeadership }">
{{ props.info.leadership }}
</strong></span
>
<span>최대 통솔 : {{ props.info.fullLeadership }}</span>
<span>현재 병종 : {{ props.info.currentCrewTypeName }}</span>
<span>현재 병사 : {{ props.info.crew.toLocaleString() }}</span>
<span>현재 자금 : {{ props.info.gold.toLocaleString() }}</span>
</div>
<div v-if="selectedCrewType" class="mobile-selected-panel legacy-bg0">
<div
class="crew-image"
:style="{ backgroundImage: `url(${JSON.stringify(imageUrl(selectedCrewType.id))})` }"
/>
<button
type="button"
class="crew-name"
:class="availabilityClass(selectedCrewType)"
:title="selectedCrewType.available ? '현재 선택 가능' : '현재 선택 불가'"
>
{{ selectedCrewType.name }}<small>{{ selectedCrewType.available ? '가능' : '불가' }}</small>
</button>
<div class="amount-panel">
<div class="quick-buttons">
<button type="button" @click="setHalf">절반</button>
<button type="button" @click="setFilled">채우기</button>
<button type="button" @click="setFull">가득</button>
</div>
<label>
<span>병력</span>
<input :value="amount" type="number" min="1" step="1" @input="updateAmount" />
<span>00</span><output>{{ estimatedGold.toLocaleString() }}</output>
</label>
</div>
<button type="button" class="submit-recruit" :disabled="!valid" @click="submit()">
{{ commandName }}
</button>
</div>
<div class="crew-grid crew-header legacy-bg1" aria-hidden="true">
<span class="crew-image">사진</span><span class="crew-name">병종</span><span class="attack">공격</span
><span class="defence">방어</span><span class="speed">기동</span><span class="avoid">회피</span
><span class="cost">가격</span><span class="rice">군량</span><span class="amount-panel">병사 </span
><span class="crew-action">행동</span><span class="crew-info">특징</span>
</div>
</div>
<div class="crew-list">
<section v-for="group in props.info.groups" :key="group.armType" class="crew-group">
<header>
<strong>{{ group.armName }} 계열</strong>
<button
type="button"
:class="{ active: showUnavailable[group.armType] }"
@click="showUnavailable[group.armType] = !showUnavailable[group.armType]"
>
{{
showUnavailable[group.armType]
? '선택 할 수 있는 병종만 보기'
: '선택 할 수 없는 병종도 보기'
}}
</button>
</header>
<div
v-for="crewType in group.values.filter(
(entry) => showUnavailable[group.armType] || entry.available
)"
:key="crewType.id"
class="crew-grid crew-row"
:class="{ selected: crewType.id === selectedCrewTypeId }"
role="button"
tabindex="0"
:aria-label="`${crewType.name} ${crewType.available ? '선택 가능' : '선택 불가'}`"
@click="selectCrewType(crewType)"
@keydown.enter="selectCrewType(crewType)"
>
<span
class="crew-image"
:style="{ backgroundImage: `url(${JSON.stringify(imageUrl(crewType.id))})` }"
/>
<span class="crew-name" :class="availabilityClass(crewType)"
>{{ crewType.name }}<small>{{ crewType.available ? '가능' : '불가' }}</small></span
>
<span class="attack"><small>공격</small>{{ crewType.attack }}</span>
<span class="defence"><small>방어</small>{{ crewType.defence }}</span>
<span class="speed"><small>기동</small>{{ crewType.speed }}</span>
<span class="avoid"><small>회피</small>{{ crewType.avoid }}</span>
<span class="cost"><small>가격</small>{{ displayDecimal(crewType.baseCost) }}</span>
<span class="rice"><small>군량</small>{{ displayDecimal(crewType.baseRice) }}</span>
<span class="amount-panel" @click.stop>
<span class="quick-buttons">
<button
type="button"
@click="
selectCrewType(crewType);
setHalf();
"
>
절반
</button>
<button
type="button"
@click="
selectCrewType(crewType);
setFilled();
"
>
채우기
</button>
<button
type="button"
@click="
selectCrewType(crewType);
setFull();
"
>
가득
</button>
</span>
<label>
<span>병력</span>
<input
:value="crewType.id === selectedCrewTypeId ? amount : filledAmount(crewType)"
type="number"
min="1"
step="1"
@focus="selectCrewType(crewType)"
@input="
selectCrewType(crewType);
updateAmount($event);
"
/>
<span>00명</span>
</label>
</span>
<span class="crew-action" @click.stop>
<button type="button" :disabled="!crewType.available" @click="submit(crewType)">
{{ commandName }}
</button>
</span>
<span class="crew-info"
><span v-for="line in crewType.info" :key="line">{{ line }}</span></span
>
</div>
</section>
</div>
</section>
</template>
<style scoped>
.recruitment-command-form {
box-sizing: border-box;
width: 100%;
min-width: 0;
color: #fff;
background: #1d1d1d;
font: 14px/1.25 var(--sammo-font-sans);
}
.recruitment-intro {
padding: 6px;
}
.legend,
.crew-name {
color: #fff;
}
.available {
background: green !important;
}
.special {
background: limegreen !important;
}
.unavailable {
background: #c90000 !important;
}
.mercenary-notice {
margin: 0;
padding: 4px;
text-align: center;
}
.recruitment-status {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
text-align: center;
}
.recruitment-status > span {
padding: 4px 2px;
}
.injured {
color: red;
}
.crew-grid {
display: grid;
grid-template-columns: 64px 90px repeat(6, minmax(42px, 1fr)) 210px 72px 250px;
align-items: stretch;
text-align: center;
}
.crew-header {
min-height: 30px;
align-items: center;
}
.crew-header > span,
.crew-row > span {
min-width: 0;
display: grid;
place-items: center;
}
.crew-group > header {
min-height: 38px;
display: grid;
grid-template-columns: 1fr 205px;
align-items: center;
border-bottom: 1px solid #777;
}
.crew-group > header strong {
padding: 0 12px;
font-size: 1.3em;
}
.crew-group > header button {
min-height: 34px;
border: 0;
background: #444;
color: #fff;
}
.crew-group > header button.active {
background: #d89a00;
color: #111;
}
.crew-row {
width: 100%;
min-height: 64px;
border: 0;
border-bottom: 1px solid #777;
padding: 0;
background: #242424;
color: #fff;
font: inherit;
cursor: pointer;
}
.crew-row:hover,
.crew-row:focus-visible,
.crew-row.selected {
outline: 2px solid #5bc0de;
outline-offset: -2px;
}
.crew-image {
min-height: 64px;
background: #222 no-repeat center;
background-size: 64px;
outline: 1px solid gray;
}
.crew-name {
height: 100%;
border: 0;
font: inherit;
}
.crew-name small {
display: block;
font-size: 0.72em;
}
.crew-row small {
display: none;
}
.amount-panel {
align-content: center;
padding: 2px 5px;
}
.quick-buttons {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.quick-buttons button,
.crew-action button,
.submit-recruit {
min-height: 28px;
border: 1px solid #777;
background: #444;
color: #fff;
}
.amount-panel label {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
}
.amount-panel input {
min-width: 0;
height: 28px;
box-sizing: border-box;
text-align: right;
}
.crew-action button,
.submit-recruit {
width: 100%;
height: 100%;
background: #375a7f;
}
.crew-action button:disabled,
.submit-recruit:disabled {
opacity: 0.45;
}
.crew-info {
padding: 4px 8px;
text-align: left;
}
.crew-info > span {
display: block;
}
.mobile-selected-panel {
display: none;
}
@media (max-width: 600px) {
.recruitment-command-form {
width: 500px;
}
.recruitment-list-front {
position: sticky;
z-index: 5;
top: 0;
background: #1d1d1d;
}
.recruitment-status {
grid-template-columns: repeat(3, 1fr);
}
.mobile-selected-panel {
display: grid;
grid-template-columns: 64px 76px 270px 90px;
min-height: 64px;
align-items: stretch;
}
.mobile-selected-panel .crew-name {
display: grid;
place-items: center;
}
.mobile-selected-panel .amount-panel label {
grid-template-columns: auto minmax(0, 1fr) auto 85px;
}
.mobile-selected-panel output {
box-sizing: border-box;
min-width: 0;
padding: 4px;
background: #ddd;
color: #303030;
text-align: right;
}
.crew-grid {
grid-template:
'image name attack defence speed info' 32px
'image name avoid cost rice info' 32px /
64px 76px 30px 30px 30px 270px;
}
.crew-grid .crew-image {
grid-area: image;
}
.crew-grid .crew-name {
grid-area: name;
}
.crew-grid .attack {
grid-area: attack;
}
.crew-grid .defence {
grid-area: defence;
}
.crew-grid .speed {
grid-area: speed;
}
.crew-grid .avoid {
grid-area: avoid;
}
.crew-grid .cost {
grid-area: cost;
}
.crew-grid .rice {
grid-area: rice;
}
.crew-grid .crew-info {
grid-area: info;
}
.crew-grid .amount-panel,
.crew-grid .crew-action {
display: none;
}
.crew-row small {
display: block;
font-size: 0.62em;
line-height: 1;
}
.crew-row .crew-name small {
font-size: 0.72em;
}
.crew-header .attack,
.crew-header .defence,
.crew-header .speed,
.crew-header .avoid,
.crew-header .cost,
.crew-header .rice {
font-size: 0.78em;
}
.crew-group > header {
grid-template-columns: 7fr 5fr;
}
.crew-group > header button {
font-size: 0.82em;
}
.crew-info {
overflow: hidden;
font-size: 0.9em;
}
}
</style>
@@ -4,6 +4,7 @@ import CommandArgumentForm from '../main/CommandArgumentForm.vue';
import CommandSelectForm from '../main/CommandSelectForm.vue';
import { commandArgumentPresentation } from './commandArgumentPresentation';
import DragSelect from './DragSelect.vue';
import RecruitmentCommandForm from './RecruitmentCommandForm.vue';
import {
amplifyPattern,
CommandStorage,
@@ -132,6 +133,9 @@ 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 isRecruitmentCommand = computed(
() => selectedCommand.value?.key === 'che_징병' || selectedCommand.value?.key === 'che_모병'
);
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());
@@ -599,6 +603,7 @@ const clickOutsideMenu = (event: Event) => {
<div
v-if="pickerOpen"
class="command-picker"
:class="{ 'recruitment-picker': isRecruitmentCommand }"
data-testid="command-picker"
:style="quickTarget === null || props.compact ? undefined : { top: quickPickerTop }"
>
@@ -623,8 +628,20 @@ const clickOutsideMenu = (event: Event) => {
>현재 상태: {{ selectedCommand.reason }} · 예약 입력은 가능합니다.</small
>
</div>
<RecruitmentCommandForm
v-if="
isRecruitmentCommand &&
props.commandTable?.inputOptions.recruitment &&
(selectedCommand.key === 'che_징병' || selectedCommand.key === 'che_모병')
"
:command-key="selectedCommand.key"
:info="props.commandTable.inputOptions.recruitment"
@update:args="commandArgs = $event"
@update:valid="commandArgsValid = $event"
@submit="submitCommand"
/>
<CommandArgumentForm
v-if="selectedCommand.reqArg && props.commandTable"
v-else-if="selectedCommand.reqArg && props.commandTable"
:command-key="selectedCommand.key"
:fields="selectedCommand.inputFields"
:options="props.commandTable.inputOptions"
@@ -950,6 +967,20 @@ const clickOutsideMenu = (event: Event) => {
max-height: 344px;
}
.reserved-command-editor .command-picker.recruitment-picker {
position: fixed;
z-index: 1100;
top: 76px;
right: auto;
bottom: auto;
left: 50%;
width: 1000px;
height: auto;
max-height: calc(100vh - 82px);
overflow: auto;
transform: translateX(-50%);
}
@media (min-width: 1025px) {
.argument-expanded:not(.compact) .command-picker {
right: 0;
@@ -971,6 +1002,14 @@ const clickOutsideMenu = (event: Event) => {
max-height: calc(100vh - 104px);
overflow: auto;
}
.compact:not(.mobile) .command-picker.recruitment-picker {
top: 76px;
left: 50%;
width: 1000px;
max-height: calc(100vh - 82px);
overflow: auto;
transform: translateX(-50%);
}
}
.mobile.compact .editor-layout {
@@ -1016,9 +1055,36 @@ const clickOutsideMenu = (event: Event) => {
margin-top: -330px;
overflow: visible;
}
.mobile.compact .command-picker.recruitment-picker {
position: fixed;
top: 76px;
left: 0;
width: 500px;
height: auto;
max-height: calc(100vh - 82px);
margin-top: 0;
overflow: auto;
transform: none;
}
.mobile.compact .advanced-actions {
right: 0;
bottom: 0;
left: 109px;
}
@media (max-width: 600px) {
.reserved-command-editor .command-picker.recruitment-picker,
.reserved-command-editor.compact .command-picker.recruitment-picker {
top: 76px;
left: 0;
width: 500px;
height: auto;
max-height: calc(100vh - 82px);
overflow: auto;
padding: 0;
border-right: 0;
border-left: 0;
transform: none;
}
}
</style>
@@ -58,6 +58,36 @@ export type CommandAvailability = {
export type CommandGroup = { category: string; values: CommandAvailability[] };
export type RecruitmentCrewType = {
id: number;
armType: number;
name: string;
available: boolean;
special: boolean;
attack: number;
defence: number;
speed: number;
avoid: number;
baseCost: number;
baseRice: number;
info: string[];
};
export type RecruitmentInfo = {
techLevel: number;
leadership: number;
fullLeadership: number;
currentCrewTypeId: number;
currentCrewTypeName: string;
crew: number;
gold: number;
groups: Array<{
armType: number;
armName: string;
values: RecruitmentCrewType[];
}>;
};
export type CommandTable = {
general: CommandGroup[];
nation: CommandGroup[];
@@ -70,6 +100,7 @@ export type CommandTable = {
nationTypes: CommandOption[];
colors: CommandOption[];
items: Record<string, CommandOption[]>;
recruitment: RecruitmentInfo | null;
context?: CommandInputContext;
};
};