From 119a0425fb3172616aaec9c2645ea6c4e17b068b Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sat, 4 Jul 2026 13:12:31 +0900 Subject: [PATCH] Connect battle objectives to play feedback --- src/game/data/battles.ts | 4 +- src/game/scenes/BattleScene.ts | 234 +++++++++++++++++++++++++++++---- 2 files changed, 215 insertions(+), 23 deletions(-) diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index d730698..fa75b4b 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -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', diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index b44b80f..71f521d 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -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 }; }