From 91b8abc7e3a53f549bbcf7e90ada4fa0abadb87c Mon Sep 17 00:00:00 2001 From: Wickedness Date: Tue, 23 Jun 2026 01:57:24 +0900 Subject: [PATCH] Enhance tactical sortie preparation --- docs/roadmap.md | 1 + scripts/verify-flow.mjs | 53 +++++++++-- src/game/scenes/CampScene.ts | 165 ++++++++++++++++++++++++++++++----- 3 files changed, 190 insertions(+), 29 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index c3d967a..5b9f529 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -29,6 +29,7 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of - Xu Province camp recruitment for Jian Yong and Mi Zhu, including new strategist/quartermaster roles, bond dialogue, and selectable sortie participation - Eighth battle Xiaopei supply-road defense, where the expanded Xu Province roster can be selected into battle and Lu Bu's refuge arc is foreshadowed - Ninth battle Xuzhou gate night raid, opening Lu Bu's refuge and the internal instability that leads toward the loss of Xu Province +- Tactical sortie preparation panel with class role, named equipment, core stats, bond partner, and next-map terrain suitability for each deployable officer - Flow verification script from title through the ninth battle victory, recruit sortie selection, and camp save state ## Next Steps diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index 9645db0..7f11550 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -360,9 +360,10 @@ try { if (!campStateWithSortiePrep?.sortieVisible) { throw new Error(`Expected sortie preparation overlay from next story button: ${JSON.stringify(campStateWithSortiePrep)}`); } + assertSortieTacticalRoster(campStateWithSortiePrep, ['liu-bei', 'guan-yu', 'zhang-fei']); await page.screenshot({ path: 'dist/verification-camp-sortie.png', fullPage: true }); - await page.mouse.click(792, 596); + await page.mouse.click(922, 646); await page.waitForTimeout(140); const campStateAfterSortieClose = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (campStateAfterSortieClose?.sortieVisible) { @@ -371,7 +372,7 @@ try { await page.mouse.click(1120, 38); await page.waitForTimeout(120); - await page.mouse.click(938, 596); + await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); @@ -443,7 +444,7 @@ try { if (!secondCampSortieState?.sortieVisible) { throw new Error(`Expected second camp sortie preparation overlay: ${JSON.stringify(secondCampSortieState)}`); } - await page.mouse.click(938, 596); + await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); @@ -515,7 +516,7 @@ try { if (!thirdCampSortieState?.sortieVisible) { throw new Error(`Expected third camp sortie preparation overlay: ${JSON.stringify(thirdCampSortieState)}`); } - await page.mouse.click(938, 596); + await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); @@ -587,7 +588,7 @@ try { if (!fourthCampSortieState?.sortieVisible) { throw new Error(`Expected fourth camp sortie preparation overlay: ${JSON.stringify(fourthCampSortieState)}`); } - await page.mouse.click(938, 596); + await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); @@ -669,7 +670,7 @@ try { if (!fifthCampSortieState?.sortieVisible) { throw new Error(`Expected fifth camp sortie preparation overlay: ${JSON.stringify(fifthCampSortieState)}`); } - await page.mouse.click(938, 596); + await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); @@ -754,6 +755,7 @@ try { if (!sixthCampSortieState.selectedSortieUnitIds?.includes('liu-bei') || sixthCampSortieState.selectedSortieUnitIds.length < 3) { throw new Error(`Expected sortie prep to default to the core brother roster: ${JSON.stringify(sixthCampSortieState)}`); } + assertSortieTacticalRoster(sixthCampSortieState, ['liu-bei', 'guan-yu', 'zhang-fei']); await page.mouse.click(254, 450); await page.waitForTimeout(220); @@ -766,7 +768,7 @@ try { ) { throw new Error(`Expected sortie selection toggle to bench Guan Yu while keeping Liu Bei/Zhang Fei: ${JSON.stringify(sixthCampAfterSortieToggle)}`); } - await page.mouse.click(938, 596); + await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); @@ -862,7 +864,8 @@ try { if (!seventhCampRecruitSortieState.selectedSortieUnitIds?.includes('jian-yong')) { throw new Error(`Expected recruit to be selectable in seventh camp sortie prep: ${JSON.stringify(seventhCampRecruitSortieState)}`); } - await page.mouse.click(938, 596); + assertSortieTacticalRoster(seventhCampRecruitSortieState, ['liu-bei', 'zhang-fei', 'jian-yong', 'mi-zhu']); + await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); @@ -944,7 +947,8 @@ try { if (!eighthCampMiZhuSortieState.selectedSortieUnitIds?.includes('mi-zhu')) { throw new Error(`Expected Mi Zhu to be selectable for ninth battle sortie: ${JSON.stringify(eighthCampMiZhuSortieState)}`); } - await page.mouse.click(938, 596); + assertSortieTacticalRoster(eighthCampMiZhuSortieState, ['liu-bei', 'zhang-fei', 'jian-yong', 'mi-zhu']); + await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); @@ -1076,3 +1080,34 @@ async function canReach(url) { function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } + +function assertSortieTacticalRoster(state, requiredUnitIds) { + if (!state?.sortieVisible || !Array.isArray(state.sortieRoster)) { + throw new Error(`Expected sortie tactical roster to be exposed: ${JSON.stringify(state)}`); + } + + const missingIds = requiredUnitIds.filter((unitId) => !state.sortieRoster.some((unit) => unit.id === unitId)); + if (missingIds.length > 0) { + throw new Error(`Expected sortie roster to include ${missingIds.join(', ')}: ${JSON.stringify(state.sortieRoster)}`); + } + + const invalidUnits = state.sortieRoster.filter((unit) => { + return ( + !unit.classRole || + !unit.statLine?.includes('무 ') || + !unit.statLine?.includes('지 ') || + !unit.equipmentLine?.includes('Lv') || + !unit.bondLine?.includes('공명') || + !unit.terrainLine?.includes('지형') + ); + }); + + if (invalidUnits.length > 0) { + throw new Error(`Expected sortie roster to include class, stats, equipment, bond, and terrain advice: ${JSON.stringify(invalidUnits)}`); + } + + const liuBei = state.sortieRoster.find((unit) => unit.id === 'liu-bei'); + if (!liuBei?.equipmentLine?.includes('자웅일대검')) { + throw new Error(`Expected Liu Bei sortie row to expose named treasure weapon: ${JSON.stringify(liuBei)}`); + } +} diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 40738e1..923a2de 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -1,8 +1,8 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; import { equipmentExpToNext, equipmentSlotLabels, equipmentSlots, getItem, type EquipmentSlot } from '../data/battleItems'; -import { getUnitClass } from '../data/battleRules'; -import { defaultBattleScenario } from '../data/battles'; +import { getUnitClass, terrainRules, type TerrainType } from '../data/battleRules'; +import { defaultBattleScenario, getBattleScenario, type BattleScenarioDefinition } from '../data/battles'; import { getSortieFlow } from '../data/campaignFlow'; import { firstBattleBonds, firstBattleUnits, xuzhouRecruitBonds, xuzhouRecruitUnits, type PortraitKey, type UnitData } from '../data/scenario'; import { @@ -64,6 +64,13 @@ type SortieChecklistItem = { detail: string; }; +type SortieUnitTacticalSummary = { + statLine: string; + equipmentLine: string; + bondLine: string; + terrainLine: string; +}; + const portraitKeys: Record = { liuBei: 'portrait-liu-bei', guanYu: 'portrait-guan-yu', @@ -1159,8 +1166,8 @@ export class CampScene extends Phaser.Scene { this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.campaign.selectedSortieUnitIds); const depth = 40; - const width = 900; - const height = 560; + const width = 1160; + const height = 660; const x = Math.floor((this.scale.width - width) / 2); const y = Math.floor((this.scale.height - height) / 2); @@ -1181,10 +1188,10 @@ export class CampScene extends Phaser.Scene { ); subtitle.setDepth(depth + 2); - this.renderSortieBriefing(x + 34, y + 112, 396, 152, depth + 2); - this.renderSortieChecklist(x + 462, y + 112, 404, 170, depth + 2); - this.renderSortieUnitSummary(x + 34, y + 292, 832, 176, depth + 2); - this.renderSortieRewardHint(x + 34, y + 480, 548, 52, 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.addSortieButton('군영으로', x + width - 298, y + height - 44, 124, () => { soundDirector.playSelect(); @@ -1239,8 +1246,8 @@ 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 ? 24 : 30; - const rowHeight = allies.length > 4 ? 22 : 26; + const rowGap = allies.length > 4 ? 32 : 36; + const rowHeight = allies.length > 4 ? 31 : 32; this.trackSortie( this.add.text(x + 18, y + 14, `출전 무장 선택 ${selectedCount}/${Math.min(maxSortieUnits, allies.length)}`, this.textStyle(18, '#f2e3bf', true)) ).setDepth(depth + 1); @@ -1250,6 +1257,7 @@ export class CampScene extends Phaser.Scene { const rowY = y + 48 + index * rowGap; const selected = this.isSortieSelected(unit.id); const required = requiredSortieUnitIds.has(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); row.setDepth(depth + 1); @@ -1260,20 +1268,123 @@ 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 + 198, rowY, unit.className, this.textStyle(13, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2); - this.trackSortie(this.add.text(x + 310, rowY, `병력 ${unit.hp}/${unit.maxHp}`, this.textStyle(13, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2); - this.drawSortieBar(x + 414, rowY + 6, 102, 7, unit.hp / unit.maxHp, selected ? palette.green : 0x53606c, 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); - const equipment = equipmentSlots - .map((slot) => { - const state = unit.equipment[slot]; - return `${equipmentSlotLabels[slot]} Lv${state.level}`; - }) - .join(' '); - this.trackSortie(this.add.text(x + 542, rowY, equipment, this.textStyle(12, selected ? '#d4dce6' : '#77818c'))).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); }); } + private renderSortieEquipmentIcons(unit: UnitData, x: number, y: number, selected: boolean, depth: number) { + equipmentSlots.forEach((slot, index) => { + const state = unit.equipment[slot]; + const item = getItem(state.itemId); + const iconFrame = this.trackSortie(this.add.rectangle(x + index * 23, y, 18, 18, selected && item.rank === 'treasure' ? 0x2c2630 : 0x0a1017, 0.92)); + iconFrame.setStrokeStyle(1, item.rank === 'treasure' ? palette.gold : 0x53606c, selected ? 0.62 : 0.34); + iconFrame.setDepth(depth); + const icon = this.trackSortie(this.add.image(x + index * 23, y, `item-${item.id}`)); + icon.setDisplaySize(14, 14); + icon.setAlpha(selected ? 1 : 0.52); + icon.setDepth(depth + 1); + }); + } + + private sortieUnitTacticalSummary(unit: UnitData): SortieUnitTacticalSummary { + return { + statLine: `무 ${unit.stats.might} 지 ${unit.stats.intelligence} 통 ${unit.stats.leadership} 민 ${unit.stats.agility} 운 ${unit.stats.luck}`, + equipmentLine: this.sortieEquipmentLine(unit), + bondLine: this.sortieBondLine(unit), + terrainLine: this.sortieTerrainLine(unit) + }; + } + + private sortieEquipmentLine(unit: UnitData) { + return equipmentSlots + .map((slot) => { + const state = unit.equipment[slot]; + const item = getItem(state.itemId); + return `${item.name} Lv${state.level}`; + }) + .join(' · '); + } + + private sortieBondLine(unit: UnitData) { + const selected = new Set(this.selectedSortieUnitIds); + const activeBonds = this.currentBonds() + .filter((bond) => bond.unitIds.includes(unit.id)) + .map((bond) => { + const partnerId = bond.unitIds.find((unitId) => unitId !== unit.id) ?? bond.unitIds[0]; + return { + bond, + partnerId, + partnerName: this.unitName(partnerId), + partnerSelected: selected.has(partnerId) + }; + }) + .sort((a, b) => Number(b.partnerSelected) - Number(a.partnerSelected) || b.bond.level - a.bond.level || b.bond.exp - a.bond.exp); + + const deployedBond = activeBonds.find((entry) => entry.partnerSelected); + if (deployedBond) { + return `공명 ${deployedBond.partnerName} Lv${deployedBond.bond.level}`; + } + + const strongestBond = activeBonds[0]; + if (strongestBond) { + return `대기 공명 ${strongestBond.partnerName} Lv${strongestBond.bond.level}`; + } + + return '공명 관계 없음'; + } + + private sortieTerrainLine(unit: UnitData) { + const scenario = this.nextSortieScenario(); + if (!scenario) { + return `${getUnitClass(unit.classKey).role}`; + } + + const terrainCounts = this.passableTerrainCounts(scenario); + const unitClass = getUnitClass(unit.classKey); + const entries = Object.entries(terrainCounts) as [TerrainType, number][]; + const total = entries.reduce((sum, [, count]) => sum + count, 0); + if (total <= 0) { + return `${unitClass.role}`; + } + + const weightedScore = + entries.reduce((sum, [terrain, count]) => sum + (unitClass.terrainRatings[terrain] ?? 0) * count, 0) / total; + const grade = weightedScore >= 110 ? '최적' : weightedScore >= 102 ? '유리' : weightedScore >= 95 ? '보통' : '주의'; + const bestTerrains = entries + .sort((a, b) => (unitClass.terrainRatings[b[0]] ?? 0) - (unitClass.terrainRatings[a[0]] ?? 0) || b[1] - a[1]) + .slice(0, 2) + .map(([terrain]) => terrainRules[terrain].label) + .join('/'); + + return `지형 ${grade}${bestTerrains ? ` · ${bestTerrains}` : ''}`; + } + + private passableTerrainCounts(scenario: BattleScenarioDefinition) { + const counts = {} as Record; + scenario.map.terrain.forEach((row) => { + row.forEach((terrain) => { + if (terrainRules[terrain].passable === false) { + return; + } + counts[terrain] = (counts[terrain] ?? 0) + 1; + }); + }); + return counts; + } + + private nextSortieScenario() { + const nextBattleId = getSortieFlow(this.campaign?.latestBattleId).nextBattleId; + return nextBattleId ? getBattleScenario(nextBattleId) : undefined; + } + 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); @@ -2143,6 +2254,20 @@ export class CampScene extends Phaser.Scene { selectedUnitId: this.selectedUnitId, selectedDialogueId: this.selectedDialogueId, selectedSortieUnitIds: [...this.selectedSortieUnitIds], + sortieRoster: this.sortieAllies().map((unit) => { + const summary = this.sortieUnitTacticalSummary(unit); + return { + id: unit.id, + name: unit.name, + selected: this.isSortieSelected(unit.id), + className: unit.className, + classRole: getUnitClass(unit.classKey).role, + statLine: summary.statLine, + equipmentLine: summary.equipmentLine, + bondLine: summary.bondLine, + terrainLine: summary.terrainLine + }; + }), campBattleId: this.currentCampBattleId(), campTitle: this.currentCampTitle(), availableDialogueIds: this.availableCampDialogues().map((dialogue) => dialogue.id),