Connect battle objectives to play feedback
This commit is contained in:
@@ -619,7 +619,9 @@ export const secondBattleScenario: BattleScenarioDefinition = {
|
||||
kind: 'secure-terrain',
|
||||
label: '북쪽 마을 확보',
|
||||
rewardGold: 180,
|
||||
terrain: 'village'
|
||||
terrain: 'village',
|
||||
targetTile: { x: 19, y: 5, radius: 2 },
|
||||
threatRadius: 1
|
||||
},
|
||||
{
|
||||
id: 'quick',
|
||||
|
||||
@@ -8539,6 +8539,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.hideBattleAlert();
|
||||
this.hideBattleEventBanner();
|
||||
this.refreshUnitLegibilityStyles();
|
||||
this.checkObjectiveOutcomeEvents(outcome);
|
||||
this.updateObjectiveTracker();
|
||||
|
||||
const message =
|
||||
@@ -8939,20 +8940,21 @@ export class BattleScene extends Phaser.Scene {
|
||||
const rowY = y + 42 + index * 21;
|
||||
const category = this.objectiveCategoryLabel(objective.category);
|
||||
const mark = objective.achieved ? objective.summary || '완료' : objective.failureReason ? '미달' : '미획득';
|
||||
const color = objective.achieved ? '#a8ffd0' : '#aeb7c2';
|
||||
const color = objective.achieved ? '#a8ffd0' : '#ffb6a6';
|
||||
const detail = objective.achieved ? objective.detail : objective.failureReason ?? objective.detail;
|
||||
const line = this.trackResultObject(this.add.text(x + 16, rowY, `${category} · ${mark} ${objective.label} · ${detail}`, {
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: '13px',
|
||||
color,
|
||||
wordWrap: { width: width - 98, useAdvancedWrap: true }
|
||||
wordWrap: { width: width - 136, useAdvancedWrap: true }
|
||||
}));
|
||||
line.setDepth(depth + 1);
|
||||
|
||||
const reward = this.trackResultObject(this.add.text(x + width - 16, rowY, objective.achieved ? `+${objective.rewardGold}` : '-', {
|
||||
const rewardText = objective.achieved ? `+${objective.rewardGold}` : objective.rewardGold > 0 ? `미획득 +${objective.rewardGold}` : '미획득';
|
||||
const reward = this.trackResultObject(this.add.text(x + width - 16, rowY, rewardText, {
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: '13px',
|
||||
color: objective.achieved ? '#d8b15f' : '#6f7a84',
|
||||
color: objective.achieved ? '#d8b15f' : '#ffb6a6',
|
||||
fontStyle: '700'
|
||||
}));
|
||||
reward.setOrigin(1, 0);
|
||||
@@ -10437,19 +10439,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
]);
|
||||
}
|
||||
|
||||
const secureObjective = this.tacticalSecureObjective();
|
||||
const objectiveTerrain = this.tacticalObjectiveTerrain();
|
||||
const objectiveTerrainLabel = this.tacticalTerrainLabel(objectiveTerrain);
|
||||
if (this.isObjectiveTileHeld(secureObjective, objectiveTerrain, 2)) {
|
||||
this.triggerTacticalEvent('village-approach', `${objectiveTerrainLabel} 접근`, [
|
||||
`${objectiveTerrainLabel} 주변에 적이 버티고 있습니다.`,
|
||||
'주변의 적을 몰아내면 승리 보상이 추가됩니다.'
|
||||
]);
|
||||
}
|
||||
|
||||
if (this.triggeredBattleEvents.has('village-approach') && !this.hasEnemyThreateningObjective(secureObjective, objectiveTerrain, 2)) {
|
||||
this.triggerTacticalEvent('village-secured', `${objectiveTerrainLabel} 확보`, [`${objectiveTerrainLabel} 주변의 적을 몰아냈습니다.`, '승리 시 목표 확보 보상이 추가됩니다.']);
|
||||
}
|
||||
this.checkObjectiveFlowEvents();
|
||||
|
||||
const vanguardUnitIds = this.tacticalVanguardUnitIds();
|
||||
if (vanguardUnitIds.length > 0 && this.areUnitsDefeated(vanguardUnitIds)) {
|
||||
@@ -10471,6 +10461,82 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
}
|
||||
|
||||
private checkObjectiveFlowEvents(outcome?: BattleOutcome) {
|
||||
battleScenario.objectives.forEach((objective) => {
|
||||
const state = this.objectiveState(objective, outcome);
|
||||
if (objective.kind === 'secure-terrain') {
|
||||
this.checkSecureObjectiveApproachEvent(objective, state);
|
||||
}
|
||||
if (state.status === 'done') {
|
||||
this.triggerObjectiveAchievedFeedback(objective, state);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private checkObjectiveOutcomeEvents(outcome: BattleOutcome) {
|
||||
this.checkObjectiveFlowEvents(outcome);
|
||||
this.objectiveStates(outcome)
|
||||
.filter((objective) => objective.status === 'failed')
|
||||
.forEach((objective) => this.triggerObjectiveFailedFeedback(objective));
|
||||
}
|
||||
|
||||
private checkSecureObjectiveApproachEvent(objective: BattleObjectiveDefinition, state: BattleObjectiveState) {
|
||||
if (state.status !== 'active') {
|
||||
return;
|
||||
}
|
||||
|
||||
const terrain = this.objectiveTerrain(objective);
|
||||
const approachDistance = objective.targetTile?.radius ?? 2;
|
||||
if (!this.isObjectiveTileHeld(objective, terrain, approachDistance)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const event = battleScenario.tacticalGuide?.events?.['village-approach'];
|
||||
const terrainLabel = this.tacticalTerrainLabel(terrain);
|
||||
const title = event?.title ?? `${terrainLabel} 접근`;
|
||||
const guardCount = this.enemiesThreateningObjective(objective, terrain, 2).length;
|
||||
const lines = event?.lines ?? [
|
||||
`${terrainLabel} 주변에 적이 버티고 있습니다.`,
|
||||
guardCount > 0 ? `주변 방어 병력 ${guardCount}명을 몰아내면 보급 보상이 붙습니다.` : '주변을 정리하면 승리 보상이 추가됩니다.'
|
||||
];
|
||||
this.triggerBattleEvent(this.objectiveEventKey(objective.id, 'approach'), title, lines);
|
||||
}
|
||||
|
||||
private triggerObjectiveAchievedFeedback(objective: BattleObjectiveDefinition, state: BattleObjectiveState) {
|
||||
const key = this.objectiveEventKey(objective.id, 'achieved');
|
||||
if (this.triggeredBattleEvents.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.triggeredBattleEvents.add(key);
|
||||
const rewardText = objective.rewardGold > 0 ? `보상 +${objective.rewardGold}` : '보상 없음';
|
||||
this.pushBattleLog(`목표 달성: ${state.label}\n${state.detail} · ${rewardText}`);
|
||||
this.showObjectiveMapFeedback(objective, state, [`목표 달성`, objective.rewardGold > 0 ? `+${objective.rewardGold}` : '완료'], '#a8ffd0', '#072416');
|
||||
}
|
||||
|
||||
private triggerObjectiveFailedFeedback(objective: BattleObjectiveState) {
|
||||
const key = this.objectiveEventKey(objective.id, 'failed');
|
||||
if (this.triggeredBattleEvents.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.triggeredBattleEvents.add(key);
|
||||
const rewardText = objective.rewardGold > 0 ? `보상 미획득 +${objective.rewardGold}` : '보상 없음';
|
||||
this.pushBattleLog(`목표 미달: ${objective.label}\n${objective.failureReason ?? objective.detail} · ${rewardText}`);
|
||||
}
|
||||
|
||||
private objectiveEventKey(objectiveId: string, state: 'approach' | 'achieved' | 'failed') {
|
||||
return `objective-${objectiveId}-${state}`;
|
||||
}
|
||||
|
||||
private objectiveTerrain(objective: BattleObjectiveDefinition): TerrainType {
|
||||
const terrain = objective.terrain;
|
||||
if (terrain === 'camp' || terrain === 'fort' || terrain === 'village') {
|
||||
return terrain;
|
||||
}
|
||||
return 'village';
|
||||
}
|
||||
|
||||
private tacticalVanguardUnitIds() {
|
||||
if (battleScenario.id === 'second-battle-yellow-turban-pursuit') {
|
||||
return ['pursuit-raider-a', 'pursuit-raider-b', 'pursuit-bandit-a'];
|
||||
@@ -10482,11 +10548,8 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private tacticalObjectiveTerrain(): TerrainType {
|
||||
const terrain = this.tacticalSecureObjective()?.terrain;
|
||||
if (terrain === 'camp' || terrain === 'fort' || terrain === 'village') {
|
||||
return terrain;
|
||||
}
|
||||
return 'village';
|
||||
const objective = this.tacticalSecureObjective();
|
||||
return objective ? this.objectiveTerrain(objective) : 'village';
|
||||
}
|
||||
|
||||
private tacticalSecureObjective() {
|
||||
@@ -10536,6 +10599,15 @@ export class BattleScene extends Phaser.Scene {
|
||||
return this.isAnyAllyNearTerrain(terrain, distance);
|
||||
}
|
||||
|
||||
private isUnitNearObjective(unit: UnitData, objective: BattleObjectiveDefinition | undefined, terrain: TerrainType, distance: number) {
|
||||
const target = objective?.targetTile;
|
||||
if (target) {
|
||||
return this.tileDistanceTo(unit.x, unit.y, target.x, target.y) <= (target.radius ?? distance);
|
||||
}
|
||||
|
||||
return this.isUnitNearTerrain(unit, terrain, distance);
|
||||
}
|
||||
|
||||
private isAnyAllyNearTile(x: number, y: number, distance: number) {
|
||||
return battleUnits.some((unit) => {
|
||||
return unit.faction === 'ally' && unit.hp > 0 && this.tileDistanceTo(unit.x, unit.y, x, y) <= distance;
|
||||
@@ -11358,6 +11430,11 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private chooseEnemyTarget(enemy: UnitData, behavior: EnemyAiBehavior) {
|
||||
const objectiveTarget = this.chooseObjectiveDefenseTarget(enemy, behavior);
|
||||
if (objectiveTarget) {
|
||||
return objectiveTarget;
|
||||
}
|
||||
|
||||
if (behavior === 'hold') {
|
||||
return this.chooseTargetInRange(enemy);
|
||||
}
|
||||
@@ -11378,6 +11455,46 @@ export class BattleScene extends Phaser.Scene {
|
||||
return nearest;
|
||||
}
|
||||
|
||||
private chooseObjectiveDefenseTarget(enemy: UnitData, behavior: EnemyAiBehavior) {
|
||||
if (behavior === 'aggressive') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const objective = this.tacticalSecureObjective();
|
||||
if (!objective) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const terrain = this.objectiveTerrain(objective);
|
||||
if (!this.isObjectiveDefenseUnit(enemy, objective, terrain)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const objectiveRadius = (objective.targetTile?.radius ?? 2) + 1;
|
||||
const candidates = battleUnits
|
||||
.filter((unit) => unit.faction === 'ally' && unit.hp > 0 && this.isUnitNearObjective(unit, objective, terrain, objectiveRadius))
|
||||
.sort((a, b) => this.tileDistance(enemy, a) - this.tileDistance(enemy, b) || a.hp - b.hp);
|
||||
const inRange = candidates.find((unit) => this.canAttack(enemy, unit));
|
||||
if (inRange) {
|
||||
return inRange;
|
||||
}
|
||||
|
||||
return behavior === 'guard' ? candidates[0] : undefined;
|
||||
}
|
||||
|
||||
private isObjectiveDefenseUnit(enemy: UnitData, objective: BattleObjectiveDefinition, terrain: TerrainType) {
|
||||
if (enemy.faction !== 'enemy' || enemy.hp <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const target = objective.targetTile;
|
||||
if (target) {
|
||||
return this.tileDistanceTo(enemy.x, enemy.y, target.x, target.y) <= Math.max(2, (objective.threatRadius ?? 1) + 3);
|
||||
}
|
||||
|
||||
return this.isUnitNearTerrain(enemy, terrain, 2);
|
||||
}
|
||||
|
||||
private chooseTargetInRange(enemy: UnitData) {
|
||||
return battleUnits
|
||||
.filter((unit) => unit.faction === 'ally' && unit.hp > 0 && this.canAttack(enemy, unit))
|
||||
@@ -15297,6 +15414,73 @@ export class BattleScene extends Phaser.Scene {
|
||||
});
|
||||
}
|
||||
|
||||
private showObjectiveMapFeedback(
|
||||
objective: BattleObjectiveDefinition,
|
||||
state: BattleObjectiveState,
|
||||
lines: string[],
|
||||
color: string,
|
||||
stroke: string
|
||||
) {
|
||||
const anchor = this.objectiveFeedbackAnchor(objective, state);
|
||||
if (!anchor || !this.isTileVisible(anchor.x, anchor.y)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const popup = this.add.text(
|
||||
this.tileCenterX(anchor.x),
|
||||
this.tileTopLeftY(anchor.y) + this.layout.tileSize * 0.28,
|
||||
lines.filter(Boolean).join('\n'),
|
||||
{
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: '17px',
|
||||
color,
|
||||
stroke,
|
||||
strokeThickness: 4,
|
||||
fontStyle: '700',
|
||||
align: 'center',
|
||||
lineSpacing: -2
|
||||
}
|
||||
);
|
||||
popup.setOrigin(0.5, 1);
|
||||
popup.setDepth(35);
|
||||
if (this.mapMask) {
|
||||
popup.setMask(this.mapMask);
|
||||
}
|
||||
this.tweens.add({
|
||||
targets: popup,
|
||||
y: popup.y - 26,
|
||||
alpha: 0,
|
||||
duration: 880,
|
||||
delay: 80,
|
||||
ease: 'Sine.easeOut',
|
||||
onComplete: () => popup.destroy()
|
||||
});
|
||||
}
|
||||
|
||||
private objectiveFeedbackAnchor(objective: BattleObjectiveDefinition, state: BattleObjectiveState) {
|
||||
if (state.targetTile) {
|
||||
return { x: state.targetTile.x, y: state.targetTile.y };
|
||||
}
|
||||
|
||||
const targetId = objective.unitId ?? (objective.kind === 'defeat-leader' || objective.kind === 'pacify-leader' ? leaderUnitId : undefined);
|
||||
if (targetId) {
|
||||
const unit = battleUnits.find((candidate) => candidate.id === targetId);
|
||||
if (unit) {
|
||||
return { x: unit.x, y: unit.y };
|
||||
}
|
||||
}
|
||||
|
||||
if (objective.kind === 'secure-terrain') {
|
||||
const terrain = this.objectiveTerrain(objective);
|
||||
const holder = battleUnits.find((unit) => unit.faction === 'ally' && unit.hp > 0 && this.isUnitNearObjective(unit, objective, terrain, 2));
|
||||
if (holder) {
|
||||
return { x: holder.x, y: holder.y };
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private showTurnEndPrompt(message?: string, options: TurnEndPromptOptions = {}) {
|
||||
this.hideTurnEndPrompt();
|
||||
this.turnPromptMode = options.mode ?? 'turn-end';
|
||||
@@ -15432,6 +15616,12 @@ export class BattleScene extends Phaser.Scene {
|
||||
|
||||
private battleLogVisual(entry: string): { icon: BattleUiIconKey; tone: number } {
|
||||
const { text } = this.battleLogDisplayParts(entry);
|
||||
if (text.includes('목표 미달') || text.includes('보상 미획득')) {
|
||||
return { icon: 'failure', tone: 0xb86b55 };
|
||||
}
|
||||
if (text.includes('목표 달성') || text.includes('확보 완료')) {
|
||||
return { icon: 'success', tone: 0x59d18c };
|
||||
}
|
||||
if (text.includes('화상') || text.includes('지속 피해')) {
|
||||
return { icon: 'fire', tone: 0xd8732c };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user