Connect battle objectives to play feedback

This commit is contained in:
2026-07-04 13:12:31 +09:00
parent c1e53e0b94
commit 119a0425fb
2 changed files with 215 additions and 23 deletions

View File

@@ -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',

View File

@@ -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 };
}