From 041f798475a6480123eeae7f7c54fe82ed5e2c94 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Tue, 23 Jun 2026 02:33:34 +0900 Subject: [PATCH] Enhance sortie formation planning --- docs/roadmap.md | 2 +- scripts/verify-flow.mjs | 37 ++++- src/game/scenes/CampScene.ts | 255 ++++++++++++++++++++++++++++++++--- 3 files changed, 271 insertions(+), 23 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 5881af2..d1a4dbf 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -31,7 +31,7 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of - Ninth battle Xuzhou gate night raid, opening Lu Bu's refuge and the internal instability that leads toward the loss of Xu Province - Tenth battle Xuzhou breakout, covering Zhang Fei's mistake, the loss of Xu Province to Lu Bu, and the retreat toward Cao Cao's shadow - Eleventh battle Xudu refuge road, opening Liu Bei's uneasy service under Cao Cao through a Yuan Shu remnant test battle -- Tactical sortie preparation panel with class role, named equipment, core stats, bond partner, and next-map terrain suitability for each deployable officer +- Tactical sortie preparation panel with class role, named equipment, core stats, bond partner, next-map terrain suitability, deployment preview, formation roles, active bond count, and readiness advice for each deployable officer - Flow verification script from title through the eleventh battle victory, recruit sortie selection, and camp save state ## Next Steps diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index ab45820..caf8cae 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -757,7 +757,7 @@ try { } assertSortieTacticalRoster(sixthCampSortieState, ['liu-bei', 'guan-yu', 'zhang-fei']); - await page.mouse.click(254, 450); + await clickSortieRosterUnit(page, 'guan-yu'); await page.waitForTimeout(220); const sixthCampAfterSortieToggle = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -858,7 +858,7 @@ try { if (!seventhCampSortieState?.sortieVisible) { throw new Error(`Expected seventh camp story bridge overlay: ${JSON.stringify(seventhCampSortieState)}`); } - await page.mouse.click(256, 492); + await clickSortieRosterUnit(page, 'jian-yong'); await page.waitForTimeout(120); const seventhCampRecruitSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!seventhCampRecruitSortieState.selectedSortieUnitIds?.includes('jian-yong')) { @@ -940,7 +940,7 @@ try { throw new Error(`Expected eighth camp sortie prep overlay: ${JSON.stringify(eighthCampSortieState)}`); } if (!eighthCampSortieState.selectedSortieUnitIds?.includes('mi-zhu')) { - await page.mouse.click(256, 516); + await clickSortieRosterUnit(page, 'mi-zhu'); await page.waitForTimeout(120); } const eighthCampMiZhuSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -1024,7 +1024,7 @@ try { throw new Error(`Expected ninth camp sortie prep overlay: ${JSON.stringify(ninthCampSortieState)}`); } if (!ninthCampSortieState.selectedSortieUnitIds?.includes('guan-yu')) { - await page.mouse.click(256, 420); + await clickSortieRosterUnit(page, 'guan-yu'); await page.waitForTimeout(120); } const ninthCampGuanYuSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -1256,6 +1256,7 @@ function assertSortieTacticalRoster(state, requiredUnitIds) { const invalidUnits = state.sortieRoster.filter((unit) => { return ( !unit.classRole || + !unit.formationRole || !unit.statLine?.includes('무 ') || !unit.statLine?.includes('지 ') || !unit.equipmentLine?.includes('Lv') || @@ -1272,4 +1273,32 @@ function assertSortieTacticalRoster(state, requiredUnitIds) { if (!liuBei?.equipmentLine?.includes('자웅일대검')) { throw new Error(`Expected Liu Bei sortie row to expose named treasure weapon: ${JSON.stringify(liuBei)}`); } + + if ( + !state.sortiePlan || + !state.sortiePlan.deploymentLine?.includes('전열') || + !state.sortiePlan.objectiveLine || + !Array.isArray(state.sortiePlan.formationSlots) || + state.sortiePlan.formationSlots.length < 3 || + typeof state.sortiePlan.activeBondCount !== 'number' || + typeof state.sortiePlan.terrainScore !== 'number' + ) { + throw new Error(`Expected sortie preparation to expose deployment plan, bond count, and terrain score: ${JSON.stringify(state.sortiePlan)}`); + } +} + +async function clickSortieRosterUnit(page, unitId) { + const state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if (!state?.sortieVisible || !Array.isArray(state.sortieRoster)) { + throw new Error(`Cannot click sortie unit ${unitId}; sortie roster is not visible: ${JSON.stringify(state)}`); + } + + const index = state.sortieRoster.findIndex((unit) => unit.id === unitId); + if (index < 0) { + throw new Error(`Cannot click sortie unit ${unitId}; unit is missing from roster: ${JSON.stringify(state.sortieRoster)}`); + } + + const rowGap = state.sortieRoster.length > 5 ? 38 : state.sortieRoster.length > 4 ? 43 : 48; + await page.mouse.click(254, 314 + 50 + index * rowGap); + await page.waitForTimeout(120); } diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index ffd44ea..d871dd5 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -71,6 +71,27 @@ type SortieUnitTacticalSummary = { terrainLine: string; }; +type SortieFormationRole = 'front' | 'flank' | 'support' | 'reserve'; + +type SortieFormationSlot = { + role: SortieFormationRole; + label: string; + description: string; + unitNames: string[]; +}; + +type SortiePlanSummary = { + selectedCount: number; + maxCount: number; + terrainScore: number; + terrainGrade: string; + activeBondCount: number; + deploymentLine: string; + objectiveLine: string; + formationSlots: SortieFormationSlot[]; + warnings: string[]; +}; + const portraitKeys: Record = { liuBei: 'portrait-liu-bei', guanYu: 'portrait-guan-yu', @@ -146,6 +167,12 @@ const campBattleIds = { const requiredSortieUnitIds = new Set(['liu-bei']); const maxSortieUnits = 6; +const sortieFormationSlotDefinitions: Omit[] = [ + { role: 'front', label: '전열', description: '길목을 막고 반격을 받아냄' }, + { role: 'flank', label: '돌파', description: '측면 기동과 추격 담당' }, + { role: 'support', label: '후원', description: '원거리, 책략, 보급 지원' }, + { role: 'reserve', label: '예비', description: '빈틈 보강과 공명 연결' } +]; const campDialogues: CampDialogue[] = [ { @@ -1343,8 +1370,8 @@ export class CampScene extends Phaser.Scene { this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.campaign.selectedSortieUnitIds); const depth = 40; - const width = 1160; - const height = 660; + const width = 1188; + const height = 676; const x = Math.floor((this.scale.width - width) / 2); const y = Math.floor((this.scale.height - height) / 2); @@ -1365,16 +1392,17 @@ export class CampScene extends Phaser.Scene { ); subtitle.setDepth(depth + 2); - this.renderSortieBriefing(x + 34, y + 112, 520, 178, depth + 2); - this.renderSortieChecklist(x + 576, y + 112, 550, 178, depth + 2); - this.renderSortieUnitSummary(x + 34, y + 310, 1092, 252, depth + 2); - this.renderSortieRewardHint(x + 34, y + 576, 696, 52, depth + 2); + this.renderSortieBriefing(x + 34, y + 104, 520, 168, depth + 2); + this.renderSortieChecklist(x + 576, y + 104, 578, 168, depth + 2); + this.renderSortieUnitSummary(x + 34, y + 292, 728, 286, depth + 2); + this.renderSortieFormationPlan(x + 782, y + 292, 372, 286, depth + 2); + this.renderSortieRewardHint(x + 34, y + 596, 808, 52, depth + 2); - this.addSortieButton('군영으로', x + width - 298, y + height - 44, 124, () => { + this.addSortieButton('군영으로', x + width - 298, y + height - 42, 124, () => { soundDirector.playSelect(); this.hideSortiePrep(); }, depth + 3); - this.addSortieButton('출진', x + width - 152, y + height - 44, 124, () => { + this.addSortieButton('출진', x + width - 152, y + height - 42, 124, () => { soundDirector.playSelect(); this.startVictoryStory(); }, depth + 3); @@ -1423,15 +1451,15 @@ export class CampScene extends Phaser.Scene { bg.setStrokeStyle(1, palette.blue, 0.48); const allies = this.sortieAllies(); const selectedCount = this.selectedSortieUnitIds.length; - const rowGap = allies.length > 4 ? 32 : 36; - const rowHeight = allies.length > 4 ? 31 : 32; + const rowGap = allies.length > 5 ? 38 : allies.length > 4 ? 43 : 48; + const rowHeight = rowGap - 6; this.trackSortie( this.add.text(x + 18, y + 14, `출전 무장 선택 ${selectedCount}/${Math.min(maxSortieUnits, allies.length)}`, 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); allies.forEach((unit, index) => { - const rowY = y + 48 + index * rowGap; + const rowY = y + 50 + index * rowGap; const selected = this.isSortieSelected(unit.id); const required = requiredSortieUnitIds.has(unit.id); const summary = this.sortieUnitTacticalSummary(unit); @@ -1445,18 +1473,57 @@ export class CampScene extends Phaser.Scene { const marker = selected ? '출전' : '대기'; this.trackSortie(this.add.text(x + 30, rowY, required ? '필수' : marker, this.textStyle(12, required ? '#ffdf7b' : selected ? '#a8ffd0' : '#9fb0bf', true))).setDepth(depth + 2); this.trackSortie(this.add.text(x + 84, rowY, `${unit.name} Lv ${unit.level}`, this.textStyle(14, selected ? '#f2e3bf' : '#87919c', true))).setDepth(depth + 2); - this.trackSortie(this.add.text(x + 218, rowY, `${unit.className} · ${getUnitClass(unit.classKey).role}`, this.textStyle(12, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2); - this.trackSortie(this.add.text(x + 462, rowY, `병력 ${unit.hp}/${unit.maxHp}`, this.textStyle(12, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2); - this.drawSortieBar(x + 554, rowY + 6, 86, 7, unit.hp / unit.maxHp, selected ? palette.green : 0x53606c, depth + 2); - this.trackSortie(this.add.text(x + 666, rowY, summary.terrainLine, this.textStyle(11, selected ? '#d8b15f' : '#77818c', true))).setDepth(depth + 2); + this.trackSortie(this.add.text(x + 214, rowY, this.compactText(`${unit.className} · ${getUnitClass(unit.classKey).role}`, 19), this.textStyle(12, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2); + this.trackSortie(this.add.text(x + 424, rowY, `병력 ${unit.hp}/${unit.maxHp}`, this.textStyle(12, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2); + this.drawSortieBar(x + 514, rowY + 6, 74, 7, unit.hp / unit.maxHp, selected ? palette.green : 0x53606c, depth + 2); + this.trackSortie(this.add.text(x + 610, rowY, this.compactText(summary.terrainLine, 13), this.textStyle(11, selected ? '#d8b15f' : '#77818c', true))).setDepth(depth + 2); this.renderSortieEquipmentIcons(unit, x + 84, rowY + 19, selected, depth + 2); - this.trackSortie(this.add.text(x + 162, rowY + 18, summary.equipmentLine, this.textStyle(11, selected ? '#c8d2dd' : '#68727e'))).setDepth(depth + 2); - this.trackSortie(this.add.text(x + 522, rowY + 18, summary.statLine, this.textStyle(11, selected ? '#c8d2dd' : '#68727e'))).setDepth(depth + 2); - this.trackSortie(this.add.text(x + 760, rowY + 18, summary.bondLine, this.textStyle(11, selected ? '#a8ffd0' : '#68727e', selected))).setDepth(depth + 2); + this.trackSortie(this.add.text(x + 162, rowY + 18, this.compactText(summary.equipmentLine, 34), this.textStyle(11, selected ? '#c8d2dd' : '#68727e'))).setDepth(depth + 2); + this.trackSortie(this.add.text(x + 424, rowY + 18, this.compactText(summary.statLine, 23), this.textStyle(11, selected ? '#c8d2dd' : '#68727e'))).setDepth(depth + 2); + this.trackSortie(this.add.text(x + 610, rowY + 18, this.compactText(summary.bondLine, 15), this.textStyle(11, selected ? '#a8ffd0' : '#68727e', selected))).setDepth(depth + 2); }); } + private renderSortieFormationPlan(x: number, y: number, width: number, height: number, depth: number) { + const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92)); + bg.setOrigin(0); + bg.setDepth(depth); + bg.setStrokeStyle(1, palette.gold, 0.5); + + const plan = this.sortiePlanSummary(); + 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 + 17, `${plan.selectedCount}/${plan.maxCount}명 · 지형 ${plan.terrainGrade}`, this.textStyle(12, '#d8b15f', true)) + ).setOrigin(1, 0).setDepth(depth + 1); + + this.renderSortieDeploymentMap(x + 18, y + 46, width - 36, 92, depth + 1); + + const summaryLines = [ + plan.deploymentLine, + `활성 공명 ${plan.activeBondCount}개 · 평균 적성 ${plan.terrainScore}`, + plan.objectiveLine + ]; + summaryLines.forEach((line, index) => { + this.trackSortie(this.add.text(x + 18, y + 150 + index * 18, this.compactText(line, 31), 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 * 28; + 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); + }); + + const warning = plan.warnings[0] ?? '전열, 돌파, 지원의 균형이 좋습니다.'; + this.trackSortie(this.add.text(x + 18, y + height - 28, this.compactText(`조언: ${warning}`, 42), this.textStyle(12, plan.warnings.length > 0 ? '#ffdf7b' : '#a8ffd0', true))).setDepth(depth + 1); + } + private renderSortieEquipmentIcons(unit: UnitData, x: number, y: number, selected: boolean, depth: number) { equipmentSlots.forEach((slot, index) => { const state = unit.equipment[slot]; @@ -1562,6 +1629,149 @@ export class CampScene extends Phaser.Scene { return nextBattleId ? getBattleScenario(nextBattleId) : undefined; } + 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)); + frame.setOrigin(0); + frame.setDepth(depth); + frame.setStrokeStyle(1, palette.blue, 0.48); + + if (!scenario) { + this.trackSortie(this.add.text(x + 14, y + 34, '다음 전투 배치 정보가 없습니다.', this.textStyle(12, '#9fb0bf'))).setDepth(depth + 1); + return; + } + + const mapWidth = scenario.map.width; + const mapHeight = scenario.map.height; + const cell = Math.min((width - 16) / mapWidth, (height - 16) / mapHeight); + const originX = x + Math.floor((width - mapWidth * cell) / 2); + const originY = y + Math.floor((height - mapHeight * cell) / 2); + const graphics = this.trackSortie(this.add.graphics()); + graphics.setDepth(depth + 1); + + scenario.map.terrain.forEach((row, rowIndex) => { + row.forEach((terrain, columnIndex) => { + const rule = terrainRules[terrain]; + graphics.fillStyle(rule.color, terrain === 'river' || terrain === 'cliff' ? 0.82 : 0.58); + graphics.fillRect(originX + columnIndex * cell, originY + rowIndex * cell, Math.ceil(cell), Math.ceil(cell)); + }); + }); + + const selected = new Set(this.selectedSortieUnitIds); + scenario.units.forEach((unit) => { + if (unit.faction !== 'enemy' && !selected.has(unit.id)) { + return; + } + const dotX = originX + (unit.x + 0.5) * cell; + const dotY = originY + (unit.y + 0.5) * cell; + const color = unit.faction === 'enemy' ? 0xd35f4a : unit.id === 'liu-bei' ? palette.gold : palette.green; + const alpha = unit.faction === 'enemy' ? 0.72 : 0.96; + const dot = this.trackSortie(this.add.circle(dotX, dotY, unit.faction === 'enemy' ? 2.2 : 3.7, color, alpha)); + dot.setDepth(depth + 2); + if (unit.faction !== 'enemy') { + dot.setStrokeStyle(1, 0x05070a, 0.8); + } + }); + + this.trackSortie(this.add.text(x + 10, y + height - 16, '녹색: 출전 아군 · 붉은색: 적 배치', this.textStyle(10, '#9fb0bf'))).setDepth(depth + 2); + } + + private sortiePlanSummary(): SortiePlanSummary { + const selectedUnits = this.selectedSortieUnits(); + const selectedIds = new Set(selectedUnits.map((unit) => unit.id)); + const scenario = this.nextSortieScenario(); + const terrainScores = selectedUnits.map((unit) => this.sortieTerrainScore(unit, scenario)); + const terrainScore = terrainScores.length + ? Math.round(terrainScores.reduce((sum, score) => sum + score, 0) / terrainScores.length) + : 0; + const terrainGrade = this.sortieTerrainGrade(terrainScore); + const activeBondCount = this.currentBonds().filter((bond) => bond.unitIds.every((unitId) => selectedIds.has(unitId))).length; + const roleCounts = selectedUnits.reduce( + (counts, unit) => { + counts[this.sortieFormationRole(unit)] += 1; + return counts; + }, + { front: 0, flank: 0, support: 0, reserve: 0 } as Record + ); + const formationSlots = sortieFormationSlotDefinitions.map((slot) => ({ + ...slot, + unitNames: selectedUnits.filter((unit) => this.sortieFormationRole(unit) === slot.role).map((unit) => unit.name) + })); + const warnings: string[] = []; + const maxCount = Math.min(maxSortieUnits, this.sortieAllies().length); + + if (selectedUnits.length < maxCount) { + warnings.push(`아직 ${maxCount - selectedUnits.length}명 더 출전할 수 있습니다.`); + } + if (roleCounts.front <= 0) { + warnings.push('전열 병과가 없어 길목을 버티기 어렵습니다.'); + } + if (roleCounts.flank <= 0) { + warnings.push('돌파 병과가 없어 추격과 측면 압박이 느릴 수 있습니다.'); + } + if (roleCounts.support <= 0) { + warnings.push('후원 병과가 없어 회복, 보급, 원거리 견제가 약합니다.'); + } + if (activeBondCount <= 0 && selectedUnits.length > 1) { + warnings.push('함께 출전하는 공명 조합이 없습니다.'); + } + if (terrainScore > 0 && terrainScore < 98) { + warnings.push('선택 무장의 평균 지형 적성이 낮습니다.'); + } + + return { + selectedCount: selectedUnits.length, + maxCount, + terrainScore, + terrainGrade, + activeBondCount, + deploymentLine: `전열 ${roleCounts.front} · 돌파 ${roleCounts.flank} · 후원 ${roleCounts.support} · 예비 ${roleCounts.reserve}`, + objectiveLine: scenario ? `${scenario.title} · ${scenario.victoryConditionLabel}` : '다음 전투 정보 없음', + formationSlots, + warnings + }; + } + + private sortieFormationRole(unit: UnitData): SortieFormationRole { + if (unit.classKey === 'cavalry') { + return 'flank'; + } + if (unit.classKey === 'archer' || unit.classKey === 'strategist' || unit.classKey === 'quartermaster') { + return 'support'; + } + if (unit.classKey === 'lord' || unit.classKey === 'infantry' || unit.classKey === 'spearman') { + return 'front'; + } + return 'reserve'; + } + + private sortieTerrainScore(unit: UnitData, scenario: BattleScenarioDefinition | undefined) { + if (!scenario) { + return 100; + } + const terrainCounts = this.passableTerrainCounts(scenario); + const entries = Object.entries(terrainCounts) as [TerrainType, number][]; + const total = entries.reduce((sum, [, count]) => sum + count, 0); + if (total <= 0) { + return 100; + } + const unitClass = getUnitClass(unit.classKey); + return Math.round(entries.reduce((sum, [terrain, count]) => sum + (unitClass.terrainRatings[terrain] ?? 0) * count, 0) / total); + } + + private sortieTerrainGrade(score: number) { + if (score >= 110) { + return '최적'; + } + if (score >= 102) { + return '유리'; + } + if (score >= 95) { + return '보통'; + } + return score > 0 ? '주의' : '미정'; + } + private renderSortieRewardHint(x: number, y: number, width: number, height: number, depth: number) { const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x151f2a, 0.9)); bg.setOrigin(0); @@ -2418,6 +2628,13 @@ export class CampScene extends Phaser.Scene { return object; } + private compactText(value: string, maxLength: number) { + if (value.length <= maxLength) { + return value; + } + return `${value.slice(0, Math.max(0, maxLength - 1))}…`; + } + private clearContent() { this.contentObjects.forEach((object) => object.destroy()); this.contentObjects = []; @@ -2439,12 +2656,14 @@ export class CampScene extends Phaser.Scene { selected: this.isSortieSelected(unit.id), className: unit.className, classRole: getUnitClass(unit.classKey).role, + formationRole: this.sortieFormationRole(unit), statLine: summary.statLine, equipmentLine: summary.equipmentLine, bondLine: summary.bondLine, terrainLine: summary.terrainLine }; }), + sortiePlan: this.sortiePlanSummary(), campBattleId: this.currentCampBattleId(), campTitle: this.currentCampTitle(), availableDialogueIds: this.availableCampDialogues().map((dialogue) => dialogue.id),