From 83b2bb63610131ea47b308bc682081dd998eafe4 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Tue, 23 Jun 2026 02:41:52 +0900 Subject: [PATCH] Add battle-specific sortie recommendations --- docs/roadmap.md | 2 +- scripts/verify-flow.mjs | 10 +- src/game/scenes/CampScene.ts | 175 +++++++++++++++++++++++++++++++++-- 3 files changed, 175 insertions(+), 12 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index d1a4dbf..e98bc61 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, next-map terrain suitability, deployment preview, formation roles, active bond count, 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, 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 caf8cae..95df283 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -1278,13 +1278,21 @@ function assertSortieTacticalRoster(state, requiredUnitIds) { !state.sortiePlan || !state.sortiePlan.deploymentLine?.includes('전열') || !state.sortiePlan.objectiveLine || + !state.sortiePlan.recommendationLine?.includes('추천') || !Array.isArray(state.sortiePlan.formationSlots) || state.sortiePlan.formationSlots.length < 3 || typeof state.sortiePlan.activeBondCount !== 'number' || - typeof state.sortiePlan.terrainScore !== 'number' + typeof state.sortiePlan.terrainScore !== 'number' || + typeof state.sortiePlan.recommendedSelectedCount !== 'number' || + typeof state.sortiePlan.recommendedTotal !== 'number' ) { throw new Error(`Expected sortie preparation to expose deployment plan, bond count, and terrain score: ${JSON.stringify(state.sortiePlan)}`); } + + const recommendedUnits = state.sortieRoster.filter((unit) => unit.recommended); + if (recommendedUnits.length === 0 || recommendedUnits.some((unit) => !unit.recommendationReason)) { + throw new Error(`Expected sortie roster to expose recommended officers with reasons: ${JSON.stringify(state.sortieRoster)}`); + } } async function clickSortieRosterUnit(page, unitId) { diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index d871dd5..f3d92de 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -2,7 +2,7 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; import { equipmentExpToNext, equipmentSlotLabels, equipmentSlots, getItem, type EquipmentSlot } from '../data/battleItems'; import { getUnitClass, terrainRules, type TerrainType } from '../data/battleRules'; -import { defaultBattleScenario, getBattleScenario, type BattleScenarioDefinition } from '../data/battles'; +import { defaultBattleScenario, getBattleScenario, type BattleScenarioDefinition, type BattleScenarioId } from '../data/battles'; import { getSortieFlow } from '../data/campaignFlow'; import { firstBattleBonds, firstBattleUnits, xuzhouRecruitBonds, xuzhouRecruitUnits, type PortraitKey, type UnitData } from '../data/scenario'; import { @@ -71,6 +71,17 @@ type SortieUnitTacticalSummary = { terrainLine: string; }; +type SortieRecommendation = { + unitId: string; + reason: string; +}; + +type SortieRuleDefinition = { + maxUnits: number; + recommended: SortieRecommendation[]; + note: string; +}; + type SortieFormationRole = 'front' | 'flank' | 'support' | 'reserve'; type SortieFormationSlot = { @@ -86,7 +97,10 @@ type SortiePlanSummary = { terrainScore: number; terrainGrade: string; activeBondCount: number; + recommendedSelectedCount: number; + recommendedTotal: number; deploymentLine: string; + recommendationLine: string; objectiveLine: string; formationSlots: SortieFormationSlot[]; warnings: string[]; @@ -174,6 +188,109 @@ const sortieFormationSlotDefinitions: Omit[] = { role: 'reserve', label: '예비', description: '빈틈 보강과 공명 연결' } ]; +const defaultSortieRule: SortieRuleDefinition = { + maxUnits: maxSortieUnits, + recommended: [ + { unitId: 'liu-bei', reason: '패배 조건인 주장이며 공명 중심입니다.' } + ], + note: '유비 생존을 중심에 두고 전열, 돌파, 후원을 고르게 배치하십시오.' +}; + +const sortieRulesByBattleId: Partial> = { + 'second-battle-yellow-turban-pursuit': { + maxUnits: 3, + recommended: [ + { unitId: 'liu-bei', reason: '마을 안정과 지휘를 맡습니다.' }, + { unitId: 'guan-yu', reason: '숲길 전열을 안정적으로 밀 수 있습니다.' }, + { unitId: 'zhang-fei', reason: '잔당 추격과 막타 돌파에 강합니다.' } + ], + note: '첫 추격전은 세 형제의 공명과 기본 진형을 익히는 전장입니다.' + }, + 'third-battle-guangzong-road': { + maxUnits: 3, + recommended: [ + { unitId: 'liu-bei', reason: '강가 길목에서 부대를 묶어 줍니다.' }, + { unitId: 'guan-yu', reason: '요새와 숲의 방어선을 버팁니다.' }, + { unitId: 'zhang-fei', reason: '좁은 길의 적 전령을 빠르게 압박합니다.' } + ], + note: '강과 요새가 많은 길목이라 전열 유지와 빠른 추격이 모두 필요합니다.' + }, + 'fourth-battle-guangzong-camp': { + maxUnits: 3, + recommended: [ + { unitId: 'liu-bei', reason: '장각 격파까지 전선을 지휘합니다.' }, + { unitId: 'guan-yu', reason: '요새 방어선을 뚫는 전열 핵심입니다.' }, + { unitId: 'zhang-fei', reason: '본영 측면을 흔드는 압박 담당입니다.' } + ], + note: '황건 본영전은 세 형제 전원 출전으로 공명 피해와 전선 안정성을 확보하십시오.' + }, + 'fifth-battle-sishui-vanguard': { + maxUnits: 3, + recommended: [ + { unitId: 'liu-bei', reason: '반동탁 연합에서 이름을 세울 주장입니다.' }, + { unitId: 'guan-yu', reason: '전초 요새 주변을 안정적으로 장악합니다.' }, + { unitId: 'zhang-fei', reason: '호진의 전열을 빠르게 흔듭니다.' } + ], + note: '사수관 전초전은 중앙 길을 서두르되 숲길 척후를 놓치지 않는 편성이 좋습니다.' + }, + 'sixth-battle-jieqiao-relief': { + maxUnits: 3, + recommended: [ + { unitId: 'liu-bei', reason: '공손찬 원군로의 신뢰를 지켜야 합니다.' }, + { unitId: 'guan-yu', reason: '강가와 진영 지형에서 방어 적성이 좋습니다.' }, + { unitId: 'zhang-fei', reason: '원소군 별동대 추격에 적합합니다.' } + ], + note: '계교 원군로는 빠른 압박보다 길목 안정이 중요합니다.' + }, + 'seventh-battle-xuzhou-rescue': { + maxUnits: 4, + recommended: [ + { unitId: 'liu-bei', reason: '서주 원군 요청의 중심 인물입니다.' }, + { unitId: 'guan-yu', reason: '하후돈 선봉을 받아낼 전열 핵심입니다.' }, + { unitId: 'zhang-fei', reason: '조조군 기병을 압박할 돌파 역할입니다.' } + ], + note: '서주 구원전부터 출전 여유가 생깁니다. 전열을 유지하면서 한 명을 상황에 맞게 더 넣으십시오.' + }, + 'eighth-battle-xiaopei-supply-road': { + maxUnits: 5, + recommended: [ + { unitId: 'liu-bei', reason: '서주 방위선의 사기를 붙잡습니다.' }, + { unitId: 'jian-yong', reason: '보급로 전투에서 책략 보조가 빛납니다.' }, + { unitId: 'mi-zhu', reason: '소패 보급과 회복 운용에 어울립니다.' } + ], + note: '새로 합류한 서주 인재를 시험하기 좋은 전장입니다.' + }, + 'ninth-battle-xuzhou-gate-night-raid': { + maxUnits: 5, + recommended: [ + { unitId: 'liu-bei', reason: '성문을 되찾는 정치적 명분의 중심입니다.' }, + { unitId: 'guan-yu', reason: '야습 방어와 성문 전열 유지에 강합니다.' }, + { unitId: 'mi-zhu', reason: '성내 보급 안정에 도움을 줍니다.' } + ], + note: '성문 야습은 전열 장악과 보급 유지가 함께 필요합니다.' + }, + 'tenth-battle-xuzhou-breakout': { + maxUnits: 5, + recommended: [ + { unitId: 'liu-bei', reason: '탈출전의 패배 조건입니다.' }, + { unitId: 'guan-yu', reason: '추격대를 막아 퇴로를 엽니다.' }, + { unitId: 'zhang-fei', reason: '실책을 만회할 돌파와 반격 담당입니다.' }, + { unitId: 'mi-zhu', reason: '피난민과 보급을 보존합니다.' } + ], + note: '서주 탈출전은 주력과 보급 담당을 함께 데려가 병력 손실을 줄이는 편성이 좋습니다.' + }, + 'eleventh-battle-xudu-refuge-road': { + maxUnits: 5, + recommended: [ + { unitId: 'liu-bei', reason: '조조의 시험을 직접 받아야 합니다.' }, + { unitId: 'guan-yu', reason: '기령의 주력과 맞설 전열 핵심입니다.' }, + { unitId: 'jian-yong', reason: '감시 속 군율과 책략 보조를 맡습니다.' }, + { unitId: 'mi-zhu', reason: '허도 입성 전 보급 안정에 유리합니다.' } + ], + note: '허도 입성로는 조조군의 감시를 받는 시험입니다. 군율과 보급을 보여줄 무장을 챙기십시오.' + } +}; + const campDialogues: CampDialogue[] = [ { id: 'liu-guan-after-first-battle', @@ -1424,6 +1541,12 @@ export class CampScene extends Phaser.Scene { lineSpacing: 4 }) ).setDepth(depth + 1); + this.trackSortie( + this.add.text(x + 18, y + height - 30, `편성 주안점: ${this.nextSortieRule().note}`, { + ...this.textStyle(12, '#d8b15f', true), + wordWrap: { width: width - 36, useAdvancedWrap: true } + }) + ).setDepth(depth + 1); } private renderSortieChecklist(x: number, y: number, width: number, height: number, depth: number) { @@ -1451,10 +1574,11 @@ export class CampScene extends Phaser.Scene { bg.setStrokeStyle(1, palette.blue, 0.48); const allies = this.sortieAllies(); const selectedCount = this.selectedSortieUnitIds.length; + const maxUnits = this.sortieMaxUnits(); 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)) + 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); @@ -1462,6 +1586,7 @@ export class CampScene extends Phaser.Scene { const rowY = y + 50 + index * rowGap; const selected = this.isSortieSelected(unit.id); const required = requiredSortieUnitIds.has(unit.id); + const recommendation = this.sortieRecommendation(unit.id); const summary = this.sortieUnitTacticalSummary(unit); const row = this.trackSortie(this.add.rectangle(x + 18, rowY - 5, width - 36, rowHeight, selected ? 0x172a22 : 0x151b24, selected ? 0.96 : 0.82)); row.setOrigin(0); @@ -1471,7 +1596,8 @@ export class CampScene extends Phaser.Scene { row.on('pointerdown', () => this.toggleSortieUnit(unit.id)); 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); + const markerLabel = required ? '필수' : recommendation ? '추천' : marker; + this.trackSortie(this.add.text(x + 30, rowY, markerLabel, this.textStyle(12, required || recommendation ? '#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 + 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); @@ -1501,7 +1627,7 @@ export class CampScene extends Phaser.Scene { const summaryLines = [ plan.deploymentLine, - `활성 공명 ${plan.activeBondCount}개 · 평균 적성 ${plan.terrainScore}`, + `${plan.recommendationLine} · 공명 ${plan.activeBondCount}개`, plan.objectiveLine ]; summaryLines.forEach((line, index) => { @@ -1629,6 +1755,21 @@ export class CampScene extends Phaser.Scene { return nextBattleId ? getBattleScenario(nextBattleId) : undefined; } + private nextSortieRule(scenario = this.nextSortieScenario()) { + if (!scenario) { + return defaultSortieRule; + } + return sortieRulesByBattleId[scenario.id] ?? defaultSortieRule; + } + + private sortieMaxUnits(scenario = this.nextSortieScenario()) { + return Math.min(this.nextSortieRule(scenario).maxUnits, this.sortieAllies().length); + } + + private sortieRecommendation(unitId: string, scenario = this.nextSortieScenario()) { + return this.nextSortieRule(scenario).recommended.find((entry) => entry.unitId === unitId); + } + 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)); @@ -1680,12 +1821,17 @@ export class CampScene extends Phaser.Scene { const selectedUnits = this.selectedSortieUnits(); const selectedIds = new Set(selectedUnits.map((unit) => unit.id)); const scenario = this.nextSortieScenario(); + const rule = this.nextSortieRule(scenario); + const availableIds = new Set(this.sortieAllies().map((unit) => unit.id)); + const recommended = rule.recommended.filter((entry) => availableIds.has(entry.unitId)); + const missingRecommended = recommended.filter((entry) => !selectedIds.has(entry.unitId)); 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 recommendedSelectedCount = recommended.filter((entry) => selectedIds.has(entry.unitId)).length; const roleCounts = selectedUnits.reduce( (counts, unit) => { counts[this.sortieFormationRole(unit)] += 1; @@ -1698,11 +1844,14 @@ export class CampScene extends Phaser.Scene { unitNames: selectedUnits.filter((unit) => this.sortieFormationRole(unit) === slot.role).map((unit) => unit.name) })); const warnings: string[] = []; - const maxCount = Math.min(maxSortieUnits, this.sortieAllies().length); + const maxCount = this.sortieMaxUnits(scenario); if (selectedUnits.length < maxCount) { warnings.push(`아직 ${maxCount - selectedUnits.length}명 더 출전할 수 있습니다.`); } + if (missingRecommended.length > 0) { + warnings.push(`추천 무장 ${missingRecommended.map((entry) => this.unitName(entry.unitId)).join(', ')}이 대기 중입니다.`); + } if (roleCounts.front <= 0) { warnings.push('전열 병과가 없어 길목을 버티기 어렵습니다.'); } @@ -1725,7 +1874,10 @@ export class CampScene extends Phaser.Scene { terrainScore, terrainGrade, activeBondCount, + recommendedSelectedCount, + recommendedTotal: recommended.length, deploymentLine: `전열 ${roleCounts.front} · 돌파 ${roleCounts.flank} · 후원 ${roleCounts.support} · 예비 ${roleCounts.reserve}`, + recommendationLine: recommended.length > 0 ? `추천 ${recommendedSelectedCount}/${recommended.length}` : '추천 없음', objectiveLine: scenario ? `${scenario.title} · ${scenario.victoryConditionLabel}` : '다음 전투 정보 없음', formationSlots, warnings @@ -2508,6 +2660,7 @@ export class CampScene extends Phaser.Scene { private normalizedSortieUnitIds(candidateIds?: string[]) { const allies = this.sortieAllies(); const allyIds = new Set(allies.map((unit) => unit.id)); + const maxUnits = this.sortieMaxUnits(); const useDefaultSelection = !candidateIds || candidateIds.length === 0; const candidateSet = new Set((candidateIds ?? []).filter((id) => allyIds.has(id))); requiredSortieUnitIds.forEach((id) => { @@ -2517,16 +2670,16 @@ export class CampScene extends Phaser.Scene { }); if (useDefaultSelection) { - allies.slice(0, maxSortieUnits).forEach((unit) => candidateSet.add(unit.id)); + allies.slice(0, maxUnits).forEach((unit) => candidateSet.add(unit.id)); } const required = allies.filter((unit) => requiredSortieUnitIds.has(unit.id)).map((unit) => unit.id); const optional = allies .filter((unit) => !requiredSortieUnitIds.has(unit.id) && candidateSet.has(unit.id)) - .slice(0, Math.max(0, maxSortieUnits - required.length)) + .slice(0, Math.max(0, maxUnits - required.length)) .map((unit) => unit.id); - return [...new Set([...required, ...optional])].slice(0, maxSortieUnits); + return [...new Set([...required, ...optional])].slice(0, maxUnits); } private isSortieSelected(unitId: string) { @@ -2546,8 +2699,8 @@ export class CampScene extends Phaser.Scene { const selected = new Set(this.selectedSortieUnitIds); if (selected.has(unitId)) { selected.delete(unitId); - } else if (selected.size >= maxSortieUnits) { - this.showCampNotice(`이번 전투는 최대 ${maxSortieUnits}명까지 출전할 수 있습니다.`); + } else if (selected.size >= this.sortieMaxUnits()) { + this.showCampNotice(`이번 전투는 최대 ${this.sortieMaxUnits()}명까지 출전할 수 있습니다.`); return; } else { selected.add(unitId); @@ -2657,6 +2810,8 @@ export class CampScene extends Phaser.Scene { className: unit.className, classRole: getUnitClass(unit.classKey).role, formationRole: this.sortieFormationRole(unit), + recommended: Boolean(this.sortieRecommendation(unit.id)), + recommendationReason: this.sortieRecommendation(unit.id)?.reason ?? null, statLine: summary.statLine, equipmentLine: summary.equipmentLine, bondLine: summary.bondLine,