Merge remote-tracking branch 'origin/main' into feature/recruitment-command-parity-20260813

# Conflicts:
#	app/game-api/src/router/turns/index.ts
#	app/game-api/src/turns/commandInput.ts
#	app/game-frontend/e2e/commandArguments.spec.ts
#	app/game-frontend/src/components/command/ReservedCommandEditor.vue
#	app/game-frontend/src/components/command/types.ts
#	app/game-frontend/src/views/ChiefCenterView.vue
This commit is contained in:
2026-08-13 16:28:35 +00:00
83 changed files with 4463 additions and 1050 deletions
@@ -1,7 +1,13 @@
<script setup lang="ts">
import { computed } from 'vue';
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
import type { CommandPatternEntry, CommandTable, ReservedCommandRow } from '../command/types';
import type {
CommandMapData,
CommandMapLayout,
CommandPatternEntry,
CommandTable,
ReservedCommandRow,
} from '../command/types';
const props = defineProps<{
officerLevelText: string;
@@ -13,6 +19,8 @@ const props = defineProps<{
generalId: number;
officerLevel: number;
mobile?: boolean;
mapData?: CommandMapData | null;
mapLayout?: CommandMapLayout | null;
}>();
const commandRows = computed(() => props.rows.map((row) => ({ ...row, action: row.actionCode ?? row.action })));
@@ -37,6 +45,8 @@ const emit = defineEmits<{
:title="props.officerLevelText"
:name="props.name"
:current-time="props.rows[0]?.time"
:map-data="props.mapData"
:map-layout="props.mapLayout"
@reserve-bulk="emit('reserve-bulk', $event)"
@shift="emit('shift', $event)"
@repeat="emit('repeat', $event)"
@@ -2,6 +2,7 @@
import { computed, onMounted, ref, shallowRef, watch } from 'vue';
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 {
@@ -12,7 +13,14 @@ import {
normalizedSelection,
selectStep,
} from './commandQueue';
import type { CommandAvailability, CommandPatternEntry, CommandTable, ReservedCommandRow } from './types';
import type {
CommandAvailability,
CommandMapData,
CommandMapLayout,
CommandPatternEntry,
CommandTable,
ReservedCommandRow,
} from './types';
const props = withDefaults(
defineProps<{
@@ -27,8 +35,19 @@ const props = withDefaults(
title?: string;
name?: string | null;
currentTime?: string;
mapData?: CommandMapData | null;
mapLayout?: CommandMapLayout | null;
}>(),
{ maxPushTurn: 6, compact: false, mobile: false, title: '', name: null, currentTime: '--:--' }
{
maxPushTurn: 6,
compact: false,
mobile: false,
title: '',
name: null,
currentTime: '--:--',
mapData: null,
mapLayout: null,
}
);
const emit = defineEmits<{
@@ -153,6 +172,14 @@ const closePicker = () => {
quickTarget.value = null;
selectedCommand.value = null;
};
const togglePicker = (turnIndex?: number) => {
const target = turnIndex ?? null;
if (pickerOpen.value && quickTarget.value === target) {
closePicker();
return;
}
openPicker(turnIndex);
};
const selectCommand = (commandKey: string) => {
const command = props.commandTable?.[props.scope]
.flatMap((group) => group.values)
@@ -242,7 +269,15 @@ const clickOutsideMenu = (event: Event) => {
<template>
<article
class="reserved-command-editor"
:class="{ compact: props.compact, mobile: props.mobile, 'edit-mode': editMode, 'picker-open': pickerOpen }"
:class="{
compact: props.compact,
mobile: props.mobile,
'edit-mode': editMode,
'picker-open': pickerOpen,
'argument-expanded': Boolean(
selectedCommand?.reqArg && commandArgumentPresentation(selectedCommand.key).lines.length
),
}"
:data-command-scope="props.scope"
>
<header v-if="props.compact && !props.mobile" class="identity legacy-bg1">
@@ -309,6 +344,7 @@ const clickOutsideMenu = (event: Event) => {
>
짝수턴
</button>
<hr class="menu-divider" />
<template v-for="step in [3, 4, 5, 6, 7]" :key="step">
<small>{{ step }} 간격</small>
<div class="step-buttons">
@@ -433,6 +469,7 @@ const clickOutsideMenu = (event: Event) => {
>
붙여넣기
</button>
<hr class="menu-divider" />
<button
@click="
textCopy();
@@ -441,6 +478,7 @@ const clickOutsideMenu = (event: Event) => {
>
텍스트 복사
</button>
<hr class="menu-divider" />
<button
@click="
saveTemplate();
@@ -457,6 +495,7 @@ const clickOutsideMenu = (event: Event) => {
>
반복하기
</button>
<hr class="menu-divider" />
<button
@click="
clearSelection();
@@ -483,7 +522,7 @@ const clickOutsideMenu = (event: Event) => {
</button>
</div>
</details>
<button type="button" class="select-command" @click="openPicker()">명령 선택 </button>
<button type="button" class="select-command" @click="togglePicker()">명령 선택 </button>
</div>
<div class="queue-area">
@@ -546,7 +585,7 @@ const clickOutsideMenu = (event: Event) => {
:key="row.index"
type="button"
:aria-label="`${row.index + 1} 명령 입력`"
@click="openPicker(row.index)"
@click="togglePicker(row.index)"
>
</button>
@@ -606,6 +645,8 @@ const clickOutsideMenu = (event: Event) => {
:command-key="selectedCommand.key"
:fields="selectedCommand.inputFields"
:options="props.commandTable.inputOptions"
:map-data="props.mapData"
:map-layout="props.mapLayout"
@update:args="commandArgs = $event"
@update:valid="commandArgsValid = $event"
/>
@@ -725,6 +766,14 @@ const clickOutsideMenu = (event: Event) => {
padding: 5px 8px;
color: #bbb;
}
.menu-divider {
width: 100%;
height: 0;
margin: 4px 0;
border: 0;
border-top: 1px solid #444;
opacity: 1;
}
.step-buttons,
.template-row {
display: flex;
@@ -933,6 +982,11 @@ const clickOutsideMenu = (event: Event) => {
}
@media (min-width: 1025px) {
.argument-expanded:not(.compact) .command-picker {
right: 0;
left: auto;
width: 700px;
}
.compact:not(.mobile) .command-picker {
position: fixed;
z-index: 1000;
@@ -941,6 +995,13 @@ const clickOutsideMenu = (event: Event) => {
left: calc(50% - 476px);
width: 238px;
}
.compact.argument-expanded:not(.mobile) .command-picker {
left: calc(50% - 350px);
width: 700px;
height: auto;
max-height: calc(100vh - 104px);
overflow: auto;
}
.compact:not(.mobile) .command-picker.recruitment-picker {
top: 76px;
left: 50%;
@@ -984,12 +1045,25 @@ const clickOutsideMenu = (event: Event) => {
width: 370px;
height: 327px;
}
.mobile.compact.argument-expanded .command-picker {
position: relative;
top: auto;
left: auto;
width: 100%;
height: auto;
max-height: none;
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 {
@@ -0,0 +1,92 @@
export type CommandArgumentPresentation = {
lines: string[];
mapTarget?: 'city' | 'nation';
};
const cityTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'city' });
const nationTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'nation' });
// Ref hwe/ts/processing의 명령별 안내를 예약 명령 옵션창에 맞게 옮긴다.
// 징병/모병은 별도 이관 범위이므로 이 표에 넣지 않는다.
const PRESENTATIONS: Record<string, CommandArgumentPresentation> = {
che_강행: cityTarget(['선택한 도시로 강행합니다.', '최대 3칸 안의 도시만 선택할 수 있습니다.']),
che_이동: cityTarget(['선택한 도시로 이동합니다.', '인접한 도시로만 이동할 수 있습니다.']),
che_출병: cityTarget([
'선택한 도시를 향해 침공합니다.',
'침공 경로에 적군 도시가 있으면 그 도시에서 전투를 벌입니다.',
]),
che_첩보: cityTarget(['선택한 도시에 첩보를 실행합니다.', '인접 도시에서는 더 많은 정보를 얻습니다.']),
che_화계: cityTarget(['선택한 도시에 화계를 실행합니다.']),
che_탈취: cityTarget(['선택한 도시에 탈취를 실행합니다.']),
che_파괴: cityTarget(['선택한 도시에 파괴를 실행합니다.']),
che_선동: cityTarget(['선택한 도시에 선동을 실행합니다.']),
che_수몰: cityTarget(['선택한 도시에 수몰을 발동합니다.', '전쟁 중인 상대국 도시만 대상이 됩니다.']),
che_백성동원: cityTarget(['선택한 도시에 백성을 동원해 성벽을 쌓습니다.', '아국 도시만 대상이 됩니다.']),
che_천도: cityTarget([
'선택한 도시로 수도를 옮깁니다.',
'현재 수도에서 연결된 도시만 가능하며 1 + 2 × 거리만큼의 턴이 필요합니다.',
]),
che_허보: cityTarget(['선택한 도시에 허보를 발동합니다.', '선포 또는 전쟁 중인 상대국 도시만 대상이 됩니다.']),
che_초토화: cityTarget([
'선택한 도시를 초토화해 공백지로 만듭니다.',
'인구와 내정 상태에 따라 국고를 확보하고, 수뇌 명성과 모든 장수의 배신 수치에 영향을 줍니다.',
]),
cr_인구이동: cityTarget(['현재 도시의 인구를 선택한 인접 도시로 이동합니다.']),
che_발령: cityTarget(['선택한 도시로 아국 장수를 발령합니다.', '아국 도시만 대상이 됩니다.']),
che_선전포고: nationTarget([
'선택한 국가에 선전포고합니다.',
'고립되지 않은 아국 도시와 인접한 국가에만 가능하며 초반 제한의 영향을 받습니다.',
]),
che_급습: nationTarget(['선택한 국가에 급습을 발동합니다.', '선포 또는 전쟁 중인 상대국만 대상이 됩니다.']),
che_불가침파기제의: nationTarget(['불가침 중인 국가에 조약 파기를 제의합니다.']),
che_이호경식: nationTarget(['선택한 국가에 이호경식을 발동합니다.', '선포 또는 전쟁 중인 상대국만 대상이 됩니다.']),
che_종전제의: nationTarget(['전쟁 중인 국가에 종전을 제의합니다.']),
che_불가침제의: nationTarget([
'선택한 국가에 불가침을 제의합니다.',
'불가침 기한 다음 달부터 다시 선전포고할 수 있습니다.',
]),
che_피장파장: nationTarget([
'선택한 국가가 지정한 전략을 일정 턴 동안 사용하지 못하게 합니다.',
'아국에도 지정 전략의 재사용 제한이 생깁니다.',
]),
che_물자원조: nationTarget(['타국에 금과 쌀을 원조합니다.', '국가 작위에 따라 보낼 수 있는 금액이 제한됩니다.']),
che_증여: { lines: ['자신의 금이나 쌀을 선택한 장수에게 증여합니다.'] },
che_헌납: { lines: ['자신의 금이나 쌀을 국가 재산으로 헌납합니다.'] },
che_군량매매: { lines: ['자신의 군량을 사거나 팝니다.'] },
che_몰수: { lines: ['선택한 장수의 금이나 쌀을 몰수해 국가 재산으로 귀속합니다.'] },
che_포상: { lines: ['국고에서 선택한 장수에게 금이나 쌀을 지급합니다.'] },
che_부대탈퇴지시: { lines: ['선택한 장수에게 부대 탈퇴를 지시합니다.', '현재 부대원인 장수만 대상이 됩니다.'] },
che_등용: { lines: ['재야 또는 타국 장수에게 등용 서신을 보냅니다.', '서신은 개인 메시지로 전달됩니다.'] },
che_선양: { lines: ['군주의 자리를 선택한 아국 장수에게 물려줍니다.'] },
che_임관: {
lines: [
'선택한 국가에 임관하고 군주의 위치로 이동합니다.',
'이미 임관하거나 등용되었던 국가는 선택할 수 없습니다.',
],
},
che_장수대상임관: {
lines: ['선택한 장수를 따라 그 장수의 국가에 임관하고 군주의 위치로 이동합니다.'],
},
che_숙련전환: {
lines: ['선택한 병과 숙련을 40% 줄이고, 줄어든 숙련의 90%를 다른 병과 숙련으로 전환합니다.'],
},
che_장비매매: { lines: ['장비를 구입하거나 매각합니다.', '가격과 요구 치안, 장비 효과를 확인한 뒤 선택하세요.'] },
che_건국: {
lines: ['현재 중·소도시에서 나라를 세웁니다.', '국가 성향별 장단점을 확인한 뒤 국명과 색상을 정하세요.'],
},
che_무작위건국: {
lines: ['무작위 공백 중·소도시에서 나라를 세웁니다.', '국가 성향별 장단점을 확인한 뒤 국명과 색상을 정하세요.'],
},
cr_건국: { lines: ['현재 도시에서 규모 제한 없이 나라를 세웁니다.', '국가 성향별 장단점을 확인하세요.'] },
che_국기변경: { lines: ['국기의 색상을 변경합니다.', '이 명령은 한 번만 실행할 수 있습니다.'] },
che_국호변경: { lines: ['국가 이름을 변경합니다.', '황제가 된 뒤 한 번만 실행할 수 있습니다.'] },
che_등용수락: { lines: ['도착한 등용 제의에 응할 행동을 선택합니다.'] },
che_NPC능동: { lines: ['NPC 장수의 능동 행동 방식을 선택합니다.'] },
};
export const commandArgumentPresentation = (commandKey: string): CommandArgumentPresentation =>
PRESENTATIONS[commandKey] ?? { lines: [] };
export const presentedCommandKeys = (): string[] => Object.keys(PRESENTATIONS);
@@ -1,4 +1,36 @@
export type CommandOption = { value: string | number; label: string; color?: string };
export type CommandOption = {
value: string | number;
label: string;
color?: string;
description?: string;
};
export type CommandMapData = {
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;
myNation?: number | null;
};
export type CommandMapLayout = {
mapName: string;
cityList: Array<{ id: number; name: string; level: number; region: number; x: number; y: number; path: number[] }>;
regionMap: Record<number, string>;
levelMap: Record<number, string>;
};
export type CommandInputContext = {
actorGold: number;
actorRice: number;
citySecurity?: number;
nationGold?: number;
nationRice?: number;
nationLevel?: number;
};
export type CommandInputField = {
key: string;
@@ -69,6 +101,7 @@ export type CommandTable = {
colors: CommandOption[];
items: Record<string, CommandOption[]>;
recruitment: RecruitmentInfo | null;
context?: CommandInputContext;
};
};
@@ -1,40 +1,24 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import MapViewer from './MapViewer.vue';
import { commandArgumentPresentation } from '../command/commandArgumentPresentation';
import type {
CommandInputContext,
CommandInputField,
CommandMapData,
CommandMapLayout,
CommandOption,
CommandTable,
} from '../command/types';
type OptionValue = string | number;
interface CommandOption {
value: OptionValue;
label: string;
color?: string;
}
interface CommandInputField {
key: string;
label: string;
kind: 'text' | 'number' | 'boolean' | 'select' | 'numberTuple' | 'hidden';
required: boolean;
min?: number;
max?: number;
step?: number;
constValue?: OptionValue;
options?: CommandOption[];
optionSource?: 'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items';
tupleLabels?: string[];
}
interface CommandInputOptions {
cities: CommandOption[];
nations: CommandOption[];
generals: CommandOption[];
crewTypes: CommandOption[];
armTypes: CommandOption[];
nationTypes: CommandOption[];
colors: CommandOption[];
items: Record<string, CommandOption[]>;
}
type CommandInputOptions = CommandTable['inputOptions'];
const props = defineProps<{
commandKey: string;
fields: CommandInputField[];
options: CommandInputOptions;
mapData?: CommandMapData | null;
mapLayout?: CommandMapLayout | null;
}>();
const emit = defineEmits<{
@@ -43,6 +27,8 @@ const emit = defineEmits<{
}>();
const values = reactive<Record<string, unknown>>({});
const presentation = computed(() => commandArgumentPresentation(props.commandKey));
const visibleFields = computed(() => props.fields.filter((entry) => entry.kind !== 'hidden'));
const optionsFor = (field: CommandInputField): CommandOption[] => {
if (field.options) return field.options;
@@ -58,7 +44,16 @@ const defaultValue = (field: CommandInputField): unknown => {
if (field.kind === 'boolean') return true;
if (field.kind === 'numberTuple') return [field.min ?? 0, field.min ?? 0];
if (field.kind === 'number') return field.min ?? 0;
if (field.kind === 'select') return optionsFor(field)[0]?.value ?? '';
if (field.kind === 'select') {
const options = optionsFor(field);
const mapDefault =
field.optionSource === 'cities' && (field.key === 'destCityId' || field.key === 'destCityID')
? props.mapData?.myCity
: field.optionSource === 'nations' && field.key === 'destNationId'
? props.mapData?.myNation
: null;
return options.find((option) => option.value === mapDefault)?.value ?? options[0]?.value ?? '';
}
return '';
};
@@ -78,6 +73,129 @@ const setSelectValue = (field: CommandInputField, rawValue: string) => {
}
};
const selectedOptionFor = (field: CommandInputField): CommandOption | undefined =>
optionsFor(field).find((entry) => entry.value === values[field.key]);
const cityTargetField = computed(() =>
props.fields.find(
(field) =>
field.kind === 'select' &&
field.optionSource === 'cities' &&
(field.key === 'destCityId' || field.key === 'destCityID')
)
);
const nationTargetField = computed(() =>
props.fields.find(
(field) => field.kind === 'select' && field.optionSource === 'nations' && field.key === 'destNationId'
)
);
const showMap = computed(
() =>
Boolean(props.mapData && props.mapLayout) &&
((presentation.value.mapTarget === 'city' && cityTargetField.value) ||
(presentation.value.mapTarget === 'nation' && nationTargetField.value))
);
const mapSelectedCityId = computed<number | null>(() => {
if (!props.mapData) return null;
if (presentation.value.mapTarget === 'city' && cityTargetField.value) {
const value = values[cityTargetField.value.key];
return typeof value === 'number' ? value : null;
}
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
const value = values[nationTargetField.value.key];
if (typeof value !== 'number') return null;
return props.mapData.cityList.find((entry) => entry[3] === value)?.[0] ?? null;
}
return null;
});
const distanceFromMyCity = (destination: number): number | null => {
const start = props.mapData?.myCity;
if (!start || !props.mapLayout) return null;
if (start === destination) return 0;
const paths = new Map(props.mapLayout.cityList.map((city) => [city.id, city.path]));
const visited = new Set<number>([start]);
let frontier = [start];
for (let distance = 1; frontier.length; distance += 1) {
const next: number[] = [];
for (const cityId of frontier) {
for (const adjacentId of paths.get(cityId) ?? []) {
if (visited.has(adjacentId)) continue;
if (adjacentId === destination) return distance;
visited.add(adjacentId);
next.push(adjacentId);
}
}
frontier = next;
}
return null;
};
const mapTargetSummary = computed(() => {
if (!props.mapData || !props.mapLayout) return '';
if (presentation.value.mapTarget === 'city' && mapSelectedCityId.value) {
const city = props.mapLayout.cityList.find((entry) => entry.id === mapSelectedCityId.value);
const dynamic = props.mapData.cityList.find((entry) => entry[0] === mapSelectedCityId.value);
if (!city) return '';
const nation = props.mapData.nationList.find((entry) => entry[0] === dynamic?.[3]);
const distance = distanceFromMyCity(city.id);
return [
city.name,
nation?.[1] ?? '무주',
props.mapLayout.regionMap[dynamic?.[4] ?? city.region],
props.mapLayout.levelMap[dynamic?.[1] ?? city.level],
distance === null ? null : `현재 도시에서 ${distance}`,
]
.filter(Boolean)
.join(' · ');
}
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
const value = values[nationTargetField.value.key];
if (typeof value !== 'number') return '';
const nation = props.mapData.nationList.find((entry) => entry[0] === value);
if (!nation) return '';
const capital = props.mapLayout.cityList.find((entry) => entry.id === nation[3]);
const cityCount = props.mapData.cityList.filter((entry) => entry[3] === value).length;
return `${nation[1]} · 수도 ${capital?.name ?? '-'} · 도시 ${cityCount.toLocaleString()}`;
}
return '';
});
const selectMapCity = (cityId: number) => {
if (!props.mapData) return;
if (presentation.value.mapTarget === 'city' && cityTargetField.value) {
setSelectValue(cityTargetField.value, String(cityId));
return;
}
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
const nationId = props.mapData.cityList.find((entry) => entry[0] === cityId)?.[3];
if (nationId && nationId > 0) setSelectValue(nationTargetField.value, String(nationId));
}
};
const resourceSummary = computed(() => {
const context: CommandInputContext | undefined = props.options.context;
if (!context) return [];
const result: string[] = [];
const usesActorResources = new Set(['che_증여', 'che_헌납', 'che_군량매매', 'che_장비매매']);
const usesNationResources = new Set(['che_몰수', 'che_포상', 'che_물자원조']);
if (usesActorResources.has(props.commandKey)) {
result.push(
`현재 자금 ${context.actorGold.toLocaleString()}`,
`현재 군량 ${context.actorRice.toLocaleString()}`
);
}
if (props.commandKey === 'che_장비매매' && context.citySecurity !== undefined) {
result.push(`현재 도시 치안 ${context.citySecurity.toLocaleString()}`);
}
if (usesNationResources.has(props.commandKey)) {
if (context.nationGold !== undefined) result.push(`국고 ${context.nationGold.toLocaleString()}`);
if (context.nationRice !== undefined) result.push(`국가 군량 ${context.nationRice.toLocaleString()}`);
if (context.nationLevel !== undefined) result.push(`국가 작위 ${context.nationLevel}`);
}
return result;
});
const setTupleValue = (field: CommandInputField, index: number, rawValue: string) => {
const tuple = Array.isArray(values[field.key]) ? [...(values[field.key] as unknown[])] : [0, 0];
tuple[index] = Number(rawValue);
@@ -89,17 +207,32 @@ const isValid = computed(() =>
const value = values[field.key];
if (field.kind === 'text') {
const length = typeof value === 'string' ? value.trim().length : 0;
return (!field.required || length > 0) && (field.min === undefined || length >= field.min) &&
(field.max === undefined || length <= field.max);
return (
(!field.required || length > 0) &&
(field.min === undefined || length >= field.min) &&
(field.max === undefined || length <= field.max)
);
}
if (field.kind === 'number') {
return typeof value === 'number' && Number.isFinite(value) &&
(field.min === undefined || value >= field.min) && (field.max === undefined || value <= field.max);
return (
typeof value === 'number' &&
Number.isFinite(value) &&
(field.min === undefined || value >= field.min) &&
(field.max === undefined || value <= field.max)
);
}
if (field.kind === 'numberTuple') {
return Array.isArray(value) && value.length === 2 &&
value.every((entry) => typeof entry === 'number' && Number.isFinite(entry) &&
(field.min === undefined || entry >= field.min) && (field.max === undefined || entry <= field.max));
return (
Array.isArray(value) &&
value.length === 2 &&
value.every(
(entry) =>
typeof entry === 'number' &&
Number.isFinite(entry) &&
(field.min === undefined || entry >= field.min) &&
(field.max === undefined || entry <= field.max)
)
);
}
if (field.kind === 'select') return optionsFor(field).some((option) => option.value === value);
return value !== undefined;
@@ -119,11 +252,28 @@ watch(
<template>
<div v-if="props.fields.length" class="command-argument-form" data-testid="command-argument-form">
<div
v-for="field in props.fields.filter((entry) => entry.kind !== 'hidden')"
:key="field.key"
class="argument-row"
>
<div v-if="showMap" class="command-map" data-testid="command-argument-map">
<MapViewer
:map-data="props.mapData ?? null"
:map-layout="props.mapLayout ?? null"
:loading="false"
:selected-city-id="mapSelectedCityId"
:detail-mode="false"
:fit-container="true"
@select-city="selectMapCity"
/>
<small>지도에서 도시를 클릭하거나 아래 목록에서 대상을 선택하세요.</small>
<div v-if="mapTargetSummary" class="map-target-summary" data-testid="command-map-target-summary">
{{ mapTargetSummary }}
</div>
</div>
<div v-if="presentation.lines.length" class="command-guidance" data-testid="command-argument-guidance">
<div v-for="line in presentation.lines" :key="line">{{ line }}</div>
</div>
<div v-if="resourceSummary.length" class="resource-summary" data-testid="command-resource-summary">
<span v-for="entry in resourceSummary" :key="entry">{{ entry }}</span>
</div>
<div v-for="field in visibleFields" :key="field.key" class="argument-row">
<label :for="`command-arg-${field.key}`">{{ field.label }}</label>
<input
v-if="field.kind === 'text'"
@@ -182,6 +332,21 @@ watch(
/>
</label>
</div>
<div
v-if="
field.kind === 'select' &&
(selectedOptionFor(field)?.description || selectedOptionFor(field)?.color)
"
class="option-detail"
>
<span
v-if="selectedOptionFor(field)?.color"
class="option-color"
:style="{ backgroundColor: selectedOptionFor(field)?.color }"
aria-hidden="true"
/>
<span>{{ selectedOptionFor(field)?.description }}</span>
</div>
</div>
<div v-if="!isValid" class="argument-error" role="alert">필수 입력을 확인하세요.</div>
</div>
@@ -193,6 +358,43 @@ watch(
font-size: 0.75rem;
}
.command-map {
width: 100%;
overflow: hidden;
background: #111;
}
.command-map small {
display: block;
padding: 5px 8px;
color: rgba(232, 221, 196, 0.72);
}
.map-target-summary {
padding: 0 8px 6px;
color: #f1d89a;
line-height: 1.35;
}
.command-guidance {
display: grid;
gap: 3px;
padding: 8px;
border-bottom: 1px solid rgba(201, 164, 90, 0.35);
background: #191919;
color: #eee;
line-height: 1.35;
}
.resource-summary {
display: flex;
flex-wrap: wrap;
gap: 5px 14px;
padding: 6px 8px;
border-bottom: 1px solid rgba(201, 164, 90, 0.25);
color: #f1d89a;
}
.argument-row {
display: grid;
grid-template-columns: minmax(76px, 0.36fr) 1fr;
@@ -200,6 +402,23 @@ watch(
align-items: center;
}
.option-detail {
grid-column: 2;
display: flex;
align-items: center;
gap: 6px;
padding: 0 6px 6px 0;
color: rgba(232, 221, 196, 0.74);
line-height: 1.35;
}
.option-color {
width: 18px;
height: 18px;
flex: 0 0 18px;
border: 1px solid #ddd;
}
.argument-row:nth-child(odd) {
background: rgba(255, 255, 255, 0.035);
}
@@ -2,7 +2,13 @@
import { computed } from 'vue';
import { addMinutes } from 'date-fns';
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
import type { CommandPatternEntry, CommandTable, ReservedCommandRow } from '../command/types';
import type {
CommandMapData,
CommandMapLayout,
CommandPatternEntry,
CommandTable,
ReservedCommandRow,
} from '../command/types';
const props = defineProps<{
commandTable: CommandTable | null;
@@ -14,6 +20,8 @@ const props = defineProps<{
turnTermMinutes?: number;
autorunLimit?: number | null;
storageKey?: string;
mapData?: CommandMapData | null;
mapLayout?: CommandMapLayout | null;
}>();
const emit = defineEmits<{
@@ -63,6 +71,8 @@ const rows = computed<ReservedCommandRow[]>(() => {
:loading="props.loading"
:storage-key="props.storageKey ?? `core2026:general:${props.general?.id ?? 0}`"
:current-time="rows[0]?.time"
:map-data="props.mapData"
:map-layout="props.mapLayout"
@reserve-bulk="emit('set-general-turns', $event)"
@shift="emit('shift-general-turns', $event)"
@repeat="emit('repeat-general-turns', $event)"
@@ -1,9 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue';
import SkeletonLines from '../ui/SkeletonLines.vue';
import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
import { formatSeoulHourMinute } from '../../utils/legacyDateTime';
import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconBackgroundImage } from '../../utils/generalIcon';
import { configuredGameAssetUrl } from '../../utils/imageAssets';
interface GeneralStats {
leadership: number;
@@ -19,9 +22,18 @@ interface GeneralProgression {
statUpgradeLimit?: number;
}
interface ItemDisplayNames {
horse?: string | null;
weapon?: string | null;
book?: string | null;
item?: string | null;
}
interface GeneralInfo {
id: number;
name: string;
picture?: string | null;
imageServer?: number | null;
npcState: number;
officerLevel: number;
officerLevelText: string;
@@ -35,17 +47,36 @@ interface GeneralInfo {
experience: number;
dedication: number;
age?: number;
turnTime?: string;
turnTime?: string | null;
troopId?: number;
crewTypeId?: number;
crewTypeName?: string;
traits?: { personal: string; specialWar: string; specialDomestic: string };
progression?: GeneralProgression;
itemNames?: ItemDisplayNames;
equipmentNames?: ItemDisplayNames;
}
const props = defineProps<{
general: GeneralInfo | null;
loading: boolean;
}>();
const props = withDefaults(
defineProps<{
general: GeneralInfo | null;
loading: boolean;
nationColor?: string | null;
defenceText?: string | null;
killTurn?: number | null;
remainingMinutes?: number | null;
troopText?: string | null;
penaltyText?: string | number | null;
}>(),
{
nationColor: '#173d27',
defenceText: null,
killTurn: null,
remainingMinutes: null,
troopText: null,
penaltyText: null,
}
);
const statRows = computed(() => {
const general = props.general;
@@ -72,137 +103,320 @@ const statRows = computed(() => {
const experiencePercent = computed(() =>
legacyExperiencePercent(props.general?.experience ?? 0, props.general?.progression?.experienceLevel ?? 0)
);
const itemNames = computed<ItemDisplayNames>(() => props.general?.itemNames ?? props.general?.equipmentNames ?? {});
const generalIconBackground = computed(() => resolveGeneralIconBackgroundImage(props.general ?? {}));
const crewTypeIconBackground = computed(() => {
const crewTypeId = props.general?.crewTypeId;
if (crewTypeId === undefined || !Number.isFinite(crewTypeId)) {
return `url(${JSON.stringify(DEFAULT_GENERAL_ICON_URL)})`;
}
const crewTypeUrl = `${configuredGameAssetUrl()}/crewtype${Math.trunc(crewTypeId)}.png`;
return `url(${JSON.stringify(crewTypeUrl)}), url(${JSON.stringify(DEFAULT_GENERAL_ICON_URL)})`;
});
const injuryInfo = computed(() => {
const injury = props.general?.injury ?? 0;
if (injury > 60) return { text: '위독', color: '#ff4d4f' };
if (injury > 40) return { text: '심각', color: '#ff00ff' };
if (injury > 20) return { text: '중상', color: '#ff9f1a' };
if (injury > 0) return { text: '경상', color: '#ffff00' };
return { text: '건강', color: '#ffffff' };
});
const isBrightColor = (color: string): boolean => {
const normalized = /^#[0-9a-f]{6}$/iu.test(color) ? color.slice(1) : '173d27';
const red = Number.parseInt(normalized.slice(0, 2), 16);
const green = Number.parseInt(normalized.slice(2, 4), 16);
const blue = Number.parseInt(normalized.slice(4, 6), 16);
return (red * 299 + green * 587 + blue * 114) / 1000 >= 150;
};
const titleStyle = computed(() => {
const backgroundColor = props.nationColor || '#173d27';
return {
backgroundColor,
color: isBrightColor(backgroundColor) ? '#000000' : '#ffffff',
};
});
const ageColor = computed(() => {
const age = props.general?.age;
if (age === undefined) return '#ffffff';
if (age < 53) return '#32cd32';
if (age < 70) return '#ffff00';
return '#ff4d4f';
});
const displayTroop = computed(() => props.troopText ?? (props.general?.troopId ? String(props.general.troopId) : '-'));
const displayPenalty = computed(() => {
const penalty = props.penaltyText ?? '-';
const dedication = props.general?.progression?.dedicationText ?? '무품관';
return `${penalty} · 계급 ${dedication}`;
});
const displayDefence = computed(() => props.defenceText ?? '-');
const specialText = computed(() => {
const traits = props.general?.traits;
return traits ? `${traits.specialDomestic || '-'} / ${traits.specialWar || '-'}` : '-';
});
</script>
<template>
<div class="general-card">
<div v-if="props.loading">
<div class="general-card" data-general-basic-card>
<div v-if="props.loading" class="general-loading">
<SkeletonLines :lines="5" />
</div>
<div v-else-if="!props.general" class="empty">장수 정보를 불러오지 못했습니다.</div>
<div v-else class="general-body">
<div class="general-title">
{{ props.general.name }} · {{ props.general.officerLevelText }} · {{ props.general.age ?? '-' }} ·
다음
{{ props.general.turnTime ? formatSeoulHourMinute(props.general.turnTime) : '-' }}
</div>
<template v-else>
<div class="general-basic-grid general-body">
<span
class="general-image general-icon"
role="img"
:aria-label="`${props.general.name} 초상`"
:style="{ backgroundImage: generalIconBackground }"
/>
<div class="general-title battle-general-name" :style="titleStyle">
{{ props.general.name }} {{ props.general.officerLevelText }} |
<span :style="{ color: injuryInfo.color }">{{ injuryInfo.text }}</span> 다음
{{ props.general.turnTime ? formatSeoulHourMinute(props.general.turnTime) : '-' }}
</div>
<div class="stat-progress-grid">
<template v-for="stat of statRows" :key="stat.key">
<span class="cell-label">{{ stat.label }}</span>
<strong>{{ stat.value }}</strong>
<div class="bar-cell" :data-stat-progress="stat.key">
<LegacyProgressBar
:percent="stat.percent"
:label="`${stat.label} 성장 ${stat.accumulated} / ${stat.limit}`"
/>
</div>
<strong class="stat-value">
<span>{{ stat.value }}</span>
<span class="bar-cell" :data-stat-progress="stat.key">
<LegacyProgressBar
:percent="stat.percent"
:label="`${stat.label} 성장 ${stat.accumulated} / ${stat.limit}`"
/>
</span>
</strong>
</template>
</div>
<div class="legacy-grid">
<span>자금</span><strong>{{ props.general.gold.toLocaleString() }}</strong> <span>군량</span
><strong>{{ props.general.rice.toLocaleString() }}</strong> <span>병력</span
><strong>{{ props.general.crew.toLocaleString() }}</strong> <span>훈련</span
><strong>{{ props.general.train }}</strong> <span>사기</span><strong>{{ props.general.atmos }}</strong>
<span>부상</span><strong>{{ props.general.injury }}</strong> <span>병종</span
><strong>{{ props.general.crewTypeName ?? '-' }}</strong> <span>성격</span
><strong>{{ props.general.traits?.personal ?? '-' }}</strong> <span>전투특기</span
><strong>{{ props.general.traits?.specialWar ?? '-' }}</strong> <span>내정특기</span
><strong>{{ props.general.traits?.specialDomestic ?? '-' }}</strong> <span>계급</span
><strong>{{ props.general.progression?.dedicationText ?? '무품관' }}</strong> <span>공헌</span
><strong>{{ props.general.dedication.toLocaleString() }}</strong>
</div>
<span class="cell-label">명마</span><strong>{{ itemNames.horse ?? '-' }}</strong>
<span class="cell-label">무기</span><strong>{{ itemNames.weapon ?? '-' }}</strong>
<span class="cell-label">서적</span><strong>{{ itemNames.book ?? '-' }}</strong>
<div class="experience-row">
<span class="cell-label">Lv</span>
<strong>{{ props.general.progression?.experienceLevel ?? 0 }}</strong>
<div class="bar-cell" data-experience-progress>
<span
class="general-image general-crew-type-icon"
role="img"
:aria-label="`${props.general.crewTypeName ?? '병종'} 이미지`"
:style="{ backgroundImage: crewTypeIconBackground }"
/>
<span class="cell-label">자금</span><strong>{{ props.general.gold.toLocaleString('ko-KR') }}</strong>
<span class="cell-label">군량</span><strong>{{ props.general.rice.toLocaleString('ko-KR') }}</strong>
<span class="cell-label">도구</span><strong>{{ itemNames.item ?? '-' }}</strong>
<span class="cell-label">병종</span><strong>{{ props.general.crewTypeName ?? '-' }}</strong>
<span class="cell-label">병사</span><strong>{{ props.general.crew.toLocaleString('ko-KR') }}</strong>
<span class="cell-label">성격</span><strong>{{ props.general.traits?.personal ?? '-' }}</strong>
<span class="cell-label">훈련</span><strong>{{ props.general.train }}</strong>
<span class="cell-label">사기</span><strong>{{ props.general.atmos }}</strong>
<span class="cell-label">특기</span><strong :title="specialText">{{ specialText }}</strong>
<span class="cell-label level-label">Lv</span>
<strong class="level-value">{{ props.general.progression?.experienceLevel ?? 0 }}</strong>
<span class="experience-bar" data-experience-progress>
<LegacyProgressBar
:percent="experiencePercent"
:label="`경험 레벨 진행 ${experiencePercent.toFixed(1)}%`"
/>
</div>
<span class="experience-total">명성 {{ props.general.experience.toLocaleString() }}</span>
</span>
<span class="cell-label age-label">연령</span>
<strong class="age-value" :style="{ color: ageColor }">{{ props.general.age ?? '-' }}</strong>
<span class="cell-label defence-label">수비</span>
<strong class="defence-value">{{ displayDefence }}</strong>
<span class="cell-label kill-label">삭턴</span>
<strong class="kill-value">{{ props.killTurn === null ? '-' : `${props.killTurn}` }}</strong>
<span class="cell-label execute-label">실행</span>
<strong class="execute-value">{{
props.remainingMinutes === null ? '-' : `${props.remainingMinutes}분 남음`
}}</strong>
<span class="cell-label troop-label">부대</span>
<strong class="troop-value">{{ displayTroop }}</strong>
<span class="cell-label penalty-label">벌점</span>
<strong class="penalty-value">{{ displayPenalty }}</strong>
</div>
</div>
<slot name="details" />
</template>
</div>
</template>
<style scoped>
.general-title {
.general-card {
box-sizing: border-box;
height: 20px;
min-height: 20px;
padding: 1px 6px;
border-bottom: 1px solid #777;
background: #173d27;
text-align: center;
font-size: 12px;
font-weight: 700;
}
.stat-progress-grid {
display: grid;
grid-template-columns: repeat(3, minmax(30px, 1fr) minmax(34px, 1fr) 45px);
grid-auto-rows: 21px;
font-size: 12px;
}
.stat-progress-grid > *,
.legacy-grid > * {
box-sizing: border-box;
height: 21px;
min-height: 0;
border-right: 1px solid #666;
border-bottom: 1px solid #666;
padding: 2px 4px;
width: 100%;
min-width: 0;
overflow: hidden;
background-color: #172a52;
background-image: var(--sammo-texture-blue);
color: #fff;
font-size: 12px;
}
.general-basic-grid {
display: grid;
box-sizing: border-box;
width: 100%;
min-width: 0;
grid-template-columns: 64px repeat(3, minmax(30px, 2fr) minmax(60px, 5fr));
grid-template-rows: repeat(9, calc(64px / 3));
border-right: 1px solid #777;
border-bottom: 1px solid #777;
text-align: center;
}
.general-basic-grid > * {
box-sizing: border-box;
min-width: 0;
min-height: 0;
border-top: 1px solid #777;
border-left: 1px solid #777;
padding: 1px 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cell-label,
.legacy-grid > span {
background: rgb(20 75 42 / 70%);
.general-basic-grid > strong {
font-weight: 500;
text-align: center;
}
.stat-progress-grid > strong,
.legacy-grid > strong {
text-align: right;
font-weight: 400;
.cell-label {
background-color: rgb(20 75 42 / 70%);
}
.bar-cell {
.general-image {
display: block;
width: 64px;
height: 64px;
padding: 0;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
.general-icon {
grid-column: 1;
grid-row: 1 / 4;
}
.general-title {
grid-column: 2 / 8;
grid-row: 1;
font-size: 12px;
font-weight: 700;
line-height: 18px;
}
.stat-value {
display: grid;
grid-template-columns: minmax(22px, auto) minmax(26px, 1fr);
align-items: center;
gap: 2px;
}
.bar-cell,
.experience-bar {
display: grid;
align-content: center;
padding: 0 1px;
}
.legacy-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
grid-auto-rows: 21px;
font-size: 12px;
.general-crew-type-icon {
grid-column: 1;
grid-row: 4 / 7;
}
.experience-row {
display: grid;
box-sizing: border-box;
grid-template-columns: 32px 38px minmax(120px, 1fr) 112px;
height: 20px;
min-height: 20px;
border-bottom: 1px solid #666;
font-size: 12px;
.level-label {
grid-column: 1;
grid-row: 7;
}
.experience-row > * {
display: grid;
align-content: center;
box-sizing: border-box;
border-right: 1px solid #666;
padding: 1px 4px;
text-align: center;
.level-value {
grid-column: 2;
grid-row: 7;
}
.experience-bar {
grid-column: 3 / 6;
grid-row: 7;
}
.age-label {
grid-column: 6;
grid-row: 7;
}
.age-value {
grid-column: 7;
grid-row: 7;
}
.defence-label {
grid-column: 1;
grid-row: 8;
}
.defence-value {
grid-column: 2 / 4;
grid-row: 8;
}
.kill-label {
grid-column: 4;
grid-row: 8;
}
.kill-value {
grid-column: 5;
grid-row: 8;
}
.execute-label {
grid-column: 6;
grid-row: 8;
}
.execute-value {
grid-column: 7;
grid-row: 8;
}
.troop-label {
grid-column: 1;
grid-row: 9;
}
.troop-value {
grid-column: 2 / 4;
grid-row: 9;
}
.penalty-label {
grid-column: 4;
grid-row: 9;
}
.penalty-value {
grid-column: 5 / 8;
grid-row: 9;
}
.general-loading,
.empty {
min-height: 192px;
padding: 8px;
}
.empty {
@@ -1,5 +1,9 @@
<script setup lang="ts">
defineProps<{
import { computed } from 'vue';
import { resolveTournamentStageName } from '../../utils/tournamentStatus';
const props = defineProps<{
tournamentStage: number;
status: {
onlineUserCount: number;
onlineNations: string;
@@ -13,15 +17,24 @@ defineProps<{
} | null;
} | null;
}>();
const tournamentStatus = computed(() => resolveTournamentStageName(props.tournamentStage));
</script>
<template>
<section class="front-status" aria-label="접속 현황과 국가 방침">
<div class="status-row vote-status">
<RouterLink v-if="status?.latestVote" to="/survey">
<span class="vote-label">설문 진행 : </span>{{ status.latestVote.title }}
</RouterLink>
<span v-else class="vote-empty">진행중인 설문 없음</span>
<div class="activity-status" aria-label="설문과 토너먼트 진행 현황">
<div class="status-row tournament-status">
<RouterLink to="/tournament">
<span class="tournament-label">토너먼트: </span>{{ tournamentStatus }}
</RouterLink>
</div>
<div class="status-row vote-status">
<RouterLink v-if="status?.latestVote" to="/survey">
<span class="vote-label">설문: </span>{{ status.latestVote.title }}
</RouterLink>
<span v-else class="vote-empty">설문: 진행 중인 설문 없음</span>
</div>
</div>
<div class="status-row online-nations">접속중인 국가: {{ status?.onlineNations ?? '' }}</div>
<div class="status-row online-users"> 접속자 {{ status?.onlineGenerals ?? '' }}</div>
@@ -71,19 +84,28 @@ defineProps<{
margin: 0;
}
.vote-status {
width: 33.333333%;
.activity-status {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
width: 66.666667%;
margin-left: auto;
}
.activity-status .status-row {
padding-right: 0;
padding-left: 0;
text-align: center;
}
.vote-status a {
.activity-status a {
color: #fff;
text-decoration: gray underline;
}
.tournament-label {
color: #ffc107;
}
.vote-label {
color: cyan;
}
@@ -93,8 +115,8 @@ defineProps<{
}
@media (max-width: 991px) {
.vote-status {
width: 50%;
.activity-status {
width: 100%;
}
}
</style>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
interface MapCityView {
id: number;
name: string;
@@ -20,6 +21,7 @@ const props = defineProps<{
city: MapCityView;
showName: boolean;
mapScale: number;
selectOnly?: boolean;
}>();
const emit = defineEmits<{
@@ -31,12 +33,15 @@ const emit = defineEmits<{
const size = computed(() => (6 + props.city.level * 2) * props.mapScale);
const stateSize = computed(() => 8 * props.mapScale);
const stateOffset = computed(() => -6 * props.mapScale);
const selectCity = () => emit('select', props.city.id);
</script>
<template>
<RouterLink
<component
:is="props.selectOnly ? 'button' : RouterLink"
class="map-city"
:to="{ name: 'current-city', query: { cityId: props.city.id } }"
:type="props.selectOnly ? 'button' : undefined"
:to="props.selectOnly ? undefined : { name: 'current-city', query: { cityId: props.city.id } }"
:class="[
`state-${props.city.stateClass}`,
{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply },
@@ -44,7 +49,7 @@ const stateOffset = computed(() => -6 * props.mapScale);
:style="{ left: `${props.city.x}px`, top: `${props.city.y}px` }"
@mouseenter="emit('hover', props.city.id)"
@mouseleave="emit('leave')"
@click.stop="emit('select', props.city.id)"
@click.stop="selectCity"
>
<div class="city-dot" :style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }">
<span v-if="props.city.isCapital" class="capital" />
@@ -61,7 +66,7 @@ const stateOffset = computed(() => -6 * props.mapScale);
}"
/>
<div v-if="props.showName" class="city-name">{{ props.city.name }}</div>
</RouterLink>
</component>
</template>
<style scoped>
@@ -76,6 +81,9 @@ const stateOffset = computed(() => -6 * props.mapScale);
color: rgba(232, 221, 196, 0.8);
cursor: pointer;
text-decoration: none;
padding: 0;
border: 0;
background: transparent;
}
.city-dot {
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import { buildAssetUrl, normalizeColorToken } from '../../utils/mapAssets';
interface MapCityView {
@@ -46,6 +47,7 @@ const props = defineProps<{
imageBaseUrl: string;
themeName: string;
mapScale: number;
selectOnly?: boolean;
}>();
const emit = defineEmits<{
@@ -141,6 +143,8 @@ const capitalIconStyle = computed(() => ({
height: `${10 * props.mapScale}px`,
}));
const selectCity = () => emit('select', props.city.id);
const cityStateStyle = computed(() => ({
width: `${12 * props.mapScale}px`,
height: `${12 * props.mapScale}px`,
@@ -149,14 +153,16 @@ const cityStateStyle = computed(() => ({
</script>
<template>
<RouterLink
<component
:is="props.selectOnly ? 'button' : RouterLink"
class="city-base"
:to="{ name: 'current-city', query: { cityId: props.city.id } }"
:type="props.selectOnly ? 'button' : undefined"
:to="props.selectOnly ? undefined : { name: 'current-city', query: { cityId: props.city.id } }"
:class="[{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply }]"
:style="cityBaseStyle"
@mouseenter="emit('hover', props.city.id)"
@mouseleave="emit('leave')"
@click.stop="emit('select', props.city.id)"
@click.stop="selectCity"
>
<div v-if="cityBgStyle" class="city-bg" :style="[cityBgWrapperStyle, cityBgStyle]" />
<div class="city-img" :style="cityIconStyle">
@@ -173,7 +179,7 @@ const cityStateStyle = computed(() => ({
<div v-if="stateIcon" class="city-state" :style="cityStateStyle">
<img :src="stateIcon" />
</div>
</RouterLink>
</component>
</template>
<style scoped>
@@ -184,6 +190,9 @@ const cityStateStyle = computed(() => ({
color: #fff;
cursor: auto;
text-decoration: none;
padding: 0;
border: 0;
background: transparent;
}
.city-bg {
@@ -67,6 +67,13 @@ const props = defineProps<{
mapData: MapSummary | null;
mapLayout: MapLayout | null;
loading: boolean;
selectedCityId?: number | null;
detailMode?: boolean;
fitContainer?: boolean;
}>();
const emit = defineEmits<{
(event: 'select-city', cityId: number): void;
}>();
const BASE_MAP_WIDTH = 700;
@@ -75,7 +82,12 @@ const SMALL_MAP_SCALE = 5 / 7;
const isWide = useMediaQuery('(min-width: 1024px)');
const mapStore = useMapViewerStore();
const { showCityName, detailMode, hoveredCityId, selectedCityId } = storeToRefs(mapStore);
const {
showCityName,
detailMode: storeDetailMode,
hoveredCityId,
selectedCityId: storeSelectedCityId,
} = storeToRefs(mapStore);
const mapArea = ref<HTMLElement | null>(null);
const mapBody = ref<HTMLElement | null>(null);
@@ -140,15 +152,20 @@ const dynamicCityById = computed(() => {
});
const mapScale = computed(() => {
if (isWide.value) {
if (isWide.value && !props.fitContainer) {
return 1;
}
if (mapBodyWidth.value <= 0) {
return SMALL_MAP_SCALE;
}
return Math.min(SMALL_MAP_SCALE, mapBodyWidth.value / BASE_MAP_WIDTH);
return Math.min(props.fitContainer ? 1 : SMALL_MAP_SCALE, mapBodyWidth.value / BASE_MAP_WIDTH);
});
const effectiveDetailMode = computed(() => props.detailMode ?? storeDetailMode.value);
const effectiveSelectedCityId = computed(() =>
props.selectedCityId === undefined ? storeSelectedCityId.value : props.selectedCityId
);
const mapWidth = computed(() => `${BASE_MAP_WIDTH * mapScale.value}px`);
const mapHeight = computed(() => `${BASE_MAP_HEIGHT * mapScale.value}px`);
@@ -185,7 +202,7 @@ const cityViews = computed<CityView[]>(() => {
y,
isCapital: nation?.capitalCityId === layoutCity.id,
isMyCity: props.mapData?.myCity === layoutCity.id,
selected: selectedCityId.value === layoutCity.id,
selected: effectiveSelectedCityId.value === layoutCity.id,
};
});
});
@@ -258,7 +275,7 @@ const titleTooltipLines = computed(() => {
});
const titleBandStyle = computed(() =>
detailMode.value
effectiveDetailMode.value
? {
backgroundImage: `url('${resolveAsset('ltitle.jpg')}'), url('${resolveAsset('rtitle.jpg')}')`,
}
@@ -266,7 +283,7 @@ const titleBandStyle = computed(() =>
);
const titleTextStyle = computed(() =>
detailMode.value
effectiveDetailMode.value
? {
color: titleColor.value,
backgroundImage: `url('${resolveAsset('ad.gif')}'), url('${resolveAsset(`${mapSeason.value}.gif`)}')`,
@@ -327,7 +344,7 @@ const mapRoadStyle = computed(() => ({
}));
const detailProps = computed(() =>
detailMode.value
effectiveDetailMode.value
? {
imageBaseUrl: assetBaseUrl.value,
themeName: mapTheme.value,
@@ -365,7 +382,10 @@ const setHoveredCity = (cityId: number | null) => {
};
const selectCity = (cityId: number) => {
mapStore.setSelectedCity(cityId);
emit('select-city', cityId);
if (props.selectedCityId === undefined) {
mapStore.setSelectedCity(cityId);
}
};
</script>
@@ -394,12 +414,13 @@ const selectCity = (cityId: number) => {
<div class="map-layer map-bglayer2" />
<div v-if="mapRoadImage" class="map-layer map-bgroad" :style="mapRoadStyle" />
<component
:is="detailMode ? MapCityDetail : MapCityBasic"
:is="effectiveDetailMode ? MapCityDetail : MapCityBasic"
v-for="city in cityViews"
:key="city.id"
:city="city"
:map-scale="mapScale"
:show-name="showCityName"
:select-only="props.selectedCityId !== undefined"
v-bind="detailProps"
@hover="setHoveredCity"
@leave="setHoveredCity(null)"
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import GeneralIdentity from '../ui/GeneralIdentity.vue';
import {
buildTournamentBracket,
type TournamentBracketMatch,
@@ -89,7 +90,12 @@ const odds = (id: number | null) => {
:class="{ advanced: bracket.champion.advanced }"
:data-general-id="bracket.champion.id ?? undefined"
>
{{ bracket.champion.name }}
<GeneralIdentity
:name="bracket.champion.name"
:picture="bracket.champion.picture"
:image-server="bracket.champion.imageServer"
:icon-size="24"
/>
</span>
</div>
@@ -110,7 +116,12 @@ const odds = (id: number | null) => {
:class="{ advanced: slot.advanced }"
:data-general-id="slot.id ?? undefined"
>
{{ slot.name }}
<GeneralIdentity
:name="slot.name"
:picture="slot.picture"
:image-server="slot.imageServer"
:icon-size="22"
/>
</span>
</div>
<div class="connector-row" :style="{ '--connector-count': round.slots.length }">
@@ -140,7 +151,12 @@ const odds = (id: number | null) => {
:class="{ advanced: slot.advanced }"
:data-general-id="slot.id ?? undefined"
>
{{ slot.name }}
<GeneralIdentity
:name="slot.name"
:picture="slot.picture"
:image-server="slot.imageServer"
:icon-size="20"
/>
</span>
</div>
<div class="bracket-round bracket-odds" :style="roundStyle(bracket.top16)">
@@ -183,9 +199,17 @@ const odds = (id: number | null) => {
:key="`mobile-${columnIndex}-${slot.id ?? 'empty'}-${slotIndex}`"
class="mobile-bracket-name"
:class="{ advanced: slot.advanced }"
:style="{ left: `${mobileX[columnIndex]}px`, top: `${mobileY(columnIndex, slotIndex)}px` }"
:style="{
left: `${(mobileX[columnIndex]! / 390) * 100}%`,
top: `${mobileY(columnIndex, slotIndex)}px`,
}"
>
{{ slot.name }}
<GeneralIdentity
:name="slot.name"
:picture="slot.picture"
:image-server="slot.imageServer"
:icon-size="18"
/>
</span>
</template>
</div>
@@ -210,21 +234,23 @@ const odds = (id: number | null) => {
white-space: nowrap;
}
.bracket-canvas {
width: 2000px;
min-width: 2000px;
width: 100%;
min-width: 1000px;
max-width: 1200px;
margin: 0 auto;
}
.mobile-bracket {
position: relative;
display: none;
width: 390px;
width: 100%;
max-width: 390px;
height: 544px;
margin: 0 auto;
}
.mobile-bracket svg {
position: absolute;
inset: 0;
width: 390px;
width: 100%;
height: 544px;
}
.mobile-connector {
@@ -239,14 +265,16 @@ const odds = (id: number | null) => {
.mobile-bracket-name {
position: absolute;
z-index: 1;
width: 64px;
width: clamp(58px, 18vw, 72px);
overflow: hidden;
transform: translate(-50%, -50%);
border: 1px solid #555;
background: rgb(58 33 24 / 92%);
color: #fff;
font-size: 12px;
line-height: 22px;
min-height: 26px;
padding: 2px;
font-size: 11px;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -265,7 +293,7 @@ const odds = (id: number | null) => {
}
.bracket-name {
overflow: hidden;
padding: 0 3px;
padding: 2px 3px;
color: #fff;
text-overflow: ellipsis;
white-space: nowrap;
@@ -321,8 +349,8 @@ const odds = (id: number | null) => {
}
@media (max-width: 800px) {
.tournament-bracket {
width: 100vw;
max-width: 100vw;
width: 100%;
max-width: 100%;
overflow-x: hidden;
}
.bracket-canvas {
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { computed } from 'vue';
import { resolveGeneralIconUrl, useDefaultGeneralIcon, type GeneralIconSource } from '../../utils/generalIcon';
const props = withDefaults(
defineProps<{
name: string;
picture?: GeneralIconSource['picture'];
imageServer?: GeneralIconSource['imageServer'];
iconSize?: number;
hideIcon?: boolean;
}>(),
{
picture: null,
imageServer: 0,
iconSize: 28,
hideIcon: false,
}
);
const iconUrl = computed(() =>
resolveGeneralIconUrl({
picture: props.picture,
imageServer: props.imageServer,
})
);
const identityStyle = computed(() => ({ '--general-identity-icon-size': `${props.iconSize}px` }));
</script>
<template>
<span class="general-identity" :style="identityStyle">
<img
v-if="!hideIcon && name !== '-'"
class="general-identity-icon"
:src="iconUrl"
alt=""
aria-hidden="true"
@error="useDefaultGeneralIcon"
/>
<span class="general-identity-name">{{ name }}</span>
</span>
</template>
<style scoped>
.general-identity {
display: inline-flex;
min-width: 0;
max-width: 100%;
align-items: center;
justify-content: center;
gap: 5px;
vertical-align: middle;
}
.general-identity-icon {
width: var(--general-identity-icon-size);
height: var(--general-identity-icon-size);
flex: 0 0 var(--general-identity-icon-size);
border: 1px solid rgb(255 255 255 / 28%);
background: #111;
object-fit: cover;
}
.general-identity-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -15,7 +15,9 @@ type GeneralProgress = {
};
};
const props = defineProps<{ general: GeneralProgress }>();
const props = withDefaults(defineProps<{ general: GeneralProgress; showPrimary?: boolean }>(), {
showPrimary: true,
});
const statRows = computed(() =>
[
@@ -50,7 +52,7 @@ const experiencePercent = computed(() =>
<template>
<div class="legacy-general-progress">
<div class="stat-grid">
<div v-if="props.showPrimary" class="stat-grid">
<template v-for="stat of statRows" :key="stat.key">
<span class="cell-label">{{ stat.label }}</span>
<strong>{{ stat.value }}</strong>
@@ -60,7 +62,7 @@ const experiencePercent = computed(() =>
/>
</template>
</div>
<div class="experience-row">
<div v-if="props.showPrimary" class="experience-row">
<span class="cell-label">Lv</span>
<strong>{{ props.general.progression.experienceLevel }}</strong>
<LegacyProgressBar