feat: redesign first sortie preparation flow

This commit is contained in:
2026-07-10 05:20:45 +09:00
parent 69a0bf8ba4
commit 151841adf0

View File

@@ -1,5 +1,6 @@
import Phaser from 'phaser';
import storyMilitiaUrl from '../../assets/images/story/04-volunteer-militia.png';
import storyFirstSortieUrl from '../../assets/images/story/05-first-sortie.png';
import { soundDirector } from '../audio/SoundDirector';
import { battleUiIconFrames, battleUiIconTextureKey, loadBattleUiIcons, type BattleUiIconKey } from '../data/battleUiIcons';
import {
@@ -190,6 +191,14 @@ type SortieChecklistItem = {
priority?: 'required' | 'recommended';
};
type SortiePrepStep = 'briefing' | 'formation' | 'loadout';
const firstSortiePrepSteps: { id: SortiePrepStep; number: string; label: string; hint: string }[] = [
{ id: 'briefing', number: '01', label: '전장 파악', hint: '목표와 위험 확인' },
{ id: 'formation', number: '02', label: '무장 편성', hint: '역할과 공명 조율' },
{ id: 'loadout', number: '03', label: '장비·보급', hint: '최종 출진 점검' }
];
type SortieUnitTacticalSummary = {
statLine: string;
equipmentLine: string;
@@ -10760,6 +10769,7 @@ export class CampScene extends Phaser.Scene {
private sortieFocusedUnitId = 'liu-bei';
private sortieRosterScroll = 0;
private sortiePlanFeedback = '';
private sortiePrepStep: SortiePrepStep = 'briefing';
private terrainCountCache = new Map<BattleScenarioId, SortieTerrainCounts>();
private openSortiePrepOnCreate = false;
private skipIntroStoryForSortie = false;
@@ -10793,6 +10803,7 @@ export class CampScene extends Phaser.Scene {
this.selectedVisitId = this.availableCampVisits()[0]?.id ?? campVisits[0].id;
soundDirector.playMusic('militia-theme');
this.input.keyboard?.on('keydown-ESC', () => this.handleEscapeKey());
this.input.keyboard?.on('keydown-ENTER', (event: KeyboardEvent) => this.handleSortieEnterKey(event));
this.input.keyboard?.on('keydown', (event: KeyboardEvent) => this.handleSaveSlotKey(event));
loadBattleUiIcons(this, () => this.ensureCampAssets(() => this.drawCampScene()));
}
@@ -10825,17 +10836,25 @@ export class CampScene extends Phaser.Scene {
}
private ensureCampAssets(onReady: () => void) {
const useFirstSortieAssets = this.isFirstSortiePrepSlice();
const missingPortraits = this.currentUnits()
.map((unit) => {
.flatMap((unit): PortraitAssetEntry[] => {
const portraitKey = portraitByUnitId[unit.id];
return portraitKey ? portraitAssetEntriesForKey(portraitKey)[0] : undefined;
if (!portraitKey) {
return [];
}
const entries = portraitAssetEntriesForKey(portraitKey);
const periodPortrait = useFirstSortieAssets
? entries.find((entry) => entry.textureKey.endsWith('-yellow-turban'))
: undefined;
return [entries[0], periodPortrait].filter((entry): entry is PortraitAssetEntry => entry !== undefined);
})
.filter((entry): entry is PortraitAssetEntry => entry !== undefined)
.filter((entry, index, entries) => entries.findIndex((candidate) => candidate.textureKey === entry.textureKey) === index)
.filter((entry) => !this.textures.exists(entry.textureKey));
const missingMilitia = !this.textures.exists('story-militia');
const missingFirstSortieArtwork = useFirstSortieAssets && !this.textures.exists('story-first-sortie');
if (!missingMilitia && missingPortraits.length === 0) {
if (!missingMilitia && !missingFirstSortieArtwork && missingPortraits.length === 0) {
onReady();
return;
}
@@ -10860,6 +10879,9 @@ export class CampScene extends Phaser.Scene {
if (missingMilitia) {
this.load.image('story-militia', storyMilitiaUrl);
}
if (missingFirstSortieArtwork) {
this.load.image('story-first-sortie', storyFirstSortieUrl);
}
missingPortraits.forEach(({ textureKey, url }) => this.load.image(textureKey, url));
this.load.start();
}
@@ -12175,6 +12197,28 @@ export class CampScene extends Phaser.Scene {
}
}
private handleSortieEnterKey(event: KeyboardEvent) {
if (
this.sortieObjects.length === 0 ||
this.saveSlotObjects.length > 0 ||
this.saveSlotConfirmObjects.length > 0 ||
!this.isFirstSortiePrepSlice()
) {
return;
}
event.preventDefault();
if (this.sortiePrepStep === 'briefing') {
this.setSortiePrepStep('formation');
return;
}
if (this.sortiePrepStep === 'formation') {
this.setSortiePrepStep('loadout');
return;
}
soundDirector.playSelect();
this.startVictoryStory();
}
private addTabButton(label: string, tab: CampTab, x: number, y: number) {
const bg = this.add.rectangle(x, y, 76, 34, 0x182431, 0.94);
bg.setStrokeStyle(1, palette.blue, 0.62);
@@ -12267,6 +12311,7 @@ export class CampScene extends Phaser.Scene {
}
private showSortiePrep() {
const wasVisible = this.sortieObjects.length > 0;
this.hideCampSaveSlotPanel();
this.hideSortiePrep();
this.campaign = getCampaignState();
@@ -12283,31 +12328,91 @@ export class CampScene extends Phaser.Scene {
const y = Math.floor((this.scale.height - height) / 2);
const flow = this.currentSortieFlow();
const isBattleSortie = Boolean(flow.nextBattleId);
const isFirstSortieSlice = this.isFirstSortiePrepSlice(flow);
if (isFirstSortieSlice && !wasVisible) {
this.sortiePrepStep = 'briefing';
}
const checklist = this.sortieChecklist();
const prepTitle = isBattleSortie ? '출진 준비' : '군영 의정';
const prepSubtitle = isBattleSortie
? this.sortiePrepSubtitle(checklist)
: this.isFinalEpilogueFlow(flow)
? '마지막 군영 기록을 정리하고 언제든 엔딩 화면으로 돌아갈 수 있습니다.'
: '군영 대화와 장부를 정리한 뒤 다음 의정에 함께할 무장을 선택합니다.';
const firstSliceSubtitle: Record<SortiePrepStep, string> = {
briefing: '추격로의 목표와 위험을 확인한 뒤 세 형제를 편성하십시오.',
formation: '큰 초상 카드를 고르고 전열·돌파·후원 역할을 맞바꾸십시오.',
loadout: '장비·보급과 필수 조건을 마지막으로 확인하십시오.'
};
const prepSubtitle = isFirstSortieSlice
? firstSliceSubtitle[this.sortiePrepStep]
: isBattleSortie
? this.sortiePrepSubtitle(checklist)
: this.isFinalEpilogueFlow(flow)
? '마지막 군영 기록을 정리하고 언제든 엔딩 화면으로 돌아갈 수 있습니다.'
: '군영 대화와 장부를 정리한 뒤 다음 의정에 함께할 무장을 선택합니다.';
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));
const panel = this.trackSortie(this.add.rectangle(x, y, width, height, isFirstSortieSlice ? 0x0b1118 : 0x101820, isFirstSortieSlice ? 0.94 : 0.98));
panel.setOrigin(0);
panel.setDepth(depth + 1);
panel.setStrokeStyle(3, palette.gold, 0.92);
if (isFirstSortieSlice) {
this.renderFirstSortieArtwork(x + 3, y + 3, width - 6, height - 6, depth + 1, 0.13);
}
const title = this.trackSortie(this.add.text(x + 34, y + 28, prepTitle, this.textStyle(30, '#f2e3bf', true)));
const title = this.trackSortie(this.add.text(x + 34, y + (isFirstSortieSlice ? 22 : 28), prepTitle, this.textStyle(isFirstSortieSlice ? 28 : 30, '#f2e3bf', true)));
title.setDepth(depth + 2);
const subtitle = this.trackSortie(
this.add.text(x + 34, y + 70, this.compactText(prepSubtitle, 72), this.textStyle(15, '#d4dce6'))
this.add.text(
x + 34,
y + (isFirstSortieSlice ? 60 : 70),
this.compactText(prepSubtitle, isFirstSortieSlice ? 54 : 72),
this.textStyle(15, '#d4dce6')
)
);
subtitle.setDepth(depth + 2);
this.renderSortieHeaderSummary(x + 690, y + 26, 464, 58, depth + 2);
this.renderSortieHeaderSummary(x + (isFirstSortieSlice ? 748 : 690), y + 26, isFirstSortieSlice ? 406 : 464, 58, depth + 2);
if (isFirstSortieSlice) {
this.renderFirstSortieStepNavigation(x + 34, y + 92, width - 68, 42, depth + 2, checklist);
const contentX = x + 34;
const contentY = y + 150;
const contentWidth = width - 68;
const contentHeight = 430;
if (this.sortiePrepStep === 'briefing') {
this.renderFirstSortieBriefingStep(contentX, contentY, contentWidth, contentHeight, depth + 2);
} else if (this.sortiePrepStep === 'formation') {
this.renderFirstSortieFormationStep(contentX, contentY, contentWidth, contentHeight, depth + 2);
} else {
this.renderFirstSortieLoadoutStep(contentX, contentY, contentWidth, contentHeight, depth + 2, checklist);
}
this.addSortieButton('저장', x + 82, y + height - 42, 96, () => {
soundDirector.playSelect();
this.showCampSaveSlotPanel();
}, depth + 3);
this.addSortieButton('군영으로', x + 200, y + height - 42, 112, () => {
soundDirector.playSelect();
this.hideSortiePrep();
}, depth + 3);
const stepIndex = firstSortiePrepSteps.findIndex((step) => step.id === this.sortiePrepStep);
if (stepIndex > 0) {
const previousStep = firstSortiePrepSteps[stepIndex - 1].id;
this.addSortieButton('이전', x + width - 250, y + height - 42, 104, () => this.setSortiePrepStep(previousStep), depth + 3);
}
const nextStep = firstSortiePrepSteps[stepIndex + 1]?.id;
const primaryActionLabel = nextStep ? (nextStep === 'formation' ? '편성하기' : '장비·보급') : '출진';
this.addSortieButton(primaryActionLabel, x + width - 134, y + height - 42, 128, () => {
if (nextStep) {
this.setSortiePrepStep(nextStep);
return;
}
soundDirector.playSelect();
this.startVictoryStory();
}, depth + 3, true);
return;
}
this.renderSortieBriefing(x + 34, y + 104, 560, 168, depth + 2);
this.renderSortieChecklist(x + 612, y + 104, 542, 168, depth + 2, checklist);
@@ -12332,6 +12437,474 @@ export class CampScene extends Phaser.Scene {
}, depth + 3, true);
}
private isFirstSortiePrepSlice(flow = this.currentSortieFlow()) {
return flow.afterBattleId === campBattleIds.first && flow.nextBattleId === campBattleIds.second;
}
private setSortiePrepStep(step: SortiePrepStep) {
if (this.sortiePrepStep === step) {
return;
}
this.sortiePrepStep = step;
soundDirector.playSelect();
this.showSortiePrep();
}
private firstSortieStepComplete(step: SortiePrepStep, checklist: SortieChecklistItem[]) {
if (step === 'briefing') {
return true;
}
if (step === 'formation') {
const plan = this.sortiePlanSummary();
return plan.selectedCount > 0 && plan.formationCoverageComplete;
}
return this.firstSortieFinalCheckItems(checklist).every((item) => item.complete);
}
private firstSortieFinalCheckItems(checklist: SortieChecklistItem[]) {
const labels = new Set(['출전 구성', '전열 역할', '전투 보급']);
return checklist.filter((item) => item.priority === 'required' || labels.has(item.label)).slice(0, 4);
}
private renderFirstSortieStepNavigation(
x: number,
y: number,
width: number,
height: number,
depth: number,
checklist: SortieChecklistItem[]
) {
const gap = 12;
const stepWidth = Math.floor((width - gap * (firstSortiePrepSteps.length - 1)) / firstSortiePrepSteps.length);
firstSortiePrepSteps.forEach((step, index) => {
const stepX = x + index * (stepWidth + gap);
const active = step.id === this.sortiePrepStep;
const complete = this.firstSortieStepComplete(step.id, checklist);
const fill = active ? 0x2a2419 : complete ? 0x172a22 : 0x111922;
const line = active ? palette.gold : complete ? palette.green : 0x53606c;
const bg = this.trackSortie(this.add.rectangle(stepX, y, stepWidth, height, fill, active ? 0.98 : 0.86));
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(active ? 2 : 1, line, active ? 0.94 : complete ? 0.62 : 0.36);
bg.setInteractive({ useHandCursor: true });
bg.on('pointerover', () => bg.setFillStyle(active ? 0x352d1d : complete ? 0x20362a : 0x1a2630, 0.98));
bg.on('pointerout', () => bg.setFillStyle(fill, active ? 0.98 : 0.86));
bg.on('pointerdown', () => this.setSortiePrepStep(step.id));
const seal = this.trackSortie(this.add.circle(stepX + 23, y + height / 2, 13, active ? palette.red : complete ? palette.green : 0x3b4658, 0.96));
seal.setDepth(depth + 1);
seal.setStrokeStyle(1, active ? palette.gold : complete ? 0xa8ffd0 : 0x73808c, 0.76);
const number = this.trackSortie(this.add.text(stepX + 23, y + height / 2, complete && !active ? '✓' : step.number, this.textStyle(11, '#f2e3bf', true)));
number.setOrigin(0.5);
number.setDepth(depth + 2);
const label = this.trackSortie(this.add.text(stepX + 46, y + 7, step.label, this.textStyle(15, active ? '#ffdf7b' : '#f2e3bf', true)));
label.setDepth(depth + 1);
const hint = this.trackSortie(this.add.text(stepX + 46, y + 25, step.hint, this.textStyle(11, active ? '#d4dce6' : '#9fb0bf')));
hint.setDepth(depth + 1);
[number, label, hint].forEach((object) => {
object.setInteractive({ useHandCursor: true });
object.on('pointerdown', () => this.setSortiePrepStep(step.id));
});
});
}
private renderFirstSortieBriefingStep(x: number, y: number, width: number, height: number, depth: number) {
const gap = 18;
const briefingWidth = 690;
const tacticsWidth = width - briefingWidth - gap;
this.renderSortieBriefing(x, y, briefingWidth, 220, depth);
this.renderSortieDeploymentMap(x, y + 238, briefingWidth, height - 238, depth, true);
const mapLabelBg = this.trackSortie(this.add.rectangle(x + 12, y + 250, 162, 28, 0x070b10, 0.88));
mapLabelBg.setOrigin(0);
mapLabelBg.setDepth(depth + 3);
mapLabelBg.setStrokeStyle(1, palette.gold, 0.44);
this.trackSortie(this.add.text(x + 24, y + 257, '전장 배치 미리보기', this.textStyle(12, '#f2e3bf', true))).setDepth(depth + 4);
this.renderFirstSortieTacticalNotes(x + briefingWidth + gap, y, tacticsWidth, height, depth);
}
private renderFirstSortieTacticalNotes(x: number, y: number, width: number, height: number, depth: number) {
const scenario = this.nextSortieScenario();
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.94));
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(1, palette.gold, 0.58);
this.trackSortie(this.add.text(x + 20, y + 16, '전술 핵심', this.textStyle(20, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 20, y + 44, '세 형제의 역할이 갈리는 첫 추격전입니다.', this.textStyle(12, '#9fb0bf'))).setDepth(depth + 1);
if (!scenario) {
return;
}
const notes = [
{ seal: '승', label: '승리 조건', value: scenario.victoryConditionLabel, tone: palette.gold },
{ seal: '수', label: '패배 조건', value: scenario.defeatConditionLabel, tone: palette.red },
{
seal: '로',
label: '추천 동선',
value: scenario.tacticalGuide?.route ?? scenario.openingObjectiveLines[0] ?? '중앙 길과 마을 어귀를 먼저 확보하십시오.',
tone: palette.blue
}
];
notes.forEach((note, index) => {
const cardY = y + 72 + index * 82;
const card = this.trackSortie(this.add.rectangle(x + 18, cardY, width - 36, 68, 0x131d27, 0.96));
card.setOrigin(0);
card.setDepth(depth + 1);
card.setStrokeStyle(1, note.tone, 0.52);
const seal = this.trackSortie(this.add.circle(x + 47, cardY + 34, 18, note.tone, 0.88));
seal.setDepth(depth + 2);
this.trackSortie(this.add.text(x + 47, cardY + 34, note.seal, this.textStyle(14, '#f2e3bf', true))).setOrigin(0.5).setDepth(depth + 3);
this.trackSortie(this.add.text(x + 78, cardY + 10, note.label, this.textStyle(12, '#d8b15f', true))).setDepth(depth + 2);
this.trackSortie(
this.add.text(x + 78, cardY + 29, note.value, {
...this.textStyle(14, '#f2e3bf', true),
wordWrap: { width: width - 116, useAdvancedWrap: true },
lineSpacing: 2
})
).setDepth(depth + 2);
});
const commandY = y + height - 104;
const command = this.trackSortie(this.add.rectangle(x + 18, commandY, width - 36, 86, 0x211b14, 0.94));
command.setOrigin(0);
command.setDepth(depth + 1);
command.setStrokeStyle(1, palette.gold, 0.6);
this.trackSortie(this.add.text(x + 34, commandY + 12, '지휘관 판단', this.textStyle(14, '#ffdf7b', true))).setDepth(depth + 2);
this.trackSortie(
this.add.text(x + 34, commandY + 36, scenario.tacticalGuide?.summary ?? scenario.openingObjectiveLines[1] ?? '세 형제가 서로 다른 길을 맡아 적의 전열을 흔드십시오.', {
...this.textStyle(13, '#d4dce6'),
wordWrap: { width: width - 68, useAdvancedWrap: true },
lineSpacing: 3
})
).setDepth(depth + 2);
}
private renderFirstSortieArtwork(x: number, y: number, width: number, height: number, depth: number, alpha: number) {
if (!this.textures.exists('story-first-sortie')) {
return;
}
const artwork = this.trackSortie(this.add.image(x + width / 2, y + height / 2, 'story-first-sortie'));
const sourceWidth = Math.max(1, artwork.width);
const sourceHeight = Math.max(1, artwork.height);
const scale = Math.max(width / sourceWidth, height / sourceHeight);
const cropWidth = Math.min(sourceWidth, width / scale);
const cropHeight = Math.min(sourceHeight, height / scale);
artwork.setCrop((sourceWidth - cropWidth) / 2, (sourceHeight - cropHeight) / 2, cropWidth, cropHeight);
artwork.setScale(scale);
artwork.setTint(0xaeb9b4);
artwork.setAlpha(alpha);
artwork.setDepth(depth);
}
private renderFirstSortieFormationStep(x: number, y: number, width: number, height: number, depth: number) {
const gap = 18;
const rosterWidth = 704;
const planWidth = width - rosterWidth - gap;
this.renderFirstSortieCommanderCards(x, y, rosterWidth, height, depth);
this.renderFirstSortieRoleDiagram(x + rosterWidth + gap, y, planWidth, 292, depth);
this.renderFirstSortieSynergyPanel(x + rosterWidth + gap, y + 310, planWidth, height - 310, depth);
}
private renderFirstSortieRoleDiagram(x: number, y: number, width: number, height: number, depth: number) {
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.94));
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(1, palette.gold, 0.54);
this.trackSortie(this.add.text(x + 18, y + 14, '배치 도식', this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + width - 18, y + 18, '역할 변경은 왼쪽 카드', this.textStyle(10, '#9fb0bf', true))).setOrigin(1, 0).setDepth(depth + 1);
this.renderSortieDeploymentMap(x + 16, y + 48, width - 32, 126, depth + 1, true);
const selectedUnits = this.selectedSortieUnits();
const roles = sortieFormationSlotDefinitions.filter((definition) => definition.role !== 'reserve');
const gap = 8;
const slotWidth = Math.floor((width - 32 - gap * (roles.length - 1)) / roles.length);
roles.forEach((definition, index) => {
const slotX = x + 16 + index * (slotWidth + gap);
const slotY = y + height - 104;
const unitNames = selectedUnits
.filter((unit) => this.sortieFormationRole(unit) === definition.role)
.map((unit) => unit.name);
const filled = unitNames.length > 0;
const card = this.trackSortie(this.add.rectangle(slotX, slotY, slotWidth, 86, filled ? 0x172a22 : 0x211b14, 0.96));
card.setOrigin(0);
card.setDepth(depth + 1);
card.setStrokeStyle(1, filled ? palette.green : palette.gold, filled ? 0.62 : 0.52);
this.renderSortieBattleUiIcon(this.formationRoleIconKey(definition.role), slotX + 18, slotY + 22, 26, depth + 2, filled ? 1 : 0.62);
this.trackSortie(this.add.text(slotX + 38, slotY + 10, definition.label, this.textStyle(13, filled ? '#a8ffd0' : '#ffdf7b', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(slotX + 12, slotY + 40, unitNames.join(', ') || '담당 없음', this.textStyle(13, filled ? '#f2e3bf' : '#ff9d7d', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(slotX + 12, slotY + 62, this.compactText(definition.description, 12), this.textStyle(9, '#9fb0bf'))).setDepth(depth + 2);
});
}
private renderFirstSortieCommanderCards(x: number, y: number, width: number, height: number, depth: number) {
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.94));
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(1, palette.blue, 0.54);
this.trackSortie(this.add.text(x + 18, y + 14, '함께 싸울 무장', this.textStyle(20, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 190, y + 19, '초상을 고른 뒤 역할을 바꾸십시오.', this.textStyle(12, '#9fb0bf'))).setDepth(depth + 1);
this.renderFirstSortieInlineButton('추천 편성', x + width - 134, y + 8, 116, 38, this.canApplyRecommendedSortiePlan(), false, () => this.applyRecommendedSortiePlan(), depth + 1);
const units = this.sortieRosterDisplayUnits(this.sortieRosterUnits()).slice(0, 3);
const gap = 12;
const cardWidth = Math.floor((width - 36 - gap * 2) / 3);
const cardY = y + 54;
const cardHeight = height - 70;
units.forEach((unit, index) => {
const cardX = x + 18 + index * (cardWidth + gap);
const selected = this.isSortieSelected(unit.id);
const focused = this.sortieFocusedUnitId === unit.id;
const required = this.isRequiredSortieUnit(unit.id);
const recommendation = this.sortieRecommendation(unit.id);
const role = this.sortieFormationRole(unit);
const card = this.trackSortie(this.add.rectangle(cardX, cardY, cardWidth, cardHeight, selected ? 0x172a22 : 0x121820, selected ? 0.98 : 0.78));
card.setOrigin(0);
card.setDepth(depth + 1);
card.setStrokeStyle(focused ? 2 : 1, focused ? palette.gold : selected ? palette.green : 0x53606c, focused ? 0.96 : selected ? 0.66 : 0.34);
card.setInteractive({ useHandCursor: true });
card.on('pointerover', () => card.setFillStyle(selected ? 0x20362a : 0x1a222c, 0.98));
card.on('pointerout', () => card.setFillStyle(selected ? 0x172a22 : 0x121820, selected ? 0.98 : 0.78));
card.on('pointerdown', () => {
this.sortieFocusedUnitId = unit.id;
soundDirector.playSelect();
this.showSortiePrep();
});
this.renderFirstSortiePortrait(cardX + cardWidth / 2, cardY + 88, 132, unit, depth + 2, selected ? 1 : 0.5);
this.trackSortie(this.add.text(cardX + 14, cardY + 162, `${unit.name} Lv${unit.level}`, this.textStyle(20, selected ? '#f2e3bf' : '#87919c', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(cardX + 14, cardY + 190, `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(12, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2);
this.drawSortieBar(cardX + 14, cardY + 211, cardWidth - 28, 7, unit.hp / unit.maxHp, selected ? palette.green : 0x53606c, depth + 2);
const recommendationText = recommendation
? this.conciseSortieText(recommendation.reason)
: `${this.sortieFormationRoleLabel(role)} 역할로 전장에 합류합니다.`;
this.trackSortie(
this.add.text(cardX + 14, cardY + 230, recommendationText, {
...this.textStyle(12, recommendation ? '#ffdf7b' : '#9fb0bf', recommendation ? true : false),
wordWrap: { width: cardWidth - 28, useAdvancedWrap: true },
lineSpacing: 2
})
).setDepth(depth + 2);
this.trackSortie(this.add.text(cardX + 14, cardY + 278, this.compactText(this.sortieBondLine(unit), 26), this.textStyle(12, selected ? '#a8ffd0' : '#77818c', true))).setDepth(depth + 2);
this.renderFirstSortieInlineButton(
required ? '필수 출전' : selected ? '출전 확정' : '대기',
cardX + cardWidth - 90,
cardY + 12,
78,
30,
false,
selected,
() => undefined,
depth + 3
);
const roles = sortieFormationSlotDefinitions.filter((definition) => definition.role !== 'reserve');
const roleGap = 6;
const roleButtonWidth = Math.floor((cardWidth - 28 - roleGap * (roles.length - 1)) / roles.length);
roles.forEach((definition, roleIndex) => {
this.renderFirstSortieInlineButton(
definition.label,
cardX + 14 + roleIndex * (roleButtonWidth + roleGap),
cardY + cardHeight - 52,
roleButtonWidth,
40,
selected,
role === definition.role,
() => this.assignFirstSortieRole(unit.id, definition.role),
depth + 3
);
});
});
}
private renderFirstSortiePortrait(x: number, y: number, size: number, unit: UnitData, depth: number, alpha = 1) {
const frame = this.trackSortie(this.add.rectangle(x, y, size + 8, size + 8, 0x090e14, 0.98));
frame.setDepth(depth);
frame.setStrokeStyle(2, palette.gold, 0.58);
const portraitKey = portraitByUnitId[unit.id];
const periodPortraitTexture = portraitKey
? portraitAssetEntriesForKey(portraitKey).find((entry) => entry.textureKey.endsWith('-yellow-turban'))?.textureKey
: undefined;
const portraitTexture = periodPortraitTexture && this.textures.exists(periodPortraitTexture)
? periodPortraitTexture
: portraitKey
? portraitTextureKey(portraitKey)
: undefined;
if (portraitTexture && this.textures.exists(portraitTexture)) {
const portrait = this.trackSortie(this.add.image(x, y, portraitTexture));
portrait.setDisplaySize(size, size);
portrait.setAlpha(alpha);
portrait.setDepth(depth + 1);
return;
}
const fallback = this.trackSortie(this.add.circle(x, y, size / 2, 0x1f3140, 0.96));
fallback.setDepth(depth + 1);
fallback.setStrokeStyle(2, palette.gold, 0.58);
const initial = this.trackSortie(this.add.text(x, y - 8, unit.name.slice(0, 1), this.textStyle(34, '#f2e3bf', true)));
initial.setOrigin(0.5);
initial.setDepth(depth + 2);
const className = this.trackSortie(this.add.text(x, y + 30, unit.className, this.textStyle(12, '#d4dce6', true)));
className.setOrigin(0.5);
className.setDepth(depth + 2);
}
private assignFirstSortieRole(unitId: string, role: SortieFormationRole) {
const unit = this.sortieAllies().find((candidate) => candidate.id === unitId);
if (!unit || !this.isSortieSelected(unit.id)) {
this.showCampNotice('먼저 출전 명단에 포함한 뒤 역할을 정하십시오.');
return;
}
const currentRole = this.sortieFormationRole(unit);
const occupied = this.selectedSortieUnits().find(
(candidate) => candidate.id !== unit.id && this.sortieFormationRole(candidate) === role
);
const nextAssignments = { ...this.sortieFormationAssignments, [unit.id]: role };
if (occupied) {
nextAssignments[occupied.id] = currentRole;
}
this.sortieFocusedUnitId = unit.id;
this.sortieFormationAssignments = nextAssignments;
this.sortiePlanFeedback = occupied
? `${unit.name} ${this.sortieFormationRoleLabel(role)} · ${occupied.name} ${this.sortieFormationRoleLabel(currentRole)}`
: `${unit.name} 역할 변경 · ${this.sortieFormationRoleLabel(role)}`;
this.persistSortieSelection();
soundDirector.playSelect();
this.showSortiePrep();
}
private renderFirstSortieSynergyPanel(x: number, y: number, width: number, height: number, depth: number) {
const plan = this.sortiePlanSummary();
const selectedIds = new Set(this.selectedSortieUnitIds);
const activeBonds = this.currentBonds()
.filter((bond) => bond.unitIds.every((unitId) => selectedIds.has(unitId)))
.sort((a, b) => b.level - a.level || b.exp - a.exp)
.slice(0, 3);
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x151f2a, 0.94));
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(1, plan.warningCount > 0 ? palette.gold : palette.green, 0.54);
this.trackSortie(this.add.text(x + 18, y + 14, `공명 연계 ${plan.activeBondCount}`, this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + width - 18, y + 17, `지형 ${plan.terrainGrade}`, this.textStyle(12, '#d8b15f', true))).setOrigin(1, 0).setDepth(depth + 1);
const activeBondLine = activeBonds.length > 0
? activeBonds.map((bond) => `${bond.title} Lv${bond.level}`).join(' · ')
: '활성 공명 없음';
this.trackSortie(this.add.text(x + 18, y + 44, this.compactText(activeBondLine, 34), this.textStyle(13, activeBonds.length > 0 ? '#a8ffd0' : '#ffdf7b', true))).setDepth(depth + 1);
const feedback = this.sortiePlanFeedback || plan.warningLine || '전열·돌파·후원이 갖춰졌습니다.';
this.trackSortie(
this.add.text(x + 18, y + 70, feedback, {
...this.textStyle(12, plan.warningCount > 0 && !this.sortiePlanFeedback ? '#ffdf7b' : '#d4dce6'),
wordWrap: { width: width - 36, useAdvancedWrap: true },
lineSpacing: 2
})
).setDepth(depth + 1);
}
private renderFirstSortieLoadoutStep(
x: number,
y: number,
width: number,
height: number,
depth: number,
checklist: SortieChecklistItem[]
) {
const gap = 18;
const rosterWidth = 350;
const focusWidth = 438;
const checksWidth = width - rosterWidth - focusWidth - gap * 2;
this.renderSortieUnitSummary(x, y, rosterWidth, height, depth, 'focus');
this.renderSortieFocusPanel(x + rosterWidth + gap, y, focusWidth, 320, depth);
this.renderSortieRewardHint(x + rosterWidth + gap, y + 338, focusWidth, height - 338, depth);
const focused = this.sortieFocusedUnit();
if (focused) {
this.trackSortie(
this.add.text(
x + rosterWidth + gap + 16,
y + 397,
this.compactText(this.sortieBondLine(focused), 52),
this.textStyle(12, '#a8ffd0', true)
)
).setDepth(depth + 2);
}
this.renderFirstSortieFinalChecks(x + rosterWidth + focusWidth + gap * 2, y, checksWidth, height, depth, checklist);
}
private renderFirstSortieFinalChecks(
x: number,
y: number,
width: number,
height: number,
depth: number,
checklist: SortieChecklistItem[]
) {
const summary = this.sortieChecklistSummary(checklist);
const items = this.firstSortieFinalCheckItems(checklist);
const finalChecksComplete = items.every((item) => item.complete);
const heading = finalChecksComplete ? '출진 준비 완료' : summary.requiredComplete ? '권장 점검 남음' : '필수 확인 필요';
const headingColor = finalChecksComplete ? '#a8ffd0' : summary.requiredComplete ? '#ffdf7b' : '#ff9d7d';
const headingTone = finalChecksComplete ? palette.green : summary.requiredComplete ? palette.gold : palette.red;
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.96));
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(1, headingTone, 0.62);
this.trackSortie(
this.add.text(x + 18, y + 16, heading, this.textStyle(20, headingColor, true))
).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 18, y + 45, `${items.filter((item) => item.complete).length}/${items.length} 핵심 조건`, this.textStyle(12, '#9fb0bf', true))).setDepth(depth + 1);
items.forEach((item, index) => {
const cardY = y + 74 + index * 67;
const tone = item.complete ? palette.green : item.priority === 'required' ? palette.red : palette.gold;
const card = this.trackSortie(this.add.rectangle(x + 14, cardY, width - 28, 57, item.complete ? 0x172a22 : 0x211b14, 0.92));
card.setOrigin(0);
card.setDepth(depth + 1);
card.setStrokeStyle(1, tone, 0.5);
this.trackSortie(this.add.text(x + 26, cardY + 8, item.complete ? '✓' : item.priority === 'required' ? '!' : '·', this.textStyle(14, item.complete ? '#a8ffd0' : item.priority === 'required' ? '#ff9d7d' : '#ffdf7b', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(x + 48, cardY + 8, item.label, this.textStyle(13, '#f2e3bf', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(x + 26, cardY + 31, this.compactText(item.detail, 34), this.textStyle(11, '#9fb0bf'))).setDepth(depth + 2);
});
const rewardY = y + height - 76;
this.trackSortie(this.add.text(x + 18, rewardY, '예상 보상', this.textStyle(14, '#d8b15f', true))).setDepth(depth + 1);
this.trackSortie(
this.add.text(x + 18, rewardY + 24, this.compactText(this.currentSortieFlow().rewardHint, 36), {
...this.textStyle(11, '#d4dce6'),
wordWrap: { width: width - 36, useAdvancedWrap: true },
lineSpacing: 2
})
).setDepth(depth + 1);
}
private renderFirstSortieInlineButton(
label: string,
x: number,
y: number,
width: number,
height: number,
enabled: boolean,
active: boolean,
action: () => void,
depth: number
) {
const fill = active ? 0x263a2d : 0x1a2630;
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, fill, enabled || active ? 0.96 : 0.56));
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(1, active ? palette.green : enabled ? palette.gold : 0x53606c, active ? 0.78 : enabled ? 0.56 : 0.3);
if (enabled) {
bg.setInteractive({ useHandCursor: true });
bg.on('pointerover', () => bg.setFillStyle(active ? 0x314738 : 0x283947, 0.98));
bg.on('pointerout', () => bg.setFillStyle(fill, 0.96));
bg.on('pointerdown', action);
}
const text = this.trackSortie(this.add.text(x + width / 2, y + height / 2, label, this.textStyle(12, enabled || active ? '#f2e3bf' : '#77818c', true)));
text.setOrigin(0.5);
text.setDepth(depth + 1);
if (enabled) {
text.setInteractive({ useHandCursor: true });
text.on('pointerdown', action);
}
}
private renderSortieHeaderSummary(x: number, y: number, width: number, height: number, depth: number) {
const selectedUnits = this.selectedSortieUnits();
const selectedIds = new Set(selectedUnits.map((unit) => unit.id));
@@ -12429,7 +13002,14 @@ export class CampScene extends Phaser.Scene {
});
}
private renderSortieUnitSummary(x: number, y: number, width: number, height: number, depth: number) {
private renderSortieUnitSummary(
x: number,
y: number,
width: number,
height: number,
depth: number,
interaction: 'toggle' | 'focus' = 'toggle'
) {
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
bg.setOrigin(0);
bg.setDepth(depth);
@@ -12491,6 +13071,15 @@ export class CampScene extends Phaser.Scene {
this.sortieFocusedUnitId = unit.id;
});
row.on('pointerdown', () => {
if (interaction === 'focus') {
this.sortieFocusedUnitId = unit.id;
soundDirector.playSelect();
if (!availability.available) {
this.showCampNotice(`${unit.name}: ${availability.reason}`);
}
this.showSortiePrep();
return;
}
if (!availability.available) {
this.sortieFocusedUnitId = unit.id;
this.showCampNotice(`${unit.name}: ${availability.reason}`);
@@ -13241,13 +13830,17 @@ export class CampScene extends Phaser.Scene {
};
}
private renderSortieDeploymentMap(x: number, y: number, width: number, height: number, depth: number) {
private renderSortieDeploymentMap(x: number, y: number, width: number, height: number, depth: number, showArtwork = false) {
const scenario = this.nextSortieScenario();
const frame = this.trackSortie(this.add.rectangle(x, y, width, height, 0x070b10, 0.9));
const frame = this.trackSortie(this.add.rectangle(x, y, width, height, 0x070b10, showArtwork ? 0.72 : 0.9));
frame.setOrigin(0);
frame.setDepth(depth);
frame.setStrokeStyle(1, palette.blue, 0.48);
if (showArtwork) {
this.renderFirstSortieArtwork(x + 1, y + 1, width - 2, height - 2, depth + 0.25, 0.28);
}
if (!scenario) {
this.trackSortie(this.add.text(x + 14, y + 20, '군영 동행 확인', this.textStyle(12, '#d8b15f', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 14, y + 38, '전투 배치 없이 의정에 함께할 무장을 정리합니다.', this.textStyle(11, '#9fb0bf'))).setDepth(depth + 1);
@@ -15811,6 +16404,8 @@ export class CampScene extends Phaser.Scene {
scene: this.scene.key,
activeTab: this.activeTab,
sortieVisible: this.sortieObjects.length > 0,
sortiePrepStep: this.sortiePrepStep,
stagedSortiePrep: this.isFirstSortiePrepSlice(),
sortieHasBattle: Boolean(sortieScenario),
nextSortieBattleId: sortieScenario?.id ?? null,
nextSortieBattleTitle: sortieScenario?.title ?? null,