feat(game): align in-game GUI with ref captures
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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) ?? [],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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월: 기록 없음'],
|
||||
},
|
||||
|
||||
@@ -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) ?? [],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -10,7 +10,7 @@ interface MapViewerState {
|
||||
export const useMapViewerStore = defineStore('mapViewer', {
|
||||
state: (): MapViewerState => ({
|
||||
showCityName: true,
|
||||
detailMode: false,
|
||||
detailMode: true,
|
||||
hoveredCityId: null,
|
||||
selectedCityId: null,
|
||||
}),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user