Add camp sortie preparation overlay

This commit is contained in:
2026-06-22 20:24:43 +09:00
parent d36b5adeb6
commit ab5870eaa8
2 changed files with 218 additions and 2 deletions

View File

@@ -265,6 +265,21 @@ try {
throw new Error(`Expected campaign slot 1 to persist progress: ${JSON.stringify(campaignSlotAfterDialogue)}`);
}
await page.mouse.click(1120, 38);
await page.waitForTimeout(180);
const campStateWithSortiePrep = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
if (!campStateWithSortiePrep?.sortieVisible) {
throw new Error(`Expected sortie preparation overlay from next story button: ${JSON.stringify(campStateWithSortiePrep)}`);
}
await page.screenshot({ path: 'dist/verification-camp-sortie.png', fullPage: true });
await page.mouse.click(792, 596);
await page.waitForTimeout(140);
const campStateAfterSortieClose = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
if (campStateAfterSortieClose?.sortieVisible) {
throw new Error(`Expected sortie preparation overlay to close: ${JSON.stringify(campStateAfterSortieClose)}`);
}
await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene'));
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];

View File

@@ -40,6 +40,12 @@ type CampSupplyDefinition = {
bondExp: number;
};
type SortieChecklistItem = {
label: string;
complete: boolean;
detail: string;
};
const portraitKeys: Record<PortraitKey, string> = {
liuBei: 'portrait-liu-bei',
guanYu: 'portrait-guan-yu',
@@ -118,7 +124,9 @@ export class CampScene extends Phaser.Scene {
private selectedDialogueId = campDialogues[0].id;
private contentObjects: Phaser.GameObjects.GameObject[] = [];
private dialogueObjects: Phaser.GameObjects.GameObject[] = [];
private sortieObjects: Phaser.GameObjects.GameObject[] = [];
private tabButtons: CampTabButtonView[] = [];
private visitedTabs = new Set<CampTab>();
constructor() {
super('CampScene');
@@ -127,7 +135,9 @@ export class CampScene extends Phaser.Scene {
create() {
this.contentObjects = [];
this.dialogueObjects = [];
this.sortieObjects = [];
this.tabButtons = [];
this.visitedTabs = new Set(['status']);
this.campaign = getCampaignState();
this.report = this.campaign.firstBattleReport ?? getFirstBattleReport() ?? this.createFallbackReport();
this.selectedUnitId = this.currentUnits().find((unit) => unit.faction === 'ally')?.id ?? 'liu-bei';
@@ -197,8 +207,7 @@ export class CampScene extends Phaser.Scene {
});
this.addCommandButton('다음 이야기', 1120, 38, 142, () => {
soundDirector.playSelect();
markCampaignStep('first-victory-story');
this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' });
this.showSortiePrep();
});
}
@@ -259,6 +268,7 @@ export class CampScene extends Phaser.Scene {
private render() {
this.clearContent();
this.visitedTabs.add(this.activeTab);
this.updateTabButtons();
this.renderUnitColumn();
this.renderReportPanel();
@@ -276,6 +286,196 @@ export class CampScene extends Phaser.Scene {
this.renderStatusPanel();
}
private showSortiePrep() {
this.hideSortiePrep();
this.campaign = getCampaignState();
this.report = this.campaign.firstBattleReport ?? this.report;
const depth = 40;
const width = 900;
const height = 560;
const x = Math.floor((this.scale.width - width) / 2);
const y = Math.floor((this.scale.height - height) / 2);
const shade = this.trackSortie(this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.68));
shade.setOrigin(0);
shade.setDepth(depth);
shade.setInteractive();
const panel = this.trackSortie(this.add.rectangle(x, y, width, height, 0x101820, 0.98));
panel.setOrigin(0);
panel.setDepth(depth + 1);
panel.setStrokeStyle(3, palette.gold, 0.92);
const title = this.trackSortie(this.add.text(x + 34, y + 28, '출진 준비', this.textStyle(30, '#f2e3bf', true)));
title.setDepth(depth + 2);
const subtitle = this.trackSortie(
this.add.text(x + 34, y + 70, '다음 전장으로 향하기 전 부대 상태와 준비 항목을 확인합니다.', this.textStyle(15, '#d4dce6'))
);
subtitle.setDepth(depth + 2);
this.renderSortieBriefing(x + 34, y + 112, 396, 152, depth + 2);
this.renderSortieChecklist(x + 462, y + 112, 404, 170, depth + 2);
this.renderSortieUnitSummary(x + 34, y + 292, 832, 148, depth + 2);
this.renderSortieRewardHint(x + 34, y + 462, 548, 52, depth + 2);
this.addSortieButton('군영으로', x + width - 298, y + height - 44, 124, () => {
soundDirector.playSelect();
this.hideSortiePrep();
}, depth + 3);
this.addSortieButton('출진', x + width - 152, y + height - 44, 124, () => {
soundDirector.playSelect();
this.startVictoryStory();
}, depth + 3);
}
private renderSortieBriefing(x: number, y: number, width: number, height: number, depth: number) {
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(1, palette.blue, 0.48);
this.trackSortie(this.add.text(x + 18, y + 14, '다음 전장', this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 18, y + 48, '황건 잔당 추격', this.textStyle(23, '#d8b15f', true))).setDepth(depth + 1);
this.trackSortie(
this.add.text(x + 18, y + 84, '탁현을 물러난 황건 잔당이 인근 마을을 위협하고 있습니다. 의용군은 첫 승리의 기세를 몰아 추격에 나섭니다.', {
...this.textStyle(14, '#d4dce6'),
wordWrap: { width: width - 36, useAdvancedWrap: true },
lineSpacing: 4
})
).setDepth(depth + 1);
}
private renderSortieChecklist(x: number, y: number, width: number, height: number, depth: number) {
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(1, palette.gold, 0.5);
this.trackSortie(this.add.text(x + 18, y + 14, '준비 체크', this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1);
this.sortieChecklist().forEach((item, index) => {
const rowY = y + 42 + index * 27;
const color = item.complete ? '#a8ffd0' : '#ffdf7b';
const status = item.complete ? '완료' : '주의';
this.trackSortie(this.add.text(x + 18, rowY, status, this.textStyle(13, color, true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 70, rowY, item.label, this.textStyle(14, '#d4dce6', true))).setDepth(depth + 1);
const detail = this.trackSortie(this.add.text(x + 190, rowY, item.detail, this.textStyle(12, item.complete ? '#9fb0bf' : '#d8b15f')));
detail.setDepth(depth + 1);
});
}
private renderSortieUnitSummary(x: number, y: number, width: number, height: number, depth: number) {
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(1, palette.blue, 0.48);
this.trackSortie(this.add.text(x + 18, y + 14, '출진 부대', this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1);
this.currentUnits()
.filter((unit) => unit.faction === 'ally')
.forEach((unit, index) => {
const rowY = y + 52 + index * 30;
this.trackSortie(this.add.text(x + 18, rowY, `${unit.name} Lv ${unit.level}`, this.textStyle(14, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 132, rowY, unit.className, this.textStyle(13, '#9fb0bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 236, rowY, `병력 ${unit.hp}/${unit.maxHp}`, this.textStyle(13, '#d4dce6', true))).setDepth(depth + 1);
this.drawSortieBar(x + 338, rowY + 6, 128, 7, unit.hp / unit.maxHp, palette.green, depth + 1);
const equipment = equipmentSlots
.map((slot) => {
const state = unit.equipment[slot];
return `${equipmentSlotLabels[slot]} Lv${state.level}`;
})
.join(' ');
this.trackSortie(this.add.text(x + 496, rowY, equipment, this.textStyle(12, '#d4dce6'))).setDepth(depth + 1);
});
}
private renderSortieRewardHint(x: number, y: number, width: number, height: number, depth: number) {
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x151f2a, 0.9));
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(1, palette.gold, 0.4);
const completedDialogues = this.completedCampDialogues().length;
const inventory = this.inventoryLabels().join(', ');
this.trackSortie(this.add.text(x + 16, y + 10, `예상 보상: 군자금, 소모품, 의용군 명성`, this.textStyle(13, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 16, y + 30, `현재 준비: 대화 ${completedDialogues}/${campDialogues.length} · 보유 ${inventory || '없음'}`, this.textStyle(12, '#d4dce6'))).setDepth(depth + 1);
}
private sortieChecklist(): SortieChecklistItem[] {
const units = this.currentUnits().filter((unit) => unit.faction === 'ally');
const liuBei = units.find((unit) => unit.id === 'liu-bei');
const injured = units.filter((unit) => unit.hp < unit.maxHp);
const supplyCount = campSupplies.reduce((total, supply) => total + this.inventoryAmount(supply.label), 0);
const completedDialogues = this.completedCampDialogues().length;
return [
{
label: '유비 생존',
complete: Boolean(liuBei && liuBei.hp > 0),
detail: liuBei ? `병력 ${liuBei.hp}/${liuBei.maxHp}` : '유비 없음'
},
{
label: '부상 장수 확인',
complete: injured.length === 0,
detail: injured.length === 0 ? '전원 완전한 병력' : `${injured.map((unit) => unit.name).join(', ')} 회복 권장`
},
{
label: '소모품 보유',
complete: supplyCount > 0,
detail: supplyCount > 0 ? `보유 ${supplyCount}` : '정비 탭에서 보급 확인'
},
{
label: '공명 대화',
complete: completedDialogues >= campDialogues.length,
detail: `${completedDialogues}/${campDialogues.length} 완료`
},
{
label: '장비 상태',
complete: this.visitedTabs.has('status'),
detail: this.visitedTabs.has('status') ? '장수 탭 확인됨' : '장수 탭 확인 권장'
}
];
}
private addSortieButton(label: string, x: number, y: number, width: number, action: () => void, depth: number) {
const bg = this.trackSortie(this.add.rectangle(x, y, width, 36, 0x1a2630, 0.96));
bg.setDepth(depth);
bg.setStrokeStyle(1, label === '출진' ? palette.gold : palette.blue, 0.78);
bg.setInteractive({ useHandCursor: true });
bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98));
bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.96));
bg.on('pointerdown', action);
const text = this.trackSortie(this.add.text(x, y, label, this.textStyle(15, '#f2e3bf', true)));
text.setOrigin(0.5);
text.setDepth(depth + 1);
text.setInteractive({ useHandCursor: true });
text.on('pointerdown', action);
}
private startVictoryStory() {
markCampaignStep('first-victory-story');
this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' });
}
private hideSortiePrep() {
this.sortieObjects.forEach((object) => object.destroy());
this.sortieObjects = [];
}
private trackSortie<T extends Phaser.GameObjects.GameObject>(object: T) {
this.sortieObjects.push(object);
return object;
}
private drawSortieBar(x: number, y: number, width: number, height: number, ratio: number, color: number, depth: number) {
const track = this.trackSortie(this.add.rectangle(x, y, width, height, 0x070b10, 0.86));
track.setOrigin(0);
track.setDepth(depth);
const fill = this.trackSortie(this.add.rectangle(x, y, Math.max(2, width * Phaser.Math.Clamp(ratio, 0, 1)), height, color, 0.96));
fill.setOrigin(0);
fill.setDepth(depth + 1);
}
private renderUnitColumn() {
const allies = this.currentUnits().filter((unit) => unit.faction === 'ally');
allies.forEach((unit, index) => {
@@ -838,6 +1038,7 @@ export class CampScene extends Phaser.Scene {
return {
scene: this.scene.key,
activeTab: this.activeTab,
sortieVisible: this.sortieObjects.length > 0,
selectedUnitId: this.selectedUnitId,
selectedDialogueId: this.selectedDialogueId,
campaign: this.campaign