Add tactical battle objective guidance

This commit is contained in:
2026-07-04 03:08:58 +09:00
parent d95954519a
commit 51ee60dc03
2 changed files with 203 additions and 36 deletions

View File

@@ -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) {