feat: complete legacy game screen parity

This commit is contained in:
2026-08-04 13:31:05 +00:00
parent c4c4ee6e0f
commit a333ef9681
19 changed files with 436 additions and 207 deletions
@@ -29,37 +29,52 @@ const props = defineProps<{
</div> </div>
<div v-else-if="!props.city" class="empty">도시 정보를 불러오지 못했습니다.</div> <div v-else-if="!props.city" class="empty">도시 정보를 불러오지 못했습니다.</div>
<div v-else class="city-body"> <div v-else class="city-body">
<div class="title">{{ props.city.name }} (Lv {{ props.city.level }})</div> <div class="title">
{{ props.city.name }} (Lv {{ props.city.level }}) · 국가 {{ props.city.nationId || '무주' }}
</div>
<div class="grid"> <div class="grid">
<div>인구 {{ props.city.population }}</div> <span>인구</span><strong>{{ props.city.population.toLocaleString() }}</strong> <span>농업</span
<div>농업 {{ props.city.agriculture }}</div> ><strong>{{ props.city.agriculture.toLocaleString() }}</strong> <span>상업</span
<div>상업 {{ props.city.commerce }}</div> ><strong>{{ props.city.commerce.toLocaleString() }}</strong> <span>치안</span
<div>치안 {{ props.city.security }}</div> ><strong>{{ props.city.security.toLocaleString() }}</strong> <span>수비</span
<div>방어 {{ props.city.defence }}</div> ><strong>{{ props.city.defence.toLocaleString() }}</strong> <span>성벽</span
<div>성벽 {{ props.city.wall }}</div> ><strong>{{ props.city.wall.toLocaleString() }}</strong> <span>보급</span
<div>보급 {{ props.city.supplyState }}</div> ><strong>{{ props.city.supplyState }}</strong> <span>전방</span
<div>전방 {{ props.city.frontState }}</div> ><strong>{{ props.city.frontState }}</strong>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.city-card {
display: flex;
flex-direction: column;
gap: 8px;
}
.title { .title {
min-height: 24px;
padding: 2px 6px;
border-bottom: 1px solid #666;
background: #173d27;
text-align: center;
font-weight: 600; font-weight: 600;
} }
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 6px; font-size: 12px;
font-size: 0.85rem; }
.grid > * {
min-height: 23px;
box-sizing: border-box;
border-right: 1px solid #666;
border-bottom: 1px solid #666;
padding: 2px 5px;
}
.grid > span {
background: rgb(20 75 42 / 70%);
text-align: center;
}
.grid > strong {
text-align: right;
font-weight: 400;
} }
.empty { .empty {
@@ -150,8 +150,7 @@ const clearNationTurn = (index: number) => {
emit('set-nation-turn', { index, action: '휴식', args: {} }); emit('set-nation-turn', { index, action: '휴식', args: {} });
}; };
const canNationReserve = () => const canNationReserve = () => Boolean(props.general && props.general.nationId > 0 && props.general.officerLevel >= 5);
Boolean(props.general && props.general.nationId > 0 && props.general.officerLevel >= 5);
</script> </script>
<template> <template>
@@ -160,7 +159,8 @@ const canNationReserve = () =>
<div class="label">선택 도시</div> <div class="label">선택 도시</div>
<div class="value"> <div class="value">
<span v-if="props.selectedCity"> <span v-if="props.selectedCity">
{{ props.selectedCity.name }} · {{ props.selectedCity.nationName }} · {{ props.selectedCity.regionName }} {{ props.selectedCity.name }} · {{ props.selectedCity.nationName }} ·
{{ props.selectedCity.regionName }}
</span> </span>
<span v-else>선택된 도시 없음</span> <span v-else>선택된 도시 없음</span>
</div> </div>
@@ -260,24 +260,32 @@ const canNationReserve = () =>
} }
.command-selection { .command-selection {
border: 1px solid rgba(201, 164, 90, 0.35); display: grid;
padding: 6px 8px; grid-template-columns: 64px minmax(0, 1fr);
font-size: 0.75rem; min-height: 24px;
display: flex; border: 1px solid #666;
flex-direction: column; font-size: 12px;
gap: 4px;
} }
.command-selection .label { .command-selection .label {
color: rgba(232, 221, 196, 0.6); padding: 2px 5px;
background: #173d27;
color: #fff;
text-align: center;
}
.command-selection .value {
overflow: hidden;
padding: 2px 5px;
white-space: nowrap;
text-overflow: ellipsis;
} }
.command-selected { .command-selected {
border: 1px solid rgba(201, 164, 90, 0.3); border: 1px solid #666;
padding: 8px; padding: 3px 5px;
display: grid; display: grid;
gap: 6px; grid-template-columns: 64px minmax(0, 1fr);
font-size: 0.75rem; font-size: 12px;
} }
.command-selected .label { .command-selected .label {
@@ -98,18 +98,8 @@ watch(selectedCategory, (value) => {
} }
}); });
const statusLabel = (command: CommandAvailability) => { const commandTitle = (command: CommandAvailability) =>
if (command.status === 'available') { command.reason || (command.reqArg ? '대상을 선택하는 명령입니다.' : command.possible ? '실행 가능' : '실행 불가');
return '가능';
}
if (command.status === 'needsInput') {
return '입력 필요';
}
if (command.status === 'blocked') {
return '불가';
}
return '확인 필요';
};
</script> </script>
<template> <template>
@@ -140,10 +130,10 @@ const statusLabel = (command: CommandAvailability) => {
command.status === 'blocked' ? 'blocked' : '', command.status === 'blocked' ? 'blocked' : '',
]" ]"
:disabled="!command.possible" :disabled="!command.possible"
:title="commandTitle(command)"
@click="emit('select', command.key)" @click="emit('select', command.key)"
> >
<span class="command-name">{{ command.name }}</span> <span class="command-name">{{ command.name }}</span>
<span class="command-status">{{ statusLabel(command) }}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -154,49 +144,63 @@ const statusLabel = (command: CommandAvailability) => {
.command-form { .command-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 0;
border-top: 1px solid #666;
border-left: 1px solid #666;
} }
.category-list { .category-list {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px; gap: 0;
} }
.category-btn { .category-btn {
border: 1px solid rgba(201, 164, 90, 0.4); min-height: 24px;
padding: 6px 8px; border: 0;
font-size: 0.75rem; border-right: 1px solid #666;
border-bottom: 1px solid #666;
padding: 2px 4px;
background: #173d27;
color: #fff;
font-size: 12px;
cursor: pointer; cursor: pointer;
} }
.category-btn.active { .category-btn.active {
background: rgba(201, 164, 90, 0.2); background: #28633f;
color: #ffe38a;
} }
.command-grid { .command-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px; gap: 0;
} }
.command-item { .command-item {
border: 1px solid rgba(201, 164, 90, 0.3); min-height: 24px;
padding: 6px; border: 0;
border-right: 1px solid #666;
border-bottom: 1px solid #666;
padding: 2px 5px;
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 4px; justify-content: center;
text-align: left; background: #302016 var(--sammo-texture-walnut);
font-size: 0.75rem; color: #fff;
text-align: center;
font-size: 12px;
cursor: pointer; cursor: pointer;
} }
.command-item.ok { .command-item.ok {
border-color: rgba(201, 164, 90, 0.6); color: #d9f7df;
} }
.command-item.blocked { .command-item.blocked {
opacity: 0.5; color: #888;
opacity: 0.72;
cursor: not-allowed; cursor: not-allowed;
} }
@@ -204,11 +208,6 @@ const statusLabel = (command: CommandAvailability) => {
font-weight: 600; font-weight: 600;
} }
.command-status {
font-size: 0.7rem;
color: rgba(232, 221, 196, 0.6);
}
.empty { .empty {
color: rgba(232, 221, 196, 0.6); color: rgba(232, 221, 196, 0.6);
} }
@@ -21,6 +21,11 @@ interface GeneralInfo {
injury: number; injury: number;
experience: number; experience: number;
dedication: number; dedication: number;
age?: number;
turnTime?: string;
crewTypeId?: number;
traits?: { personal: string; specialWar: string; specialDomestic: string };
progression?: { experienceLevel: number; dedicationLevel: number; dex: number[] };
} }
const props = defineProps<{ const props = defineProps<{
@@ -36,61 +41,71 @@ const props = defineProps<{
</div> </div>
<div v-else-if="!props.general" class="empty">장수 정보를 불러오지 못했습니다.</div> <div v-else-if="!props.general" class="empty">장수 정보를 불러오지 못했습니다.</div>
<div v-else class="general-body"> <div v-else class="general-body">
<div class="general-header"> <div class="general-title">
<span class="name">{{ props.general.name }}</span> {{ props.general.name }} · 관직 {{ props.general.officerLevel }} · {{ props.general.age ?? '-' }}
<span class="meta">ID {{ props.general.id }} · 관직 {{ props.general.officerLevel }}</span>
</div> </div>
<div class="stats"> <div class="legacy-grid">
<div>통솔 {{ props.general.stats.leadership }}</div> <span>통솔</span><strong>{{ props.general.stats.leadership }}</strong> <span>무력</span
<div>무력 {{ props.general.stats.strength }}</div> ><strong>{{ props.general.stats.strength }}</strong> <span>지력</span
<div>지력 {{ props.general.stats.intelligence }}</div> ><strong>{{ props.general.stats.intelligence }}</strong> <span>자금</span
</div> ><strong>{{ props.general.gold.toLocaleString() }}</strong> <span>군량</span
<div class="resources"> ><strong>{{ props.general.rice.toLocaleString() }}</strong> <span>병력</span
<div> {{ props.general.gold }}</div> ><strong>{{ props.general.crew.toLocaleString() }}</strong> <span>훈련</span
<div> {{ props.general.rice }}</div> ><strong>{{ props.general.train }}</strong> <span>사기</span><strong>{{ props.general.atmos }}</strong>
<div> {{ props.general.crew }}</div> <span>부상</span><strong>{{ props.general.injury }}</strong> <span>명망</span
</div> ><strong
<div class="status"> >Lv {{ props.general.progression?.experienceLevel ?? 0 }} ({{ props.general.experience }})</strong
<div>훈련 {{ props.general.train }}</div> >
<div>사기 {{ props.general.atmos }}</div> <span>계급</span
<div>부상 {{ props.general.injury }}</div> ><strong
<div>경험 {{ props.general.experience }}</div> >Lv {{ props.general.progression?.dedicationLevel ?? 0 }} ({{ props.general.dedication }})</strong
<div>공헌 {{ props.general.dedication }}</div> >
<span>병종</span><strong>{{ props.general.crewTypeId || '-' }}</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.turnTime?.slice(11, 16) ?? '-' }}</strong>
</div> </div>
<div class="dex">숙련도 {{ props.general.progression?.dex?.join(' / ') ?? '0 / 0 / 0 / 0 / 0' }}</div>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.general-card { .general-title {
display: flex; min-height: 24px;
flex-direction: column; padding: 2px 6px;
gap: 8px; border-bottom: 1px solid #777;
background: #173d27;
text-align: center;
font-weight: 700;
} }
.legacy-grid {
.general-header {
display: flex;
flex-direction: column;
gap: 4px;
}
.general-header .name {
font-size: 1.1rem;
font-weight: 600;
}
.general-header .meta {
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.7);
}
.stats,
.resources,
.status {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 6px; font-size: 12px;
font-size: 0.85rem; }
.legacy-grid > * {
min-height: 22px;
box-sizing: border-box;
border-right: 1px solid #666;
border-bottom: 1px solid #666;
padding: 2px 4px;
overflow: hidden;
white-space: nowrap;
}
.legacy-grid > span {
background: rgb(20 75 42 / 70%);
text-align: center;
}
.legacy-grid > strong {
text-align: right;
font-weight: 400;
}
.dex {
padding: 3px 6px;
font-size: 12px;
color: #ddd;
} }
.empty { .empty {
@@ -36,8 +36,9 @@ defineProps<{
<style scoped> <style scoped>
.front-status { .front-status {
width: calc(100% + 48px); box-sizing: border-box;
margin-left: -24px; width: 100%;
margin-left: 0;
background-color: #302016; background-color: #302016;
background-image: var(--sammo-texture-walnut); background-image: var(--sammo-texture-walnut);
color: #fff; color: #fff;
@@ -7,6 +7,7 @@ import {
type NationNavigationAccess, type NationNavigationAccess,
} from './mainNavigation'; } from './mainNavigation';
import { useMenuPopup } from './useMenuPopup'; import { useMenuPopup } from './useMenuPopup';
import { legacyNationTextColor } from '../../utils/legacyNationColor';
const props = defineProps<{ const props = defineProps<{
access: NationNavigationAccess; access: NationNavigationAccess;
@@ -23,6 +24,7 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
:ref="setRoot" :ref="setRoot"
class="main-nation-menu" class="main-nation-menu"
:style="{ '--nation-menu-color': nationColor || '#000000' }" :style="{ '--nation-menu-color': nationColor || '#000000' }"
:class="{ 'dark-label': legacyNationTextColor(nationColor) === '#000000' }"
aria-label="국가 메뉴" aria-label="국가 메뉴"
> >
<template v-for="entry in nationNavigation" :key="entry.id"> <template v-for="entry in nationNavigation" :key="entry.id">
@@ -83,6 +85,10 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
background-color: var(--nation-menu-color); background-color: var(--nation-menu-color);
background-image: none; background-image: none;
} }
.main-nation-menu.dark-label :deep(.main-menu-link),
.main-nation-menu.dark-label .main-menu-button {
color: #000;
}
.nation-menu-split { .nation-menu-split {
position: relative; position: relative;
@@ -280,18 +280,11 @@ const selectCity = (cityId: number) => {
<div class="map-viewer"> <div class="map-viewer">
<div class="map-top"> <div class="map-top">
<div class="map-title">{{ mapSummary }}</div> <div class="map-title">{{ mapSummary }}</div>
<div class="map-controls">
<button class="map-toggle" :class="{ active: showCityName }" @click="mapStore.toggleCityName">
도시명 표기 {{ showCityName ? '끄기' : '켜기' }}
</button>
</div>
</div> </div>
<div v-if="props.loading"> <div v-if="props.loading">
<SkeletonLines :lines="4" /> <SkeletonLines :lines="4" />
</div> </div>
<div v-else-if="!props.mapData || !props.mapLayout" class="map-empty"> <div v-else-if="!props.mapData || !props.mapLayout" class="map-empty">지도 데이터를 불러오지 못했습니다.</div>
지도 데이터를 불러오지 못했습니다.
</div>
<div v-else ref="mapBody" class="map-body"> <div v-else ref="mapBody" class="map-body">
<div <div
ref="mapArea" ref="mapArea"
@@ -324,14 +317,11 @@ const selectCity = (cityId: number) => {
{{ hoveredCity.nationName }} · {{ hoveredCity.regionName }} · {{ hoveredCity.levelName }} {{ hoveredCity.nationName }} · {{ hoveredCity.regionName }} · {{ hoveredCity.levelName }}
</div> </div>
</div> </div>
</div> <div class="map-controls">
<div class="map-meta"> <button class="map-toggle" :class="{ active: showCityName }" @click="mapStore.toggleCityName">
<span>도시 {{ props.mapData.cityList.length }}</span> 도시명 표기 {{ showCityName ? '끄기' : '켜기' }}
<span>세력 {{ props.mapData.nationList.length }}</span> </button>
<span>테마 {{ props.mapLayout.mapName }}</span> </div>
</div>
<div class="map-footnote">
좌표/도시명은 시나리오 레이아웃을 기준으로 표시됩니다.
</div> </div>
</div> </div>
</div> </div>
@@ -423,19 +413,6 @@ const selectCity = (cityId: number) => {
color: rgba(232, 221, 196, 0.6); color: rgba(232, 221, 196, 0.6);
} }
.map-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.6);
}
.map-footnote {
font-size: 0.65rem;
color: rgba(232, 221, 196, 0.5);
}
.map-empty { .map-empty {
color: rgba(232, 221, 196, 0.6); color: rgba(232, 221, 196, 0.6);
} }
@@ -377,7 +377,8 @@ const forwardResponse = (messageId: number, response: boolean) => {
} }
.empty-message { .empty-message {
min-height: 22px; min-height: 0;
padding: 2px 7px;
} }
.MessageList { .MessageList {
@@ -426,7 +427,7 @@ const forwardResponse = (messageId: number, response: boolean) => {
} }
.MessageList { .MessageList {
height: 650px; max-height: 650px;
overflow-y: auto; overflow-y: auto;
} }
} }
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import SkeletonLines from '../ui/SkeletonLines.vue'; import SkeletonLines from '../ui/SkeletonLines.vue';
import { legacyNationTextColor } from '../../utils/legacyNationColor';
interface NationInfo { interface NationInfo {
id: number; id: number;
@@ -26,46 +27,50 @@ const props = defineProps<{
</div> </div>
<div v-else-if="!props.nation" class="empty">국가 정보를 불러오지 못했습니다.</div> <div v-else-if="!props.nation" class="empty">국가 정보를 불러오지 못했습니다.</div>
<div v-else class="nation-body"> <div v-else class="nation-body">
<div class="title"> <div
<span class="color" :style="{ backgroundColor: props.nation.color }" /> class="title"
:style="{ backgroundColor: props.nation.color, color: legacyNationTextColor(props.nation.color) }"
>
{{ props.nation.name }} (Lv {{ props.nation.level }}) {{ props.nation.name }} (Lv {{ props.nation.level }})
</div> </div>
<div class="grid"> <div class="grid">
<div>국고 {{ props.nation.gold }}</div> <span>국고</span><strong>{{ props.nation.gold.toLocaleString() }}</strong> <span>국량</span
<div>국량 {{ props.nation.rice }}</div> ><strong>{{ props.nation.rice.toLocaleString() }}</strong> <span>기술</span
<div>기술 {{ props.nation.tech }}</div> ><strong>{{ props.nation.tech.toLocaleString() }}</strong> <span>체제</span
<div>체제 {{ props.nation.typeCode }}</div> ><strong>{{ props.nation.typeCode }}</strong> <span>수도</span
<div>수도 {{ props.nation.capitalCityId ?? '-' }}</div> ><strong>{{ props.nation.capitalCityId ?? '-' }}</strong> <span>국가 등급</span
><strong>{{ props.nation.level }}</strong>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.nation-card {
display: flex;
flex-direction: column;
gap: 8px;
}
.title { .title {
display: flex; min-height: 24px;
align-items: center; padding: 2px 6px;
gap: 8px; text-align: center;
font-weight: 600; font-weight: 600;
} }
.color {
width: 14px;
height: 14px;
border: 1px solid rgba(232, 221, 196, 0.6);
}
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px; font-size: 12px;
font-size: 0.85rem; }
.grid > * {
min-height: 23px;
box-sizing: border-box;
border-right: 1px solid #666;
border-bottom: 1px solid #666;
padding: 2px 5px;
}
.grid > span {
background: rgb(20 75 42 / 70%);
text-align: center;
}
.grid > strong {
text-align: right;
font-weight: 400;
} }
.empty { .empty {
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { onBeforeUnmount, watch } from 'vue';
import { EditorContent, useEditor } from '@tiptap/vue-3';
import StarterKit from '@tiptap/starter-kit';
import Underline from '@tiptap/extension-underline';
import Link from '@tiptap/extension-link';
const props = withDefaults(defineProps<{ modelValue: string; maxLength?: number }>(), { maxLength: 16384 });
const emit = defineEmits<{ (event: 'update:modelValue', value: string): void }>();
const editor = useEditor({
content: props.modelValue,
extensions: [StarterKit, Underline, Link.configure({ openOnClick: false })],
editorProps: {
attributes: { class: 'legacy-html-editor__content', 'aria-label': 'HTML 편집기' },
},
onUpdate: ({ editor: instance }) => {
const html = instance.getHTML();
if (html.length <= props.maxLength) emit('update:modelValue', html);
},
});
watch(
() => props.modelValue,
(value) => {
if (editor.value && editor.value.getHTML() !== value) {
editor.value.commands.setContent(value || '', { emitUpdate: false });
}
}
);
const setLink = () => {
const previous = editor.value?.getAttributes('link').href as string | undefined;
const href = window.prompt('링크 주소', previous ?? 'https://');
if (href === null || !editor.value) return;
if (!href.trim()) editor.value.chain().focus().unsetLink().run();
else editor.value.chain().focus().extendMarkRange('link').setLink({ href: href.trim() }).run();
};
onBeforeUnmount(() => editor.value?.destroy());
</script>
<template>
<div class="legacy-html-editor">
<div class="legacy-html-editor__toolbar" role="toolbar" aria-label="서식">
<button
type="button"
:class="{ active: editor?.isActive('bold') }"
@click="editor?.chain().focus().toggleBold().run()"
>
<b>굵게</b>
</button>
<button
type="button"
:class="{ active: editor?.isActive('italic') }"
@click="editor?.chain().focus().toggleItalic().run()"
>
<i>기울임</i>
</button>
<button
type="button"
:class="{ active: editor?.isActive('underline') }"
@click="editor?.chain().focus().toggleUnderline().run()"
>
<u>밑줄</u>
</button>
<button
type="button"
:class="{ active: editor?.isActive('bulletList') }"
@click="editor?.chain().focus().toggleBulletList().run()"
>
목록
</button>
<button type="button" :class="{ active: editor?.isActive('link') }" @click="setLink">링크</button>
<button type="button" @click="editor?.chain().focus().unsetAllMarks().clearNodes().run()">
서식 지우기
</button>
</div>
<EditorContent :editor="editor" />
</div>
</template>
<style scoped>
.legacy-html-editor {
border: 1px solid #777;
background: #fff;
color: #111;
}
.legacy-html-editor__toolbar {
display: flex;
flex-wrap: wrap;
gap: 2px;
border-bottom: 1px solid #aaa;
padding: 3px;
background: #ddd;
}
.legacy-html-editor__toolbar button {
border: 1px solid #777;
border-radius: 2px;
padding: 2px 7px;
background: #f5f5f5;
color: #111;
cursor: pointer;
}
.legacy-html-editor__toolbar button.active {
background: #b9d4f0;
}
:deep(.legacy-html-editor__content) {
min-height: 110px;
padding: 6px;
outline: none;
overflow-wrap: anywhere;
}
:deep(.legacy-html-editor__content p) {
margin: 0 0 0.4em;
}
</style>
@@ -457,7 +457,7 @@ onMounted(() => {
.log-block { .log-block {
border: 1px solid #666; border: 1px solid #666;
padding: 0; padding: 0;
background: #111; background: #000;
min-height: 0; min-height: 0;
} }
@@ -469,7 +469,7 @@ onMounted(() => {
justify-content: center; justify-content: center;
border-bottom: 1px solid #666; border-bottom: 1px solid #666;
color: orange; color: orange;
background: #252525; background: #000;
font-size: 1.3em; font-size: 1.3em;
font-weight: 500; font-weight: 500;
} }
@@ -479,6 +479,11 @@ onMounted(() => {
border-bottom: 0; border-bottom: 0;
} }
.log-line :deep(.hidden_but_copyable) {
color: transparent !important;
font-size: 0;
}
.empty { .empty {
padding: 2px 8px; padding: 2px 8px;
color: #999; color: #999;
+5 -12
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import MapViewer from '../components/main/MapViewer.vue'; import MapViewer from '../components/main/MapViewer.vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import { legacyNationTextColor } from '../utils/legacyNationColor';
type Result = Awaited<ReturnType<typeof trpc.world.getGlobalInfo.query>>; type Result = Awaited<ReturnType<typeof trpc.world.getGlobalInfo.query>>;
type Layout = Awaited<ReturnType<typeof trpc.world.getMapLayout.query>>; type Layout = Awaited<ReturnType<typeof trpc.world.getMapLayout.query>>;
@@ -14,17 +15,9 @@ const goBack = () => router.push('/');
const state = (value: number) => ({ 0: '★', 1: '▲', 2: '', 7: '@' })[value] ?? 'ㆍ'; const state = (value: number) => ({ 0: '★', 1: '▲', 2: '', 7: '@' })[value] ?? 'ㆍ';
const stateClass = (value: number) => `state-${value}`; const stateClass = (value: number) => `state-${value}`;
const nationMap = computed(() => new Map(data.value?.nations.map((nation) => [nation.id, nation]) ?? [])); const nationMap = computed(() => new Map(data.value?.nations.map((nation) => [nation.id, nation]) ?? []));
const isBrightColor = (color: string): boolean => {
const normalized = color.trim().replace(/^#/u, '');
if (!/^[0-9a-f]{6}$/iu.test(normalized)) return false;
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 * 0.299 + green * 0.587 + blue * 0.114 > 170;
};
const nationNameStyle = (color: string) => ({ const nationNameStyle = (color: string) => ({
backgroundColor: color, backgroundColor: color,
color: isBrightColor(color) ? '#000' : '#fff', color: legacyNationTextColor(color),
}); });
onMounted(async () => { onMounted(async () => {
try { try {
@@ -55,7 +48,7 @@ onMounted(async () => {
v-for="nation in data.nations" v-for="nation in data.nations"
:key="nation.id" :key="nation.id"
class="vertical" class="vertical"
:style="{ backgroundColor: nation.color }" :style="nationNameStyle(nation.color)"
> >
{{ nation.name }} {{ nation.name }}
</th> </th>
@@ -63,7 +56,7 @@ onMounted(async () => {
</thead> </thead>
<tbody> <tbody>
<tr v-for="me in data.nations" :key="me.id"> <tr v-for="me in data.nations" :key="me.id">
<th :style="{ backgroundColor: me.color }">{{ me.name }}</th> <th :style="nationNameStyle(me.color)">{{ me.name }}</th>
<td <td
v-for="you in data.nations" v-for="you in data.nations"
:key="you.id" :key="you.id"
@@ -93,7 +86,7 @@ onMounted(async () => {
<strong>{{ conflict.cityName }}</strong> <strong>{{ conflict.cityName }}</strong>
<div> <div>
<div v-for="(percent, id) in conflict.nations" :key="id" class="conflict-row"> <div v-for="(percent, id) in conflict.nations" :key="id" class="conflict-row">
<span :style="{ backgroundColor: nationMap.get(Number(id))?.color }">{{ <span :style="nationNameStyle(nationMap.get(Number(id))?.color ?? '#000000')">{{
nationMap.get(Number(id))?.name nationMap.get(Number(id))?.name
}}</span }}</span
><em>{{ percent.toFixed(1) }}%</em ><em>{{ percent.toFixed(1) }}%</em
+8 -1
View File
@@ -199,6 +199,9 @@ const specialNameMap = computed(() => {
} }
return map; return map;
}); });
const selectedSpecialWarInfo = computed(
() => status.value?.availableSpecialWar.find((entry) => entry.key === nextSpecialKey.value)?.info ?? ''
);
const buffCost = (key: BuffKey, target: number): number => { const buffCost = (key: BuffKey, target: number): number => {
const points = status.value?.inheritConst.inheritBuffPoints ?? [0, 0, 0, 0, 0, 0]; const points = status.value?.inheritConst.inheritBuffPoints ?? [0, 0, 0, 0, 0, 0];
@@ -493,7 +496,11 @@ onMounted(() => {
</select> </select>
</div> </div>
<small <small
>{{ specialNameMap.get(nextSpecialKey) }} 특기를 다음에 얻도록 지정합니다.<br /><b ><span v-if="selectedSpecialWarInfo" class="special-description">{{
selectedSpecialWarInfo
}}</span
><br v-if="selectedSpecialWarInfo" />{{ specialNameMap.get(nextSpecialKey) }} 특기를 다음에
얻도록 지정합니다.<br /><b
>필요 포인트: {{ status.inheritConst.inheritSpecificSpecialPoint }}</b >필요 포인트: {{ status.inheritConst.inheritSpecificSpecialPoint }}</b
></small ></small
> >
+26 -8
View File
@@ -65,6 +65,18 @@ const nationAccess = computed(() => ({
})); }));
const nationColor = computed(() => nation.value?.color ?? '#000000'); const nationColor = computed(() => nation.value?.color ?? '#000000');
const voteActive = computed(() => Boolean(frontStatus.value?.latestVote)); const voteActive = computed(() => Boolean(frontStatus.value?.latestVote));
const formatRecord = (entry: { text: string; createdAt?: string | Date }, appendTime = false): string => {
if (!appendTime || /\d{2}:\d{2}\s*$/u.test(entry.text)) return formatLog(entry.text);
const parsed = entry.createdAt ? new Date(entry.createdAt) : null;
if (!parsed || Number.isNaN(parsed.getTime())) return formatLog(entry.text);
const time = new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(parsed);
return formatLog(`${entry.text} ${time}`);
};
let surveyNoticeTimer: ReturnType<typeof setTimeout> | null = null; let surveyNoticeTimer: ReturnType<typeof setTimeout> | null = null;
watch(surveyNotice, (notice) => { watch(surveyNotice, (notice) => {
@@ -151,7 +163,9 @@ watch(
> >
실시간 동기화: {{ realtimeLabel }} 실시간 동기화: {{ realtimeLabel }}
</button> </button>
<button class="game-shell__action game-shell__action--navigation" type="button" @click="loadMainData"> </button> <button class="game-shell__action game-shell__action--navigation" type="button" @click="loadMainData">
</button>
<button class="game-shell__action" type="button" @click="moveLobby">로비로</button> <button class="game-shell__action" type="button" @click="moveLobby">로비로</button>
</div> </div>
</header> </header>
@@ -241,7 +255,7 @@ watch(
v-for="entry in globalRecords" v-for="entry in globalRecords"
:key="entry.id" :key="entry.id"
class="record-line" class="record-line"
v-html="formatLog(entry.text)" v-html="formatRecord(entry)"
/> />
<div v-if="globalRecords.length === 0" class="record-empty">기록이 없습니다.</div> <div v-if="globalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
</div> </div>
@@ -255,7 +269,7 @@ watch(
v-for="entry in generalRecords" v-for="entry in generalRecords"
:key="entry.id" :key="entry.id"
class="record-line" class="record-line"
v-html="formatLog(entry.text)" v-html="formatRecord(entry, true)"
/> />
<div v-if="generalRecords.length === 0" class="record-empty">기록이 없습니다.</div> <div v-if="generalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
</div> </div>
@@ -269,7 +283,7 @@ watch(
v-for="entry in worldHistory" v-for="entry in worldHistory"
:key="entry.id" :key="entry.id"
class="record-line" class="record-line"
v-html="formatLog(entry.text)" v-html="formatRecord(entry)"
/> />
<div v-if="worldHistory.length === 0" class="record-empty">기록이 없습니다.</div> <div v-if="worldHistory.length === 0" class="record-empty">기록이 없습니다.</div>
</div> </div>
@@ -350,7 +364,7 @@ watch(
v-for="entry in globalRecords" v-for="entry in globalRecords"
:key="entry.id" :key="entry.id"
class="record-line" class="record-line"
v-html="formatLog(entry.text)" v-html="formatRecord(entry)"
/> />
<div v-if="globalRecords.length === 0" class="record-empty">기록이 없습니다.</div> <div v-if="globalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
</div> </div>
@@ -364,7 +378,7 @@ watch(
v-for="entry in generalRecords" v-for="entry in generalRecords"
:key="entry.id" :key="entry.id"
class="record-line" class="record-line"
v-html="formatLog(entry.text)" v-html="formatRecord(entry, true)"
/> />
<div v-if="generalRecords.length === 0" class="record-empty">기록이 없습니다.</div> <div v-if="generalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
</div> </div>
@@ -378,7 +392,7 @@ watch(
v-for="entry in worldHistory" v-for="entry in worldHistory"
:key="entry.id" :key="entry.id"
class="record-line" class="record-line"
v-html="formatLog(entry.text)" v-html="formatRecord(entry)"
/> />
<div v-if="worldHistory.length === 0" class="record-empty">기록이 없습니다.</div> <div v-if="worldHistory.length === 0" class="record-empty">기록이 없습니다.</div>
</div> </div>
@@ -624,7 +638,6 @@ button {
.desktop-message-panel { .desktop-message-panel {
grid-column: 1 / -1; grid-column: 1 / -1;
height: 1377.5px;
} }
.common-menu-middle { .common-menu-middle {
@@ -654,6 +667,11 @@ button {
white-space: nowrap; white-space: nowrap;
} }
.record-line :deep(.hidden_but_copyable) {
color: transparent !important;
font-size: 0;
}
.record-empty { .record-empty {
color: #aaa; color: #aaa;
} }
+33 -4
View File
@@ -386,20 +386,49 @@ onMounted(() => {
<dt>경험/공헌</dt> <dt>경험/공헌</dt>
<dd>{{ data.general.experience }} / {{ data.general.dedication }}</dd> <dd>{{ data.general.experience }} / {{ data.general.dedication }}</dd>
</div> </div>
<div>
<dt>성격/특기</dt>
<dd>
{{ data.general.traits?.personal ?? '-' }} /
{{ data.general.traits?.specialWar ?? '-' }}
</dd>
</div>
<div>
<dt>나이/다음턴</dt>
<dd>{{ data.general.age ?? '-' }} / {{ data.general.turnTime?.slice(11, 16) ?? '-' }}</dd>
</div>
</dl> </dl>
</div> </div>
<div v-if="data" class="legacy-general-details"> <div v-if="data" class="legacy-general-details">
<div> <div>
명망 <strong>약간 ({{ data.general.experience }})</strong> · 계급 명망
<strong>약간 ({{ data.general.dedication }})</strong> <strong
>Lv {{ data.general.progression?.experienceLevel ?? 0 }} ({{
data.general.experience
}})</strong
>
· 계급
<strong
>Lv {{ data.general.progression?.dedicationLevel ?? 0 }} ({{
data.general.dedication
}})</strong
>
</div> </div>
<div>전투 0 · 계략 0 · 사관 7</div> <div>전투 0 · 계략 0 · 사관 7</div>
<div>승률 0% · 승리 0 · 패배 0</div> <div>승률 0% · 승리 0 · 패배 0</div>
<div>살상률 0% · 사살 0 · 피살 0</div> <div>살상률 0% · 사살 0 · 피살 0</div>
<div class="dexterity-title">숙련도</div> <div class="dexterity-title">숙련도</div>
<div>보병 0.0K · 궁병 0.0K · 기병 0.0K · 귀병 0.0K · 차병 0.0K</div>
<div> <div>
{{ data.general.crew ? '보병' : '-' }} · 부상 {{ data.general.injury }} · 부대 - · 벌점 - {{ data.general.progression?.dex?.[0] ?? 0 }} · 궁병
{{ data.general.progression?.dex?.[1] ?? 0 }} · 기병
{{ data.general.progression?.dex?.[2] ?? 0 }} · 귀병
{{ data.general.progression?.dex?.[3] ?? 0 }} · 차병
{{ data.general.progression?.dex?.[4] ?? 0 }}
</div>
<div>
병종 {{ data.general.crewTypeId || '-' }} · 내정특기
{{ data.general.traits?.specialDomestic ?? '-' }} · 부상 {{ data.general.injury }} · 부대
{{ data.general.troopId || '-' }} · 벌점 {{ penalties.length || '-' }}
</div> </div>
</div> </div>
</div> </div>
@@ -2,6 +2,7 @@
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { getNpcColor } from '../utils/npcColor'; import { getNpcColor } from '../utils/npcColor';
import { legacyNationTextColor } from '../utils/legacyNationColor';
import { cityLevelMap, regionMap } from '../utils/nationFormat'; import { cityLevelMap, regionMap } from '../utils/nationFormat';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
@@ -149,7 +150,14 @@ onMounted(async () => {
> >
<tbody> <tbody>
<tr> <tr>
<td colspan="10" class="city-title" :style="{ backgroundColor: data?.nation.color }"> <td
colspan="10"
class="city-title"
:style="{
backgroundColor: data?.nation.color,
color: legacyNationTextColor(data?.nation.color ?? '#000000'),
}"
>
{{ regionMap[city.region] }} | {{ cityLevelMap[city.level] }} {{ regionMap[city.region] }} | {{ cityLevelMap[city.level] }}
<span :class="{ capital: city.id === data?.nation.capitalCityId }">{{ <span :class="{ capital: city.id === data?.nation.capitalCityId }">{{
city.id === data?.nation.capitalCityId ? `[${city.name}]` : city.name city.id === data?.nation.capitalCityId ? `[${city.name}]` : city.name
@@ -2,6 +2,7 @@
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { formatLog } from '../utils/formatLog'; import { formatLog } from '../utils/formatLog';
import { legacyNationTextColor } from '../utils/legacyNationColor';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.nation.getNationInfo.query>>; type Result = Awaited<ReturnType<typeof trpc.nation.getNationInfo.query>>;
@@ -35,7 +36,11 @@ onMounted(async () => {
<table v-if="data" class="legacy-table info-table legacy-bg2"> <table v-if="data" class="legacy-table info-table legacy-bg2">
<tbody> <tbody>
<tr> <tr>
<td colspan="8" class="nation-title" :style="{ backgroundColor: data.nation.color }"> <td
colspan="8"
class="nation-title"
:style="{ backgroundColor: data.nation.color, color: legacyNationTextColor(data.nation.color) }"
>
{{ data.nation.name }} {{ data.nation.name }}
</td> </td>
</tr> </tr>
@@ -5,6 +5,7 @@ import { useRouter } from 'vue-router';
import { resolveGeneralIconBackgroundImage } from '../utils/generalIcon'; import { resolveGeneralIconBackgroundImage } from '../utils/generalIcon';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import { cityLevelMap, formatOfficerLevelText, getNationChiefLevel, regionMap } from '../utils/nationFormat'; import { cityLevelMap, formatOfficerLevelText, getNationChiefLevel, regionMap } from '../utils/nationFormat';
import { legacyNationTextColor } from '../utils/legacyNationColor';
type PersonnelResponse = Awaited<ReturnType<typeof trpc.nation.getPersonnelInfo.query>>; type PersonnelResponse = Awaited<ReturnType<typeof trpc.nation.getPersonnelInfo.query>>;
type GeneralEntry = PersonnelResponse['generals'][number]; type GeneralEntry = PersonnelResponse['generals'][number];
@@ -213,7 +214,10 @@ onMounted(() => void loadPersonnel());
<td <td
class="nation-heading" class="nation-heading"
colspan="6" colspan="6"
:style="{ color: '#fff', backgroundColor: data.nation.color }" :style="{
color: legacyNationTextColor(data.nation.color),
backgroundColor: data.nation.color,
}"
> >
{{ data.nation.name }} {{ data.nation.name }}
</td> </td>
@@ -415,10 +419,22 @@ onMounted(() => void loadPersonnel());
<td colspan="5" class="region-heading"> {{ regionMap[city.region] ?? '-' }} </td> <td colspan="5" class="region-heading"> {{ regionMap[city.region] ?? '-' }} </td>
</tr> </tr>
<tr> <tr>
<td class="nation-city" :style="{ backgroundColor: data.nation.color }"> <td
class="nation-city"
:style="{
backgroundColor: data.nation.color,
color: legacyNationTextColor(data.nation.color),
}"
>
{{ cityLevelMap[city.level] ?? '-' }} {{ cityLevelMap[city.level] ?? '-' }}
</td> </td>
<td class="nation-city city-name" :style="{ backgroundColor: data.nation.color }"> <td
class="nation-city city-name"
:style="{
backgroundColor: data.nation.color,
color: legacyNationTextColor(data.nation.color),
}"
>
{{ city.name }} {{ city.name }}
</td> </td>
<td <td
@@ -3,6 +3,8 @@ import { computed, onMounted, reactive, ref } from 'vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import { resolveDiplomacyInfo } from '../utils/diplomacy'; import { resolveDiplomacyInfo } from '../utils/diplomacy';
import { legacyNationTextColor } from '../utils/legacyNationColor';
import LegacyHtmlEditor from '../components/ui/LegacyHtmlEditor.vue';
type StratFinanResponse = Awaited<ReturnType<typeof trpc.nation.getStratFinan.query>>; type StratFinanResponse = Awaited<ReturnType<typeof trpc.nation.getStratFinan.query>>;
type NationEntry = StratFinanResponse['nationsList'][number]; type NationEntry = StratFinanResponse['nationsList'][number];
@@ -194,7 +196,9 @@ onMounted(() => void loadStratFinan());
<div>종료 시점</div> <div>종료 시점</div>
</div> </div>
<div v-for="nation in nationsList" :key="nation.id" class="diplomacy-row"> <div v-for="nation in nationsList" :key="nation.id" class="diplomacy-row">
<div :style="{ backgroundColor: nation.color }">{{ nation.name }}</div> <div :style="{ backgroundColor: nation.color, color: legacyNationTextColor(nation.color) }">
{{ nation.name }}
</div>
<div>{{ formatNumber(nation.power) }}</div> <div>{{ formatNumber(nation.power) }}</div>
<div>{{ formatNumber(nation.generalCount) }}</div> <div>{{ formatNumber(nation.generalCount) }}</div>
<div>{{ formatNumber(nation.cityCount) }}</div> <div>{{ formatNumber(nation.cityCount) }}</div>
@@ -245,7 +249,7 @@ onMounted(() => void loadStratFinan());
</span> </span>
</header> </header>
<div v-if="!editingNationMsg" class="message-preview" v-html="nationMsg || '내용 없음'" /> <div v-if="!editingNationMsg" class="message-preview" v-html="nationMsg || '내용 없음'" />
<textarea v-else v-model="nationMsgDraft" aria-label="국가 방침" maxlength="16384" /> <LegacyHtmlEditor v-else v-model="nationMsgDraft" :max-length="16384" />
</section> </section>
<section id="scout-message-form" class="message-form"> <section id="scout-message-form" class="message-form">
<header class="green-header"> <header class="green-header">
@@ -279,7 +283,7 @@ onMounted(() => void loadStratFinan());
</header> </header>
<div class="scout-limit">870px x 200px를 넘어서는 내용은 표시되지 않습니다.</div> <div class="scout-limit">870px x 200px를 넘어서는 내용은 표시되지 않습니다.</div>
<div v-if="!editingScoutMsg" class="message-preview scout-preview" v-html="scoutMsg || '내용 없음'" /> <div v-if="!editingScoutMsg" class="message-preview scout-preview" v-html="scoutMsg || '내용 없음'" />
<textarea v-else v-model="scoutMsgDraft" class="scout-editor" aria-label="임관 권유" maxlength="1000" /> <LegacyHtmlEditor v-else v-model="scoutMsgDraft" :max-length="1000" />
</section> </section>
<div class="finance-title">예산&amp;정책</div> <div class="finance-title">예산&amp;정책</div>