feat: optimize battle ui for hd displays

This commit is contained in:
2026-07-11 21:02:58 +09:00
parent a0780a7ac6
commit 7d3c49ac0b
4 changed files with 751 additions and 58 deletions

View File

@@ -390,7 +390,21 @@ try {
throw new Error(`Expected edge scrolling to move camera right: ${JSON.stringify({ cameraBeforeScroll, cameraAfterEdgeScroll })}`); throw new Error(`Expected edge scrolling to move camera right: ${JSON.stringify({ cameraBeforeScroll, cameraAfterEdgeScroll })}`);
} }
await page.mouse.click(1138, 553); const miniMapClickPoint = await page.evaluate(() => {
const miniMap = window.__HEROS_DEBUG__?.battle()?.battleHud?.miniMap;
if (!miniMap?.visible || !miniMap.mapBounds || !Number.isFinite(miniMap.cellSize)) {
return null;
}
return {
x: miniMap.mapBounds.x + miniMap.mapBounds.width - miniMap.cellSize / 2,
y: miniMap.mapBounds.y + miniMap.cellSize / 2,
miniMap
};
});
if (!miniMapClickPoint) {
throw new Error('Expected the tactical minimap bounds before testing camera navigation.');
}
await page.mouse.click(miniMapClickPoint.x, miniMapClickPoint.y);
await page.waitForTimeout(160); await page.waitForTimeout(160);
const cameraAfterMiniMapClick = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.camera); const cameraAfterMiniMapClick = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.camera);
if ( if (
@@ -398,7 +412,7 @@ try {
cameraAfterMiniMapClick.x < cameraAfterMiniMapClick.mapWidth - cameraAfterMiniMapClick.visibleColumns || cameraAfterMiniMapClick.x < cameraAfterMiniMapClick.mapWidth - cameraAfterMiniMapClick.visibleColumns ||
cameraAfterMiniMapClick.y !== 0 cameraAfterMiniMapClick.y !== 0
) { ) {
throw new Error(`Expected minimap click to move camera to the northeast: ${JSON.stringify(cameraAfterMiniMapClick)}`); throw new Error(`Expected minimap click to move camera to the northeast: ${JSON.stringify({ miniMapClickPoint, cameraAfterMiniMapClick })}`);
} }
const readyUnits = result.battleState.units.filter((unit) => !unit.acted); const readyUnits = result.battleState.units.filter((unit) => !unit.acted);
@@ -9931,7 +9945,7 @@ async function ensureSortiePrepStep(page, expectedStep) {
} }
async function launchSortieFromCurrentStep(page) { async function launchSortieFromCurrentStep(page) {
const state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); let state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
if (!state?.sortieVisible) { if (!state?.sortieVisible) {
throw new Error(`Cannot launch sortie; preparation is not visible: ${JSON.stringify(state)}`); throw new Error(`Cannot launch sortie; preparation is not visible: ${JSON.stringify(state)}`);
} }
@@ -9941,13 +9955,35 @@ async function launchSortieFromCurrentStep(page) {
return; return;
} }
if (state.sortieOperationOrder?.required && !state.sortieOperationOrder.complete) {
await page.evaluate(() => {
window.__HEROS_GAME__?.scene.getScene('CampScene')?.selectSortieOrder?.('elite');
});
await page.waitForFunction(() => {
const camp = window.__HEROS_DEBUG__?.camp();
return camp?.sortieVisible === true && camp?.sortieOperationOrder?.selectedId === 'elite' && camp?.sortieOperationOrder?.complete === true;
}, undefined, { timeout: 30000 });
state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
}
await ensureSortiePrepStep(page, 'loadout'); await ensureSortiePrepStep(page, 'loadout');
await page.waitForTimeout(160);
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await page.waitForFunction(() => { try {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; await page.waitForFunction(() => {
const camp = window.__HEROS_DEBUG__?.camp(); const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
return !activeScenes.includes('CampScene') || camp?.sortieVisible !== true; const camp = window.__HEROS_DEBUG__?.camp();
}, undefined, { timeout: 30000 }); return !activeScenes.includes('CampScene') || camp?.sortieVisible !== true;
}, undefined, { timeout: 30000 });
} catch (error) {
const stalledState = await page.evaluate(() => ({
activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [],
camp: window.__HEROS_DEBUG__?.camp() ?? null,
battle: window.__HEROS_DEBUG__?.battle() ?? null,
gameScenes: window.__HEROS_GAME__?.scene.getScenes(true).map((scene) => scene.scene.key) ?? []
}));
throw new Error(`Sortie launch stalled after Enter: ${JSON.stringify(stalledState)}\n${error instanceof Error ? error.message : String(error)}`);
}
} }
async function clickSortieRosterUnit(page, unitId) { async function clickSortieRosterUnit(page, unitId) {

View File

@@ -41,6 +41,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' }); await page.reload({ waitUntil: 'domcontentloaded' });
await waitForTitle(page); await waitForTitle(page);
await assertReleaseShellReady(page); await assertReleaseShellReady(page);
await assertHdPlusCanvasFit(page);
await assertHdPlusInputMapping(browser, targetUrl);
await assertLargeRosterHud(browser, targetUrl);
await page.screenshot({ path: `${screenshotDir}/rc-title-first-run.png`, fullPage: true }); await page.screenshot({ path: `${screenshotDir}/rc-title-first-run.png`, fullPage: true });
await assertCanvasPainted(page, 'title first run'); await assertCanvasPainted(page, 'title first run');
@@ -60,16 +63,36 @@ try {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const state = window.__HEROS_DEBUG__?.battle(); const state = window.__HEROS_DEBUG__?.battle();
const sideTexts = textValues(scene?.sidePanelObjects); const sideTexts = textValues(scene?.sidePanelObjects);
const miniMapTexts = textValues((scene?.miniMapObjects ?? []).filter((object) => object?.active && object?.visible));
const miniMapFrame = (scene?.miniMapObjects ?? []).find((object) => object?.name === 'mini-map-frame');
return { return {
battleId: state?.battleId, battleId: state?.battleId,
objectiveText: scene?.objectiveTrackerText?.text, objectiveText: scene?.objectiveTrackerText?.text,
objectiveSubText: scene?.objectiveTrackerSubText?.text, objectiveSubText: scene?.objectiveTrackerSubText?.text,
sideTexts sideTexts,
miniMapTexts,
actualMiniMapFrameBounds: miniMapFrame ? boundsValue(miniMapFrame.getBounds()) : null,
hud: state?.battleHud ?? null,
camera: state?.camera ?? null,
battleLog: [...(state?.battleLog ?? [])],
sceneBounds: scene ? { x: 0, y: 0, width: scene.scale.width, height: scene.scale.height } : null,
panelBounds: scene
? {
x: scene.layout.panelX,
y: scene.layout.panelY,
width: scene.layout.panelWidth,
height: scene.layout.panelHeight
}
: null
}; };
function textValues(objects = []) { function textValues(objects = []) {
return objects.filter((object) => object?.type === 'Text').map((object) => object.text); return objects.filter((object) => object?.type === 'Text').map((object) => object.text);
} }
function boundsValue(bounds) {
return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
}
}); });
assert(firstBattleProbe.battleId === 'first-battle-zhuo-commandery', `Expected first battle: ${JSON.stringify(firstBattleProbe)}`); assert(firstBattleProbe.battleId === 'first-battle-zhuo-commandery', `Expected first battle: ${JSON.stringify(firstBattleProbe)}`);
assert(firstBattleProbe.objectiveText?.startsWith('승리 목표:'), `Expected clear victory objective label: ${JSON.stringify(firstBattleProbe)}`); assert(firstBattleProbe.objectiveText?.startsWith('승리 목표:'), `Expected clear victory objective label: ${JSON.stringify(firstBattleProbe)}`);
@@ -85,6 +108,50 @@ try {
!firstBattleProbe.sideTexts.some((text) => text.includes('...')), !firstBattleProbe.sideTexts.some((text) => text.includes('...')),
`Expected sortie doctrine opening message without ASCII truncation: ${JSON.stringify(firstBattleProbe.sideTexts)}` `Expected sortie doctrine opening message without ASCII truncation: ${JSON.stringify(firstBattleProbe.sideTexts)}`
); );
const firstBattleMiniMap = firstBattleProbe.hud?.miniMap;
const firstBattleRecentActions = firstBattleProbe.hud?.recentActions;
assert(
firstBattleMiniMap?.visible === true &&
firstBattleMiniMap.title === '전장 지도' &&
sameJsonValue(firstBattleMiniMap.legend, ['아', '적', '목']) &&
firstBattleProbe.sceneBounds?.width === 1280 &&
firstBattleProbe.sceneBounds?.height === 720 &&
firstBattleMiniMap.cellSize >= 1 &&
firstBattleMiniMap.objectiveHighlighted === true &&
firstBattleMiniMap.selectedUnitId === null &&
firstBattleMiniMap.selectedHighlighted === false &&
firstBattleProbe.miniMapTexts.includes('전장 지도') &&
['아', '적', '목'].every((label) => firstBattleProbe.miniMapTexts.includes(label)) &&
sameJsonValue(firstBattleProbe.actualMiniMapFrameBounds, firstBattleMiniMap.frameBounds) &&
isFiniteBounds(firstBattleProbe.sceneBounds) &&
isFiniteBounds(firstBattleProbe.panelBounds) &&
isFiniteBounds(firstBattleMiniMap.frameBounds) &&
isFiniteBounds(firstBattleMiniMap.headerBounds) &&
isFiniteBounds(firstBattleMiniMap.mapBounds) &&
isFiniteBounds(firstBattleMiniMap.viewportBounds) &&
boundsInside(firstBattleProbe.panelBounds, firstBattleProbe.sceneBounds) &&
boundsInside(firstBattleMiniMap.frameBounds, firstBattleProbe.panelBounds) &&
boundsInside(firstBattleMiniMap.headerBounds, firstBattleMiniMap.frameBounds) &&
boundsInside(firstBattleMiniMap.mapBounds, firstBattleMiniMap.frameBounds) &&
boundsInside(firstBattleMiniMap.viewportBounds, firstBattleMiniMap.mapBounds, 2) &&
firstBattleMiniMap.mapBounds.width === firstBattleProbe.camera?.mapWidth * firstBattleMiniMap.cellSize &&
firstBattleMiniMap.mapBounds.height === firstBattleProbe.camera?.mapHeight * firstBattleMiniMap.cellSize &&
firstBattleMiniMap.headerBounds.y + firstBattleMiniMap.headerBounds.height <= firstBattleMiniMap.mapBounds.y,
`Expected a framed tactical minimap with title, legend, objective emphasis, and separate map hit bounds: ${JSON.stringify(firstBattleProbe)}`
);
assert(
isFiniteBounds(firstBattleRecentActions) &&
boundsInside(firstBattleRecentActions, firstBattleProbe.panelBounds) &&
boundsInside(firstBattleRecentActions, firstBattleProbe.sceneBounds) &&
firstBattleRecentActions?.compact === true &&
firstBattleRecentActions.height === 84 &&
firstBattleRecentActions.rowCount === Math.min(2, firstBattleProbe.battleLog.length) &&
firstBattleProbe.sideTexts.includes('최근 행동') &&
firstBattleRecentActions.bottom === firstBattleRecentActions.y + firstBattleRecentActions.height &&
firstBattleRecentActions.gapToMiniMap >= 6 &&
firstBattleRecentActions.bottom <= firstBattleMiniMap.frameBounds.y,
`Expected the 720p recent-action panel to compact to two rows without touching the minimap frame: ${JSON.stringify(firstBattleProbe.hud)}`
);
await setDebugQueryParam(page, 'debugForceBondChain', '1'); await setDebugQueryParam(page, 'debugForceBondChain', '1');
const firstBattleBondChain = await page.evaluate(() => { const firstBattleBondChain = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
@@ -3997,6 +4064,264 @@ async function assertReleaseShellReady(page) {
assert(state.gameApiPresent === true, `Expected release QA game handle to be enabled: ${JSON.stringify(state)}`); assert(state.gameApiPresent === true, `Expected release QA game handle to be enabled: ${JSON.stringify(state)}`);
} }
async function assertHdPlusCanvasFit(page) {
const cases = [
{
viewport: { width: 1600, height: 900 },
expectedBounds: { x: 0, y: 0, width: 1600, height: 900 }
},
{
viewport: { width: 1920, height: 1200 },
expectedBounds: { x: 0, y: 60, width: 1920, height: 1080 }
}
];
for (const testCase of cases) {
await page.setViewportSize(testCase.viewport);
await page.waitForFunction(({ expectedBounds }) => {
const canvas = document.querySelector('canvas');
if (!canvas) {
return false;
}
const bounds = canvas.getBoundingClientRect();
return (
canvas.width === 1280 &&
canvas.height === 720 &&
Math.abs(bounds.x - expectedBounds.x) <= 1 &&
Math.abs(bounds.y - expectedBounds.y) <= 1 &&
Math.abs(bounds.width - expectedBounds.width) <= 1 &&
Math.abs(bounds.height - expectedBounds.height) <= 1
);
}, testCase, { timeout: 30000 });
const state = await page.evaluate(() => {
const canvas = document.querySelector('canvas');
const bounds = canvas?.getBoundingClientRect();
const game = window.__HEROS_GAME__;
return {
viewport: { width: window.innerWidth, height: window.innerHeight },
gameSize: game ? { width: game.scale.width, height: game.scale.height } : null,
canvas: canvas && bounds
? {
width: canvas.width,
height: canvas.height,
bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }
}
: null
};
});
assert(state.canvas?.width === 1280 && state.canvas?.height === 720, `Expected a fixed HD game surface: ${JSON.stringify(state)}`);
assert(state.gameSize?.width === 1280 && state.gameSize?.height === 720, `Expected fixed HD scene coordinates: ${JSON.stringify(state)}`);
assert(
boundsNear(state.canvas?.bounds, testCase.expectedBounds),
`Expected HD surface to fit ${testCase.viewport.width}x${testCase.viewport.height}: ${JSON.stringify(state)}`
);
}
await page.setViewportSize({ width: 1280, height: 720 });
await page.waitForFunction(() => {
const canvas = document.querySelector('canvas');
const bounds = canvas?.getBoundingClientRect();
return Boolean(
canvas &&
bounds &&
canvas.width === 1280 &&
canvas.height === 720 &&
Math.abs(bounds.x) <= 1 &&
Math.abs(bounds.y) <= 1 &&
Math.abs(bounds.width - 1280) <= 1 &&
Math.abs(bounds.height - 720) <= 1
);
}, undefined, { timeout: 30000 });
}
function boundsNear(actual, expected, tolerance = 1) {
return Boolean(
actual &&
Math.abs(actual.x - expected.x) <= tolerance &&
Math.abs(actual.y - expected.y) <= tolerance &&
Math.abs(actual.width - expected.width) <= tolerance &&
Math.abs(actual.height - expected.height) <= tolerance
);
}
async function assertHdPlusInputMapping(browser, url) {
const page = await browser.newPage({ viewport: { width: 1920, height: 1200 } });
try {
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.evaluate(() => window.localStorage.clear());
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForTitle(page);
const clickPoint = await page.evaluate(() => {
const canvas = document.querySelector('canvas');
const bounds = canvas?.getBoundingClientRect();
if (!canvas || !bounds) {
return null;
}
return {
x: bounds.x + (962 / canvas.width) * bounds.width,
y: bounds.y + (240 / canvas.height) * bounds.height,
canvasBounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }
};
});
assert(
clickPoint && boundsNear(clickPoint.canvasBounds, { x: 0, y: 60, width: 1920, height: 1080 }),
`Expected a centered HD canvas before testing scaled input: ${JSON.stringify(clickPoint)}`
);
await page.mouse.click(clickPoint.x, clickPoint.y);
await waitForStoryReady(page);
const activeScenes = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []);
assert(activeScenes.includes('StoryScene'), `Expected scaled title input to enter the story: ${JSON.stringify(activeScenes)}`);
} finally {
await page.close();
}
}
async function assertLargeRosterHud(browser, url) {
const expectedRosterIds = [
'zhuge-liang',
'jiang-wei',
'wang-ping',
'zhao-yun',
'ma-dai',
'ma-chao',
'huang-quan',
'li-yan'
];
const battleUrl = new URL(url);
battleUrl.searchParams.set('debugBattle', 'sixty-sixth-battle-wuzhang-final');
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
try {
await page.goto(battleUrl.toString(), { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => {
const battle = window.__HEROS_DEBUG__?.battle();
return battle?.battleId === 'sixty-sixth-battle-wuzhang-final' && battle?.mapBackgroundReady === true;
}, undefined, { timeout: 90000 });
await page.evaluate((selectedSortieUnitIds) => {
window.__HEROS_GAME__?.scene.getScene('BattleScene')?.scene.restart({
battleId: 'sixty-sixth-battle-wuzhang-final',
selectedSortieUnitIds
});
}, expectedRosterIds);
await page.waitForFunction((expectedIds) => {
const battle = window.__HEROS_DEBUG__?.battle();
return (
battle?.battleId === 'sixty-sixth-battle-wuzhang-final' &&
battle?.mapBackgroundReady === true &&
JSON.stringify([...battle.deployedAllyIds].sort()) === JSON.stringify([...expectedIds].sort())
);
}, expectedRosterIds, { timeout: 90000 });
await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
if (scene?.phase === 'deployment') {
scene.confirmPreBattleDeployment?.();
}
scene?.hideBattleEventBanner?.();
scene?.renderRosterPanel?.('ally', '출전 위치 확정. 행동할 장수를 선택하세요.');
});
await page.waitForFunction(() => {
const roster = window.__HEROS_DEBUG__?.battle()?.battleHud?.rosterPanel;
return roster?.tab === 'ally' && roster?.pageCount > 1;
}, undefined, { timeout: 30000 });
const firstPage = await readLargeRosterHudProbe(page);
assert(firstPage.roster.totalCount === expectedRosterIds.length, `Expected the exact eight-officer sortie roster: ${JSON.stringify(firstPage)}`);
assert(firstPage.roster.page === 0 && firstPage.roster.pageCount >= 2, `Expected paged late-battle roster: ${JSON.stringify(firstPage)}`);
assert(
firstPage.roster.visibleUnitIds.length > 0 && firstPage.roster.visibleUnitIds.length <= firstPage.roster.rowsPerPage,
`Expected only the current roster page to render: ${JSON.stringify(firstPage)}`
);
assertLargeRosterPageLayout(firstPage, 'first');
assert(firstPage.recentActions === null, `Expected the paged roster to prioritize selectable units over the action log: ${JSON.stringify(firstPage)}`);
const visibleRosterIds = new Set(firstPage.roster.visibleUnitIds);
let visibleRosterCount = firstPage.roster.visibleUnitIds.length;
let currentPage = firstPage;
for (let pageIndex = 1; pageIndex < firstPage.roster.pageCount; pageIndex += 1) {
await page.mouse.click(
currentPage.roster.navigationBounds.x + currentPage.roster.navigationBounds.width - 39,
currentPage.roster.navigationBounds.y + 15
);
await page.waitForFunction(
(expectedPage) => window.__HEROS_DEBUG__?.battle()?.battleHud?.rosterPanel?.page === expectedPage,
pageIndex,
{ timeout: 30000 }
);
currentPage = await readLargeRosterHudProbe(page);
visibleRosterCount += currentPage.roster.visibleUnitIds.length;
currentPage.roster.visibleUnitIds.forEach((unitId) => visibleRosterIds.add(unitId));
assertLargeRosterPageLayout(currentPage, `${pageIndex + 1}`);
}
assert(
visibleRosterCount === expectedRosterIds.length &&
JSON.stringify([...visibleRosterIds].sort()) === JSON.stringify([...expectedRosterIds].sort()),
`Expected roster paging to expose every selected officer exactly once: ${JSON.stringify({ expectedRosterIds, visibleRosterCount, visibleRosterIds: [...visibleRosterIds] })}`
);
await page.screenshot({ path: `${screenshotDir}/rc-final-battle-roster-last-page.png`, fullPage: true });
await assertCanvasPainted(page, 'final battle roster last page');
await page.evaluate((selectedSortieUnitIds) => {
window.__HEROS_GAME__?.scene.getScene('BattleScene')?.scene.restart({
battleId: 'sixty-sixth-battle-wuzhang-final',
selectedSortieUnitIds
});
}, expectedRosterIds);
await page.waitForFunction((expectedIds) => {
const battle = window.__HEROS_DEBUG__?.battle();
return (
battle?.battleId === 'sixty-sixth-battle-wuzhang-final' &&
battle?.mapBackgroundReady === true &&
battle?.phase === 'deployment' &&
JSON.stringify([...battle.deployedAllyIds].sort()) === JSON.stringify([...expectedIds].sort())
);
}, expectedRosterIds, { timeout: 90000 });
await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
scene?.confirmPreBattleDeployment?.();
scene?.hideBattleEventBanner?.();
scene?.renderRosterPanel?.('ally', '출전 위치 확정. 행동할 장수를 선택하세요.');
});
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.battleHud?.rosterPanel?.pageCount > 1, undefined, { timeout: 30000 });
const restartedPage = await readLargeRosterHudProbe(page);
assert(
restartedPage.roster.page === 0,
`Expected a restarted battle to reset the roster to page one: ${JSON.stringify(restartedPage)}`
);
} finally {
await page.close();
}
}
async function readLargeRosterHudProbe(page) {
return page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const hud = window.__HEROS_DEBUG__?.battle()?.battleHud;
return {
roster: hud?.rosterPanel ?? null,
recentActions: hud?.recentActions ?? null,
miniMap: hud?.miniMap ?? null,
panelBounds: scene
? { x: scene.layout.panelX, y: scene.layout.panelY, width: scene.layout.panelWidth, height: scene.layout.panelHeight }
: null
};
});
}
function assertLargeRosterPageLayout(probe, label) {
const { roster, panelBounds } = probe;
assert(boundsInside(roster.listBounds, panelBounds), `Expected roster rows on page ${label} inside the side panel: ${JSON.stringify(probe)}`);
assert(boundsInside(roster.navigationBounds, panelBounds), `Expected roster navigation on page ${label} inside the side panel: ${JSON.stringify(probe)}`);
assert(boundsInside(roster.messageBounds, panelBounds), `Expected roster guidance on page ${label} inside the side panel: ${JSON.stringify(probe)}`);
assert(
roster.listBounds.y + roster.listBounds.height <= roster.navigationBounds.y &&
roster.navigationBounds.y + roster.navigationBounds.height <= roster.messageBounds.y &&
roster.gapToMiniMap >= 6,
`Expected roster page ${label} content to stay vertically separated: ${JSON.stringify(probe)}`
);
}
async function waitForStoryReady(page) { async function waitForStoryReady(page) {
await page.waitForFunction(() => { await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];

View File

@@ -1131,6 +1131,32 @@ type MiniMapLayout = {
width: number; width: number;
height: number; height: number;
cellSize: number; cellSize: number;
frameX: number;
frameY: number;
frameWidth: number;
frameHeight: number;
headerHeight: number;
};
type RecentActionLogLayout = {
x: number;
y: number;
width: number;
height: number;
compact: boolean;
rowCount: number;
};
type RosterPanelLayout = {
tab: RosterTab;
page: number;
pageCount: number;
rowsPerPage: number;
totalCount: number;
visibleUnitIds: string[];
listBounds: { x: number; y: number; width: number; height: number };
navigationBounds: { x: number; y: number; width: number; height: number } | null;
messageBounds: { x: number; y: number; width: number; height: number } | null;
}; };
type UnitView = { type UnitView = {
@@ -3336,6 +3362,8 @@ export class BattleScene extends Phaser.Scene {
private markers: Phaser.GameObjects.Rectangle[] = []; private markers: Phaser.GameObjects.Rectangle[] = [];
private objectiveMarkerObjects: Phaser.GameObjects.GameObject[] = []; private objectiveMarkerObjects: Phaser.GameObjects.GameObject[] = [];
private rosterTab: RosterTab = 'ally'; private rosterTab: RosterTab = 'ally';
private rosterPage = 0;
private rosterPanelLayout?: RosterPanelLayout;
private sidePanelObjects: Phaser.GameObjects.GameObject[] = []; private sidePanelObjects: Phaser.GameObjects.GameObject[] = [];
private commandButtons: CommandButton[] = []; private commandButtons: CommandButton[] = [];
private commandMenuObjects: Phaser.GameObjects.GameObject[] = []; private commandMenuObjects: Phaser.GameObjects.GameObject[] = [];
@@ -3424,6 +3452,7 @@ export class BattleScene extends Phaser.Scene {
private miniMapUnitDots = new Map<string, Phaser.GameObjects.Rectangle>(); private miniMapUnitDots = new Map<string, Phaser.GameObjects.Rectangle>();
private miniMapViewport?: Phaser.GameObjects.Rectangle; private miniMapViewport?: Phaser.GameObjects.Rectangle;
private miniMapVisible = true; private miniMapVisible = true;
private recentActionLogLayout?: RecentActionLogLayout;
private cameraTileX = 0; private cameraTileX = 0;
private cameraTileY = 0; private cameraTileY = 0;
private edgeScrollElapsed = 0; private edgeScrollElapsed = 0;
@@ -3445,6 +3474,9 @@ export class BattleScene extends Phaser.Scene {
init(data?: BattleSceneData) { init(data?: BattleSceneData) {
configureBattleScenario(getBattleScenario(data?.battleId)); configureBattleScenario(getBattleScenario(data?.battleId));
this.rosterTab = 'ally';
this.rosterPage = 0;
this.rosterPanelLayout = undefined;
const campaign = getCampaignState(); const campaign = getCampaignState();
const selectedOrderId = campaign.sortieOrderSelection?.battleId === battleScenario.id const selectedOrderId = campaign.sortieOrderSelection?.battleId === battleScenario.id
? campaign.sortieOrderSelection.orderId ? campaign.sortieOrderSelection.orderId
@@ -5531,7 +5563,7 @@ export class BattleScene extends Phaser.Scene {
const { panelX, panelY, panelWidth, panelHeight } = this.layout; const { panelX, panelY, panelWidth, panelHeight } = this.layout;
const left = panelX + 24; const left = panelX + 24;
const miniMapTop = this.miniMapLayout?.y ?? panelY + panelHeight - 156; const miniMapTop = this.miniMapLayout?.frameY ?? panelY + panelHeight - 156;
const top = Math.max(panelY + 212, miniMapTop - 148); const top = Math.max(panelY + 212, miniMapTop - 148);
const width = panelWidth - 48; const width = panelWidth - 48;
const height = 128; const height = 128;
@@ -5589,20 +5621,76 @@ export class BattleScene extends Phaser.Scene {
private drawMiniMap() { private drawMiniMap() {
const { panelX, panelY, panelWidth, panelHeight } = this.layout; const { panelX, panelY, panelWidth, panelHeight } = this.layout;
const maxWidth = panelWidth - 48; const maxWidth = panelWidth - 48;
const maxHeight = 132; const maxHeight = 108;
const cellSize = Math.floor(Math.min(maxWidth / battleMap.width, maxHeight / battleMap.height)); const cellSize = Math.max(1, Math.floor(Math.min(maxWidth / battleMap.width, maxHeight / battleMap.height)));
const width = battleMap.width * cellSize; const width = battleMap.width * cellSize;
const height = battleMap.height * cellSize; const height = battleMap.height * cellSize;
const x = panelX + Math.floor((panelWidth - width) / 2); const x = panelX + Math.floor((panelWidth - width) / 2);
const y = panelY + panelHeight - height - 24; const y = panelY + panelHeight - height - 12;
this.miniMapLayout = { x, y, width, height, cellSize }; const headerHeight = 26;
const frameWidth = Math.min(maxWidth, Math.max(width + 12, 190));
const frameHeight = headerHeight + height + 6;
const frameX = panelX + Math.floor((panelWidth - frameWidth) / 2);
const frameY = y - headerHeight;
this.miniMapLayout = {
x,
y,
width,
height,
cellSize,
frameX,
frameY,
frameWidth,
frameHeight,
headerHeight
};
const frame = this.add.rectangle(x - 5, y - 5, width + 10, height + 10, 0x0a1014, 0.92); const frame = this.add.rectangle(frameX, frameY, frameWidth, frameHeight, 0x0a1014, 0.96);
frame.setOrigin(0); frame.setOrigin(0);
frame.setStrokeStyle(2, palette.gold, 0.72); frame.setStrokeStyle(1, palette.gold, 0.78);
frame.setDepth(24); frame.setDepth(24);
frame.setName('mini-map-frame');
this.miniMapObjects.push(frame); this.miniMapObjects.push(frame);
const title = this.add.text(frameX + 10, frameY + 6, '전장 지도', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '12px',
color: '#f2e3bf',
fontStyle: '700'
});
title.setDepth(27);
title.setName('mini-map-title');
this.miniMapObjects.push(title);
const separator = this.add.rectangle(frameX + 5, frameY + headerHeight - 1, frameWidth - 10, 1, palette.gold, 0.34);
separator.setOrigin(0);
separator.setDepth(25);
this.miniMapObjects.push(separator);
const legendStartX = frameX + frameWidth - 82;
[
{ label: '아', tone: 0x4aa9ff, target: false },
{ label: '적', tone: 0xff715f, target: false },
{ label: '목', tone: palette.gold, target: true }
].forEach((legend, index) => {
const legendX = legendStartX + index * 27;
const dot = this.add.rectangle(legendX, frameY + 13, 7, 7, legend.target ? 0x0a1014 : legend.tone, 1);
if (legend.target) {
dot.setStrokeStyle(1, legend.tone, 1);
}
dot.setDepth(27);
this.miniMapObjects.push(dot);
const label = this.add.text(legendX + 6, frameY + 7, legend.label, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '9px',
color: legend.target ? '#ffdf7b' : '#c8d2dd',
fontStyle: '700'
});
label.setDepth(27);
this.miniMapObjects.push(label);
});
const hitArea = this.add.rectangle(x, y, width, height, 0x111820, 0.08); const hitArea = this.add.rectangle(x, y, width, height, 0x111820, 0.08);
hitArea.setOrigin(0); hitArea.setOrigin(0);
hitArea.setInteractive({ useHandCursor: true }); hitArea.setInteractive({ useHandCursor: true });
@@ -5790,9 +5878,21 @@ export class BattleScene extends Phaser.Scene {
if (!unit) { if (!unit) {
return; return;
} }
const selected = this.selectedUnit?.id === unit.id;
const objective = unit.id === leaderUnitId;
const baseSize = Math.max(3, cellSize);
const markerSize = baseSize + (objective ? 2 : 0) + (selected ? 2 : 0);
dot.setPosition(x + unit.x * cellSize + cellSize / 2, y + unit.y * cellSize + cellSize / 2); dot.setPosition(x + unit.x * cellSize + cellSize / 2, y + unit.y * cellSize + cellSize / 2);
dot.setDisplaySize(markerSize, markerSize);
dot.setVisible(unit.hp > 0); dot.setVisible(unit.hp > 0);
dot.setFillStyle(unit.faction === 'ally' ? 0x4aa9ff : 0xff715f, unit.hp > 0 ? 0.96 : 0.18); dot.setFillStyle(unit.faction === 'ally' ? 0x4aa9ff : 0xff715f, unit.hp > 0 ? 0.96 : 0.18);
if (selected) {
dot.setStrokeStyle(2, 0xffffff, 1);
} else if (objective) {
dot.setStrokeStyle(2, palette.gold, 1);
} else {
dot.setStrokeStyle(0, 0x000000, 0);
}
}); });
this.miniMapViewport?.setPosition(x + this.cameraTileX * cellSize, y + this.cameraTileY * cellSize); this.miniMapViewport?.setPosition(x + this.cameraTileX * cellSize, y + this.cameraTileY * cellSize);
@@ -6369,6 +6469,7 @@ export class BattleScene extends Phaser.Scene {
this.pendingMove = undefined; this.pendingMove = undefined;
this.phase = 'idle'; this.phase = 'idle';
this.hideCommandMenu(); this.hideCommandMenu();
this.updateMiniMap();
this.refreshUnitLegibilityStyles(); this.refreshUnitLegibilityStyles();
this.showEnemyUnitThreatRange(unit); this.showEnemyUnitThreatRange(unit);
this.renderUnitDetail(unit, this.enemyDetailMessage(unit)); this.renderUnitDetail(unit, this.enemyDetailMessage(unit));
@@ -6385,6 +6486,7 @@ export class BattleScene extends Phaser.Scene {
this.clearMarkers(); this.clearMarkers();
this.hideCommandMenu(); this.hideCommandMenu();
this.phase = 'idle'; this.phase = 'idle';
this.updateMiniMap();
this.refreshUnitLegibilityStyles(); this.refreshUnitLegibilityStyles();
this.renderUnitDetail(unit, '이미 행동을 마친 장수입니다.'); this.renderUnitDetail(unit, '이미 행동을 마친 장수입니다.');
return; return;
@@ -6396,6 +6498,7 @@ export class BattleScene extends Phaser.Scene {
this.clearMarkers(); this.clearMarkers();
this.hideCommandMenu(); this.hideCommandMenu();
this.showMoveRange(unit); this.showMoveRange(unit);
this.updateMiniMap();
this.refreshUnitLegibilityStyles(); this.refreshUnitLegibilityStyles();
this.renderUnitDetail(unit, '이동할 파란 칸을 선택하세요.'); this.renderUnitDetail(unit, '이동할 파란 칸을 선택하세요.');
} }
@@ -21077,37 +21180,52 @@ export class BattleScene extends Phaser.Scene {
private sideContentBottom(padding = 16) { private sideContentBottom(padding = 16) {
const panelBottom = this.layout.panelY + this.layout.panelHeight - padding; const panelBottom = this.layout.panelY + this.layout.panelHeight - padding;
return this.miniMapVisible && this.miniMapLayout ? this.miniMapLayout.y - padding : panelBottom; return this.miniMapVisible && this.miniMapLayout ? this.miniMapLayout.frameY - padding : panelBottom;
}
private recentActionLogPlacement(minTop: number) {
const bottom = this.sideContentBottom(6);
const availableHeight = bottom - minTop;
const height = availableHeight >= 118 ? 118 : availableHeight >= 84 ? 84 : 0;
return height > 0
? { y: bottom - height, height, compact: height < 100 }
: undefined;
} }
private renderRecentActionLogPanel(x: number, y: number, width: number, height = 118) { private renderRecentActionLogPanel(x: number, y: number, width: number, height = 118) {
const compact = height < 100;
const headerHeight = compact ? 28 : 32;
const rowHeight = compact ? 21 : 24;
const rowStep = compact ? 24 : 28;
const maxRows = compact ? 2 : 3;
const bg = this.trackSideObject(this.add.rectangle(x, y, width, height, 0x0b1118, 0.96)); const bg = this.trackSideObject(this.add.rectangle(x, y, width, height, 0x0b1118, 0.96));
bg.setOrigin(0); bg.setOrigin(0);
bg.setDepth(30); bg.setDepth(30);
bg.setStrokeStyle(1, 0x647485, 0.68); bg.setStrokeStyle(1, 0x647485, 0.68);
const title = this.trackSideObject(this.add.text(x + 12, y + 8, '최근 행동', { const title = this.trackSideObject(this.add.text(x + 12, y + (compact ? 6 : 8), '최근 행동', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '15px', fontSize: compact ? '14px' : '15px',
color: '#f2e3bf', color: '#f2e3bf',
fontStyle: '700' fontStyle: '700'
})); }));
title.setDepth(31); title.setDepth(31);
const countText = this.trackSideObject(this.add.text(x + width - 12, y + 9, `${this.battleLog.length}`, { const countText = this.trackSideObject(this.add.text(x + width - 12, y + (compact ? 7 : 9), `${this.battleLog.length}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '12px', fontSize: compact ? '11px' : '12px',
color: '#9fb0bf', color: '#9fb0bf',
fontStyle: '700' fontStyle: '700'
})); }));
countText.setOrigin(1, 0); countText.setOrigin(1, 0);
countText.setDepth(31); countText.setDepth(31);
const entries = this.battleLog.slice(0, 3); const entries = this.battleLog.slice(0, maxRows);
this.recentActionLogLayout = { x, y, width, height, compact, rowCount: entries.length };
if (entries.length === 0) { if (entries.length === 0) {
const emptyText = this.trackSideObject(this.add.text(x + 12, y + 45, '아직 기록된 행동이 없습니다.', { const emptyText = this.trackSideObject(this.add.text(x + 12, y + (compact ? 37 : 45), '아직 기록된 행동이 없습니다.', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '13px', fontSize: compact ? '12px' : '13px',
color: '#9fb0bf' color: '#9fb0bf'
})); }));
emptyText.setDepth(31); emptyText.setDepth(31);
@@ -21115,35 +21233,38 @@ export class BattleScene extends Phaser.Scene {
} }
entries.forEach((entry, index) => { entries.forEach((entry, index) => {
const rowY = y + 32 + index * 28; const rowY = y + headerHeight + index * rowStep;
const { turn, text } = this.battleLogDisplayParts(entry); const { turn, text } = this.battleLogDisplayParts(entry);
const visual = this.battleLogVisual(entry); const visual = this.battleLogVisual(entry);
const row = this.trackSideObject(this.add.rectangle(x + 8, rowY, width - 16, 24, index === 0 ? 0x17232e : 0x101820, index === 0 ? 0.98 : 0.88)); const row = this.trackSideObject(this.add.rectangle(x + 8, rowY, width - 16, rowHeight, index === 0 ? 0x17232e : 0x101820, index === 0 ? 0.98 : 0.88));
row.setOrigin(0); row.setOrigin(0);
row.setDepth(31); row.setDepth(31);
row.setStrokeStyle(1, visual.tone, index === 0 ? 0.72 : 0.34); row.setStrokeStyle(1, visual.tone, index === 0 ? 0.72 : 0.34);
const iconFrame = this.trackSideObject(this.add.rectangle(x + 23, rowY + 12, 22, 22, 0x0a0f14, 0.96)); const iconX = x + (compact ? 21 : 23);
const iconY = rowY + rowHeight / 2;
const iconSize = compact ? 18 : 22;
const iconFrame = this.trackSideObject(this.add.rectangle(iconX, iconY, iconSize, iconSize, 0x0a0f14, 0.96));
iconFrame.setDepth(32); iconFrame.setDepth(32);
iconFrame.setStrokeStyle(1, visual.tone, index === 0 ? 0.72 : 0.52); iconFrame.setStrokeStyle(1, visual.tone, index === 0 ? 0.72 : 0.52);
const icon = this.trackSideIcon(x + 23, rowY + 12, visual.icon, 21); const icon = this.trackSideIcon(iconX, iconY, visual.icon, compact ? 17 : 21);
icon.setDepth(33); icon.setDepth(33);
const turnText = this.trackSideObject(this.add.text(x + width - 12, rowY + 6, turn, { const turnText = this.trackSideObject(this.add.text(x + width - 12, rowY + (compact ? 4 : 6), turn, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '10px', fontSize: compact ? '9px' : '10px',
color: '#9fb0bf', color: '#9fb0bf',
fontStyle: '700' fontStyle: '700'
})); }));
turnText.setOrigin(1, 0); turnText.setOrigin(1, 0);
turnText.setDepth(32); turnText.setDepth(32);
const eventText = this.trackSideObject(this.add.text(x + 42, rowY + 5, this.truncateBattleLogText(text, index === 0 ? 39 : 34), { const eventText = this.trackSideObject(this.add.text(x + (compact ? 37 : 42), rowY + (compact ? 3 : 5), this.truncateBattleLogText(text, compact ? 32 : index === 0 ? 39 : 34), {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: index === 0 ? '13px' : '12px', fontSize: compact ? (index === 0 ? '12px' : '11px') : index === 0 ? '13px' : '12px',
color: index === 0 ? '#e8dfca' : '#c8d2dd', color: index === 0 ? '#e8dfca' : '#c8d2dd',
fontStyle: index === 0 ? '700' : '400', fontStyle: index === 0 ? '700' : '400',
fixedWidth: width - 92 fixedWidth: width - (compact ? 84 : 92)
})); }));
eventText.setDepth(32); eventText.setDepth(32);
}); });
@@ -21218,8 +21339,8 @@ export class BattleScene extends Phaser.Scene {
const allyCount = battleUnits.filter((unit) => unit.faction === 'ally').length; const allyCount = battleUnits.filter((unit) => unit.faction === 'ally').length;
const enemyCount = battleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp > 0).length; const enemyCount = battleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp > 0).length;
const objectiveStates = this.objectiveStates(this.battleOutcome); const objectiveStates = this.objectiveStates(this.battleOutcome);
const logHeight = 118; const logPlacement = this.recentActionLogPlacement(top + 232);
const logTop = Math.max(top + 236, this.sideContentBottom(10) - logHeight); const logTop = logPlacement?.y ?? this.sideContentBottom(6);
const messageTop = top + 168; const messageTop = top + 168;
const messageHeight = Math.max(52, Math.min(72, logTop - messageTop - 12)); const messageHeight = Math.max(52, Math.min(72, logTop - messageTop - 12));
@@ -21236,15 +21357,23 @@ export class BattleScene extends Phaser.Scene {
if (message) { if (message) {
this.renderPanelMessage(this.compactSituationMessage(message), left, messageTop, width, messageHeight); this.renderPanelMessage(this.compactSituationMessage(message), left, messageTop, width, messageHeight);
this.renderRecentActionLogPanel(left, logTop, width, logHeight); if (logPlacement) {
this.renderRecentActionLogPanel(left, logPlacement.y, width, logPlacement.height);
}
return; return;
} }
const guideHeight = this.renderTacticalGuideCard(left, messageTop, width); const compactGuide = logTop - messageTop < 178;
const guideHeight = this.renderTacticalGuideCard(left, messageTop, width, compactGuide);
const objectiveTop = messageTop + (guideHeight > 0 ? guideHeight + 12 : 0); const objectiveTop = messageTop + (guideHeight > 0 ? guideHeight + 12 : 0);
const objectiveHeight = logTop - objectiveTop - 8;
this.renderObjectiveStateGroups(objectiveStates, left, objectiveTop, width, Math.max(72, logTop - objectiveTop - 8)); if (objectiveHeight >= 58) {
this.renderRecentActionLogPanel(left, logTop, width, logHeight); this.renderObjectiveStateGroups(objectiveStates, left, objectiveTop, width, objectiveHeight);
}
if (logPlacement) {
this.renderRecentActionLogPanel(left, logPlacement.y, width, logPlacement.height);
}
} }
private compactSituationMessage(message: string) { private compactSituationMessage(message: string) {
@@ -21255,26 +21384,44 @@ export class BattleScene extends Phaser.Scene {
.join('\n'); .join('\n');
} }
private renderTacticalGuideCard(x: number, y: number, width: number) { private renderTacticalGuideCard(x: number, y: number, width: number, compact = false) {
const guide = battleScenario.tacticalGuide; const guide = battleScenario.tacticalGuide;
if (!guide) { if (!guide) {
return 0; return 0;
} }
const height = 96; const height = compact ? 56 : 96;
const bg = this.trackSideObject(this.add.rectangle(x, y, width, height, 0x101820, 0.92)); const bg = this.trackSideObject(this.add.rectangle(x, y, width, height, 0x101820, 0.92));
bg.setOrigin(0); bg.setOrigin(0);
bg.setDepth(30); bg.setDepth(30);
bg.setStrokeStyle(1, palette.gold, 0.5); bg.setStrokeStyle(1, palette.gold, 0.5);
const title = this.trackSideObject(this.add.text(x + 12, y + 8, '작전 흐름', { const title = this.trackSideObject(this.add.text(x + 12, y + (compact ? 7 : 8), '작전 흐름', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '15px', fontSize: compact ? '14px' : '15px',
color: '#f2e3bf', color: '#f2e3bf',
fontStyle: '700' fontStyle: '700'
})); }));
title.setDepth(31); title.setDepth(31);
if (compact) {
const summary = this.trackSideObject(this.add.text(
x + 12,
y + 30,
this.truncateBattleLogText(`진군 ${guide.route} · ${guide.focus}`, 42),
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '11px',
color: '#d4dce6',
fontStyle: '700',
fixedWidth: width - 24,
maxLines: 1
}
));
summary.setDepth(31);
return height;
}
const route = this.trackSideObject(this.add.text(x + 12, y + 31, this.truncateBattleLogText(`진군: ${guide.route}`, 34), { const route = this.trackSideObject(this.add.text(x + 12, y + 31, this.truncateBattleLogText(`진군: ${guide.route}`, 34), {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '12px', fontSize: '12px',
@@ -21619,7 +21766,11 @@ export class BattleScene extends Phaser.Scene {
} }
private renderRosterPanel(tab: RosterTab = this.rosterTab, message?: string) { private renderRosterPanel(tab: RosterTab = this.rosterTab, message?: string) {
const tabChanged = this.rosterTab !== tab;
this.rosterTab = tab; this.rosterTab = tab;
if (tabChanged) {
this.rosterPage = 0;
}
this.clearSidePanelContent(); this.clearSidePanelContent();
this.setMiniMapVisible(true); this.setMiniMapVisible(true);
@@ -21650,23 +21801,126 @@ export class BattleScene extends Phaser.Scene {
const units = battleUnits.filter((unit) => unit.faction === tab); const units = battleUnits.filter((unit) => unit.faction === tab);
const listTop = top + 54; const listTop = top + 54;
units.forEach((unit, index) => { const contentBottom = this.sideContentBottom();
this.renderRosterRow(unit, left, listTop + index * 50, width); const fullListBottom = listTop + units.length * 50;
const needsPagination = fullListBottom + (message ? 64 : 0) > contentBottom;
let visibleUnits = units;
let rowsPerPage = Math.max(1, units.length);
let pageCount = 1;
let navigationBounds: RosterPanelLayout['navigationBounds'] = null;
let messageBounds: RosterPanelLayout['messageBounds'] = null;
let rowStep = 50;
if (needsPagination) {
const navigationHeight = 30;
const navigationGap = 8;
const messageReserve = message ? 60 : 0;
rowStep = 46;
rowsPerPage = Math.max(
1,
Math.floor((contentBottom - listTop - navigationHeight - navigationGap - messageReserve) / rowStep)
);
pageCount = Math.max(1, Math.ceil(units.length / rowsPerPage));
this.rosterPage = Phaser.Math.Clamp(this.rosterPage, 0, pageCount - 1);
const pageStart = this.rosterPage * rowsPerPage;
visibleUnits = units.slice(pageStart, pageStart + rowsPerPage);
} else {
this.rosterPage = 0;
}
visibleUnits.forEach((unit, index) => {
this.renderRosterRow(unit, left, listTop + index * rowStep, width);
}); });
const logHeight = 118; const listBottom = listTop + visibleUnits.length * rowStep;
const logTop = this.sideContentBottom(10) - logHeight; if (pageCount > 1) {
const listBottom = listTop + units.length * 50; const navigationY = listBottom + 4;
const canShowLog = listBottom + 12 < logTop; navigationBounds = { x: left, y: navigationY, width, height: 30 };
if (message) { this.renderRosterPageNavigation(left, navigationY, width, pageCount, message);
const messageHeight = canShowLog ? 70 : 104;
const messageBottomLimit = canShowLog ? logTop - 10 : panelY + panelHeight - 124; if (message) {
const messageTop = Math.min(messageBottomLimit - messageHeight, listBottom + 14); const messageY = navigationY + navigationBounds.height + 8;
this.renderPanelMessage(message, left, messageTop, width, messageHeight); const messageHeight = Math.min(52, contentBottom - messageY);
} if (messageHeight >= 44) {
if (canShowLog) { this.renderPanelMessage(message, left, messageY, width, messageHeight);
this.renderRecentActionLogPanel(left, logTop, width, logHeight); messageBounds = { x: left, y: messageY, width, height: messageHeight };
}
}
} else {
const tailTop = listBottom + 12;
if (message) {
const logPlacement = this.recentActionLogPlacement(tailTop + 62);
const messageBottomLimit = logPlacement ? logPlacement.y - 10 : contentBottom;
const messageHeight = Math.min(70, messageBottomLimit - tailTop);
if (messageHeight >= 44) {
this.renderPanelMessage(message, left, tailTop, width, messageHeight);
messageBounds = { x: left, y: tailTop, width, height: messageHeight };
}
if (logPlacement) {
this.renderRecentActionLogPanel(left, logPlacement.y, width, logPlacement.height);
}
} else {
const logPlacement = this.recentActionLogPlacement(tailTop);
if (logPlacement) {
this.renderRecentActionLogPanel(left, logPlacement.y, width, logPlacement.height);
}
}
} }
this.rosterPanelLayout = {
tab,
page: this.rosterPage,
pageCount,
rowsPerPage,
totalCount: units.length,
visibleUnitIds: visibleUnits.map((unit) => unit.id),
listBounds: { x: left, y: listTop, width, height: visibleUnits.length * rowStep },
navigationBounds,
messageBounds
};
}
private renderRosterPageNavigation(x: number, y: number, width: number, pageCount: number, message?: string) {
const background = this.trackSideObject(this.add.rectangle(x, y, width, 30, 0x0b1118, 0.96));
background.setOrigin(0);
background.setDepth(30);
background.setStrokeStyle(1, 0x647485, 0.62);
const renderControl = (controlX: number, label: string, enabled: boolean, nextPage: number) => {
const controlWidth = 72;
const button = this.trackSideObject(this.add.rectangle(controlX, y + 3, controlWidth, 24, enabled ? 0x25384a : 0x141b22, 0.96));
button.setOrigin(0);
button.setDepth(31);
button.setStrokeStyle(1, enabled ? palette.gold : 0x53606c, enabled ? 0.78 : 0.36);
if (enabled) {
button.setInteractive({ useHandCursor: true });
button.on('pointerdown', () => {
this.rosterPage = nextPage;
this.renderRosterPanel(this.rosterTab, message);
});
}
const text = this.trackSideObject(this.add.text(controlX + controlWidth / 2, y + 15, label, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '12px',
color: enabled ? '#f4dfad' : '#77828c',
fontStyle: '700'
}));
text.setOrigin(0.5);
text.setDepth(32);
};
renderControl(x + 3, '이전', this.rosterPage > 0, this.rosterPage - 1);
renderControl(x + width - 75, '다음', this.rosterPage < pageCount - 1, this.rosterPage + 1);
const indicator = this.trackSideObject(this.add.text(x + width / 2, y + 15, `${this.rosterPage + 1} / ${pageCount}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '13px',
color: '#d4dce6',
fontStyle: '700'
}));
indicator.setOrigin(0.5);
indicator.setDepth(32);
} }
private renderRosterRow(unit: UnitData, x: number, y: number, width: number) { private renderRosterRow(unit: UnitData, x: number, y: number, width: number) {
@@ -22302,6 +22556,7 @@ export class BattleScene extends Phaser.Scene {
this.phase = 'idle'; this.phase = 'idle';
this.clearMarkers(); this.clearMarkers();
this.hideCommandMenu(); this.hideCommandMenu();
this.updateMiniMap();
this.refreshUnitLegibilityStyles(); this.refreshUnitLegibilityStyles();
this.renderRosterPanel(tab); this.renderRosterPanel(tab);
} }
@@ -22309,6 +22564,8 @@ export class BattleScene extends Phaser.Scene {
private clearSidePanelContent() { private clearSidePanelContent() {
this.sidePanelObjects.forEach((object) => object.destroy()); this.sidePanelObjects.forEach((object) => object.destroy());
this.sidePanelObjects = []; this.sidePanelObjects = [];
this.recentActionLogLayout = undefined;
this.rosterPanelLayout = undefined;
this.bondChainReadyFocusedPreview = undefined; this.bondChainReadyFocusedPreview = undefined;
this.clearFacingIndicator(); this.clearFacingIndicator();
} }
@@ -22494,6 +22751,80 @@ export class BattleScene extends Phaser.Scene {
}; };
} }
private battleHudDebugState() {
const miniMap = this.miniMapLayout;
const selectedDot = this.selectedUnit ? this.miniMapUnitDots.get(this.selectedUnit.id) : undefined;
const objectiveDot = this.miniMapUnitDots.get(leaderUnitId);
const recentActions = this.recentActionLogLayout
? {
...this.recentActionLogLayout,
bottom: this.recentActionLogLayout.y + this.recentActionLogLayout.height,
gapToMiniMap: miniMap
? miniMap.frameY - (this.recentActionLogLayout.y + this.recentActionLogLayout.height)
: null
}
: null;
const rosterPanel = this.rosterPanelLayout
? {
...this.rosterPanelLayout,
visibleUnitIds: [...this.rosterPanelLayout.visibleUnitIds],
listBounds: { ...this.rosterPanelLayout.listBounds },
navigationBounds: this.rosterPanelLayout.navigationBounds
? { ...this.rosterPanelLayout.navigationBounds }
: null,
messageBounds: this.rosterPanelLayout.messageBounds
? { ...this.rosterPanelLayout.messageBounds }
: null,
gapToMiniMap: miniMap
? miniMap.frameY - Math.max(
this.rosterPanelLayout.listBounds.y + this.rosterPanelLayout.listBounds.height,
this.rosterPanelLayout.navigationBounds
? this.rosterPanelLayout.navigationBounds.y + this.rosterPanelLayout.navigationBounds.height
: 0,
this.rosterPanelLayout.messageBounds
? this.rosterPanelLayout.messageBounds.y + this.rosterPanelLayout.messageBounds.height
: 0
)
: null
}
: null;
return {
miniMap: miniMap
? {
visible: this.miniMapVisible,
title: '전장 지도',
legend: ['아', '적', '목'],
frameBounds: {
x: miniMap.frameX,
y: miniMap.frameY,
width: miniMap.frameWidth,
height: miniMap.frameHeight
},
headerBounds: {
x: miniMap.frameX,
y: miniMap.frameY,
width: miniMap.frameWidth,
height: miniMap.headerHeight
},
mapBounds: { x: miniMap.x, y: miniMap.y, width: miniMap.width, height: miniMap.height },
viewportBounds: this.gameObjectBoundsDebug(this.miniMapViewport),
cellSize: miniMap.cellSize,
objectiveUnitId: leaderUnitId,
objectiveHighlighted: Boolean(
objectiveDot?.visible && objectiveDot.lineWidth === 2 && objectiveDot.strokeColor === palette.gold
),
selectedUnitId: this.selectedUnit?.id ?? null,
selectedHighlighted: Boolean(
selectedDot?.visible && selectedDot.lineWidth === 2 && selectedDot.strokeColor === 0xffffff
)
}
: null,
recentActions,
rosterPanel
};
}
getDebugState() { getDebugState() {
const campaign = getCampaignState(); const campaign = getCampaignState();
const effectiveSortieUnitIds = this.effectiveSortieUnitIds(campaign); const effectiveSortieUnitIds = this.effectiveSortieUnitIds(campaign);
@@ -22520,6 +22851,7 @@ export class BattleScene extends Phaser.Scene {
sortieItemAssignments, sortieItemAssignments,
sortieOrder: sortieOrderDebug, sortieOrder: sortieOrderDebug,
sortieOperationOrder: sortieOrderDebug, sortieOperationOrder: sortieOrderDebug,
battleHud: this.battleHudDebugState(),
sortieDoctrine: this.debugSortieDoctrine(), sortieDoctrine: this.debugSortieDoctrine(),
firstSortieRoleEffects: this.debugFirstPursuitRoleEffects(), firstSortieRoleEffects: this.debugFirstPursuitRoleEffects(),
firstSortieRoleMechanicsProbe: this.debugFirstPursuitRoleMechanicsProbe(), firstSortieRoleMechanicsProbe: this.debugFirstPursuitRoleMechanicsProbe(),

View File

@@ -50,7 +50,7 @@ const config: Phaser.Types.Core.GameConfig = {
height: 720, height: 720,
backgroundColor: '#10131a', backgroundColor: '#10131a',
scale: { scale: {
mode: Phaser.Scale.RESIZE, mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH autoCenter: Phaser.Scale.CENTER_BOTH
}, },
scene: [BootScene, TitleScene] scene: [BootScene, TitleScene]