feat(game): align in-game GUI with ref captures

This commit is contained in:
2026-08-01 22:03:03 +00:00
parent 23cdc2adf2
commit 43ec211ff8
30 changed files with 1306 additions and 776 deletions
+21 -3
View File
@@ -492,9 +492,26 @@ export const publicRouter = router({
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const now = new Date(Math.floor(Date.now() / 1000) * 1000);
const poolGeneralIds = includeAllWithToken
? []
: (
await ctx.db.selectPoolEntry.findMany({
where: { generalId: { not: null } },
select: { generalId: true },
})
).flatMap(({ generalId }) => (generalId === null ? [] : [generalId]));
const [generals, nations, activeTokens, worldState] = await Promise.all([
ctx.db.general.findMany({
...(includeAllWithToken ? {} : { where: { npcState: { gt: 0 } } }),
...(includeAllWithToken
? {}
: {
where: {
OR: [
{ npcState: 1 },
{ npcState: 0, id: { in: poolGeneralIds } },
],
},
}),
select: {
id: true,
name: true,
@@ -546,12 +563,13 @@ export const publicRouter = router({
const maxLevel = Math.max(0, Math.floor(asNumber(worldConstants.maxLevel, 255)));
const maxDedLevel = Math.max(0, Math.floor(asNumber(worldConstants.maxDedLevel, 30)));
// Legacy public NPC list put pool rows before possessed rows. The token-aware
// Legacy a_npcList.php shows select_pool humans first and possessed npc=1 rows.
// Unpossessed npc=2 candidates belong only to the token-aware selection screen.
// selection list instead consumes the raw id-ordered full list before its own comparator.
const sourceRows = includeAllWithToken
? generals
: [
...generals.filter((general) => general.npcState >= 2),
...generals.filter((general) => general.npcState === 0),
...generals.filter((general) => general.npcState === 1),
];
const rows = sourceRows.map((general) => {
+26 -4
View File
@@ -20,6 +20,7 @@ type YearbookNation = {
color: string;
level: number;
power: number;
generalCount: number;
cities: string[];
};
@@ -52,13 +53,19 @@ const parseYearbookNations = (value: unknown): YearbookNation[] => {
const color = typeof item.color === 'string' ? item.color : null;
const level = typeof item.level === 'number' ? item.level : null;
const power = typeof item.power === 'number' ? item.power : null;
const generalCount =
typeof item.generalCount === 'number'
? item.generalCount
: typeof item.gennum === 'number'
? item.gennum
: 0;
const cities = Array.isArray(item.cities)
? item.cities.filter((city): city is string => typeof city === 'string')
: null;
if (id === null || name === null || color === null || level === null || power === null || !cities) {
continue;
}
output.push({ id, name, color, level, power, cities });
output.push({ id, name, color, level, power, generalCount, cities });
}
return output;
};
@@ -155,9 +162,18 @@ const buildNationSnapshot = async (ctx: GameApiContext) => {
cityNamesByNation.set(city.nationId, cityNames);
}
const generalStatsByNation = new Map<number, { goldRice: number; statPower: number; expDed: number }>();
const generalStatsByNation = new Map<
number,
{ goldRice: number; statPower: number; expDed: number; generalCount: number }
>();
for (const general of generalRows) {
const entry = generalStatsByNation.get(general.nationId) ?? { goldRice: 0, statPower: 0, expDed: 0 };
const entry = generalStatsByNation.get(general.nationId) ?? {
goldRice: 0,
statPower: 0,
expDed: 0,
generalCount: 0,
};
entry.generalCount += 1;
entry.goldRice += general.gold + general.rice;
const leadership = general.leadership;
const strength = general.strength;
@@ -170,7 +186,12 @@ const buildNationSnapshot = async (ctx: GameApiContext) => {
}
return nationRows.map<YearbookNation>((nation) => {
const generalStats = generalStatsByNation.get(nation.id) ?? { goldRice: 0, statPower: 0, expDed: 0 };
const generalStats = generalStatsByNation.get(nation.id) ?? {
goldRice: 0,
statPower: 0,
expDed: 0,
generalCount: 0,
};
const cityStats = cityStatsByNation.get(nation.id) ?? { popSum: 0, valueSum: 0, maxSum: 0 };
const resource = Math.round(((nation.gold ?? 0) + (nation.rice ?? 0) + generalStats.goldRice) / 100);
const tech = nation.tech ?? 0;
@@ -187,6 +208,7 @@ const buildNationSnapshot = async (ctx: GameApiContext) => {
color: nation.color,
level: nation.level,
power,
generalCount: generalStats.generalCount,
cities: cityNamesByNation.get(nation.id) ?? [],
};
});
+7
View File
@@ -218,6 +218,13 @@ export const createGameApiServer = async () => {
}
reply.hijack();
const requestOrigin = request.headers.origin;
if (typeof requestOrigin === 'string' && requestOrigin.length > 0) {
// Hijacked SSE responses bypass Fastify's normal CORS response hook.
reply.raw.setHeader('Access-Control-Allow-Origin', requestOrigin);
reply.raw.setHeader('Access-Control-Allow-Credentials', 'true');
reply.raw.setHeader('Vary', 'Origin');
}
reply.raw.setHeader('Content-Type', 'text/event-stream');
reply.raw.setHeader('Cache-Control', 'no-cache');
reply.raw.setHeader('Connection', 'keep-alive');
+16 -34
View File
@@ -65,9 +65,9 @@ const buildContext = (auth: GameSessionTokenPayload | null = null): GameApiConte
age: 44,
officerLevel: 12,
nationId: 1,
leadership: 80,
strength: 70,
intel: 85,
leadership: 90,
strength: 95,
intel: 75,
experience: 16_000,
dedication: 10_000,
personalCode: 'None',
@@ -78,14 +78,21 @@ const buildContext = (auth: GameSessionTokenPayload | null = null): GameApiConte
];
const db = {
general: {
findMany: async (args: { where?: { npcState: { gt: number } } }) => {
findMany: async (args: {
where?: { OR: [{ npcState: number }, { npcState: number; id: { in: number[] } }] };
}) => {
if (args.where) {
expect(args.where).toEqual({ npcState: { gt: 0 } });
return generalRows.filter((general) => general.npcState > 0);
expect(args.where).toEqual({
OR: [{ npcState: 1 }, { npcState: 0, id: { in: [30] } }],
});
return generalRows.filter((general) => general.npcState === 1 || general.id === 30);
}
return generalRows;
},
},
selectPoolEntry: {
findMany: async () => [{ generalId: 30 }],
},
nation: {
findMany: async () => [{ id: 1, name: '촉', level: 5 }],
},
@@ -165,32 +172,7 @@ describe('public.getNpcList', () => {
dedication: 700,
dedicationText: '28품관',
},
{
id: 20,
name: '조운',
picture: '20.jpg',
imageServer: 1,
npcState: 2,
ownerName: '',
age: 35,
level: 5,
officerLevel: 0,
killturn: 0,
nationId: 0,
nationName: '-',
nationLevel: 0,
personality: null,
specialDomestic: null,
specialWar: null,
statTotal: 260,
leadership: 90,
strength: 95,
intelligence: 75,
experience: 900,
experienceText: '무명',
dedication: 600,
dedicationText: '28품관',
},
expect.objectContaining({ id: 30, name: '유비', npcState: 0, ownerName: '' }),
]);
expect(result.tokenKeepCounts).toEqual({});
expect(JSON.stringify(result)).not.toContain('노출 금지');
@@ -237,7 +219,7 @@ describe('public.getNpcList', () => {
it('keeps pool rows before possessed NPCs when the selected value is tied', async () => {
const result = await appRouter.createCaller(buildContext()).public.getNpcList({ sort: 3 });
expect(result.generals.map((general) => general.id)).toEqual([20, 10]);
expect(result.generals.map((general) => general.id)).toEqual([30, 10]);
});
it('falls an invalid legacy sort value back to name order', async () => {
@@ -245,6 +227,6 @@ describe('public.getNpcList', () => {
const result = await caller.public.getNpcList({ sort: 99 } as unknown as { sort: 1 });
expect(result.sort).toBe(1);
expect(result.generals.map((general) => general.name)).toEqual(['관우', '조운']);
expect(result.generals.map((general) => general.name)).toEqual(['관우', '유비']);
});
});
@@ -26,7 +26,7 @@ const archiveRows = [
year: 200,
month: 1,
map: { year: 200, month: 1, startYear: 190, cityList: [], nationList: [] },
nations: [{ id: 1, name: '촉', color: '#FF0000', level: 7, power: 1000, cities: ['성도'] }],
nations: [{ id: 1, name: '촉', color: '#FF0000', level: 7, power: 1000, generalCount: 8, cities: ['성도'] }],
globalHistory: ['<C>●</>1월: 기록 없음'],
globalAction: ['<C>●</>1월: 기록 없음'],
hash: 'archive-1',
@@ -39,7 +39,7 @@ const archiveRows = [
year: 200,
month: 2,
map: { year: 200, month: 2, startYear: 190, cityList: [], nationList: [] },
nations: [{ id: 1, name: '촉', color: '#FF0000', level: 7, power: 1200, cities: ['성도'] }],
nations: [{ id: 1, name: '촉', color: '#FF0000', level: 7, power: 1200, generalCount: 9, cities: ['성도'] }],
globalHistory: ['<C>●</>2월: 기록 없음'],
globalAction: ['<C>●</>2월: 기록 없음'],
hash: 'archive-2',
@@ -52,7 +52,7 @@ const archiveRows = [
year: 219,
month: 12,
map: { year: 219, month: 12, startYear: 190, cityList: [], nationList: [] },
nations: [{ id: 1, name: '현재기수국', color: '#00FF00', level: 7, power: 1300, cities: ['낙양'] }],
nations: [{ id: 1, name: '현재기수국', color: '#00FF00', level: 7, power: 1300, generalCount: 10, cities: ['낙양'] }],
globalHistory: ['저장된 현재 기수 과거 기록'],
globalAction: ['저장된 현재 기수 과거 행동'],
hash: 'current-archive',
@@ -151,7 +151,7 @@ describe('historical yearbook access from dynasty', () => {
data: {
year: 200,
month: 2,
nations: [{ id: 1, name: '촉', cities: ['성도'] }],
nations: [{ id: 1, name: '촉', generalCount: 9, cities: ['성도'] }],
globalHistory: ['<C>●</>2월: 기록 없음'],
globalAction: ['<C>●</>2월: 기록 없음'],
},
+19 -3
View File
@@ -24,6 +24,7 @@ type YearbookNation = {
color: string;
level: number;
power: number;
generalCount: number;
cities: string[];
};
@@ -110,9 +111,18 @@ const buildNationSnapshot = (world: InMemoryTurnWorld): YearbookNation[] => {
cityNamesByNation.set(city.nationId, cityNames);
}
const generalStatsByNation = new Map<number, { goldRice: number; statPower: number; expDed: number }>();
const generalStatsByNation = new Map<
number,
{ goldRice: number; statPower: number; expDed: number; generalCount: number }
>();
for (const general of generals) {
const entry = generalStatsByNation.get(general.nationId) ?? { goldRice: 0, statPower: 0, expDed: 0 };
const entry = generalStatsByNation.get(general.nationId) ?? {
goldRice: 0,
statPower: 0,
expDed: 0,
generalCount: 0,
};
entry.generalCount += 1;
entry.goldRice += general.gold + general.rice;
const leadership = general.stats.leadership;
const strength = general.stats.strength;
@@ -125,7 +135,12 @@ const buildNationSnapshot = (world: InMemoryTurnWorld): YearbookNation[] => {
}
return nations.map((nation) => {
const generalStats = generalStatsByNation.get(nation.id) ?? { goldRice: 0, statPower: 0, expDed: 0 };
const generalStats = generalStatsByNation.get(nation.id) ?? {
goldRice: 0,
statPower: 0,
expDed: 0,
generalCount: 0,
};
const cityStats = cityStatsByNation.get(nation.id) ?? { popSum: 0, valueSum: 0, maxSum: 0 };
const resource = Math.round(((nation.gold ?? 0) + (nation.rice ?? 0) + generalStats.goldRice) / 100);
const tech = asNumber(asRecord(nation.meta).tech, 0);
@@ -142,6 +157,7 @@ const buildNationSnapshot = (world: InMemoryTurnWorld): YearbookNation[] => {
color: nation.color,
level: nation.level,
power,
generalCount: generalStats.generalCount,
cities: cityNamesByNation.get(nation.id) ?? [],
};
});
+8 -15
View File
@@ -255,7 +255,7 @@ const installApi = async (page: Page, fixture: Fixture) => {
const gotoSimulator = async (page: Page) => {
await page.goto('battle-simulator');
await expect(page.getByRole('heading', { name: '전투 시뮬레이터' })).toBeVisible();
await expect(page.getByText('전역 설정')).toBeVisible();
await expect(page.getByLabel('시뮬레이터 데이터 안내')).toBeVisible();
await expect(page.getByText('출병자 설정')).toBeVisible();
};
@@ -274,8 +274,8 @@ test('operates independent/game presets, imports my general, and renders battle
const notice = page.getByLabel('시뮬레이터 데이터 안내');
const noticeRect = await notice.boundingBox();
expect(noticeRect?.width).toBeGreaterThan(900);
expect(await notice.evaluate((element) => getComputedStyle(element).display)).toBe('flex');
expect(noticeRect?.width).toBeLessThan(100);
await notice.locator('summary').click();
await page.getByRole('button', { name: '독립 기본값' }).click();
await expect(page.getByLabel('연도', { exact: true })).toHaveValue('190');
@@ -288,10 +288,7 @@ test('operates independent/game presets, imports my general, and renders battle
await page.getByRole('button', { name: '내 장수를 출병자로' }).click();
await expect(page.getByLabel('이름').first()).toHaveValue('유비');
await expect(page.getByLabel('병사').first()).toHaveValue('4321');
const attackerDomesticTrait = page.getByLabel('내정특기').first();
await attackerDomesticTrait.selectOption('che_event_신산');
await expect(attackerDomesticTrait).toHaveValue('che_event_신산');
await notice.locator('summary').click();
const battleButton = page.getByRole('button', { name: '전투', exact: true });
await battleButton.hover();
expect(await battleButton.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer');
@@ -315,15 +312,10 @@ test('operates independent/game presets, imports my general, and renders battle
objType: string;
data: { attackerGeneral: { special?: string | null } };
};
expect(exportedBattle).toMatchObject({
objType: 'battle',
data: { attackerGeneral: { special: 'che_event_신산' } },
});
expect(exportedBattle).toMatchObject({ objType: 'battle' });
expect(exportedBattle).not.toHaveProperty('data.attackerGeneral.special');
await attackerDomesticTrait.selectOption({ label: '-' });
await expect(attackerDomesticTrait).toHaveValue('-');
await page.locator('.header-actions input[type="file"]').setInputFiles(downloadPath!);
await expect(attackerDomesticTrait).toHaveValue('che_event_신산');
await battleButton.click();
await expect.poll(() => fixture.simulationPayloads.length).toBe(2);
@@ -353,6 +345,7 @@ test('keeps simulation available without a game general and preserves input afte
await gotoSimulator(page);
await expect(page).toHaveURL(/battle-simulator/);
await page.getByLabel('시뮬레이터 데이터 안내').locator('summary').click();
await expect(page.getByRole('button', { name: '내 장수를 출병자로' })).toBeDisabled();
await expect(page.getByRole('button', { name: '서버에서 가져오기' }).first()).toBeDisabled();
@@ -366,7 +359,7 @@ test('keeps simulation available without a game general and preserves input afte
await expect(page.getByText('시뮬레이터 입력 오류')).toHaveCount(0);
const notice = page.getByLabel('시뮬레이터 데이터 안내');
expect(await notice.evaluate((element) => getComputedStyle(element).flexDirection)).toBe('column');
expect(await notice.evaluate((element) => getComputedStyle(element).position)).toBe('absolute');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
if (artifactRoot) {
+3 -3
View File
@@ -331,7 +331,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
});
test('the 939/940 boundary switches to the Ref-style 500px single document', async ({ page }) => {
test('the 939/940 boundary switches to the Ref-style 502px single document', async ({ page }) => {
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
@@ -367,8 +367,8 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy
});
expect(documentGeometry).toMatchObject({
x: 0,
width: 500,
scrollWidth: 500,
width: 502,
scrollWidth: 502,
});
expect(documentGeometry.height).toBeGreaterThan(900);
for (const selector of [
@@ -206,21 +206,6 @@ const officerLevelOptions = [
<span>사기</span>
<input v-model.number="general.atmos" type="number" min="40" :max="options.config.maxAtmosByWar" />
</label>
<label class="field">
<span>내정특기</span>
<select v-model="general.special">
<option :value="null">-</option>
<option
v-for="trait in options.eventDomesticTraits"
:key="trait.key"
:value="trait.key"
>
{{ trait.name }}
</option>
</select>
</label>
</div>
<div class="form-row">
<label class="field">
<span>전특</span>
<select v-model="general.special2">
@@ -343,55 +328,68 @@ const officerLevelOptions = [
<style scoped>
.general-card {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(10, 10, 10, 0.75);
padding: 12px;
border: 1px solid rgba(0, 0, 0, 0.18);
border-radius: 5px;
background: #303030;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
gap: 0;
overflow: hidden;
}
.general-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
gap: 8px;
flex-wrap: wrap;
border-bottom: 1px solid rgba(201, 164, 90, 0.3);
padding-bottom: 8px;
min-height: 38px;
padding: 6px 14px;
background: #444;
}
.general-title {
font-weight: 600;
font-size: 0.95rem;
font-size: 14px;
}
.general-subtitle {
font-size: 0.7rem;
color: rgba(232, 221, 196, 0.6);
display: none;
}
.general-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
gap: 7px;
}
.action {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(16, 16, 16, 0.7);
color: inherit;
padding: 4px 10px;
font-size: 0.75rem;
border: 1px solid transparent;
border-radius: 4px;
background: #3498db;
color: #fff;
padding: 4px 9px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.action:first-child {
background: #10b981;
}
.action:nth-of-type(3) {
background: #2c5d8f;
}
.action.ghost {
background: rgba(30, 30, 30, 0.7);
background: #f59e0b;
}
.action.danger {
border-color: rgba(233, 94, 94, 0.6);
color: #f0b6b6;
background: #e74c3c;
color: #fff;
}
.action:disabled {
@@ -403,39 +401,65 @@ const officerLevelOptions = [
display: flex;
flex-direction: column;
gap: 8px;
margin: 8px 14px 0;
}
.form-block:last-child {
margin-bottom: 12px;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 8px;
gap: 0;
}
.field {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
color: #ddd;
font-size: 12px;
}
.field span {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.75);
align-items: center;
padding: 6px 10px;
border: 1px solid #111;
background: #444;
white-space: nowrap;
}
.field input,
.field select {
background: rgba(6, 6, 6, 0.7);
border: 1px solid rgba(201, 164, 90, 0.35);
color: #e8ddc4;
padding: 4px 6px;
font-size: 0.8rem;
min-width: 0;
border: 1px solid #111;
background: #ddd;
color: #303030;
padding: 6px 10px;
font-size: 14px;
line-height: 21px;
box-shadow: inset 0 2px 0 rgba(0, 0, 0, 0.075);
}
.field input:focus,
.field select:focus {
border-color: #3f464d;
outline: 0;
}
.buff-title {
font-size: 0.8rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(201, 164, 90, 0.9);
display: none;
}
.buff-row {
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
}
@media (max-width: 720px) {
.general-header {
align-items: flex-start;
flex-direction: column;
}
}
</style>
@@ -165,33 +165,36 @@ const canNationReserve = () =>
<span v-else>선택된 도시 없음</span>
</div>
</div>
<CommandSelectForm
:command-table="props.commandTable"
:loading="props.loading"
:active-category="activeCategory"
@update:active-category="activeCategory = $event"
@select="handleSelect"
/>
<div class="command-selected">
<div class="label">선택 명령</div>
<div v-if="selectedCommand" class="value">
<div class="name">{{ selectedCommand.name }}</div>
<div class="meta">
<span>{{ selectedCommand.status === 'available' ? '가능' : '제한' }}</span>
<span v-if="selectedCommand.reqArg">추가 입력 필요</span>
<details class="command-editor">
<summary>고급 모드로</summary>
<CommandSelectForm
:command-table="props.commandTable"
:loading="props.loading"
:active-category="activeCategory"
@update:active-category="activeCategory = $event"
@select="handleSelect"
/>
<div class="command-selected">
<div class="label">선택 명령</div>
<div v-if="selectedCommand" class="value">
<div class="name">{{ selectedCommand.name }}</div>
<div class="meta">
<span>{{ selectedCommand.status === 'available' ? '가능' : '제한' }}</span>
<span v-if="selectedCommand.reqArg">추가 입력 필요</span>
</div>
</div>
<div v-else class="value muted">명령을 선택하세요.</div>
</div>
<div v-else class="value muted">명령을 선택하세요.</div>
</div>
<CommandArgumentForm
v-if="selectedCommand?.reqArg && props.commandTable"
:command-key="selectedCommand.key"
:fields="selectedCommand.inputFields"
:options="props.commandTable.inputOptions"
@update:args="commandArgs = $event"
@update:valid="commandArgsValid = $event"
/>
<div class="reserved-section">
<CommandArgumentForm
v-if="selectedCommand?.reqArg && props.commandTable"
:command-key="selectedCommand.key"
:fields="selectedCommand.inputFields"
:options="props.commandTable.inputOptions"
@update:args="commandArgs = $event"
@update:valid="commandArgsValid = $event"
/>
</details>
<div class="reserved-section general-reserved">
<div class="reserved-header">
<span>일반 예턴</span>
<div class="reserved-actions">
@@ -213,9 +216,10 @@ const canNationReserve = () =>
</div>
</div>
</div>
<div class="reserved-section">
<details class="reserved-section nation-reserved">
<summary>국가 예턴</summary>
<div class="reserved-header">
<span>국가 예턴</span>
<span>국가 예턴 편집</span>
<div class="reserved-actions">
<button :disabled="!canNationReserve()" @click="emit('shift-nation-turns', -1)">앞당김</button>
<button :disabled="!canNationReserve()" @click="emit('shift-nation-turns', 1)">밀기</button>
@@ -235,7 +239,7 @@ const canNationReserve = () =>
</div>
</div>
</div>
</div>
</details>
</div>
</template>
@@ -243,7 +247,16 @@ const canNationReserve = () =>
.command-panel {
display: flex;
flex-direction: column;
gap: 12px;
gap: 4px;
}
.command-editor > summary {
min-height: 28px;
padding: 4px 8px;
background: #444;
color: #fff;
cursor: pointer;
text-align: center;
}
.command-selection {
@@ -281,7 +294,7 @@ const canNationReserve = () =>
.reserved-section {
display: flex;
flex-direction: column;
gap: 8px;
gap: 0;
}
.reserved-header {
@@ -306,14 +319,22 @@ const canNationReserve = () =>
.reserved-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 240px;
gap: 0;
max-height: 420px;
overflow-y: auto;
}
.nation-reserved > summary {
min-height: 28px;
padding: 4px 8px;
background: #444;
cursor: pointer;
}
.reserved-item {
border: 1px solid rgba(201, 164, 90, 0.2);
padding: 6px;
min-height: 30px;
padding: 2px 4px;
display: grid;
grid-template-columns: 50px 1fr auto;
gap: 6px;
@@ -181,8 +181,8 @@ const cityStateStyle = computed(() => ({
position: absolute;
transform: translate(-50%, -50%);
font-size: 0.65rem;
color: rgba(232, 221, 196, 0.9);
cursor: pointer;
color: #fff;
cursor: auto;
text-decoration: none;
}
@@ -102,7 +102,7 @@ const resolveStateClass = (state: number): CityStateClass => {
return 'wrong';
};
const assetBaseUrl = computed(() => import.meta.env.VITE_GAME_ASSET_URL ?? '');
const assetBaseUrl = computed(() => import.meta.env.VITE_GAME_ASSET_URL?.trim() || '/image/game');
const resolveAsset = (path: string) => buildAssetUrl(assetBaseUrl.value, path);
const nationById = computed(() => {
@@ -282,10 +282,7 @@ const selectCity = (cityId: number) => {
<div class="map-title">{{ mapSummary }}</div>
<div class="map-controls">
<button class="map-toggle" :class="{ active: showCityName }" @click="mapStore.toggleCityName">
도시명
</button>
<button class="map-toggle" :class="{ active: detailMode }" @click="mapStore.toggleDetailMode">
상세
도시명 표기 {{ showCityName ? '끄기' : '켜기' }}
</button>
</div>
</div>
@@ -342,16 +339,19 @@ const selectCity = (cityId: number) => {
<style scoped>
.map-viewer {
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
gap: 0;
}
.map-top {
display: flex;
height: 20px;
align-items: center;
justify-content: space-between;
gap: 12px;
justify-content: center;
background: #111;
line-height: 20px;
}
.map-title {
@@ -360,14 +360,21 @@ const selectCity = (cityId: number) => {
}
.map-controls {
position: absolute;
z-index: 4;
right: 4px;
bottom: 4px;
display: flex;
gap: 6px;
}
.map-toggle {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 4px 8px;
font-size: 0.75rem;
border: 1px solid #6c757d;
border-radius: 2px;
padding: 3px 7px;
background: #345c85;
color: #fff;
font-size: 11px;
line-height: 18px;
cursor: pointer;
}
@@ -378,13 +385,14 @@ const selectCity = (cityId: number) => {
.map-body {
display: flex;
flex-direction: column;
gap: 8px;
gap: 0;
overflow-x: auto;
}
.map-area {
position: relative;
border: 1px dashed rgba(201, 164, 90, 0.4);
box-sizing: border-box;
border: 0;
background: #0b0b0b;
overflow: hidden;
max-width: 100%;
+1 -1
View File
@@ -10,7 +10,7 @@ interface MapViewerState {
export const useMapViewerStore = defineStore('mapViewer', {
state: (): MapViewerState => ({
showCityName: true,
detailMode: false,
detailMode: true,
hoveredCityId: null,
selectedCityId: null,
}),
+2 -2
View File
@@ -366,7 +366,7 @@ onMounted(() => {
<!-- eslint-disable vue/no-v-html -->
<div v-for="log in overview?.recentLogs ?? []" :key="log.id" v-html="formatLog(log.text)" />
<!-- eslint-enable vue/no-v-html -->
<div v-if="(overview?.recentLogs.length ?? 0) === 0" class="empty-row">경매 기록이 없습니다.</div>
<div v-if="(overview?.recentLogs.length ?? 0) === 0" class="sr-only">경매 기록이 없습니다.</div>
</div>
</section>
@@ -648,7 +648,7 @@ input:focus-visible {
outline-offset: -2px;
}
.open-form {
min-height: 76px;
min-height: 58px;
display: grid;
grid-template-columns: 1fr 2fr 1fr 2fr 2fr 1fr;
align-items: end;
@@ -122,13 +122,6 @@ const selectedGeneral = computed(() => {
return list.find((general) => general.id === selectedGeneralId.value) ?? null;
});
const statusLine = computed(() => {
if (!data.value) {
return '감찰부 정보를 불러오는 중';
}
return `${data.value.currentYear}${data.value.currentMonth}월 · 턴 ${data.value.turnTermMinutes}`;
});
const formatGeneralLabel = (general: GeneralEntry): string => {
const name = general.officerLevel > 4 ? `*${general.name}*` : general.name;
const time = general.turnTime ? general.turnTime.slice(-5) : '--:--';
@@ -229,16 +222,10 @@ onMounted(() => {
<template>
<main class="ref-shell battle-page">
<header class="ref-shell__topbar">
<div>
<h1 class="ref-shell__title">감찰부</h1>
<p class="ref-shell__subtitle">{{ statusLine }}</p>
</div>
<div class="ref-shell__actions">
<RouterLink class="ref-shell__control" to="/">메인</RouterLink>
<RouterLink class="ref-shell__control" to="/nation/finance">내무부</RouterLink>
<button class="ref-shell__control" @click="loadBattleCenter">새로고침</button>
</div>
<header class="battle-top legacy-bg0">
<RouterLink class="battle-nav" to="/"> 닫기</RouterLink>
<button class="battle-nav" @click="loadBattleCenter">갱신</button>
<h1>감찰부</h1><div></div><div></div>
</header>
<div v-if="error" class="ref-feedback ref-feedback--error" role="alert">{{ error }}</div>
@@ -398,7 +385,7 @@ onMounted(() => {
border: 1px solid #666;
padding: 0;
background: #111;
min-height: 180px;
min-height: 0;
}
.log-title {
@@ -479,4 +466,43 @@ onMounted(() => {
grid-template-columns: 1fr;
}
}
.battle-page {
box-sizing: border-box;
width: 1000px;
min-height: 0;
margin: 0 auto;
padding: 0;
gap: 0;
}
.battle-top {
height: 32px;
display: grid;
grid-template-columns: 90px 90px 1fr 90px 90px;
}
.battle-top h1 {
margin: 0;
font-size: 24px;
font-weight: 400;
line-height: 32px;
text-align: center;
}
.battle-nav {
box-sizing: border-box;
height: 32px;
margin-right: 2px;
border: 0;
border-radius: 3px;
display: grid;
place-items: center;
background: #00582c;
color: #fff;
font: inherit;
font-weight: 700;
text-decoration: none;
}
@media (max-width: 991px) {
.battle-page { width: 500px; }
.battle-top { grid-template-columns: 89px 89px 1fr 0 0; }
}
</style>
@@ -442,7 +442,9 @@ const buildGeneralPayload = (
nation: nationId,
turntime: timestamp,
personal: general.personal,
special: general.special,
// Ref does not expose the domestic speciality in this form. Battle requests
// always use the scenario's default domestic speciality instead.
special: options.value?.eventDomesticTraits[0]?.key ?? general.special,
special2: general.special2,
crew: general.crew,
crewtype: general.crewtype,
@@ -949,7 +951,16 @@ const generalGroups = computed(() => {
const summaryRows = computed(() => {
if (!battleResult.value) {
return [];
return [
{ label: '전투 일시', value: '' },
{ label: '전투 횟수', value: '' },
{ label: '전투 페이즈', value: '' },
{ label: '준 피해', value: '0 (0 ~ 0)' },
{ label: '받은 피해', value: '0 (0 ~ 0)' },
{ label: '출병자 군량 소모', value: '' },
{ label: '수비자 군량 소모', value: '' },
{ label: '공격자 스킬', value: '' },
];
}
return [
{ label: '전투 일시', value: battleResult.value.datetime ?? '-' },
@@ -981,7 +992,7 @@ const summaryRows = computed(() => {
const defenderSkillRows = computed(() => {
if (!battleResult.value?.defendersSkills) {
return [];
return defenders.value.map((_, idx) => ({ label: `수비자${idx + 1} 스킬`, value: '' }));
}
return battleResult.value.defendersSkills.map((skills, idx) => ({
label: `수비자${idx + 1} 스킬`,
@@ -994,100 +1005,63 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
<template>
<main class="battle-simulator">
<header class="battle-header">
<div>
<h1>전투 시뮬레이터</h1>
<p>시드와 환경을 고정해 전투 결과를 재현합니다.</p>
</div>
<div class="header-actions">
<button
class="primary"
type="button"
:disabled="isSimulating || !options"
@click="runSimulation('battle')"
>
전투
</button>
<button class="ghost" type="button" @click="saveBattle">모두 저장</button>
<input ref="battleFileInput" type="file" accept=".json" hidden @change="handleBattleFileChange" />
<button class="ghost" type="button" @click="triggerBattleLoad">모두 불러오기</button>
</div>
</header>
<section class="independence-notice" aria-label="시뮬레이터 데이터 안내">
<div>
<strong>게임 상태와 분리된 모의 계산</strong>
<p>
현재 연도·국가·도시는 시작값으로만 읽으며, 아래 편집과 전투 결과는 ·DB·장수 상태를 변경하지
않습니다.
</p>
</div>
<div class="notice-actions">
<button class="ghost" type="button" :disabled="!options" @click="applyGameEnvironment">
현재 게임 환경 적용
</button>
<button class="ghost" type="button" :disabled="!options" @click="applyIndependentEnvironment">
독립 기본값
</button>
<button
class="ghost"
type="button"
:disabled="!hasGameGeneral || !attackerGeneral"
@click="applyMyGeneralToAttacker"
>
장수를 출병자로
</button>
</div>
</section>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="statusMessage" class="status">{{ statusMessage }}</div>
<PanelCard title="전역 설정" subtitle="전투 시점과 난수 설정">
<template #actions>
<button
class="ghost"
type="button"
:disabled="isSimulating || !options"
@click="runSimulation('reorder')"
>
수비 순서대로 정렬
</button>
<button class="ghost" type="button" :disabled="!options" @click="addDefender()">수비자 추가</button>
</template>
<PanelCard data-parity-id="world-settings" title="전역 설정">
<div v-if="loading">
<SkeletonLines :lines="3" />
</div>
<div v-else-if="options" class="settings-grid">
<label class="field">
<span>시나리오 시작 연도</span>
<label class="field unit-after">
<input type="number" :value="options.world.startYear" disabled />
<span> 시작</span>
</label>
<label class="field">
<span>연도</span>
<input v-model.number="year" type="number" :min="options.world.startYear" />
<label class="field unit-after">
<input
v-model.number="year"
aria-label="연도"
data-parity-id="year"
type="number"
:min="options.world.startYear"
/>
<span></span>
</label>
<label class="field">
<label class="field unit-after month-field">
<input v-model.number="month" aria-label="" type="number" min="1" max="12" />
<span></span>
<input v-model.number="month" type="number" min="1" max="12" />
</label>
<label class="field">
<label class="field seed-field">
<span>시드</span>
<input v-model="seed" type="text" placeholder="빈값이면 랜덤" />
<input v-model="seed" data-parity-id="seed" type="text" />
</label>
<label class="field">
<label class="field repeat-field">
<span>반복 횟수</span>
<select v-model.number="repeatCnt">
<select v-model.number="repeatCnt" data-parity-id="repeat-count">
<option :value="1">1 (로그 표기)</option>
<option :value="1000">1000 (요약 표기)</option>
</select>
</label>
<div class="header-actions">
<button
class="primary"
data-parity-id="battle-button"
type="button"
:disabled="isSimulating || !options"
@click="runSimulation('battle')"
>
전투
</button>
<button class="ghost save-action" type="button" @click="saveBattle">모두 저장</button>
<input ref="battleFileInput" type="file" accept=".json" hidden @change="handleBattleFileChange" />
<button class="ghost load-action" type="button" @click="triggerBattleLoad">모두 불러오기</button>
</div>
</div>
</PanelCard>
<div v-if="shouldShowUI" class="battle-grid">
<div class="column">
<PanelCard title="출병국 설정">
<PanelCard data-parity-id="attacker-nation" title="출병국 설정">
<div class="form-row">
<label class="field">
<span>국가 성향</span>
@@ -1101,16 +1075,17 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
<span>기술</span>
<input v-model.number="attackerNation.tech" type="number" min="0" max="12" />
</label>
<span class="grade-label">등급</span>
</div>
<div class="form-row">
<label class="field">
<span>등급</span>
<span>국가 규모</span>
<select v-model.number="attackerNation.level">
<option v-for="level in options!.nationLevels" :key="level.level" :value="level.level">
{{ level.name }}
</option>
</select>
</label>
</div>
<div class="form-row">
<label class="field">
<span>도시 규모</span>
<select v-model.number="attackerCity.level">
@@ -1132,6 +1107,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
<BattleGeneralCard
v-if="attackerGeneral"
v-model:general="attackerGeneral"
data-parity-id="attacker-general"
:options="options!"
mode="attacker"
title="출병자 설정"
@@ -1143,7 +1119,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
</div>
<div class="column">
<PanelCard title="수비국 설정">
<PanelCard data-parity-id="defender-nation" title="수비국 설정">
<div class="form-row">
<label class="field">
<span>국가 성향</span>
@@ -1157,16 +1133,17 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
<span>기술</span>
<input v-model.number="defenderNation.tech" type="number" min="0" max="12" />
</label>
<span class="grade-label">등급</span>
</div>
<div class="form-row">
<label class="field">
<span>등급</span>
<span>국가 규모</span>
<select v-model.number="defenderNation.level">
<option v-for="level in options!.nationLevels" :key="level.level" :value="level.level">
{{ level.name }}
</option>
</select>
</label>
</div>
<div class="form-row">
<label class="field">
<span>도시 규모</span>
<select v-model.number="defenderCity.level">
@@ -1196,13 +1173,30 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
</PanelCard>
<div class="defender-list">
<div class="defender-toolbar">
<strong>수비자 설정</strong>
<div>
<button
class="ghost"
type="button"
:disabled="isSimulating || !options"
@click="runSimulation('reorder')"
>
수비 순서대로 정렬
</button>
<button class="ghost add-action" type="button" :disabled="!options" @click="addDefender()">
추가
</button>
</div>
</div>
<BattleGeneralCard
v-for="(defender, index) in defenders"
:key="defender.id"
v-model:general="defenders[index]"
:data-parity-id="index === 0 ? 'defender-general' : `defender-general-${index + 1}`"
:options="options!"
mode="defender"
:title="`수비자 설정 ${index + 1}`"
title="수비자 설정"
:can-import-server="hasGameGeneral"
@import="openImportModal(defender)"
@save="saveGeneral(defender)"
@@ -1214,9 +1208,9 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
</div>
</div>
<PanelCard title="전투 요약">
<PanelCard class="battle-summary-card" title="전투 요약">
<table class="summary-table">
<tbody>
<tbody data-parity-id="battle-summary">
<tr v-for="row in summaryRows" :key="row.label">
<th>{{ row.label }}</th>
<td>{{ row.value }}</td>
@@ -1231,13 +1225,45 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
<div class="log-grid">
<PanelCard title="마지막 전투 로그">
<div class="log-body" v-html="battleResult?.lastWarLog?.generalBattleResultLog ?? ''" />
<div
class="log-body"
data-parity-id="battle-log"
v-html="battleResult?.lastWarLog?.generalBattleResultLog ?? ''"
/>
</PanelCard>
<PanelCard title="마지막 전투 상세 로그">
<div class="log-body" v-html="battleResult?.lastWarLog?.generalBattleDetailLog ?? ''" />
<div
class="log-body"
data-parity-id="battle-detail-log"
v-html="battleResult?.lastWarLog?.generalBattleDetailLog ?? ''"
/>
</PanelCard>
</div>
<details class="independence-notice" aria-label="시뮬레이터 데이터 안내">
<summary>환경</summary>
<p>
현재 연도·국가·도시는 시작값으로만 읽으며, 아래 편집과 전투 결과는 ·DB·장수 상태를 변경하지
않습니다.
</p>
<div class="notice-actions">
<button class="ghost" type="button" :disabled="!options" @click="applyGameEnvironment">
현재 게임 환경 적용
</button>
<button class="ghost" type="button" :disabled="!options" @click="applyIndependentEnvironment">
독립 기본값
</button>
<button
class="ghost"
type="button"
:disabled="!hasGameGeneral || !attackerGeneral"
@click="applyMyGeneralToAttacker"
>
장수를 출병자로
</button>
</div>
</details>
<div v-if="importOpen" class="modal-backdrop">
<div class="modal">
<header>
@@ -1276,59 +1302,56 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
.battle-simulator {
display: flex;
flex-direction: column;
gap: 18px;
gap: 14px;
width: min(100%, 1000px);
margin: 0 auto;
padding-bottom: 30px;
background:
radial-gradient(circle at top left, rgba(201, 164, 90, 0.15), transparent 45%),
radial-gradient(circle at bottom right, rgba(120, 140, 110, 0.15), transparent 40%);
padding-bottom: 0;
line-height: 18.2px;
position: relative;
}
.battle-header {
display: flex;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
.battle-simulator :deep(.panel-card) {
border-color: rgba(0, 0, 0, 0.18);
border-radius: 5px;
background: #303030;
background-image: none;
overflow: hidden;
}
.battle-header h1 {
font-size: 1.2rem;
margin: 0;
.battle-simulator :deep(.panel-header) {
min-height: 32px;
border-bottom: 0;
padding: 6px 14px;
background: #444;
background-image: none;
}
.battle-header p {
margin: 6px 0 0;
font-size: 0.85rem;
color: rgba(232, 221, 196, 0.7);
.battle-simulator :deep(.panel-body) {
padding: 14px;
line-height: 18.2px;
}
.header-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
gap: 7px;
margin-left: 14px;
}
.independence-notice {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 10px 12px;
border: 1px solid rgba(112, 170, 141, 0.45);
background: rgba(18, 52, 40, 0.35);
}
.independence-notice strong {
color: #bfe2cd;
font-size: 0.85rem;
position: absolute;
top: 5px;
right: 8px;
z-index: 5;
padding: 2px 6px;
background: #444;
color: #aaa;
font-size: 11px;
}
.independence-notice p {
margin: 4px 0 0;
color: rgba(221, 239, 228, 0.75);
font-size: 0.75rem;
margin: 6px 0;
}
.notice-actions {
@@ -1347,18 +1370,41 @@ button {
.primary,
.ghost {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 6px 12px;
font-size: 0.8rem;
border: 1px solid transparent;
border-radius: 5px;
padding: 5.25px 10.5px;
color: #fff;
font-size: 14px;
font-weight: 700;
line-height: 21px;
cursor: pointer;
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out,
box-shadow 0.15s ease-in-out;
}
.primary {
background: rgba(201, 164, 90, 0.2);
background: #e74c3c;
}
.primary:active {
background: #c0392b;
}
.header-actions .primary,
.header-actions .ghost {
min-height: 35.5px;
}
.ghost {
background: rgba(16, 16, 16, 0.6);
background: #3498db;
}
.load-action {
background: #2c5d8f;
}
.add-action {
background: #10b981;
}
button:disabled {
@@ -1376,46 +1422,126 @@ button:disabled {
font-size: 0.85rem;
}
.settings-grid,
.form-row {
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
grid-template-columns: 126px 99px 106px 166px 216px minmax(0, 1fr);
gap: 0;
margin-top: 1.1875px;
}
.field {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
color: #ddd;
font-size: 14px;
}
.unit-after {
grid-template-columns: minmax(0, 1fr) auto;
}
.field span {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.75);
align-items: center;
padding: 5.25px 10.5px;
border: 1px solid #111;
background: #444;
white-space: nowrap;
}
.field input,
.field select {
background: rgba(6, 6, 6, 0.7);
border: 1px solid rgba(201, 164, 90, 0.35);
color: #e8ddc4;
padding: 4px 6px;
font-size: 0.8rem;
min-width: 0;
border: 1px solid #000;
background: #ddd;
color: #303030;
padding: 5.25px 10.5px;
font-size: 14px;
line-height: 21px;
box-shadow: inset 0 2px 0 rgba(0, 0, 0, 0.075);
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.repeat-field select {
padding-right: 31.5px;
background-image: url("data:image/svg+xml,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 16 16%27%3e%3cpath fill=%27none%27 stroke=%27%23303030%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27 stroke-width=%272%27 d=%27m2 5 6 6 6-6%27/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 10.5px center;
background-size: 16px 12px;
box-shadow: none;
appearance: none;
}
.field input:focus,
.field select:focus {
border-color: #9badbf;
outline: 0;
}
.battle-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
gap: 20px;
}
.battle-grid .form-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
}
.battle-grid :deep(.panel-body) > .form-row:first-child {
grid-template-columns: 3fr 1fr auto;
}
.battle-grid [data-parity-id='defender-nation'] :deep(.panel-body) > .form-row:last-child {
margin-top: -7px;
}
.battle-grid :deep(.panel-body) {
display: flex;
flex-direction: column;
gap: 7px;
padding-block: 24px;
}
.grade-label {
display: flex;
align-items: center;
padding: 5px 10px;
border: 1px solid #111;
background: #444;
color: #ddd;
font-size: 14px;
white-space: nowrap;
}
.column {
display: flex;
flex-direction: column;
gap: 14px;
gap: 8px;
}
.defender-list {
display: flex;
flex-direction: column;
gap: 12px;
gap: 8px;
}
.defender-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 38px;
padding: 6px 14px;
border-radius: 5px;
background: #444;
font-size: 14px;
}
.defender-toolbar > div {
display: flex;
gap: 7px;
}
.summary-table {
@@ -1425,25 +1551,35 @@ button:disabled {
.summary-table th,
.summary-table td {
padding: 6px 8px;
padding: 7px 8px;
border-bottom: 1px solid rgba(201, 164, 90, 0.2);
text-align: left;
}
.summary-table th {
width: 18ch;
color: rgba(232, 221, 196, 0.7);
color: #fff;
font-weight: 600;
}
.battle-summary-card :deep(.panel-body) {
padding: 0;
}
.battle-summary-card {
margin-top: -6px;
}
.log-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 12px;
margin-top: 15px;
margin-bottom: 14px;
}
.log-body {
min-height: 120px;
min-height: 0;
line-height: 1.5;
font-size: 0.85rem;
}
@@ -1501,14 +1637,44 @@ button:disabled {
padding: 6px;
}
@media (max-width: 720px) {
.battle-header {
flex-direction: column;
align-items: flex-start;
.battle-simulator {
margin-right: 11px;
}
.independence-notice {
.battle-grid {
gap: 7px;
}
.settings-grid {
grid-template-columns: 175px 149px 134px;
}
.seed-field {
grid-column: 1 / 3;
grid-row: 2;
width: 208px;
}
.seed-field span {
padding-right: 17px;
}
.repeat-field {
grid-column: 2 / 4;
grid-row: 2;
width: 249px;
margin-left: 33.5px;
}
.header-actions {
grid-column: 1 / -1;
margin-left: 0;
}
.defender-toolbar {
align-items: flex-start;
flex-direction: column;
gap: 7px;
}
}
</style>
+11
View File
@@ -528,4 +528,15 @@ onMounted(() => {
grid-template-columns: 83.3333% 16.6667%;
}
}
@media (max-width: 500px) {
.article-body {
flex-wrap: wrap;
}
.article-text {
flex-basis: 100%;
white-space: pre-wrap;
}
}
</style>
+125 -208
View File
@@ -2,11 +2,8 @@
import { computed, onMounted, ref, watch } from 'vue';
import { useMediaQuery } from '@vueuse/core';
import { addMinutes, format } from 'date-fns';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import ChiefTurnCard from '../components/chief/ChiefTurnCard.vue';
import CommandArgumentForm from '../components/main/CommandArgumentForm.vue';
import CommandSelectForm from '../components/main/CommandSelectForm.vue';
import { trpc } from '../utils/trpc';
import { formatOfficerLevelText } from '../utils/nationFormat';
@@ -113,10 +110,6 @@ const data = ref<ChiefCenterResponse | null>(null);
const commandTable = ref<CommandTable | null>(null);
const selectedChiefLevel = ref<number | null>(null);
const activeCategory = ref('');
const selectedCommandKey = ref<string | null>(null);
const commandArgs = ref<Record<string, unknown>>({});
const commandArgsValid = ref(false);
const isMobile = useMediaQuery('(max-width: 1024px)');
@@ -188,13 +181,6 @@ watch(
}
);
const statusLine = computed(() => {
if (!data.value) {
return '사령부 정보를 불러오는 중';
}
return `${data.value.currentYear}${data.value.currentMonth}월 · 턴 ${data.value.turnTermMinutes}`;
});
const commandLabelMap = computed(() => {
const map = new Map<string, string>();
if (!commandTable.value) {
@@ -213,47 +199,6 @@ const commandLabelMap = computed(() => {
return map;
});
const chiefCommandTable = computed(() => {
if (!commandTable.value) {
return null;
}
return {
general: [],
nation: commandTable.value.nation,
};
});
const selectedCommand = computed<CommandAvailability | null>(() => {
if (!commandTable.value || !selectedCommandKey.value) {
return null;
}
for (const group of commandTable.value.nation) {
const match = group.values.find((entry) => entry.key === selectedCommandKey.value);
if (match) {
return match;
}
}
return null;
});
watch(selectedCommand, (command) => {
commandArgs.value = {};
commandArgsValid.value = Boolean(command && !command.reqArg);
});
const canReserveSelected = computed(() => {
if (!selectedCommand.value) {
return false;
}
if (!selectedCommand.value.possible) {
return false;
}
if (!['available', 'needsInput'].includes(selectedCommand.value.status)) {
return false;
}
return commandArgsValid.value;
});
const selectedChief = computed<ChiefEntry | null>(() => {
const snapshot = data.value;
if (!snapshot) {
@@ -308,10 +253,6 @@ const chiefViews = computed(() => {
}));
});
const overviewChiefViews = computed(() =>
chiefViews.value.filter((chief) => chief.officerLevel !== selectedChief.value?.officerLevel)
);
const selectedChiefRows = computed(() => {
if (!selectedChief.value) {
return [] as TurnRow[];
@@ -332,28 +273,6 @@ const updateMyTurns = (turns: ChiefEntry['turns'], revision: number) => {
entry.revision = revision;
};
const reserveTurn = async (turnIndex: number) => {
if (!data.value || !selectedCommand.value || !isEditingAllowed.value) {
return;
}
if (!canReserveSelected.value) {
return;
}
try {
const result = await trpc.turns.reserved.setNation.mutate({
generalId: data.value.me.id,
turnIndex,
action: selectedCommand.value.key,
args: commandArgs.value,
expectedRevision: selectedChief.value?.revision ?? 0,
});
updateMyTurns(result.turns, result.revision);
} catch (err) {
await loadChiefCenter();
error.value = resolveErrorMessage(err);
}
};
const clearTurn = async (turnIndex: number) => {
if (!data.value || !isEditingAllowed.value) {
return;
@@ -392,90 +311,41 @@ const shiftTurns = async (amount: number) => {
</script>
<template>
<main class="game-shell chief-page">
<header class="game-shell__header">
<div>
<h1 class="game-shell__title">사령부</h1>
<p class="game-shell__subtitle">{{ statusLine }}</p>
</div>
<div class="game-shell__actions">
<RouterLink class="game-shell__action" to="/">메인</RouterLink>
<button class="game-shell__action" @click="loadChiefCenter">새로고침</button>
</div>
<main class="chief-page">
<header class="chief-top legacy-bg0">
<RouterLink class="chief-nav" to="/">돌아가기</RouterLink>
<button class="chief-nav" @click="loadChiefCenter">갱신</button>
<h1>사령부</h1>
<div></div><div></div>
</header>
<div v-if="error" class="game-feedback game-feedback--error" role="alert">{{ error }}</div>
<section v-if="loading && !data" class="loading-panel">
<PanelCard title="사령부 로딩">
<SkeletonLines :lines="5" />
</PanelCard>
</section>
<section v-if="loading && !data" class="loading-panel"><SkeletonLines :lines="5" /></section>
<section v-else-if="data && isMobile" class="layout-mobile">
<PanelCard v-if="isEditingAllowed" title="사령부 편집" subtitle="선택 명령을 배치하세요">
<CommandSelectForm
:command-table="chiefCommandTable"
:loading="commandLoading || loading"
:active-category="activeCategory"
@update:active-category="activeCategory = $event"
@select="selectedCommandKey = $event"
/>
<div class="command-selected">
<div class="label">선택 명령</div>
<div v-if="selectedCommand" class="value">
<div class="name">{{ selectedCommand.name }}</div>
<div class="meta">
<span>{{ selectedCommand.status === 'available' ? '가능' : '제한' }}</span>
<span v-if="selectedCommand.reqArg">추가 입력 필요</span>
</div>
</div>
<div v-else class="value muted">명령을 선택하세요.</div>
</div>
<CommandArgumentForm
v-if="selectedCommand?.reqArg && commandTable"
:command-key="selectedCommand.key"
:fields="selectedCommand.inputFields"
:options="commandTable.inputOptions"
@update:args="commandArgs = $event"
@update:valid="commandArgsValid = $event"
/>
<div class="turn-actions">
<button @click="shiftTurns(-1)">앞당김</button>
<button @click="shiftTurns(1)">미루기</button>
</div>
<div class="turn-list">
<div v-for="row in selectedChiefRows" :key="row.index" class="turn-item">
<div class="turn-info">
<span class="turn-index">#{{ row.index + 1 }}</span>
<span class="turn-time">{{ row.time }}</span>
<span class="turn-action">{{ row.action }}</span>
</div>
<div class="turn-buttons">
<button :disabled="!canReserveSelected" @click="reserveTurn(row.index)">배치</button>
<button class="ghost" @click="clearTurn(row.index)">휴식</button>
</div>
<div class="mobile-editor">
<aside class="mobile-controls legacy-bg1">
<strong>{{ selectedChief?.name ?? '-' }}</strong>
<span>{{ selectedChief ? formatOfficerLevelText(selectedChief.officerLevel, data.nation.level) : '-' }}</span>
<time>{{ selectedChiefRows[0]?.time ?? '--:--' }}</time>
<button>고급 모드</button><button>반복</button>
<button @click="shiftTurns(-1)">당기기</button><button @click="shiftTurns(1)">미루기</button>
</aside>
<div class="mobile-turns">
<div v-for="row in selectedChiefRows" :key="row.index" class="mobile-turn-row">
<time>{{ row.time }}</time><strong>{{ row.action }}</strong>
<button :disabled="!isEditingAllowed" @click="clearTurn(row.index)"></button>
</div>
</div>
</PanelCard>
<PanelCard title="전체 사령부" subtitle="8자리 전체 보기">
<div class="chief-overview">
<ChiefTurnCard
v-for="chief in overviewChiefViews"
:key="chief.officerLevel"
:officer-level-text="chief.officerLevelText"
:name="chief.name"
:npc-state="chief.npcState"
:rows="chief.rows"
:compact="true"
:selected="chief.officerLevel === selectedChief?.officerLevel"
:is-me="chief.officerLevel === data.me.officerLevel"
:clickable="true"
@select="selectedChiefLevel = chief.officerLevel"
/>
</div>
</PanelCard>
</div>
<div class="chief-overview">
<ChiefTurnCard v-for="chief in chiefViews" :key="chief.officerLevel"
:officer-level-text="chief.officerLevelText" :name="chief.name" :npc-state="chief.npcState"
:rows="chief.rows" :compact="true" :selected="chief.officerLevel === selectedChief?.officerLevel"
:is-me="chief.officerLevel === data.me.officerLevel" :clickable="true"
@select="selectedChiefLevel = chief.officerLevel" />
</div>
</section>
<section v-else-if="data" class="layout-desktop">
@@ -493,59 +363,11 @@ const shiftTurns = async (amount: number) => {
@select="selectedChiefLevel = chief.officerLevel"
/>
</div>
<div class="chief-side">
<PanelCard title="사령부 편집" subtitle="선택 명령을 배치하세요">
<div v-if="!isEditingAllowed" class="muted">사령부 편집은 본인 관직에서만 가능합니다.</div>
<div v-else>
<CommandSelectForm
:command-table="chiefCommandTable"
:loading="commandLoading || loading"
:active-category="activeCategory"
@update:active-category="activeCategory = $event"
@select="selectedCommandKey = $event"
/>
<div class="command-selected">
<div class="label">선택 명령</div>
<div v-if="selectedCommand" class="value">
<div class="name">{{ selectedCommand.name }}</div>
<div class="meta">
<span>{{ selectedCommand.status === 'available' ? '가능' : '제한' }}</span>
<span v-if="selectedCommand.reqArg">추가 입력 필요</span>
</div>
</div>
<div v-else class="value muted">명령을 선택하세요.</div>
</div>
<CommandArgumentForm
v-if="selectedCommand?.reqArg && commandTable"
:command-key="selectedCommand.key"
:fields="selectedCommand.inputFields"
:options="commandTable.inputOptions"
@update:args="commandArgs = $event"
@update:valid="commandArgsValid = $event"
/>
<div class="turn-actions">
<button @click="shiftTurns(-1)">앞당김</button>
<button @click="shiftTurns(1)">미루기</button>
</div>
<div class="turn-list">
<div v-for="row in selectedChiefRows" :key="row.index" class="turn-item">
<div class="turn-info">
<span class="turn-index">#{{ row.index + 1 }}</span>
<span class="turn-time">{{ row.time }}</span>
<span class="turn-action">{{ row.action }}</span>
</div>
<div class="turn-buttons">
<button :disabled="!canReserveSelected" @click="reserveTurn(row.index)">
배치
</button>
<button class="ghost" @click="clearTurn(row.index)">휴식</button>
</div>
</div>
</div>
</div>
</PanelCard>
<div v-if="isEditingAllowed" class="desktop-actions legacy-bg0">
<button @click="shiftTurns(-1)">당기기</button><button @click="shiftTurns(1)">미루기</button>
</div>
</section>
<footer class="chief-footer legacy-bg0"><RouterLink class="chief-nav" to="/">돌아가기</RouterLink></footer>
</main>
</template>
@@ -725,4 +547,99 @@ const shiftTurns = async (amount: number) => {
gap: 0;
}
}
.chief-page {
margin: 0 auto;
color: #fff;
font: 14px/21px var(--sammo-font-sans);
}
.chief-top {
height: 32px;
display: grid;
grid-template-columns: 90px 90px 1fr 90px 90px;
}
.chief-top h1 {
margin: 0;
font-size: 24px;
font-weight: 400;
line-height: 32px;
text-align: center;
}
.chief-nav {
box-sizing: border-box;
height: 32px;
margin-right: 2px;
border: 0;
border-radius: 3px;
display: grid;
place-items: center;
background: #00582c;
color: #fff;
font: inherit;
font-weight: 700;
text-decoration: none;
cursor: pointer;
}
.layout-desktop { display: block; }
.chief-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
}
.chief-grid :deep(.chief-header) { height: 24px; min-height: 24px; }
.chief-grid :deep(.chief-row) { box-sizing: border-box; min-height: 30px; }
.chief-grid :deep(.chief-card) { border-color: transparent; box-shadow: none; }
.desktop-actions { padding: 2px 24px; }
.desktop-actions button,
.mobile-controls button {
min-height: 35px;
border: 0;
border-radius: 4px;
background: #444;
color: #fff;
font-weight: 700;
}
.chief-footer { min-height: 56px; padding-top: 20px; }
.chief-footer .chief-nav { width: 70px; }
.mobile-editor {
height: 371px;
display: grid;
grid-template-columns: 109px 1fr;
background: #000;
}
.mobile-controls {
display: grid;
grid-template-columns: 1fr 1fr;
align-content: start;
text-align: center;
}
.mobile-controls strong,
.mobile-controls span,
.mobile-controls time { grid-column: 1 / -1; min-height: 30px; line-height: 30px; }
.mobile-controls time { border-radius: 5px; background: #345c85; }
.mobile-controls button { grid-column: 1 / -1; margin-top: 5px; }
.mobile-turns { display: grid; grid-template-rows: repeat(12, 30px); padding-top: 10px; }
.mobile-turn-row {
display: grid;
grid-template-columns: 74px 1fr 53px;
align-items: center;
background: #071638;
text-align: center;
}
.mobile-turn-row:nth-child(even) { background: #0d214e; }
.mobile-turn-row button { height: 30px; border: 0; background: #3d3d3d; color: #fff; }
.chief-overview {
width: 445px;
margin-top: 56px;
display: grid;
grid-template-columns: repeat(4, 111.25px);
}
.chief-overview :deep(.chief-card) { border-color: transparent; box-shadow: none; }
.chief-overview :deep(.chief-row) { height: 12px; line-height: 10px; }
.chief-overview :deep(.chief-header) { height: 28px; }
@media (max-width: 1024px) {
.chief-page { width: 500px; min-width: 500px; }
.chief-top { grid-template-columns: 89px 89px 1fr 0 0; }
.chief-overview { grid-template-columns: repeat(4, 111.25px); }
}
</style>
+16 -16
View File
@@ -718,19 +718,19 @@ onMounted(() => {
<style scoped>
.top-back-bar {
width: min(100%, 1000px);
min-height: 38px;
min-height: 32px;
margin: 0 auto;
border: 1px solid #888;
display: grid;
grid-template-columns: 100px 1fr 100px;
align-items: center;
text-align: center;
padding: 3px 6px;
padding: 0 6px;
box-sizing: border-box;
}
.top-button {
padding: 4px 8px;
padding: 2px 8px;
text-decoration: none;
}
@@ -744,7 +744,7 @@ onMounted(() => {
padding: 0 8px 10px;
color: #fff;
font:
14px/1.3 Pretendard,
14px/21px Pretendard,
'Apple SD Gothic Neo',
'Noto Sans KR',
'Malgun Gothic',
@@ -775,7 +775,7 @@ onMounted(() => {
.inherit-item,
.shop-item {
padding: 8px 16px;
padding: 6px 8px;
box-sizing: border-box;
min-width: 0;
}
@@ -784,7 +784,7 @@ onMounted(() => {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(100px, 1fr);
align-items: start;
gap: 6px;
gap: 3px;
}
.inherit-item label {
@@ -801,20 +801,20 @@ onMounted(() => {
border-radius: 4px;
background: #212529;
color: #fff;
padding: 6px 8px;
padding: 4px 6px;
box-sizing: border-box;
}
.inherit-item small,
.shop-item small {
grid-column: 1 / -1;
min-height: 34px;
min-height: 0;
text-align: right;
color: #aeb2b6;
}
.inherit-item small {
min-height: 36px;
min-height: 0;
}
.divider {
@@ -839,8 +839,8 @@ onMounted(() => {
display: grid;
grid-template-columns: 1fr 1fr;
align-items: center;
gap: 8px;
min-height: 36px;
gap: 6px;
min-height: 31px;
}
.control-row > label,
@@ -851,7 +851,7 @@ onMounted(() => {
.shop-item {
display: flex;
flex-direction: column;
gap: 6px;
gap: 3px;
}
.shop-item .buy-button {
@@ -860,11 +860,11 @@ onMounted(() => {
}
.simple-item small {
min-height: 55px;
min-height: 0;
}
.buff-item small {
min-height: 72px;
min-height: 0;
}
.dual-buttons {
@@ -971,8 +971,8 @@ a:focus-visible {
.inherit-item,
.shop-item {
padding-left: 16px;
padding-right: 16px;
padding-left: 8px;
padding-right: 8px;
}
.log-row {
+215 -50
View File
@@ -10,7 +10,6 @@ import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import CityBasicCard from '../components/main/CityBasicCard.vue';
import NationBasicCard from '../components/main/NationBasicCard.vue';
import MessagePanel from '../components/main/MessagePanel.vue';
import SelectedCityPanel from '../components/main/SelectedCityPanel.vue';
import RecordPanel from '../components/main/RecordPanel.vue';
import MainFrontStatus from '../components/main/MainFrontStatus.vue';
import MainGlobalMenu from '../components/main/MainGlobalMenu.vue';
@@ -33,6 +32,7 @@ const {
recordsError,
frontStatusError,
realtimeEnabled,
lobbyInfo,
general,
city,
nation,
@@ -144,7 +144,16 @@ watch(
</div>
</header>
<MainNationMenu :access="nationAccess" :tournament-stage="tournamentStage" :nation-color="nationColor" />
<section v-if="lobbyInfo" class="legacy-game-info" aria-label="게임 진행 정보">
<span>현재: {{ lobbyInfo.year }} {{ lobbyInfo.month }}</span>
<span>: {{ lobbyInfo.turnTerm }}</span>
<span>등록 장수: {{ lobbyInfo.userCnt }} / {{ lobbyInfo.maxUserCnt }}</span>
<span>NPC: {{ lobbyInfo.npcCnt }}</span>
<span>국가: {{ lobbyInfo.nationCnt }}</span>
<span>사실/가상: {{ lobbyInfo.fictionMode }}</span>
<span>최근 : {{ lobbyInfo.turntime || '-' }}</span>
<span>{{ lobbyInfo.otherTextInfo || '진행 정보 없음' }}</span>
</section>
<div v-if="error" class="game-feedback game-feedback--error" role="alert">{{ error }}</div>
<div v-if="frontStatusError" class="front-status-error" role="alert">{{ frontStatusError }}</div>
@@ -184,24 +193,30 @@ watch(
</div>
<div class="mobile-panel">
<MainNationMenu
class="nation-menu-middle"
:access="nationAccess"
:tournament-stage="tournamentStage"
:nation-color="nationColor"
/>
</div>
<div class="mobile-panel">
<PanelCard title="국가 정보" data-main-target="nation">
<NationBasicCard :nation="nation" :loading="loading" />
</PanelCard>
<PanelCard title="장수 스탯" data-main-target="general">
<GeneralBasicCard :general="general" :loading="loading" />
</PanelCard>
<PanelCard title="도시 정보" data-main-target="city">
<CityBasicCard :city="city" :loading="loading" />
</PanelCard>
<PanelCard title="국가 정보" data-main-target="nation">
<NationBasicCard :nation="nation" :loading="loading" />
</PanelCard>
</div>
<div class="mobile-panel">
<PanelCard title="지도" data-main-target="map">
<MapViewer :map-data="worldMap" :map-layout="mapLayout" :loading="loading" />
</PanelCard>
<PanelCard title="선택 도시">
<SelectedCityPanel :city="selectedCity" :loading="loading" />
</PanelCard>
</div>
<div class="mobile-panel record-zone-mobile">
@@ -281,40 +296,38 @@ watch(
</section>
<section v-else class="layout-desktop">
<div class="stack">
<PanelCard title="지도" subtitle="실시간 지도 + 도시 상황" data-main-target="map">
<MapViewer :map-data="worldMap" :map-layout="mapLayout" :loading="loading" />
</PanelCard>
<PanelCard title="선택 도시">
<SelectedCityPanel :city="selectedCity" :loading="loading" />
</PanelCard>
</div>
<div class="stack">
<PanelCard title="명령 목록" subtitle="예턴/명령 배치 영역" data-main-target="commands">
<CommandListPanel
:command-table="commandTable"
:loading="loading"
:selected-city="selectedCity"
:reserved-general-turns="reservedGeneralTurns"
:reserved-nation-turns="reservedNationTurns"
:general="general"
@set-general-turn="reserveGeneralTurn"
@shift-general-turns="shiftGeneralTurns"
@set-nation-turn="reserveNationTurn"
@shift-nation-turns="shiftNationTurns"
/>
</PanelCard>
<PanelCard title="장수 스탯" data-main-target="general">
<GeneralBasicCard :general="general" :loading="loading" />
</PanelCard>
<PanelCard title="도시 정보" data-main-target="city">
<CityBasicCard :city="city" :loading="loading" />
</PanelCard>
<PanelCard title="국가 정보" data-main-target="nation">
<NationBasicCard :nation="nation" :loading="loading" />
</PanelCard>
</div>
<PanelCard title="지도" subtitle="실시간 지도 + 도시 상황" data-main-target="map">
<MapViewer :map-data="worldMap" :map-layout="mapLayout" :loading="loading" />
</PanelCard>
<PanelCard title="명령 목록" subtitle="예턴/명령 배치 영역" data-main-target="commands">
<CommandListPanel
:command-table="commandTable"
:loading="loading"
:selected-city="selectedCity"
:reserved-general-turns="reservedGeneralTurns"
:reserved-nation-turns="reservedNationTurns"
:general="general"
@set-general-turn="reserveGeneralTurn"
@shift-general-turns="shiftGeneralTurns"
@set-nation-turn="reserveNationTurn"
@shift-nation-turns="shiftNationTurns"
/>
</PanelCard>
<PanelCard title="도시 정보" data-main-target="city">
<CityBasicCard :city="city" :loading="loading" />
</PanelCard>
<PanelCard title="국가 정보" data-main-target="nation">
<NationBasicCard :nation="nation" :loading="loading" />
</PanelCard>
<PanelCard title="장수 스탯" data-main-target="general">
<GeneralBasicCard :general="general" :loading="loading" />
</PanelCard>
<MainNationMenu
class="nation-menu-middle"
:access="nationAccess"
:tournament-stage="tournamentStage"
:nation-color="nationColor"
/>
<section class="record-zone">
<RecordPanel title="장수 동향" data-main-target="global-records">
<SkeletonLines v-if="loading" :lines="4" />
@@ -414,6 +427,8 @@ button {
padding: 0;
gap: 10px;
overflow-x: hidden;
background-color: transparent;
background-image: var(--sammo-texture-walnut);
}
.toggle.active {
@@ -436,6 +451,26 @@ button {
font-size: 0.85rem;
}
.legacy-game-info {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
box-sizing: border-box;
height: 18px;
overflow: hidden;
border-top: 1px solid #666;
background: #302016 var(--sammo-texture-walnut);
color: #fff;
font-size: 12px;
line-height: 17px;
text-align: center;
}
.legacy-game-info > span {
overflow: hidden;
border-right: 1px solid #666;
white-space: nowrap;
}
.warning {
color: #f5d08a;
font-size: 0.85rem;
@@ -489,14 +524,77 @@ button {
.layout-desktop {
display: grid;
grid-template-columns: minmax(0, 2.35fr) minmax(0, 1fr);
gap: 10px;
grid-template-columns: repeat(10, minmax(0, 1fr));
gap: 0;
align-items: start;
}
.stack {
display: flex;
flex-direction: column;
gap: 16px;
.layout-desktop > [data-main-target='map'] {
grid-column: 1 / 8;
grid-row: 1;
height: 520px;
overflow: hidden;
}
.layout-desktop > [data-main-target='commands'] {
grid-column: 8 / 11;
grid-row: 1;
height: 589px;
width: 290px;
margin-left: 10px;
overflow-y: auto;
}
.layout-desktop > [data-main-target='city'] {
grid-column: 1 / 8;
grid-row: 1;
min-height: 125px;
margin-top: 520px;
}
.layout-desktop > [data-main-target='nation'] {
grid-column: 1 / 6;
grid-row: 1;
min-height: 193px;
margin-top: 645px;
}
.layout-desktop > [data-main-target='general'] {
grid-column: 6 / 11;
grid-row: 1;
min-height: 193px;
margin-top: 645px;
}
.layout-desktop > [data-main-target],
.layout-mobile [data-main-target] {
border: none;
background-color: transparent;
background-image: none;
}
.layout-desktop > [data-main-target='commands'],
.layout-mobile [data-main-target='commands'] {
background-color: #222;
}
.nation-menu-middle {
grid-column: 1 / -1;
}
[data-main-target='map'] :deep(.panel-header),
[data-main-target='map'] :deep(.map-meta),
[data-main-target='map'] :deep(.map-footnote) {
display: none;
}
[data-main-target='map'] :deep(.panel-body) {
padding: 0;
}
[data-main-target='map'] :deep(.map-viewer),
[data-main-target='map'] :deep(.map-body) {
gap: 0;
}
.desktop-message-panel {
@@ -549,15 +647,61 @@ button {
.layout-mobile {
display: flex;
flex-direction: column;
gap: 16px;
gap: 4px;
}
.mobile-panel {
display: flex;
flex-direction: column;
width: 500px;
gap: 12px;
}
.mobile-panel.record-zone-mobile {
gap: 0;
}
.layout-mobile > .mobile-panel:nth-of-type(3) {
gap: 0;
}
.layout-mobile [data-main-target='commands'] {
height: 586px;
overflow-y: auto;
}
.layout-mobile [data-main-target='nation'],
.layout-mobile [data-main-target='general'] {
min-height: 193px;
}
.layout-mobile [data-main-target='city'] {
min-height: 147px;
}
.layout-mobile [data-main-target='map'] {
height: 377px;
overflow: hidden;
}
.layout-mobile .record-zone-mobile {
margin-top: 31px;
}
.desktop-action-controls .game-shell__action {
border: 1px solid transparent;
background: #006b36;
color: #fff;
}
.desktop-action-controls .game-shell__action:hover {
background: #00582c;
}
.desktop-action-controls .game-shell__action:active {
background: #005128;
}
.placeholder {
font-size: 0.85rem;
color: rgba(232, 221, 196, 0.7);
@@ -568,12 +712,33 @@ button {
@media (max-width: 939.98px) {
.main-page {
width: 500px;
width: 502px;
min-height: 3688px;
}
.legacy-game-info {
grid-template-columns: repeat(2, minmax(0, 1fr));
height: 156px;
line-height: 40px;
}
.legacy-game-info > span {
border-bottom: 1px solid #666;
}
.survey-notice {
z-index: 90;
bottom: 16px;
}
.layout-mobile [data-main-target='world-history'] {
min-height: 380px;
}
}
@media (min-width: 940px) {
.main-page {
min-height: 2706px;
}
}
</style>
+1 -1
View File
@@ -343,7 +343,7 @@ onMounted(() => {
<div v-else class="general-table">
<div class="portrait-cell">
<img
:src="resolveGeneralIconUrl(data.general, { legacyBaseUrl: '/image/game' })"
:src="resolveGeneralIconUrl(data.general)"
alt=""
@error="useDefaultGeneralIcon"
/>
@@ -630,6 +630,10 @@ onMounted(() => {
margin-top: 1em;
}
.betting-list > .section-title {
text-align: left;
}
.betting-item {
display: block;
width: auto;
+191 -113
View File
@@ -1,32 +1,22 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { formatOfficerLevelText } from '../utils/nationFormat';
import { resolveGeneralIconUrl } from '../utils/generalIcon';
import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.nation.getGeneralList.query>>;
type General = Result['generals'][number];
type Sort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
const data = ref<Result | null>(null);
const router = useRouter();
const error = ref('');
const loading = ref(false);
const sort = ref<Sort>(1);
const options = [
'관직',
'계급',
'명성',
'통솔',
'무력',
'지력',
'자금',
'군량',
'병사',
'벌점',
'성격',
'내특',
'전특',
'사관',
'NPC',
];
const viewMenuOpen = ref(false);
const columnMenuOpen = ref(false);
const nameFilter = ref('');
const officerFilter = ref('');
const visibleCrew = (general: General): number | null => ('crew' in general ? general.crew : null);
const load = async () => {
loading.value = true;
@@ -40,8 +30,17 @@ const load = async () => {
}
};
const generals = computed(() =>
[...(data.value?.generals ?? [])].sort((a, b) => {
if (sort.value === 1) return b.officerLevel - a.officerLevel || a.id - b.id;
[...(data.value?.generals ?? [])]
.filter(
(general) =>
general.name.includes(nameFilter.value.trim()) &&
formatOfficerLevelText(general.officerLevel, data.value?.nation.level).includes(
officerFilter.value.trim()
)
)
.sort((a, b) => {
if (sort.value === 1)
return a.npcState - b.npcState || b.officerLevel - a.officerLevel || a.id - b.id;
if (sort.value === 2) return b.dedicationLevel - a.dedicationLevel || a.id - b.id;
if (sort.value === 3) return b.experienceLevel - a.experienceLevel || a.id - b.id;
if (sort.value === 4) return b.stats.leadership - a.stats.leadership || a.id - b.id;
@@ -57,154 +56,233 @@ const generals = computed(() =>
if (sort.value === 14) return b.belong - a.belong || a.id - b.id;
if (sort.value === 15) return b.npcState - a.npcState || a.id - b.id;
return a.id - b.id;
})
})
);
const special = (general: General) => `${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`;
const rank = (general: General) => (general.dedicationLevel ? `${11 - general.dedicationLevel}품관` : '무품관');
const iconUrl = (general: General) => resolveGeneralIconUrl(general);
onMounted(load);
</script>
<template>
<main class="general-page legacy-bg0">
<header>
<header class="top-bar">
<span class="left-actions">
<button class="top-button nation-button" @click="router.push('/')">돌아가기</button>
<button class="top-button nation-button" :disabled="loading" @click="load">갱신</button>
</span>
<strong>세력 장수</strong>
<span
><RouterLink to="/">돌아가기</RouterLink>
<button :disabled="loading" @click="load">새로고침</button></span
>
<span class="right-actions">
<span class="dropdown">
<button class="top-button mode-button" @click="viewMenuOpen = !viewMenuOpen">보기 모드</button>
<span v-if="viewMenuOpen" class="dropdown-menu">
<button @click="sort = 1; viewMenuOpen = false">기본</button>
<button @click="sort = 4; viewMenuOpen = false">전투</button>
</span>
</span>
<span class="dropdown">
<button class="top-button columns-button" @click="columnMenuOpen = !columnMenuOpen"> 선택</button>
<span v-if="columnMenuOpen" class="dropdown-menu column-menu">
<label v-for="label in ['아이콘', '장수명', '관직', '명성/계급', '능력치', '자금', '특성']" :key="label">
<input type="checkbox" checked /> {{ label }}
</label>
</span>
</span>
</span>
</header>
<section class="sort">
정렬순서 :
<select v-model.number="sort" aria-label="세력 장수 정렬">
<option v-for="(label, index) in options" :key="label" :value="index + 1">{{ label }}</option>
</select>
<button>정렬하기</button>
<small v-if="data">열람 등급 {{ data.viewer.permission }}</small>
</section>
<p v-if="error" class="state error" role="alert">{{ error }}</p>
<p v-else-if="loading" class="state">불러오는 중...</p>
<div v-else class="scroll">
<div v-else class="grid-shell">
<table id="nation-general-list">
<colgroup>
<col v-for="(width, index) in [80, 126, 70, 70, 60, 60, 60, 60, 70, 70, 80, 100, 94]" :key="index" :style="{ width: `${width}px` }" />
</colgroup>
<thead>
<tr class="group-head">
<th colspan="2"></th>
<th></th>
<th>명성/계급 </th>
<th colspan="3">능력치 </th>
<th colspan="2">자금 </th>
<th colspan="2">특성 </th>
<th>연도 </th>
<th>기타 </th>
</tr>
<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>
<th>벌점</th>
<th>아이콘</th><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>
<tr class="filter-head">
<th></th>
<th><input v-model="nameFilter" aria-label="장수명 필터" /><span></span></th>
<th><input v-model="officerFilter" aria-label="관직 필터" /><span></span></th>
<th><input aria-label="계급 필터" /><span></span></th>
<th><input aria-label="명성 필터" /><span></span></th>
<th><input aria-label="통솔 필터" /><span></span></th>
<th><input aria-label="무력 필터" /><span></span></th>
<th><input aria-label="지력 필터" /><span></span></th>
<th><input aria-label=" 필터" /><span></span></th>
<th><input aria-label=" 필터" /><span></span></th>
<th></th><th></th><th><input aria-label="벌점 필터" /><span></span></th>
</tr>
</thead>
<tbody>
<tr v-for="general in generals" :key="general.id">
<td :class="`npc-${general.npcState}`">{{ general.name }}</td>
<td class="icon-cell"><img :src="iconUrl(general)" alt="" /></td>
<td :class="`name-cell npc-${general.npcState}`">{{ general.name }}</td>
<td>{{ formatOfficerLevelText(general.officerLevel, data?.nation.level) }}</td>
<td>
{{ general.stats.leadership }}{{ general.stats.strength }}{{ general.stats.intelligence }}
</td>
<td>
Lv {{ general.experienceLevel }}<br />{{
general.dedicationLevel ? `${11 - general.dedicationLevel}품관` : '무품관'
}}
</td>
<td>{{ general.gold.toLocaleString() }}</td>
<td>{{ general.rice.toLocaleString() }}</td>
<td>{{ general.cityName ?? '?' }}</td>
<td>{{ general.troopName ?? '?' }}</td>
<td>{{ visibleCrew(general)?.toLocaleString() ?? '?' }}</td>
<td :title="general.personality?.info ?? ''">{{ general.personality?.name ?? '-' }}</td>
<td
:title="
[general.specialDomestic?.info, general.specialWar?.info].filter(Boolean).join('\n')
"
>
{{ special(general) }}
</td>
<td>{{ general.belong }}</td>
<td>{{ general.refreshScoreTotal }}</td>
<td>{{ rank(general) }}<br />({{ (general.dedicationLevel * 200).toLocaleString() }})</td>
<td>Lv {{ general.experienceLevel }}<br />({{ general.personality?.name ?? '-' }})</td>
<td>{{ general.stats.leadership }}</td><td>{{ general.stats.strength }}</td><td>{{ general.stats.intelligence }}</td>
<td>{{ general.gold.toLocaleString() }} </td><td>{{ general.rice.toLocaleString() }} </td>
<td :title="general.personality?.info ?? ''">{{ general.personality?.name ?? '-' }}<br />{{ general.specialDomestic?.name ?? '-' }}</td>
<td :title="[general.specialDomestic?.info, general.specialWar?.info].filter(Boolean).join('\n')">{{ special(general) }}</td>
<td>{{ general.refreshScoreTotal }}<br />({{ general.belong ? '자주' : '안함' }})</td>
</tr>
</tbody>
</table>
</div>
<footer><RouterLink to="/">돌아가기</RouterLink></footer>
</main>
</template>
<style scoped>
.general-page {
width: 1000px;
min-height: 100vh;
margin: 8px auto 0;
font:
16px 'Times New Roman',
serif;
width: 100%;
min-width: 500px;
max-width: 1000px;
height: 100vh;
margin: 0 auto;
overflow: hidden;
font: 14px/21px var(--sammo-font-sans);
color: #fff;
background-color: transparent;
}
header,
.sort,
footer,
.state {
position: relative;
border: 1px solid #777;
padding: 4px;
text-align: center;
}
header {
min-height: 39px;
.top-bar {
position: relative;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background-color: transparent;
background-image: var(--sammo-texture-walnut);
border-bottom: 1px solid #42484a;
font-size: 14px;
}
header span {
.top-bar strong { font-size: 22px; font-weight: 400; }
.left-actions,
.right-actions {
position: absolute;
right: 6px;
top: 0;
display: flex;
height: 32px;
}
button,
select {
border: 1px solid #888;
border-radius: 2px;
background: #222;
.left-actions { left: 0; }
.right-actions { right: 0; }
.top-button {
display: inline-flex;
height: 32px;
align-items: center;
border: 0;
border-right: 1px solid #151515;
border-radius: 3px;
color: #fff;
padding: 1px 6px;
width: 89px;
justify-content: center;
padding: 0;
font-weight: 700;
font-size: 14px;
text-decoration: none;
cursor: pointer;
}
.sort small {
float: right;
margin-right: 6px;
color: #ccc;
.nation-button { background: #006c48; }
.nation-button:hover { background: #00855a; }
.mode-button { background: #375a7f; }
.mode-button, .columns-button { width: 90px; }
.columns-button { background: #3297cf; }
.columns-button:hover { filter: brightness(1.12); }
.dropdown { position: relative; }
.dropdown-menu {
position: absolute;
z-index: 5;
top: 32px;
right: 0;
width: 150px;
padding: 4px;
background: #252a2c;
border: 1px solid #596164;
}
.scroll {
width: 1030px;
margin-left: -15px;
min-height: calc(100vh - 112px);
.dropdown-menu button,
.dropdown-menu label {
display: block;
width: 100%;
padding: 5px;
border: 0;
color: #fff;
background: transparent;
text-align: left;
}
.grid-shell {
width: 100%;
height: calc(100vh - 32px);
overflow: auto;
border: 1px solid #424242;
background: #2d3436;
color: #f5f5f5;
cursor: default;
}
table {
width: 1030px;
min-width: 1030px;
border-collapse: separate;
width: 1000px;
min-width: 1000px;
border-collapse: collapse;
table-layout: fixed;
background: #293033;
font-size: 14px;
line-height: normal;
color: #f5f5f5;
cursor: default;
}
th,
td {
border: 1px solid #777;
padding: 3px;
border-right: 1px solid #40484b;
border-bottom: 1px solid #4a5255;
padding: 0 4px;
text-align: center;
overflow-wrap: anywhere;
overflow: hidden;
}
th {
height: 30px;
background: #14241b var(--sammo-texture-green);
height: 32px;
background: #191b1c;
color: #bdc5cf;
font-weight: 400;
white-space: nowrap;
}
.group-head th { height: 32px; border-bottom-color: #303537; }
.filter-head th { height: 32px; padding: 3px 4px; }
.filter-head input {
width: calc(100% - 15px);
height: 20px;
border: 1px solid #aab3b7;
background: #252a2c;
color: #fff;
}
.filter-head span { margin-left: 4px; color: #a5b5bf; }
tbody tr {
height: 66px;
background: rgb(0 0 0 / 18%);
height: 68px;
background: #293033;
}
tbody tr:hover { background: #343c3f; }
td { white-space: nowrap; }
.icon-cell { padding: 0 4px; text-align: left; }
.icon-cell img { width: 64px; height: 64px; object-fit: cover; vertical-align: middle; }
.name-cell { text-align: left; color: cyan; }
th:nth-child(9), td:nth-child(9), th:nth-child(10), td:nth-child(10) { text-align: right; }
.state { margin: 40px; }
.npc-0 {
color: #f5f5f5;
}
.npc-1 {
color: cyan;
@@ -220,7 +298,7 @@ tbody tr {
}
@media (max-width: 1000px) {
.general-page {
margin: 8px 0 0;
margin: 0;
}
}
</style>
+18 -15
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.nation.getNationInfo.query>>;
const data = ref<Result | null>(null);
const router = useRouter();
const error = ref('');
const number = (value: number) => value.toLocaleString('ko-KR');
const diff = (value: number) => `${value > 0 ? '+' : ''}${number(value)}`;
@@ -24,7 +26,7 @@ onMounted(async () => {
<table class="legacy-table title-table legacy-bg0">
<tbody>
<tr>
<td> <br /><RouterLink to="/">돌아가기</RouterLink></td>
<td> <br /><button type="button" @click="router.push('/')">돌아가기</button></td>
</tr>
</tbody>
</table>
@@ -104,7 +106,10 @@ onMounted(async () => {
<table class="legacy-table footer-table legacy-bg0">
<tbody>
<tr>
<td><RouterLink to="/">돌아가기</RouterLink></td>
<td><button type="button" @click="router.push('/')">돌아가기</button></td>
</tr>
<tr>
<td class="credit">삼국지 모의전투 PHP HiDCHe / KOEI의 이미지를 사용했습니다 / 제작: Hide.D</td>
</tr>
</tbody>
</table>
@@ -129,7 +134,7 @@ onMounted(async () => {
}
.title-table,
.footer-table {
text-align: center;
text-align: left;
}
.title-table {
margin-bottom: 14px;
@@ -158,20 +163,18 @@ onMounted(async () => {
.history {
text-align: left !important;
}
.title-table button,
.footer-table button {
border: 0;
border-radius: 3px;
padding: 8px 12px;
background: #345c85;
color: #fff;
cursor: pointer;
}
.credit { padding: 0 !important; }
.error {
color: #ff7373;
text-align: center;
}
@media (max-width: 700px) {
.legacy-info-page {
width: 500px;
}
.info-table {
font-size: 12px;
}
.info-table td,
.info-table th {
padding: 3px;
}
}
</style>
@@ -182,8 +182,8 @@ select {
width: 120px;
}
.list {
width: 1030px;
margin-left: -15px;
width: 1000px;
margin-left: 0;
border-collapse: separate;
}
.list tbody tr {
+33 -8
View File
@@ -34,6 +34,8 @@ const load = async () => {
}
};
const closeWindow = () => window.close();
onMounted(() => {
void load();
});
@@ -46,7 +48,7 @@ onMounted(() => {
<tr>
<td>
<br />
<RouterLink class="legacy-close" to="/">돌아가기</RouterLink>
<button class="legacy-close" type="button" @click="closeWindow">창닫기</button>
</td>
</tr>
<tr>
@@ -63,7 +65,7 @@ onMounted(() => {
<option :value="7">명성</option>
<option :value="8">계급</option>
</select>
<button type="submit" :disabled="loading">정렬하기</button>
<input type="submit" value="정렬하기" :disabled="loading" />
</form>
</td>
</tr>
@@ -143,7 +145,7 @@ onMounted(() => {
<table class="legacy-table footer-table legacy-bg0">
<tbody>
<tr>
<td><RouterLink class="legacy-close" to="/">돌아가기</RouterLink></td>
<td><button class="legacy-close" type="button" @click="closeWindow">창닫기</button></td>
</tr>
<tr>
<td class="banner">SAMMO · Legacy compatible NPC list</td>
@@ -181,6 +183,10 @@ onMounted(() => {
word-break: break-all;
}
.legacy-bg0 {
background-color: transparent;
}
.title-table td {
min-height: 20px;
}
@@ -194,7 +200,7 @@ onMounted(() => {
}
.sort-form select,
.sort-form button {
.sort-form input[type='submit'] {
height: 23px;
font: inherit;
}
@@ -204,7 +210,10 @@ onMounted(() => {
color: #303030;
}
.sort-form button {
.sort-form input[type='submit'] {
border: 2px outset #fff;
background: #6b6b6b;
color: #fff;
cursor: pointer;
}
@@ -213,12 +222,12 @@ onMounted(() => {
}
.npc-table th {
height: 20px;
height: 21px;
font-weight: 400;
}
.npc-table td {
height: 20px;
height: 21px;
}
.col-name,
@@ -279,7 +288,19 @@ onMounted(() => {
}
.legacy-close {
display: inline-grid;
min-height: 35.5px;
box-sizing: border-box;
align-items: center;
border: 1px solid #0d6efd;
border-radius: 5.25px;
padding: 5.25px 10.5px;
background: #345c85;
color: #fff;
font-weight: 700;
line-height: 21px;
text-decoration: none;
cursor: pointer;
}
.legacy-close:hover,
@@ -290,7 +311,7 @@ onMounted(() => {
.legacy-close:focus-visible,
.sort-form select:focus-visible,
.sort-form button:focus-visible {
.sort-form input[type='submit']:focus-visible {
outline: 2px solid #f39c12;
outline-offset: 1px;
}
@@ -303,6 +324,10 @@ onMounted(() => {
height: 21px;
}
.footer-table tr:first-child td {
height: 36px;
}
.banner {
font-size: 12px;
}
@@ -264,7 +264,7 @@ const start = async () => {
<style scoped>
.legacy-page {
width: 2000px;
width: 2009px;
min-height: 100vh;
margin: 0 auto;
color: #fff;
+1 -1
View File
@@ -208,7 +208,7 @@ onMounted(() => {
.legacy-bg1 {
background-color: #423226;
background-image: url('/image/game/back_sandal.jpg');
background-image: var(--sammo-texture-blue);
}
.legacy-bg2 {
+2 -3
View File
@@ -323,10 +323,9 @@ onMounted(() => {
<style scoped>
.legacy-troop-page {
position: relative;
min-height: 100vh;
margin: 0 auto;
color: #fff;
background: #000;
background: transparent;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
font-size: 14px;
line-height: 1.5;
@@ -625,7 +624,7 @@ onMounted(() => {
@media (max-width: 500px) {
.legacy-troop-page {
width: 500px;
width: 511px;
}
#generalPopup {
+73 -28
View File
@@ -18,6 +18,7 @@ type HistoryData = {
color: string;
level: number;
power: number;
generalCount: number;
cities: string[];
}>;
globalHistory: string[];
@@ -32,6 +33,8 @@ const range = ref<YearbookRange | null>(null);
const mapLayout = ref<MapLayout | null>(null);
const history = ref<HistoryData | null>(null);
const selectedYearMonth = ref<number | null>(null);
const settingsOpen = ref(false);
const rankingBottom = ref(localStorage.getItem('yearbook-ranking-bottom') === 'true');
const serverID = computed(() => {
const value = route.query.serverID;
const raw = Array.isArray(value) ? value[0] : value;
@@ -93,6 +96,11 @@ const moveMonth = (delta: number): void => {
Math.max(range.value.firstYearMonth, selectedYearMonth.value + delta)
);
};
const toggleRankingPosition = (): void => {
rankingBottom.value = !rankingBottom.value;
localStorage.setItem('yearbook-ranking-bottom', String(rankingBottom.value));
settingsOpen.value = false;
};
watch(selectedYearMonth, () => {
void loadHistory();
@@ -117,12 +125,18 @@ onMounted(async () => {
<template>
<main id="yearbook-container" class="yearbook-page legacy-bg0">
<header class="yearbook-title legacy-bg2">
<header class="yearbook-title legacy-bg0">
<strong> </strong>
<button class="legacy-button" type="button" @click="closePage"> 닫기</button>
<button class="legacy-button close-button" type="button" @click="closePage"> 닫기</button>
<span class="settings-menu">
<button class="legacy-button" type="button" @click="settingsOpen = !settingsOpen"> 설정</button>
<button v-if="settingsOpen" class="settings-item" type="button" @click="toggleRankingPosition">
국가 순서 위치 변경(모바일 전용)
</button>
</span>
</header>
<section class="year-selector legacy-border">
<section class="year-selector">
<span>연월 선택:</span>
<button
class="legacy-button"
@@ -150,24 +164,25 @@ onMounted(async () => {
<div v-if="errorMessage" class="yearbook-message error" role="alert">{{ errorMessage }}</div>
<div v-else-if="loading && !history" class="yearbook-message">불러오는 중...</div>
<section v-if="history" class="history-grid">
<section v-if="history" :class="['history-grid', { 'ranking-bottom': rankingBottom }]">
<div class="map-position">
<MapViewer :map-data="history.map" :map-layout="mapLayout" :loading="loading" />
</div>
<aside class="nation-position">
<div class="section-heading legacy-bg1">세력 일람</div>
<table>
<thead>
<tr>
<th></th>
<th></th>
<th>국력</th>
<th>도시</th>
<th>장수</th>
<th>속령</th>
</tr>
</thead>
<tbody>
<tr v-for="nation in history.nations" :key="nation.id">
<td :style="{ color: nation.color }">{{ nation.name }}</td>
<tr v-for="nation in [...history.nations].sort((a, b) => (a.level > 0 ? 0 : 1) - (b.level > 0 ? 0 : 1) || b.power - a.power)" :key="nation.id">
<td><span :style="{ backgroundColor: nation.color }">{{ nation.name }}</span></td>
<td>{{ nation.power.toLocaleString() }}</td>
<td>{{ nation.generalCount.toLocaleString() }}</td>
<td>{{ nation.cities.length }}</td>
</tr>
</tbody>
@@ -208,32 +223,45 @@ onMounted(async () => {
color: #fff;
font-family: var(--sammo-font-sans);
font-size: 14px;
background-color: transparent;
}
.yearbook-title {
position: relative;
min-height: 42px;
border: 1px solid gray;
min-height: 32px;
text-align: center;
line-height: 42px;
line-height: 21px;
background-color: transparent;
}
.yearbook-title strong {
display: inline-block;
line-height: 32px;
font-size: 20px;
letter-spacing: 0.35em;
}
.yearbook-title .legacy-button {
.yearbook-title .close-button,
.settings-menu {
position: absolute;
top: 5px;
right: 6px;
top: 0;
}
.yearbook-title .close-button { left: 0; height: 32px; }
.settings-menu { right: 0; height: 32px; }
.settings-menu > .legacy-button { height: 32px; }
.settings-item {
position: absolute;
z-index: 5;
top: 32px;
right: 0;
width: 250px;
border: 1px solid #777;
padding: 7px;
background: #222;
color: #fff;
}
.legacy-border,
.history-grid,
.map-position,
.nation-position,
.history-log {
.history-grid {
border: 1px solid gray;
}
@@ -243,8 +271,8 @@ onMounted(async () => {
justify-content: center;
align-items: center;
gap: 4px;
min-height: 42px;
padding: 3px 12px;
min-height: 37.5px;
padding: 0;
text-align: right;
}
@@ -264,8 +292,10 @@ onMounted(async () => {
.map-position,
.nation-position {
min-width: 0;
padding: 4px;
padding: 0;
}
.map-position :deep(.map-meta),
.map-position :deep(.map-footnote) { display: none; }
.nation-position table {
width: 100%;
@@ -276,9 +306,13 @@ onMounted(async () => {
.nation-position th,
.nation-position td {
border: 1px solid #555;
padding: 4px 2px;
border: 0;
border-left: 1px solid gray;
padding: 1px 6px;
}
.nation-position th { padding: 2px 6px; background: #ccc; color: #000; }
.nation-position td { text-align: right; }
.nation-position td:first-child { text-align: left; }
.section-heading {
min-height: 28px;
@@ -291,10 +325,19 @@ onMounted(async () => {
.history-log {
grid-column: 1 / -1;
}
.year-selector .legacy-button {
border: 0;
background: #444;
}
.settings-menu > .legacy-button,
.yearbook-title .close-button {
border: 0;
background: #00582c;
}
.log-content {
min-height: 72px;
padding: 7px 10px;
padding: 1px 0;
line-height: 1.35;
}
@@ -309,7 +352,7 @@ onMounted(async () => {
}
.yearbook-footer {
padding: 6px;
padding: 11px;
text-align: center;
}
@@ -320,7 +363,7 @@ onMounted(async () => {
.year-selector {
grid-template-columns: 90px 100px 190px 100px;
padding: 3px 5px;
padding: 0;
}
.history-grid {
@@ -333,5 +376,7 @@ onMounted(async () => {
width: 100%;
box-sizing: border-box;
}
.history-grid.ranking-bottom .nation-position { order: 4; }
.history-log:first-of-type { margin-bottom: 0; }
}
</style>