diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index 587f555..5d1d2e5 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -378,6 +378,23 @@ export type BattleSortieDefinition = { note: string; }; +export type BattleTacticalGuideEventKey = + | 'leader-wavering' + | 'ally-danger' + | 'village-approach' + | 'village-secured' + | 'vanguard-broken' + | 'cavalry-approach' + | 'enemy-posture'; + +export type BattleTacticalGuideDefinition = { + summary: string; + route: string; + focus: string; + roles: string[]; + events?: Partial>; +}; + export type BattleCampaignRewardDefinition = { supplies?: string[]; equipment?: string[]; @@ -394,6 +411,7 @@ export type BattleScenarioDefinition = { victoryConditionLabel: string; defeatConditionLabel: string; openingObjectiveLines: string[]; + tacticalGuide?: BattleTacticalGuideDefinition; sortie?: BattleSortieDefinition; map: BattleMap; units: UnitData[]; @@ -420,6 +438,26 @@ export const firstBattleScenario: BattleScenarioDefinition = { '유비가 퇴각하면 의용군은 전투를 지속할 수 없습니다.', '마을 지형을 밟아 보급을 확보하고, 다친 장수는 콩과 탁주로 회복하십시오.' ], + tacticalGuide: { + summary: '관우가 중앙 길목을 열고, 장비가 남쪽 궁병을 흔들며, 유비는 후방에서 생존과 회복을 챙깁니다.', + route: '시작 구역 → 중앙 길목 → 마을 어귀 → 두령 한석', + focus: '가까운 황건병을 먼저 끊고 마을 주변을 안전하게 만든 뒤 두령에게 압박을 모으십시오.', + roles: ['관우: 중앙 길목 전열', '장비: 남쪽 궁병 압박', '유비: 후방 지휘와 회복'], + events: { + 'village-approach': { + title: '마을 어귀 접근', + lines: ['마을 근처까지 전열이 올라왔습니다.', '주변 황건병을 몰아내면 보급 확보 보상이 붙습니다.'] + }, + 'vanguard-broken': { + title: '초기 선봉 정리', + lines: ['앞쪽 황건 선봉이 무너졌습니다.', '관우와 장비를 갈라 중앙과 남쪽을 동시에 밀어붙이세요.'] + }, + 'cavalry-approach': { + title: '기병 접근', + lines: ['황건 기병이 빠르게 다가옵니다.', '유비를 뒤에 두고 숲과 마을 지형에서 받아내십시오.'] + } + } + }, sortie: { sortieLimit: 3, requiredUnits: ['liu-bei'], @@ -502,6 +540,30 @@ export const secondBattleScenario: BattleScenarioDefinition = { '중앙 나루를 건너기 전에 길목의 습격조를 먼저 정리하면 북쪽 마을을 안전하게 확보할 수 있습니다.', '세 형제를 모두 출전시키고 14턴 이내에 승리하면 추격전 명성과 추가 보상이 붙습니다.' ], + tacticalGuide: { + summary: '관우가 나루 앞 숲길을 막고, 장비가 남쪽 우회로를 압박하며, 유비는 후방에서 마을 확보를 지휘합니다.', + route: '시작 구역 → 나루 길목 → 궁병 엄호선 → 북쪽 마을', + focus: '중앙 습격조를 정리한 뒤 궁병 엄호를 끊고 북쪽 마을과 두령을 차례로 압박하십시오.', + roles: ['관우: 나루 전열', '장비: 남쪽 돌파', '유비: 마을 확보 지휘'], + events: { + 'village-approach': { + title: '북쪽 마을 접근', + lines: ['잔당이 붙잡은 마을이 보입니다.', '마을 주변의 적을 걷어내면 추격전 보상이 안정됩니다.'] + }, + 'vanguard-broken': { + title: '나루 길목 돌파', + lines: ['초기 습격조가 흩어졌습니다.', '장비는 궁병 쪽을 압박하고 관우는 두령 친위병을 묶으십시오.'] + }, + 'cavalry-approach': { + title: '잔당 기병 접근', + lines: ['달아나던 기병이 되돌아옵니다.', '나루를 넘기 전에는 유비를 노출시키지 마십시오.'] + }, + 'enemy-posture': { + title: '잔당 재정렬', + lines: ['궁병은 마을 쪽에서 엄호하고 기병은 측면을 노립니다.', '중앙을 무리하게 벌리지 말고 한 줄씩 밀어내세요.'] + } + } + }, sortie: { sortieLimit: 3, requiredUnits: ['liu-bei'], @@ -583,6 +645,30 @@ export const thirdBattleScenario: BattleScenarioDefinition = { '강가 길목과 요새 주변의 궁병을 방치하면 전선이 오래 묶입니다. 지형 보정을 살펴 진군하십시오.', '18턴 이내에 승리하면 광종 구원로 확보 명성이 오릅니다.' ], + tacticalGuide: { + summary: '관우가 강가 요새 앞을 받아내고, 장비가 전령 퇴로를 압박하며, 유비는 뒤에서 전열 붕괴를 막습니다.', + route: '시작 구역 → 강가 길목 → 요새 궁병 → 전령 마원', + focus: '궁병 엄호선을 오래 방치하지 말고, 전령 마원이 도망치기 전에 좁은 길을 빠르게 밀어야 합니다.', + roles: ['관우: 강가 전열', '장비: 전령 추격', '유비: 후방 지휘와 회복'], + events: { + 'village-approach': { + title: '강가 길목 진입', + lines: ['강가 길목에 닿았습니다.', '요새 궁병을 오래 두면 전열이 묶이니 빠르게 압박하십시오.'] + }, + 'vanguard-broken': { + title: '척후선 붕괴', + lines: ['전령 앞 척후선이 무너졌습니다.', '이제 장비가 좁은 길을 열고 관우가 요새 수비병을 붙잡아야 합니다.'] + }, + 'cavalry-approach': { + title: '전령 호위 기병', + lines: ['전령 호위 기병이 압박해옵니다.', '관우로 받아내고 장비는 마원 쪽 길을 놓치지 마십시오.'] + }, + 'enemy-posture': { + title: '요새 방어 태세', + lines: ['궁병은 요새에서 버티고 기병은 좁은 길을 흔듭니다.', '피해가 쌓이면 유비를 뒤로 물려 회복 여지를 남기세요.'] + } + } + }, sortie: { sortieLimit: 3, requiredUnits: ['liu-bei'], diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 411cbbb..74b57b0 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -7,7 +7,8 @@ import { getBattleScenario, type BattleCampaignRewardDefinition, type BattleObjectiveDefinition, - type BattleScenarioDefinition + type BattleScenarioDefinition, + type BattleTacticalGuideEventKey } from '../data/battles'; import { getTerrainRule, getUnitClass, type TerrainType, type UnitClassKey } from '../data/battleRules'; import { @@ -8466,10 +8467,8 @@ export class BattleScene extends Phaser.Scene { if (objective.kind === 'secure-terrain') { const terrain = (objective.terrain ?? 'village') as TerrainType; - const secured = - terrain === 'village' - ? !this.hasEnemyThreateningTerrain(terrain, 2) - : !this.hasEnemyOnTerrain(terrain); + const allyHolding = this.isAnyAllyNearTerrain(terrain, 1); + const secured = allyHolding && !this.hasEnemyThreateningTerrain(terrain, 2); const terrainLabel = terrain === 'village' ? '마을 주변' : terrain === 'camp' ? '군영/수용소' : terrain === 'fort' ? '요새' : '목표 지형'; const achieved = outcome ? outcome === 'victory' && secured : secured; return { @@ -8477,7 +8476,7 @@ export class BattleScene extends Phaser.Scene { label: objective.label, achieved, status: achieved ? 'done' : outcome === 'defeat' ? 'failed' : 'active', - detail: secured ? `${terrainLabel} 안전` : `${terrainLabel} 교전 중`, + detail: secured ? `${terrainLabel} 안전` : allyHolding ? `${terrainLabel} 교전 중` : `${terrainLabel} 미확보`, rewardGold: objective.rewardGold }; } @@ -10046,7 +10045,14 @@ export class BattleScene extends Phaser.Scene { } private showOpeningBattleEvent() { - this.triggerBattleEvent('opening', '전투 목표', battleScenario.openingObjectiveLines); + const guide = battleScenario.tacticalGuide; + const lines = guide ? [guide.summary, `진군: ${guide.route}`, guide.focus] : battleScenario.openingObjectiveLines; + this.triggerBattleEvent('opening', '작전 목표', lines); + } + + private triggerTacticalEvent(key: BattleTacticalGuideEventKey, fallbackTitle: string, fallbackLines: string[]) { + const event = battleScenario.tacticalGuide?.events?.[key]; + this.triggerBattleEvent(key, event?.title ?? fallbackTitle, event?.lines ?? fallbackLines); } private triggerBattleEvent(key: string, title: string, lines: string[]) { @@ -10064,8 +10070,9 @@ export class BattleScene extends Phaser.Scene { private showBattleEventBanner(title: string, lines: string[]) { this.hideBattleEventBanner(); - const width = 520; - const height = 106; + const bodyLines = lines.slice(0, 2).map((line) => this.truncateBattleLogText(line, 42)); + const width = 540; + const height = Phaser.Math.Clamp(80 + bodyLines.length * 22, 108, 126); const left = this.layout.gridX + Math.max(16, (this.layout.gridWidth - width) / 2); const top = this.layout.gridY + 18; const depth = 45; @@ -10086,12 +10093,11 @@ export class BattleScene extends Phaser.Scene { titleText.setDepth(depth + 1); this.battleEventObjects.push(titleText); - const body = this.add.text(left + 20, top + 45, lines.join('\n'), { + const body = this.add.text(left + 20, top + 45, bodyLines.join('\n'), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: '#d4dce6', - lineSpacing: 4, - wordWrap: { width: width - 40, useAdvancedWrap: true } + lineSpacing: 5 }); body.setDepth(depth + 1); this.battleEventObjects.push(body); @@ -10118,49 +10124,76 @@ export class BattleScene extends Phaser.Scene { private checkBattleEvents() { const leader = battleUnits.find((unit) => unit.id === leaderUnitId); if (leader && leader.hp > 0 && leader.hp <= Math.ceil(leader.maxHp / 2)) { - this.triggerBattleEvent('leader-wavering', '두령 동요', [`${leader.name}의 기세가 꺾였습니다.`, '두령을 몰아붙이면 황건적의 전열이 무너집니다.']); + this.triggerTacticalEvent('leader-wavering', '두령 동요', [`${leader.name}의 기세가 꺾였습니다.`, '두령을 몰아붙이면 황건적의 전열이 무너집니다.']); } const endangeredAlly = this.endangeredAlly(); if (endangeredAlly) { - this.triggerBattleEvent('ally-danger', '아군 위기', [ + this.triggerTacticalEvent('ally-danger', '아군 위기', [ `${endangeredAlly.name}의 병력이 크게 줄었습니다.`, '유비가 퇴각하면 패배합니다. 마을과 성채 지형을 이용해 전열을 다시 잡으세요.' ]); } - if (this.isAnyAllyNearTerrain('village', 2)) { - this.triggerBattleEvent('village-approach', '마을 어귀', [ - '마을 주변에 황건적이 몰려 있습니다.', - '마을의 적을 몰아내면 승리 보상이 추가됩니다.' + const objectiveTerrain = this.tacticalObjectiveTerrain(); + const objectiveTerrainLabel = this.tacticalTerrainLabel(objectiveTerrain); + if (this.isAnyAllyNearTerrain(objectiveTerrain, 2)) { + this.triggerTacticalEvent('village-approach', `${objectiveTerrainLabel} 접근`, [ + `${objectiveTerrainLabel} 주변에 적이 버티고 있습니다.`, + '주변의 적을 몰아내면 승리 보상이 추가됩니다.' ]); } - if (!this.hasEnemyThreateningVillage()) { - this.triggerBattleEvent('village-secured', '마을 확보', ['마을 주변의 황건적을 몰아냈습니다.', '승리 시 마을 확보 보상이 추가됩니다.']); + if (this.triggeredBattleEvents.has('village-approach') && !this.hasEnemyThreateningTerrain(objectiveTerrain, 2)) { + this.triggerTacticalEvent('village-secured', `${objectiveTerrainLabel} 확보`, [`${objectiveTerrainLabel} 주변의 적을 몰아냈습니다.`, '승리 시 목표 확보 보상이 추가됩니다.']); } - if (this.areUnitsDefeated(['rebel-a', 'rebel-b', 'rebel-c'])) { - this.triggerBattleEvent('vanguard-broken', '선봉 격파', [ + const vanguardUnitIds = this.tacticalVanguardUnitIds(); + if (vanguardUnitIds.length > 0 && this.areUnitsDefeated(vanguardUnitIds)) { + this.triggerTacticalEvent('vanguard-broken', '선봉 격파', [ '남쪽 길목의 황건 선봉이 무너졌습니다.', '이제 중앙 마을과 기병의 움직임을 경계하세요.' ]); } if (this.isEnemyClassNearAlly('cavalry', 6)) { - this.triggerBattleEvent('cavalry-approach', '기병 접근', [ + this.triggerTacticalEvent('cavalry-approach', '기병 접근', [ '황건 기병이 빠르게 압박해옵니다.', '위험 범위를 켜고 숲이나 마을 쪽으로 전선을 잡으세요.' ]); } if (this.turnNumber === 2 && this.activeFaction === 'ally') { - this.triggerBattleEvent('enemy-posture', '적 전술 변화', ['기병은 계속 접근하고, 궁병은 자리를 지키며 사격합니다.', '숲과 마을 지형을 이용해 피해를 줄이세요.']); + this.triggerTacticalEvent('enemy-posture', '적 전술 변화', ['기병은 계속 접근하고, 궁병은 자리를 지키며 사격합니다.', '숲과 마을 지형을 이용해 피해를 줄이세요.']); } } - private hasEnemyThreateningVillage() { - return this.hasEnemyThreateningTerrain('village', 2); + private tacticalVanguardUnitIds() { + if (battleScenario.id === 'second-battle-yellow-turban-pursuit') { + return ['pursuit-raider-a', 'pursuit-raider-b', 'pursuit-bandit-a']; + } + if (battleScenario.id === 'third-battle-guangzong-road') { + return ['guangzong-scout-a', 'guangzong-bandit-a', 'guangzong-archer-a']; + } + return ['rebel-a', 'rebel-b', 'rebel-c']; + } + + private tacticalObjectiveTerrain(): TerrainType { + const terrain = battleScenario.objectives.find((objective) => objective.kind === 'secure-terrain')?.terrain; + if (terrain === 'camp' || terrain === 'fort' || terrain === 'village') { + return terrain; + } + return 'village'; + } + + private tacticalTerrainLabel(terrain: TerrainType) { + if (terrain === 'fort') { + return '요새'; + } + if (terrain === 'camp') { + return '진영'; + } + return '마을'; } private hasEnemyThreateningTerrain(terrain: TerrainType, distance: number) { @@ -10169,12 +10202,6 @@ export class BattleScene extends Phaser.Scene { }); } - private hasEnemyOnTerrain(terrain: TerrainType) { - return battleUnits.some((unit) => { - return unit.faction === 'enemy' && unit.hp > 0 && battleMap.terrain[unit.y][unit.x] === terrain; - }); - } - private isAnyAllyNearTerrain(terrain: TerrainType, distance: number) { return battleUnits.some((unit) => { return unit.faction === 'ally' && unit.hp > 0 && this.isUnitNearTerrain(unit, terrain, distance); @@ -15027,6 +15054,9 @@ export class BattleScene extends Phaser.Scene { if (text.includes('대기') || text.includes('턴') || text.includes('차례')) { return { icon: 'leadership', tone: palette.gold }; } + if (text.includes('작전') || text.includes('목표') || text.includes('진군') || text.includes('전술')) { + return { icon: 'leadership', tone: palette.gold }; + } if (text.includes('공격') || text.includes('피해') || text.includes('→')) { return { icon: 'sword', tone: 0xd8b15f }; } @@ -15202,15 +15232,18 @@ export class BattleScene extends Phaser.Scene { return; } - this.trackSideObject(this.add.text(left, messageTop, '목표 현황', { + const guideHeight = this.renderTacticalGuideCard(left, messageTop, width); + const objectiveTop = messageTop + (guideHeight > 0 ? guideHeight + 12 : 0); + + this.trackSideObject(this.add.text(left, objectiveTop, '목표 현황', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#f2e3bf', fontStyle: '700' })); - const objectiveRows = Math.max(1, Math.floor((logTop - (messageTop + 26) - 8) / 28)); + const objectiveRows = Math.max(1, Math.floor((logTop - (objectiveTop + 26) - 8) / 28)); objectiveStates.slice(0, objectiveRows).forEach((objective, index) => { - this.renderObjectiveStateRow(objective, left, messageTop + 26 + index * 28, width); + this.renderObjectiveStateRow(objective, left, objectiveTop + 26 + index * 28, width); }); this.renderRecentActionLogPanel(left, logTop, width, logHeight); } @@ -15223,6 +15256,53 @@ export class BattleScene extends Phaser.Scene { .join('\n'); } + private renderTacticalGuideCard(x: number, y: number, width: number) { + const guide = battleScenario.tacticalGuide; + if (!guide) { + return 0; + } + + const height = 96; + const bg = this.trackSideObject(this.add.rectangle(x, y, width, height, 0x101820, 0.92)); + bg.setOrigin(0); + bg.setDepth(30); + bg.setStrokeStyle(1, palette.gold, 0.5); + + const title = this.trackSideObject(this.add.text(x + 12, y + 8, '작전 흐름', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '15px', + color: '#f2e3bf', + fontStyle: '700' + })); + title.setDepth(31); + + const route = this.trackSideObject(this.add.text(x + 12, y + 31, this.truncateBattleLogText(`진군: ${guide.route}`, 34), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: '#d8b15f', + fontStyle: '700' + })); + route.setDepth(31); + + const focus = this.trackSideObject(this.add.text(x + 12, y + 52, this.truncateBattleLogText(guide.focus, 34), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: '#d4dce6', + maxLines: 1 + })); + focus.setDepth(31); + + const roles = this.trackSideObject(this.add.text(x + 12, y + 74, this.truncateBattleLogText(guide.roles.join(' · '), 36), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '11px', + color: '#9fb0bf', + maxLines: 1 + })); + roles.setDepth(31); + + return height; + } + private updateObjectiveTracker() { if (!this.objectiveTrackerText || !this.objectiveTrackerSubText) { return; @@ -15240,9 +15320,10 @@ export class BattleScene extends Phaser.Scene { this.battleOutcome === 'defeat' ? `패배 조건 발생: ${battleScenario.defeatConditionLabel}` : `패배 조건: ${battleScenario.defeatConditionLabel}`; + const routeText = battleScenario.tacticalGuide?.route; this.objectiveTrackerText.setText(victoryText); - this.objectiveTrackerSubText.setText(`${defeatText}${quickText}`); + this.objectiveTrackerSubText.setText(routeText ? `진군: ${routeText}\n${defeatText}${quickText}` : `${defeatText}${quickText}`); } private renderObjectiveStateRow(objective: BattleObjectiveState, x: number, y: number, width: number) {