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

603 lines
21 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 { formatReservedCommandBrief } from '../components/command/reservedCommandBrief';
import type { CommandTable } from '../components/command/types';
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];
type ReservedCommand = General['turns'][number];
const route = useRoute();
const router = useRouter();
const data = ref<Result | null>(null);
const commandTable = ref<CommandTable | 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);
const table = await trpc.turns.getCommandTable.query({ generalId: result.me.id });
if (sequence !== loadSequence) return;
data.value = result;
commandTable.value = table;
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);
const commandBrief = (command: ReservedCommand): string =>
formatReservedCommandBrief('general', command.action, command.args, commandTable.value);
</script>
<template>
<main class="city-page">
<table class="legacy-table legacy-bg0">
<tbody>
<tr>
<td>
<br /><button
class="legacy-button legacy-button--navigation 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-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 data-field="icon" class="icon-cell">
<img
class="general-icon"
width="64"
height="64"
:src="generalImage(general)"
@error="useDefaultGeneralIcon"
/>
</td>
<td data-field="name" :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</td>
<td data-field="lead" data-label="" :class="{ wounded: general.injury !== 0 }">
{{ woundedStat(general.leadership, general.injury)
}}<span v-if="general.leadershipBonus" class="leadership-bonus"
>+{{ general.leadershipBonus }}</span
>
</td>
<td data-field="str" data-label="" :class="{ wounded: general.injury !== 0 }">
{{ woundedStat(general.strength, general.injury) }}
</td>
<td data-field="intel" data-label="" :class="{ wounded: general.injury !== 0 }">
{{ woundedStat(general.intelligence, general.injury) }}
</td>
<td data-field="office" data-label="관직">
{{ formatOfficerLevelText(general.officerLevel) }}
</td>
<td data-field="defence" data-label="">{{ defenceTrainText(general.defenceTrain) }}</td>
<td data-field="type" data-label="병종">{{ general.crewTypeName ?? '?' }}</td>
<td data-field="crew" data-label="병사">{{ general.crew ?? '?' }}</td>
<td data-field="train" data-label="">{{ general.train ?? '?' }}</td>
<td data-field="atmos" data-label="">{{ general.atmos ?? '?' }}</td>
<td data-field="turns" 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"
:title="commandBrief(turn)"
>{{ index + 1 }} : {{ commandBrief(turn) }}</span
>
</template>
<template v-else-if="general.nationId !== data.me.nationId">
{{ general.nationId === 0 ? ' ' : `【${general.nationName} 장수` }}
</template>
<template v-else-if="general.npcState > 1">NPC 장수</template>
</td>
</tr>
</tbody>
</table>
</template>
<table class="legacy-table legacy-bg0 footer">
<tbody>
<tr>
<td>
<button
class="legacy-button legacy-button--navigation 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;
}
.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 {
font-family: var(--sammo-font-sans);
font-size: 14px;
}
.legacy-banner a {
color: #fff;
text-decoration: underline;
}
.error {
text-align: center;
color: #ff7373;
}
/* 넓은 표의 모든 셀을 유지하고 초상/명령의 높이에 통계를 나란히 배치한다. */
@media (max-width: 939.98px) {
.city-page {
width: 500px;
}
.stats,
.stats tbody,
.generals,
.generals tbody {
display: block;
}
.stats colgroup,
.generals colgroup {
display: none;
}
.stats tr {
display: grid;
grid-template-columns: repeat(3, 40px minmax(0, 1fr));
}
.stats tr:first-child {
grid-template-columns: 1fr auto;
}
.stats tr:last-child {
grid-template-columns: 40px minmax(0, 1fr);
}
.stats td,
.stats th {
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
align-content: center;
}
.generals {
width: 500px;
margin: 8px 0 0;
transform: none;
}
.generals thead {
display: none;
}
.generals tbody tr {
display: grid;
grid-template-columns: 64px 62px 68px 40px 40px minmax(0, 1fr);
grid-template-areas:
'icon name name name name turns'
'icon lead str intel intel turns'
'icon office office defence defence turns'
'icon type crew train atmos turns';
height: auto;
border: 1px solid gray;
border-bottom: 0;
}
.generals tbody tr:last-child {
border-bottom: 1px solid gray;
}
.generals tbody td {
min-width: 0;
padding: 0 2px;
border: 0;
line-height: 18.2px;
overflow-wrap: anywhere;
align-content: center;
}
.generals tbody td[data-label]::before {
content: attr(data-label);
margin-right: 3px;
font-size: 11px;
color: #bbb;
}
.generals [data-field='train'],
.generals [data-field='atmos'],
.generals [data-field='crew'] {
padding-inline: 1px;
}
.generals [data-field='train']::before,
.generals [data-field='atmos']::before,
.generals [data-field='crew']::before {
margin-right: 2px;
}
.generals td.turns--reserved {
line-height: 1.3;
}
.generals .icon-cell {
height: auto;
}
.generals td.turns {
border-left: 1px solid gray;
padding-inline: 4px;
text-align: left;
}
.generals [data-field='icon'] {
grid-area: icon;
}
.generals [data-field='name'] {
grid-area: name;
}
.generals [data-field='lead'] {
grid-area: lead;
}
.generals [data-field='str'] {
grid-area: str;
}
.generals [data-field='intel'] {
grid-area: intel;
}
.generals [data-field='office'] {
grid-area: office;
}
.generals [data-field='defence'] {
grid-area: defence;
}
.generals [data-field='type'] {
grid-area: type;
}
.generals [data-field='crew'] {
grid-area: crew;
}
.generals [data-field='train'] {
grid-area: train;
}
.generals [data-field='atmos'] {
grid-area: atmos;
}
.generals [data-field='turns'] {
grid-area: turns;
}
}
</style>