From dac4ed0f5aac81c40d026add422c432a48797da5 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Tue, 23 Jun 2026 18:28:00 +0900 Subject: [PATCH] Add focused sortie officer analysis --- docs/roadmap.md | 2 +- scripts/verify-flow.mjs | 17 ++++ src/game/scenes/CampScene.ts | 152 +++++++++++++++++++++++++++++++---- 3 files changed, 154 insertions(+), 17 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 049b0f4..ffe80c6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -68,7 +68,7 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of - Forty-second battle Jing rear-crisis route, using Ma Liang and Mi Zhu's rear-guard roles across an 84x66 Jiangling ferry battlefield to repel Lu Meng's disguised scouts and expose Mi Fang's wavering rear line - Forty-third battle Gongan gate-collapse route, using a twenty-two-officer roster across an 86x68 Gongan gate battlefield to delay Lu Meng's main infiltration, Fu Shiren's wavering guard, and Mi Fang's silent ledger crisis - Camp progress timeline tab that summarizes Liu Bei's long campaign arc, completed battles, current chapter, latest battle, and next major chapter -- Tactical sortie preparation panel with battle-specific sortie limits, recommended officers with reasons, class role, named equipment, core stats, bond partner, next-map terrain suitability, deployment preview, formation roles, active bond count, recruited-officer count, reserve roster summary, and readiness advice for each deployable officer +- Tactical sortie preparation panel with battle-specific sortie limits, recommended officers with reasons, class role, named equipment, core stats, bond partner, next-map terrain suitability, focused officer analysis, deployment preview, formation roles, active bond count, recruited-officer count, reserve roster summary, and readiness advice for each deployable officer - Officer collection support in camp, including full roster status, selected/reserve/recommended markers, selectable reserve drill focus, and post-battle reserve training growth for benched officers - Treasure equipment effects wired into battle previews and resolution, including named weapon damage bonuses, defensive treasure mitigation, support recovery bonuses, turn-start charm recovery, and equipment-specific growth bonuses - Flow verification script from title through the forty-third battle victory, King of Hanzhong council milestone, Shu-Han foundation milestone, recruit sortie selection, Liu Biao visit rewards, Zhuge Liang recruitment, Bowang/Changban/Jiangdong/Red Cliffs/Jing Province/Guiyang/Wuling/Changsha/Yi Province/Fu Pass/Luo Castle/Luofeng/Luo main gate/Mianzhu/Chengdu/Jiameng/Yangping/Dingjun/Hanzhong/Jing defense/Fan Castle/Han River/Fan siege/Jing rear/Gongan collapse camp states, campaign timeline state, and camp save state diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index 7b6aaa2..c297658 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -5887,6 +5887,23 @@ function assertSortieTacticalRoster(state, requiredUnitIds) { throw new Error(`Expected sortie preparation to expose deployment plan, reserve training, bond count, and terrain score: ${JSON.stringify(state.sortiePlan)}`); } + if ( + !state.sortieFocusedUnit || + !state.sortieFocusedUnit.id || + !state.sortieFocusedUnit.classRole || + !state.sortieFocusedUnit.statLine?.includes('무 ') || + !state.sortieFocusedUnit.equipmentLine?.includes('Lv') || + !state.sortieFocusedUnit.bondLine?.includes('공명') || + !state.sortieFocusedUnit.terrainLine?.includes('지형') || + typeof state.sortieFocusedUnit.terrainScore !== 'number' || + !state.sortieFocusedUnit.terrainGrade || + !Array.isArray(state.sortieFocusedUnit.equipment) || + state.sortieFocusedUnit.equipment.length !== 3 || + state.sortieFocusedUnit.equipment.some((item) => !item.label || !item.itemName || typeof item.next !== 'number' || !item.bonusText) + ) { + throw new Error(`Expected sortie preparation to expose focused officer analysis: ${JSON.stringify(state.sortieFocusedUnit)}`); + } + if ( !state.reserveTrainingFocus || !Array.isArray(state.reserveTrainingFocusOptions) || diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 55baf98..eda7c35 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -143,6 +143,30 @@ type SortieUnitTacticalSummary = { terrainLine: string; }; +type SortieFocusedUnitSummary = SortieUnitTacticalSummary & { + id: string; + name: string; + selected: boolean; + required: boolean; + recommended: boolean; + recommendationReason: string | null; + className: string; + classRole: string; + formationRole: SortieFormationRole; + terrainScore: number; + terrainGrade: string; + equipment: { + slot: EquipmentSlot; + label: string; + itemName: string; + level: number; + exp: number; + next: number; + rank: string; + bonusText: string; + }[]; +}; + type SortieTerrainCounts = Partial>; type SortieRecommendation = { @@ -6218,6 +6242,7 @@ export class CampScene extends Phaser.Scene { private tabButtons: CampTabButtonView[] = []; private visitedTabs = new Set(); private selectedSortieUnitIds: string[] = []; + private sortieFocusedUnitId = 'liu-bei'; private terrainCountCache = new Map(); constructor() { @@ -7235,6 +7260,7 @@ export class CampScene extends Phaser.Scene { this.campaign = getCampaignState(); this.report = this.campaign.firstBattleReport ?? this.report; this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.campaign.selectedSortieUnitIds); + this.ensureSortieFocus(); const depth = 40; const width = 1188; @@ -7332,7 +7358,7 @@ export class CampScene extends Phaser.Scene { this.trackSortie( this.add.text(x + 18, y + 14, `출전 무장 선택 ${selectedCount}/${maxUnits}`, this.textStyle(18, '#f2e3bf', true)) ).setDepth(depth + 1); - this.trackSortie(this.add.text(x + width - 18, y + 17, '클릭해서 출전/대기 전환', this.textStyle(12, '#9fb0bf'))).setOrigin(1, 0).setDepth(depth + 1); + this.trackSortie(this.add.text(x + width - 18, y + 17, '클릭해서 분석 및 출전/대기 전환', this.textStyle(12, '#9fb0bf'))).setOrigin(1, 0).setDepth(depth + 1); allies.forEach((unit, index) => { const rowY = y + (denseRoster ? 48 : 50) + index * rowGap; @@ -7345,7 +7371,10 @@ export class CampScene extends Phaser.Scene { row.setOrigin(0); row.setDepth(depth + 1); row.setStrokeStyle(1, selected ? palette.green : 0x53606c, selected ? 0.62 : 0.38); - row.setInteractive({ useHandCursor: !required }); + row.setInteractive({ useHandCursor: true }); + row.on('pointerover', () => { + this.sortieFocusedUnitId = unit.id; + }); row.on('pointerdown', () => this.toggleSortieUnit(unit.id)); const marker = selected ? '출전' : '대기'; @@ -7389,23 +7418,48 @@ export class CampScene extends Phaser.Scene { plan.objectiveLine ]; summaryLines.forEach((line, index) => { - this.trackSortie(this.add.text(x + 18, y + 146 + index * 16, this.compactText(line, 32), this.textStyle(12, index === 0 ? '#f2e3bf' : '#d4dce6', index === 0))).setDepth(depth + 1); + this.trackSortie(this.add.text(x + 18, y + 142 + index * 15, this.compactText(line, 32), this.textStyle(12, index === 0 ? '#f2e3bf' : '#d4dce6', index === 0))).setDepth(depth + 1); }); - plan.formationSlots.forEach((slot, index) => { - const column = index % 2; - const row = Math.floor(index / 2); - const rowX = x + 18 + column * 176; - const rowY = y + 208 + row * 24; - const names = slot.unitNames.length > 0 ? slot.unitNames.join(', ') : '미배치'; - const color = slot.unitNames.length > 0 ? '#a8ffd0' : '#9fb0bf'; - this.trackSortie(this.add.text(rowX, rowY, slot.label, this.textStyle(12, color, true))).setDepth(depth + 1); - this.trackSortie(this.add.text(rowX + 42, rowY, this.compactText(names, 13), this.textStyle(11, '#c8d2dd'))).setDepth(depth + 1); - this.trackSortie(this.add.text(rowX + 42, rowY + 13, this.compactText(slot.description, 14), this.textStyle(10, '#87919c'))).setDepth(depth + 1); - }); + this.renderSortieFocusPanel(x + 18, y + 190, width - 36, 86, depth + 1); + } - const warning = plan.warnings[0] ?? plan.reserveLine; - this.trackSortie(this.add.text(x + 18, y + height - 24, this.compactText(`조언: ${warning}`, 42), this.textStyle(12, plan.warnings.length > 0 ? '#ffdf7b' : '#a8ffd0', true))).setDepth(depth + 1); + private renderSortieFocusPanel(x: number, y: number, width: number, height: number, depth: number) { + const unit = this.sortieFocusedUnit(); + const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x151f2a, 0.92)); + bg.setOrigin(0); + bg.setDepth(depth); + bg.setStrokeStyle(1, palette.gold, 0.42); + + if (!unit) { + this.trackSortie(this.add.text(x + 12, y + 18, '선택 가능한 무장이 없습니다.', this.textStyle(12, '#9fb0bf'))).setDepth(depth + 1); + return; + } + + const status = this.unitRosterStatus(unit); + const summary = this.sortieUnitTacticalSummary(unit); + const recommendation = this.sortieRecommendation(unit.id); + const roleLabel = sortieFormationSlotDefinitions.find((slot) => slot.role === this.sortieFormationRole(unit))?.label ?? '예비'; + const terrainScore = this.sortieTerrainScore(unit, this.nextSortieScenario()); + const terrainGrade = this.sortieTerrainGrade(terrainScore); + const statusColor = status.tone === 'selected' ? '#a8ffd0' : status.tone === 'recommended' ? '#ffdf7b' : '#9fb0bf'; + const headline = `${unit.name} Lv${unit.level} · ${unit.className} · ${roleLabel}`; + const equipLine = equipmentSlots + .map((slot) => { + const state = unit.equipment[slot]; + const item = getItem(state.itemId); + return `${equipmentSlotLabels[slot]} ${item.name} Lv${state.level}`; + }) + .join(' / '); + const reason = recommendation?.reason ?? (this.isSortieSelected(unit.id) ? '현재 출전 편성에 포함되어 있습니다.' : '대기 훈련으로 성장시키거나 전장 역할에 맞춰 교체할 수 있습니다.'); + + this.trackSortie(this.add.text(x + 12, y + 7, '선택 무장 분석', this.textStyle(11, '#d8b15f', true))).setDepth(depth + 1); + this.trackSortie(this.add.text(x + 104, y + 6, this.compactText(headline, 24), this.textStyle(13, '#f2e3bf', true))).setDepth(depth + 1); + this.trackSortie(this.add.text(x + width - 12, y + 7, status.label, this.textStyle(11, statusColor, true))).setOrigin(1, 0).setDepth(depth + 1); + this.trackSortie(this.add.text(x + 12, y + 24, this.compactText(`${summary.statLine} · 지형 ${terrainGrade}(${terrainScore})`, 44), this.textStyle(10, '#c8d2dd'))).setDepth(depth + 1); + this.trackSortie(this.add.text(x + 12, y + 39, this.compactText(equipLine, 44), this.textStyle(10, '#c8d2dd'))).setDepth(depth + 1); + this.trackSortie(this.add.text(x + 12, y + 54, this.compactText(summary.bondLine, 44), this.textStyle(10, '#a8ffd0', true))).setDepth(depth + 1); + this.trackSortie(this.add.text(x + 12, y + height - 13, this.compactText(reason, 44), this.textStyle(10, recommendation ? '#ffdf7b' : '#9fb0bf', Boolean(recommendation)))).setDepth(depth + 1); } private renderSortieEquipmentIcons(unit: UnitData, x: number, y: number, selected: boolean, depth: number) { @@ -7542,6 +7596,69 @@ export class CampScene extends Phaser.Scene { return this.nextSortieRule(scenario).recommended.find((entry) => entry.unitId === unitId); } + private ensureSortieFocus() { + const allies = this.sortieAllies(); + if (allies.some((unit) => unit.id === this.sortieFocusedUnitId)) { + return; + } + + const selectedIds = new Set(this.selectedSortieUnitIds); + const recommended = this.nextSortieRule() + .recommended + .find((entry) => allies.some((unit) => unit.id === entry.unitId)); + this.sortieFocusedUnitId = + recommended?.unitId ?? + allies.find((unit) => selectedIds.has(unit.id))?.id ?? + allies[0]?.id ?? + 'liu-bei'; + } + + private sortieFocusedUnit() { + return this.sortieAllies().find((unit) => unit.id === this.sortieFocusedUnitId) ?? this.selectedSortieUnits()[0] ?? this.sortieAllies()[0]; + } + + private sortieFocusedUnitSummary(): SortieFocusedUnitSummary | null { + const unit = this.sortieFocusedUnit(); + if (!unit) { + return null; + } + const summary = this.sortieUnitTacticalSummary(unit); + const recommendation = this.sortieRecommendation(unit.id); + const scenario = this.nextSortieScenario(); + const terrainScore = this.sortieTerrainScore(unit, scenario); + return { + id: unit.id, + name: unit.name, + selected: this.isSortieSelected(unit.id), + required: this.isRequiredSortieUnit(unit.id), + recommended: Boolean(recommendation), + recommendationReason: recommendation?.reason ?? null, + className: unit.className, + classRole: getUnitClass(unit.classKey).role, + formationRole: this.sortieFormationRole(unit), + terrainScore, + terrainGrade: this.sortieTerrainGrade(terrainScore), + statLine: summary.statLine, + equipmentLine: summary.equipmentLine, + bondLine: summary.bondLine, + terrainLine: summary.terrainLine, + equipment: equipmentSlots.map((slot) => { + const state = unit.equipment[slot]; + const item = getItem(state.itemId); + return { + slot, + label: equipmentSlotLabels[slot], + itemName: item.name, + level: state.level, + exp: state.exp, + next: equipmentExpToNext(state.level), + rank: item.rank, + bonusText: this.itemBonusText(item) + }; + }) + }; + } + private renderSortieDeploymentMap(x: number, y: number, width: number, height: number, depth: number) { const scenario = this.nextSortieScenario(); const frame = this.trackSortie(this.add.rectangle(x, y, width, height, 0x070b10, 0.9)); @@ -9010,6 +9127,7 @@ export class CampScene extends Phaser.Scene { if (!unit) { return; } + this.sortieFocusedUnitId = unitId; if (this.isRequiredSortieUnit(unitId)) { this.showCampNotice(`${unit.name}는 반드시 출전해야 합니다.`); return; @@ -9134,6 +9252,8 @@ export class CampScene extends Phaser.Scene { selectedDialogueId: this.selectedDialogueId, selectedVisitId: this.selectedVisitId, selectedSortieUnitIds: [...this.selectedSortieUnitIds], + sortieFocusedUnitId: this.sortieFocusedUnitId, + sortieFocusedUnit: this.sortieFocusedUnitSummary(), reserveTrainingFocus: this.reserveTrainingFocusDefinition(), reserveTrainingFocusOptions: campaignReserveTrainingFocusDefinitions.map((focus) => ({ ...focus })), sortieRoster: this.sortieAllies().map((unit) => {