Files
core2026/app/game-frontend/src/views/CurrentCityView.vue
T

482 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat';
import { getNpcColor } from '../utils/npcColor';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import { legacyNationTextColor } from '../utils/legacyNationColor';
import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.world.getCurrentCity.query>>;
type General = Result['generals'][number];
const route = useRoute();
const router = useRouter();
const data = ref<Result | null>(null);
const error = ref('');
const selected = ref<number>();
let loadSequence = 0;
const parseCityId = (): number | undefined => {
const raw = route.query.cityId ?? route.query.citylist;
const value = Array.isArray(raw) ? raw[0] : raw;
if (typeof value !== 'string' || !/^\d+$/.test(value)) return undefined;
const cityId = Number(value);
return Number.isSafeInteger(cityId) && cityId > 0 ? cityId : undefined;
};
const load = async (cityId?: number) => {
const sequence = ++loadSequence;
try {
const result = await trpc.world.getCurrentCity.query(cityId ? { cityId } : undefined);
if (sequence !== loadSequence) return;
data.value = result;
selected.value = result.city.id;
error.value = '';
} catch (cause) {
if (sequence !== loadSequence) return;
error.value = cause instanceof Error ? cause.message : '도시 정보를 불러오지 못했습니다.';
}
};
watch(
() => [route.query.cityId, route.query.citylist],
() => void load(parseCityId()),
{ immediate: true }
);
const selectCity = async () => {
if (!selected.value) return;
await router.push({ name: 'current-city', query: { cityId: selected.value } });
};
const city = computed(() => data.value?.city);
const summary = computed(() => data.value?.forceSummary);
const show = (value: number | null) => (value === null ? '?' : value.toLocaleString('ko-KR'));
const showPair = (crew: number, generals: number) => `${show(crew)}/${show(generals)}`;
const populationRate = computed(() => {
if (!city.value || city.value.population === null) return '?';
return String(Math.round((city.value.population / city.value.populationMax) * 10_000) / 100);
});
const cityTitleStyle = computed(() => {
const backgroundColor = city.value?.nationColor.toUpperCase() ?? '#000000';
return {
backgroundColor,
color: legacyNationTextColor(backgroundColor),
};
});
const woundedStat = (value: number, injury: number) =>
injury === 0 ? value : Math.floor((value * (100 - injury)) / 100);
const defenceTrainText = (value: number | null) => {
if (value === null) return '?';
if (value === 999) return '×';
if (value >= 90) return '☆';
if (value >= 80) return '◎';
if (value >= 60) return '○';
return '△';
};
const generalImage = (general: General): string => resolveGeneralIconUrl(general);
</script>
<template>
<main class="city-page">
<table class="legacy-table legacy-bg0">
<tbody>
<tr>
<td>
<br /><button class="back-link" type="button" @click="router.push('/')">
돌아가기
</button>
</td>
</tr>
</tbody>
</table>
<table class="legacy-table legacy-bg0 selector">
<tbody>
<tr>
<td>
<form @submit.prevent="selectCity">
<div>
도시선택 :
<select id="citySelector" v-model.number="selected" @change="selectCity">
<option v-for="option in data?.options ?? []" :key="option.id" :value="option.id">
{{ option.name.padEnd(4, '_') }}{{
option.nationId === data?.me.nationId
? '본국'
: option.nationId === 0
? '공백지'
: '타국'
}}
</option>
</select>
</div>
<p>명령 화면에서 도시를 클릭하셔도 됩니다.</p>
</form>
</td>
</tr>
</tbody>
</table>
<p v-if="error" class="error" role="alert">{{ error }}</p>
<template v-if="data && city">
<table class="legacy-table legacy-bg0 back-row">
<tbody>
<tr>
<td><button class="back-link" type="button" @click="router.push('/')">돌아가기</button></td>
</tr>
</tbody>
</table>
<table class="legacy-table legacy-bg2 stats">
<colgroup>
<col class="label-col" />
<col class="first-value-col" />
<col class="label-col" />
<col class="value-col" />
<col class="label-col" />
<col class="value-col" />
<col class="label-col" />
<col class="value-col" />
<col class="label-col" />
<col class="value-col" />
<col class="label-col" />
<col class="value-col" />
</colgroup>
<tbody>
<tr>
<td colspan="11" class="city-title" :style="cityTitleStyle">
{{ regionMap[city.region] }} | {{ cityLevelMap[city.level] }} {{ city.name }}
</td>
<td class="city-title" :style="cityTitleStyle">{{ data.lastExecute }}</td>
</tr>
<tr>
<th>주민</th>
<td>{{ show(city.population) }}/{{ show(city.populationMax) }}</td>
<th>농업</th>
<td>{{ show(city.agriculture) }}/{{ show(city.agricultureMax) }}</td>
<th>상업</th>
<td>{{ show(city.commerce) }}/{{ show(city.commerceMax) }}</td>
<th>치안</th>
<td>{{ show(city.security) }}/{{ show(city.securityMax) }}</td>
<th>수비</th>
<td>{{ show(city.defence) }}/{{ show(city.defenceMax) }}</td>
<th>성벽</th>
<td>{{ show(city.wall) }}/{{ show(city.wallMax) }}</td>
</tr>
<tr>
<th>민심</th>
<td>{{ show(city.trust) }}</td>
<th>시세</th>
<td>{{ city.trade ?? '- ' }}%</td>
<th>인구</th>
<td>{{ populationRate }}%</td>
<th>태수</th>
<td>{{ city.officers[4] }}</td>
<th>군사</th>
<td>{{ city.officers[3] }}</td>
<th>종사</th>
<td>{{ city.officers[2] }}</td>
</tr>
<tr v-if="summary">
<th>도시명</th>
<td>{{ city.name }}</td>
<th>적군</th>
<td>
{{ show(summary.enemyCrew) }}/{{ show(summary.enemyArmedGenerals) }}({{
show(summary.enemyGenerals)
}})
</td>
<th>병장()</th>
<td>
{{ show(summary.ownCrew) }}/{{ show(summary.ownArmedGenerals) }}({{
show(summary.ownGenerals)
}})
</td>
<th>90병장</th>
<td>{{ showPair(summary.ready90Crew, summary.ready90Generals) }}</td>
<th>60병장</th>
<td>{{ showPair(summary.ready60Crew, summary.ready60Generals) }}</td>
<th>수비</th>
<td>{{ showPair(summary.defenceReadyCrew, summary.defenceReadyGenerals) }}</td>
</tr>
<tr>
<th>장수</th>
<td colspan="11" class="general-names">
<template v-if="data.visibility.detailed">
<template v-if="data.generals.length">
<template v-for="(general, index) in data.generals" :key="general.id">
<span :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</span
><template v-if="index < data.generals.length - 1">, </template>
</template>
</template>
<template v-else>-</template>
</template>
<span v-else class="unknown"> 없음</span>
</td>
</tr>
</tbody>
</table>
<table v-if="data.visibility.detailed" id="general_list" class="legacy-table legacy-bg0 generals">
<colgroup>
<col style="width: 64px" />
<col style="width: 128px" />
<col style="width: 48px" />
<col style="width: 48px" />
<col style="width: 48px" />
<col style="width: 78px" />
<col style="width: 28px" />
<col style="width: 78px" />
<col style="width: 78px" />
<col style="width: 48px" />
<col style="width: 48px" />
<col style="width: 280px" />
</colgroup>
<thead>
<tr>
<th> </th>
<th> </th>
<th>통솔</th>
<th>무력</th>
<th>지력</th>
<th> </th>
<th></th>
<th> </th>
<th> </th>
<th>훈련</th>
<th>사기</th>
<th> </th>
</tr>
</thead>
<tbody>
<tr
v-for="general in data.generals"
:key="general.id"
:data-is-our-general="general.train !== null"
:data-general-wounded="general.injury"
>
<td class="icon-cell">
<img
class="general-icon"
width="64"
height="64"
:src="generalImage(general)"
@error="useDefaultGeneralIcon"
/>
</td>
<td :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</td>
<td :class="{ wounded: general.injury !== 0 }">
{{ woundedStat(general.leadership, general.injury)
}}<span v-if="general.leadershipBonus" class="leadership-bonus"
>+{{ general.leadershipBonus }}</span
>
</td>
<td :class="{ wounded: general.injury !== 0 }">
{{ woundedStat(general.strength, general.injury) }}
</td>
<td :class="{ wounded: general.injury !== 0 }">
{{ woundedStat(general.intelligence, general.injury) }}
</td>
<td>{{ formatOfficerLevelText(general.officerLevel) }}</td>
<td>{{ defenceTrainText(general.defenceTrain) }}</td>
<td>{{ general.crewTypeName ?? '?' }}</td>
<td>{{ general.crew ?? '?' }}</td>
<td>{{ general.train ?? '?' }}</td>
<td>{{ general.atmos ?? '?' }}</td>
<td class="turns" :class="{ 'turns--reserved': general.turns.length > 0 }">
<template v-if="general.turns.length">
<span v-for="(turn, index) in general.turns" :key="index" class="turn-line"
>{{ index + 1 }} : {{ turn }}</span
>
</template>
<template v-else-if="general.npcState > 1">NPC 장수</template>
<template v-else-if="general.nationId !== data.me.nationId">
{{ general.nationId === 0 ? ' ' : `【${general.nationName} 장수` }}
</template>
</td>
</tr>
</tbody>
</table>
</template>
<table class="legacy-table legacy-bg0 footer">
<tbody>
<tr>
<td><button class="back-link" type="button" @click="router.push('/')">돌아가기</button></td>
</tr>
<tr>
<td class="legacy-banner">
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com)
/
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
</td>
</tr>
</tbody>
</table>
</main>
</template>
<style scoped>
.city-page {
width: 1000px;
margin: 0 auto;
font-family: var(--sammo-font-sans);
font-size: 14px;
line-height: 1.3;
}
.legacy-table {
width: 100%;
border-collapse: separate;
border-spacing: 2px;
}
.legacy-table td,
.legacy-table th {
border: 0;
padding: 1px;
font-weight: 400;
}
.center,
.selector,
.stats td,
.stats th,
.generals th,
.generals td:not(:last-child) {
text-align: center;
}
.selector select {
display: inline-block;
min-width: 400px;
height: 19px;
padding: 0;
border: 1px solid #767676;
background: #6b6b6b;
color: #fff;
font-family: var(--sammo-font-sans);
font-size: 13.3333px;
}
.selector {
transform: translateY(-2px);
}
.selector p {
margin: 1em 0;
}
.back-row {
margin-top: 16px;
}
.stats {
margin-top: 0;
table-layout: fixed;
}
.stats,
.generals {
border-collapse: collapse;
border-spacing: 0;
}
.stats td,
.stats th,
.generals td,
.generals th {
border: 1px solid gray;
}
.label-col {
width: 48px;
}
.value-col {
width: 108px;
}
.first-value-col {
width: 112px;
}
.stats th,
.generals th {
background-color: #14241b;
background-image: var(--sammo-texture-green);
}
.city-title {
text-align: center;
}
.stats {
height: auto;
}
.stats td,
.stats th {
white-space: nowrap;
}
.general-names {
text-align: left !important;
white-space: normal !important;
}
.unknown {
color: gray;
}
.generals {
width: 1000px;
margin: 18px 0 0 50%;
table-layout: fixed;
transform: translateX(-50%);
}
.generals td:last-child {
text-align: left;
padding-left: 1em;
}
.icon-cell {
height: 64px;
padding: 0 !important;
}
.generals tbody tr {
height: 64px;
}
.general-icon {
display: block;
width: 64px;
min-width: 64px;
height: 64px;
object-fit: fill;
}
.turns--reserved {
font-size: x-small;
}
.turn-line {
display: block;
}
.wounded {
color: red;
}
.leadership-bonus {
color: cyan;
}
.footer {
margin-top: 0;
}
.back-link {
display: inline-block;
border: 1px solid #6c757d;
border-radius: 0.2rem;
background: #6c757d;
color: #fff;
padding: 0.25rem 0.5rem;
font-family: var(--sammo-font-sans);
font-size: 14px;
line-height: 1;
text-decoration: none;
}
.back-link:hover,
.back-link:focus,
.back-link:active {
border-color: #565e64;
background: #5c636a;
color: #fff;
}
.legacy-banner a {
color: #fff;
text-decoration: underline;
}
.error {
text-align: center;
color: #ff7373;
}
@media (max-width: 700px) {
.city-page {
width: 1000px;
margin-top: 8px;
transform-origin: top left;
}
}
</style>