Connect battle objectives to play feedback
This commit is contained in:
@@ -619,7 +619,9 @@ export const secondBattleScenario: BattleScenarioDefinition = {
|
|||||||
kind: 'secure-terrain',
|
kind: 'secure-terrain',
|
||||||
label: '북쪽 마을 확보',
|
label: '북쪽 마을 확보',
|
||||||
rewardGold: 180,
|
rewardGold: 180,
|
||||||
terrain: 'village'
|
terrain: 'village',
|
||||||
|
targetTile: { x: 19, y: 5, radius: 2 },
|
||||||
|
threatRadius: 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'quick',
|
id: 'quick',
|
||||||
|
|||||||
@@ -8539,6 +8539,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
this.hideBattleAlert();
|
this.hideBattleAlert();
|
||||||
this.hideBattleEventBanner();
|
this.hideBattleEventBanner();
|
||||||
this.refreshUnitLegibilityStyles();
|
this.refreshUnitLegibilityStyles();
|
||||||
|
this.checkObjectiveOutcomeEvents(outcome);
|
||||||
this.updateObjectiveTracker();
|
this.updateObjectiveTracker();
|
||||||
|
|
||||||
const message =
|
const message =
|
||||||
@@ -8939,20 +8940,21 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
const rowY = y + 42 + index * 21;
|
const rowY = y + 42 + index * 21;
|
||||||
const category = this.objectiveCategoryLabel(objective.category);
|
const category = this.objectiveCategoryLabel(objective.category);
|
||||||
const mark = objective.achieved ? objective.summary || '완료' : objective.failureReason ? '미달' : '미획득';
|
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 detail = objective.achieved ? objective.detail : objective.failureReason ?? objective.detail;
|
||||||
const line = this.trackResultObject(this.add.text(x + 16, rowY, `${category} · ${mark} ${objective.label} · ${detail}`, {
|
const line = this.trackResultObject(this.add.text(x + 16, rowY, `${category} · ${mark} ${objective.label} · ${detail}`, {
|
||||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
color,
|
color,
|
||||||
wordWrap: { width: width - 98, useAdvancedWrap: true }
|
wordWrap: { width: width - 136, useAdvancedWrap: true }
|
||||||
}));
|
}));
|
||||||
line.setDepth(depth + 1);
|
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',
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
color: objective.achieved ? '#d8b15f' : '#6f7a84',
|
color: objective.achieved ? '#d8b15f' : '#ffb6a6',
|
||||||
fontStyle: '700'
|
fontStyle: '700'
|
||||||
}));
|
}));
|
||||||
reward.setOrigin(1, 0);
|
reward.setOrigin(1, 0);
|
||||||
@@ -10437,19 +10439,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const secureObjective = this.tacticalSecureObjective();
|
this.checkObjectiveFlowEvents();
|
||||||
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} 주변의 적을 몰아냈습니다.`, '승리 시 목표 확보 보상이 추가됩니다.']);
|
|
||||||
}
|
|
||||||
|
|
||||||
const vanguardUnitIds = this.tacticalVanguardUnitIds();
|
const vanguardUnitIds = this.tacticalVanguardUnitIds();
|
||||||
if (vanguardUnitIds.length > 0 && this.areUnitsDefeated(vanguardUnitIds)) {
|
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() {
|
private tacticalVanguardUnitIds() {
|
||||||
if (battleScenario.id === 'second-battle-yellow-turban-pursuit') {
|
if (battleScenario.id === 'second-battle-yellow-turban-pursuit') {
|
||||||
return ['pursuit-raider-a', 'pursuit-raider-b', 'pursuit-bandit-a'];
|
return ['pursuit-raider-a', 'pursuit-raider-b', 'pursuit-bandit-a'];
|
||||||
@@ -10482,11 +10548,8 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private tacticalObjectiveTerrain(): TerrainType {
|
private tacticalObjectiveTerrain(): TerrainType {
|
||||||
const terrain = this.tacticalSecureObjective()?.terrain;
|
const objective = this.tacticalSecureObjective();
|
||||||
if (terrain === 'camp' || terrain === 'fort' || terrain === 'village') {
|
return objective ? this.objectiveTerrain(objective) : 'village';
|
||||||
return terrain;
|
|
||||||
}
|
|
||||||
return 'village';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private tacticalSecureObjective() {
|
private tacticalSecureObjective() {
|
||||||
@@ -10536,6 +10599,15 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
return this.isAnyAllyNearTerrain(terrain, distance);
|
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) {
|
private isAnyAllyNearTile(x: number, y: number, distance: number) {
|
||||||
return battleUnits.some((unit) => {
|
return battleUnits.some((unit) => {
|
||||||
return unit.faction === 'ally' && unit.hp > 0 && this.tileDistanceTo(unit.x, unit.y, x, y) <= distance;
|
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) {
|
private chooseEnemyTarget(enemy: UnitData, behavior: EnemyAiBehavior) {
|
||||||
|
const objectiveTarget = this.chooseObjectiveDefenseTarget(enemy, behavior);
|
||||||
|
if (objectiveTarget) {
|
||||||
|
return objectiveTarget;
|
||||||
|
}
|
||||||
|
|
||||||
if (behavior === 'hold') {
|
if (behavior === 'hold') {
|
||||||
return this.chooseTargetInRange(enemy);
|
return this.chooseTargetInRange(enemy);
|
||||||
}
|
}
|
||||||
@@ -11378,6 +11455,46 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
return nearest;
|
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) {
|
private chooseTargetInRange(enemy: UnitData) {
|
||||||
return battleUnits
|
return battleUnits
|
||||||
.filter((unit) => unit.faction === 'ally' && unit.hp > 0 && this.canAttack(enemy, unit))
|
.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 = {}) {
|
private showTurnEndPrompt(message?: string, options: TurnEndPromptOptions = {}) {
|
||||||
this.hideTurnEndPrompt();
|
this.hideTurnEndPrompt();
|
||||||
this.turnPromptMode = options.mode ?? 'turn-end';
|
this.turnPromptMode = options.mode ?? 'turn-end';
|
||||||
@@ -15432,6 +15616,12 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
|
|
||||||
private battleLogVisual(entry: string): { icon: BattleUiIconKey; tone: number } {
|
private battleLogVisual(entry: string): { icon: BattleUiIconKey; tone: number } {
|
||||||
const { text } = this.battleLogDisplayParts(entry);
|
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('지속 피해')) {
|
if (text.includes('화상') || text.includes('지속 피해')) {
|
||||||
return { icon: 'fire', tone: 0xd8732c };
|
return { icon: 'fire', tone: 0xd8732c };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user