Add tactical battle objective guidance
This commit is contained in:
@@ -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<Record<BattleTacticalGuideEventKey, { title: string; lines: string[] }>>;
|
||||
};
|
||||
|
||||
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'],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user