feat: redesign first sortie preparation flow
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user