From 151841adf02f2d154c5f5aa0498b93bbf7297aae Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 10 Jul 2026 05:20:45 +0900 Subject: [PATCH] feat: redesign first sortie preparation flow --- src/game/scenes/CampScene.ts | 627 ++++++++++++++++++++++++++++++++++- 1 file changed, 611 insertions(+), 16 deletions(-) diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index dd08ba8..4d6c88b 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -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(); 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 = { + 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,