diff --git a/scripts/qa-representative-battles.mjs b/scripts/qa-representative-battles.mjs index a9ac3b8..382cf12 100644 --- a/scripts/qa-representative-battles.mjs +++ b/scripts/qa-representative-battles.mjs @@ -1446,7 +1446,11 @@ async function playBattleWithoutDebugVictory(page, battle) { id: objective.id, achieved: objective.achieved, status: objective.status, - detail: objective.detail + category: objective.category, + summary: objective.summary, + detail: objective.detail, + failureReason: objective.failureReason ?? '', + targetTile: objective.targetTile ?? null })); metrics.protectedStatus = protectedUnitIds.map((unitId) => { const unit = finalState.units.find((candidate) => candidate.id === unitId); diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index d936bd9..b44b80f 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1226,6 +1226,10 @@ type BattleObjectiveResult = { achieved: boolean; status: 'active' | 'done' | 'failed'; detail: string; + category: 'primary' | 'required' | 'bonus'; + summary: string; + failureReason?: string; + targetTile?: { x: number; y: number; radius?: number }; rewardGold: number; }; @@ -3036,6 +3040,7 @@ export class BattleScene extends Phaser.Scene { private objectiveTrackerText?: Phaser.GameObjects.Text; private objectiveTrackerSubText?: Phaser.GameObjects.Text; private markers: Phaser.GameObjects.Rectangle[] = []; + private objectiveMarkerObjects: Phaser.GameObjects.GameObject[] = []; private rosterTab: RosterTab = 'ally'; private sidePanelObjects: Phaser.GameObjects.GameObject[] = []; private commandButtons: CommandButton[] = []; @@ -3202,6 +3207,7 @@ export class BattleScene extends Phaser.Scene { private drawBattleScene() { this.drawMap(); + this.renderObjectiveMapMarkers(); this.drawUnits(); this.drawSidePanel(); this.drawDebugSpritePreview(); @@ -3987,6 +3993,7 @@ export class BattleScene extends Phaser.Scene { } }); + this.positionObjectiveMapMarkers(); battleUnits.forEach((unit) => this.positionUnitView(unit)); this.positionDeploymentOverlayObjects(); this.updateMiniMap(); @@ -4042,6 +4049,87 @@ export class BattleScene extends Phaser.Scene { object.setVisible(this.isTileVisible(x, y)); } + private renderObjectiveMapMarkers() { + this.clearObjectiveMapMarkers(); + battleScenario.objectives + .filter((objective) => objective.kind === 'secure-terrain' && objective.targetTile) + .forEach((objective) => { + const target = objective.targetTile; + if (!target) { + return; + } + + const marker = this.trackObjectiveMapMarker( + this.add.rectangle(this.tileTopLeftX(target.x), this.tileTopLeftY(target.y), this.layout.tileSize, this.layout.tileSize, palette.gold, 0.07) + ); + marker.setName(`objective-marker-${objective.id}`); + marker.setData('tileX', target.x); + marker.setData('tileY', target.y); + marker.setOrigin(0); + marker.setStrokeStyle(2, palette.gold, 0.68); + marker.setDepth(4.86); + if (this.mapMask) { + marker.setMask(this.mapMask); + } + + const label = this.trackObjectiveMapMarker( + this.add.text(this.tileCenterX(target.x), this.tileTopLeftY(target.y) + 4, '목표', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '10px', + color: '#fff1c8', + fontStyle: '700', + stroke: '#05070a', + strokeThickness: 3 + }) + ); + label.setName(`objective-label-${objective.id}`); + label.setData('tileX', target.x); + label.setData('tileY', target.y); + label.setData('tileOffsetX', this.layout.tileSize / 2); + label.setData('tileOffsetY', 4); + label.setOrigin(0.5, 0); + label.setDepth(4.92); + if (this.mapMask) { + label.setMask(this.mapMask); + } + }); + this.positionObjectiveMapMarkers(); + } + + private clearObjectiveMapMarkers() { + this.objectiveMarkerObjects.forEach((object) => object.destroy()); + this.objectiveMarkerObjects = []; + } + + private trackObjectiveMapMarker(object: T) { + this.objectiveMarkerObjects.push(object); + return object; + } + + private positionObjectiveMapMarkers() { + this.objectiveMarkerObjects.forEach((object) => { + const tileX = object.getData('tileX') as number | undefined; + const tileY = object.getData('tileY') as number | undefined; + if (tileX === undefined || tileY === undefined) { + return; + } + + const offsetX = (object.getData('tileOffsetX') as number | undefined) ?? 0; + const offsetY = (object.getData('tileOffsetY') as number | undefined) ?? 0; + const overlay = object as Phaser.GameObjects.GameObject & { + setPosition: (x: number, y: number) => Phaser.GameObjects.GameObject; + setVisible: (value: boolean) => Phaser.GameObjects.GameObject; + input?: { enabled: boolean }; + }; + const visible = this.isTileVisible(tileX, tileY); + overlay.setPosition(this.tileTopLeftX(tileX) + offsetX, this.tileTopLeftY(tileY) + offsetY); + overlay.setVisible(visible); + if (overlay.input) { + overlay.input.enabled = visible; + } + }); + } + private positionUnitView(unit: UnitData) { const view = this.unitViews.get(unit.id); if (!view) { @@ -5175,7 +5263,7 @@ export class BattleScene extends Phaser.Scene { const top = this.sideContentTop(); const selected = this.deploymentSelectedUnit(); - const header = this.trackSideObject(this.add.rectangle(left, top, width, 92, 0x101820, 0.96)); + const header = this.trackSideObject(this.add.rectangle(left, top, width, 124, 0x101820, 0.96)); header.setOrigin(0); header.setStrokeStyle(1, palette.gold, 0.78); this.trackSideObject(this.add.text(left + 16, top + 13, '전열 배치', { @@ -5184,14 +5272,16 @@ export class BattleScene extends Phaser.Scene { color: '#f4dfad', fontStyle: '700' })); - this.trackSideObject(this.add.text(left + 16, top + 48, this.deploymentSubtitle(), { + this.trackSideObject(this.add.text(left + 16, top + 47, this.deploymentSubtitle(), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '13px', + fontSize: '12px', color: '#c8d2dd', - wordWrap: { width: width - 32, useAdvancedWrap: true } + wordWrap: { width: width - 32, useAdvancedWrap: true }, + maxLines: 2 })); + this.renderDeploymentObjectiveBrief(left + 16, top + 82, width - 32); - const noticeTop = top + 104; + const noticeTop = top + 136; const notice = this.trackSideObject(this.add.rectangle(left, noticeTop, width, 54, 0x0b1118, 0.94)); notice.setOrigin(0); notice.setStrokeStyle(1, selected ? palette.blue : 0x647485, selected ? 0.74 : 0.56); @@ -5242,6 +5332,34 @@ export class BattleScene extends Phaser.Scene { this.renderDeploymentButton(left, panelY + panelHeight - 116, width, '추천 배치 복원', 0x647485, () => this.restoreRecommendedDeployment()); } + private renderDeploymentObjectiveBrief(x: number, y: number, width: number) { + const primary = battleScenario.objectives + .filter((objective) => this.objectiveCategory(objective) !== 'bonus') + .map((objective) => `${this.objectiveCategoryLabel(this.objectiveCategory(objective))}: ${objective.label}`) + .join(' / '); + const bonus = battleScenario.objectives + .filter((objective) => this.objectiveCategory(objective) === 'bonus') + .map((objective) => objective.label) + .join(' / '); + + const primaryText = this.trackSideObject(this.add.text(x, y, this.truncateBattleLogText(primary, 34), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: '#f4dfad', + fontStyle: '700', + fixedWidth: width + })); + primaryText.setDepth(32); + + const bonusText = this.trackSideObject(this.add.text(x, y + 18, bonus ? this.truncateBattleLogText(`보조: ${bonus}`, 36) : '보조: 전장 상황에 맞춰 추가 공훈을 노립니다.', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '11px', + color: '#9fb0bf', + fixedWidth: width + })); + bonusText.setDepth(32); + } + private deploymentRoleSummaries() { const recommendationByUnitId = new Map((battleScenario.sortie?.recommendedUnits ?? []).map((entry) => [entry.unitId, entry])); const orderByUnitId = new Map(this.deploymentSlots().filter((slot) => slot.unitId).map((slot, index) => [slot.unitId, index])); @@ -8551,6 +8669,26 @@ export class BattleScene extends Phaser.Scene { this.resultSettlementText = undefined; } + private objectiveCategory(objective: BattleObjectiveDefinition): BattleObjectiveResult['category'] { + if (objective.kind === 'defeat-leader' || objective.kind === 'pacify-leader') { + return 'primary'; + } + if (objective.kind === 'keep-unit-alive') { + return 'required'; + } + return 'bonus'; + } + + private objectiveCategoryLabel(category: BattleObjectiveResult['category']) { + if (category === 'primary') { + return '주 목표'; + } + if (category === 'required') { + return '필수'; + } + return '보조'; + } + private resultObjectives(outcome: BattleOutcome): BattleObjectiveResult[] { return this.objectiveStates(outcome).map((objective) => ({ id: objective.id, @@ -8558,6 +8696,10 @@ export class BattleScene extends Phaser.Scene { achieved: objective.achieved, status: objective.status, detail: objective.detail, + category: objective.category, + summary: objective.summary, + failureReason: objective.failureReason, + targetTile: objective.targetTile ? { ...objective.targetTile } : undefined, rewardGold: objective.rewardGold })); } @@ -8567,6 +8709,8 @@ export class BattleScene extends Phaser.Scene { } private objectiveState(objective: BattleObjectiveDefinition, outcome?: BattleOutcome): BattleObjectiveState { + const category = this.objectiveCategory(objective); + if (objective.kind === 'defeat-leader' || objective.kind === 'pacify-leader') { const targetId = objective.unitId ?? leaderUnitId; const target = battleUnits.find((unit) => unit.id === targetId); @@ -8579,6 +8723,10 @@ export class BattleScene extends Phaser.Scene { achieved, status: achieved ? 'done' : outcome === 'defeat' ? 'failed' : 'active', detail: defeated ? `${this.unitName(targetId)} ${doneText}` : `${this.unitName(targetId)} 병력 ${target?.hp ?? 0}/${target?.maxHp ?? 0}`, + category, + summary: defeated ? '완료' : '진행', + failureReason: outcome === 'defeat' && !defeated ? `${this.unitName(targetId)} 미격파` : undefined, + targetTile: target ? { x: target.x, y: target.y, radius: 0 } : undefined, rewardGold: objective.rewardGold }; } @@ -8592,6 +8740,9 @@ export class BattleScene extends Phaser.Scene { achieved: alive, status: alive ? (outcome === 'victory' ? 'done' : 'active') : 'failed', detail: alive ? `${this.unitName(targetId)} 생존` : `${this.unitName(targetId)} 퇴각`, + category, + summary: alive ? '유지' : '실패', + failureReason: alive ? undefined : `${this.unitName(targetId)} 퇴각`, rewardGold: objective.rewardGold }; } @@ -8600,15 +8751,28 @@ export class BattleScene extends Phaser.Scene { const terrain = (objective.terrain ?? 'village') as TerrainType; const allyHolding = this.isObjectiveTileHeld(objective, terrain, 1); const enemiesAlive = battleUnits.some((unit) => unit.faction === 'enemy' && unit.hp > 0); - const secured = (allyHolding || !enemiesAlive) && !this.hasEnemyThreateningObjective(objective, terrain, 2); + const threateningEnemies = this.enemiesThreateningObjective(objective, terrain, 2); + const secured = (allyHolding || !enemiesAlive) && threateningEnemies.length === 0; const terrainLabel = terrain === 'village' ? '마을 주변' : terrain === 'camp' ? '군영/수용소' : terrain === 'fort' ? '요새' : '목표 지형'; const achieved = outcome ? outcome === 'victory' && secured : secured; + const summary = secured ? '확보' : allyHolding ? '교전 중' : '미확보'; + const failureReason = achieved + ? undefined + : threateningEnemies.length > 0 + ? `${terrainLabel} 적 ${threateningEnemies.length}명 잔존` + : allyHolding + ? `${terrainLabel} 교전 지속` + : `${terrainLabel}에 아군 미도달`; return { id: objective.id, label: objective.label, achieved, - status: achieved ? 'done' : outcome === 'defeat' ? 'failed' : 'active', - detail: secured ? `${terrainLabel} 안전` : allyHolding ? `${terrainLabel} 교전 중` : `${terrainLabel} 미확보`, + status: achieved ? 'done' : outcome ? 'failed' : 'active', + detail: secured ? `${terrainLabel} 확보` : allyHolding ? `${terrainLabel} 교전 중` : `${terrainLabel} 미확보`, + category, + summary, + failureReason: outcome ? failureReason : undefined, + targetTile: objective.targetTile ? { ...objective.targetTile } : undefined, rewardGold: objective.rewardGold }; } @@ -8622,6 +8786,9 @@ export class BattleScene extends Phaser.Scene { achieved, status: achieved ? (outcome === 'victory' ? 'done' : 'active') : 'failed', detail: inTime ? (outcome === 'victory' ? `${this.turnNumber}/${maxTurn}턴 달성` : `${this.turnNumber}/${maxTurn}턴 진행 중`) : `${this.turnNumber}/${maxTurn}턴 초과`, + category, + summary: inTime ? (outcome === 'victory' ? '달성' : '가능') : '초과', + failureReason: outcome && !inTime ? `${this.turnNumber - maxTurn}턴 초과` : undefined, rewardGold: objective.rewardGold }; } @@ -8770,9 +8937,11 @@ export class BattleScene extends Phaser.Scene { objectives.forEach((objective, index) => { const rowY = y + 42 + index * 21; - const mark = objective.achieved ? '완료' : objective.status === 'failed' ? '미획득' : '보너스'; + const category = this.objectiveCategoryLabel(objective.category); + const mark = objective.achieved ? objective.summary || '완료' : objective.failureReason ? '미달' : '미획득'; const color = objective.achieved ? '#a8ffd0' : '#aeb7c2'; - const line = this.trackResultObject(this.add.text(x + 16, rowY, `${mark} ${objective.label} · ${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}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color, @@ -10334,19 +10503,22 @@ export class BattleScene extends Phaser.Scene { return '마을'; } - private hasEnemyThreateningTerrain(terrain: TerrainType, distance: number) { - return battleUnits.some((unit) => { - return unit.faction === 'enemy' && unit.hp > 0 && this.isUnitNearTerrain(unit, terrain, distance); - }); + private hasEnemyThreateningObjective(objective: BattleObjectiveDefinition | undefined, terrain: TerrainType, distance: number) { + return this.enemiesThreateningObjective(objective, terrain, distance).length > 0; } - private hasEnemyThreateningObjective(objective: BattleObjectiveDefinition | undefined, terrain: TerrainType, distance: number) { + private enemiesThreateningObjective(objective: BattleObjectiveDefinition | undefined, terrain: TerrainType, distance: number) { const target = objective?.targetTile; + const threatDistance = objective?.threatRadius ?? distance; if (target) { - return this.isEnemyNearTile(target.x, target.y, objective.threatRadius ?? distance); + return battleUnits.filter((unit) => { + return unit.faction === 'enemy' && unit.hp > 0 && this.tileDistanceTo(unit.x, unit.y, target.x, target.y) <= threatDistance; + }); } - return this.hasEnemyThreateningTerrain(terrain, objective?.threatRadius ?? distance); + return battleUnits.filter((unit) => { + return unit.faction === 'enemy' && unit.hp > 0 && this.isUnitNearTerrain(unit, terrain, threatDistance); + }); } private isAnyAllyNearTerrain(terrain: TerrainType, distance: number) { @@ -10370,12 +10542,6 @@ export class BattleScene extends Phaser.Scene { }); } - private isEnemyNearTile(x: number, y: number, distance: number) { - return battleUnits.some((unit) => { - return unit.faction === 'enemy' && unit.hp > 0 && this.tileDistanceTo(unit.x, unit.y, x, y) <= distance; - }); - } - private isUnitNearTerrain(unit: UnitData, terrain: TerrainType, distance: number) { return battleMap.terrain.some((row, y) => { return row.some((tile, x) => tile === terrain && this.tileDistanceTo(unit.x, unit.y, x, y) <= distance); @@ -15480,16 +15646,7 @@ export class BattleScene extends Phaser.Scene { 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 - (objectiveTop + 26) - 8) / 28)); - objectiveStates.slice(0, objectiveRows).forEach((objective, index) => { - this.renderObjectiveStateRow(objective, left, objectiveTop + 26 + index * 28, width); - }); + this.renderObjectiveStateGroups(objectiveStates, left, objectiveTop, width, Math.max(72, logTop - objectiveTop - 8)); this.renderRecentActionLogPanel(left, logTop, width, logHeight); } @@ -15555,8 +15712,11 @@ export class BattleScene extends Phaser.Scene { const leader = battleUnits.find((unit) => unit.id === leaderUnitId); const leaderProgress = leader && leader.hp > 0 ? `${leader.hp}/${leader.maxHp}` : '완료'; - const quickObjective = this.objectiveStates(this.battleOutcome).find((objective) => objective.id === 'quick'); - const quickText = quickObjective ? ` · 보너스: ${quickObjective.label} ${this.objectiveStatusText(quickObjective)}` : ''; + const bonusObjectives = this.objectiveStates(this.battleOutcome).filter((objective) => objective.category === 'bonus'); + const bonusSummary = bonusObjectives.map((objective) => `${objective.label} ${this.objectiveStatusText(objective)}`).join(' / '); + const bonusText = bonusObjectives.length > 0 + ? ` · 보조: ${this.truncateBattleLogText(bonusSummary, 38)}` + : ''; const victoryText = this.battleOutcome === 'victory' ? `승리 목표 완료: ${battleScenario.victoryConditionLabel}` @@ -15568,48 +15728,103 @@ export class BattleScene extends Phaser.Scene { const routeText = battleScenario.tacticalGuide?.route; this.objectiveTrackerText.setText(victoryText); - this.objectiveTrackerSubText.setText(routeText ? `진군: ${routeText}\n${defeatText}${quickText}` : `${defeatText}${quickText}`); + this.objectiveTrackerSubText.setText(routeText ? `진군: ${routeText}\n${defeatText}${bonusText}` : `${defeatText}${bonusText}`); + } + + private renderObjectiveStateGroups(objectives: BattleObjectiveState[], x: number, y: number, width: number, maxHeight: number) { + const groups = [ + { + label: '핵심 목표', + objectives: objectives.filter((objective) => objective.category !== 'bonus') + }, + { + label: '보조 공훈', + objectives: objectives.filter((objective) => objective.category === 'bonus') + } + ].filter((group) => group.objectives.length > 0); + let cursorY = y; + let remaining = maxHeight; + const headerHeight = 22; + const rowHeight = 36; + + groups.forEach((group, groupIndex) => { + if (remaining < headerHeight + rowHeight) { + return; + } + + const title = this.trackSideObject(this.add.text(x, cursorY, group.label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '16px', + color: groupIndex === 0 ? '#f2e3bf' : '#d8b15f', + fontStyle: '700' + })); + title.setDepth(31); + cursorY += headerHeight; + remaining -= headerHeight; + + group.objectives.forEach((objective) => { + if (remaining < rowHeight) { + return; + } + this.renderObjectiveStateRow(objective, x, cursorY, width); + cursorY += rowHeight; + remaining -= rowHeight; + }); + + cursorY += 5; + remaining -= 5; + }); } private renderObjectiveStateRow(objective: BattleObjectiveState, x: number, y: number, width: number) { - const bg = this.trackSideObject(this.add.rectangle(x, y, width, 25, 0x101820, 0.86)); + const bg = this.trackSideObject(this.add.rectangle(x, y, width, 32, 0x101820, 0.88)); bg.setOrigin(0); bg.setStrokeStyle(1, this.objectiveStatusStroke(objective), 0.5); - this.trackSideObject(this.add.text(x + 10, y + 5, this.objectiveStatusText(objective), { + const categoryColor = objective.category === 'primary' ? palette.gold : objective.category === 'required' ? palette.red : palette.blue; + const categoryBg = this.trackSideObject(this.add.rectangle(x + 28, y + 16, 48, 20, 0x0a0f14, 0.92)); + categoryBg.setStrokeStyle(1, categoryColor, 0.58); + + const categoryText = this.trackSideObject(this.add.text(x + 28, y + 16, this.objectiveCategoryLabel(objective.category), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '10px', + color: objective.category === 'bonus' ? '#b9d7ff' : '#f4dfad', + fontStyle: '700' + })); + categoryText.setOrigin(0.5); + + this.trackSideObject(this.add.text(x + 62, y + 4, objective.label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: '#d4dce6', + fontStyle: '700', + fixedWidth: width - 136 + })); + + this.trackSideObject(this.add.text(x + 62, y + 18, this.truncateBattleLogText(objective.failureReason ?? objective.detail, 28), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '10px', + color: objective.failureReason ? '#ffb6a6' : '#9fb0bf', + fixedWidth: width - 136 + })); + + const status = this.trackSideObject(this.add.text(x + width - 10, y + 16, this.objectiveStatusText(objective), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: this.objectiveStatusColor(objective), fontStyle: '700' })); - this.trackSideObject(this.add.text(x + 54, y + 4, objective.label, { - fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '13px', - color: '#d4dce6', - fontStyle: '700' - })); - const detail = this.trackSideObject(this.add.text(x + width - 10, y + 4, objective.detail, { - fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '12px', - color: '#9fb0bf' - })); - detail.setOrigin(1, 0); + status.setOrigin(1, 0.5); } private objectiveStatusText(objective: BattleObjectiveState) { if (objective.status === 'done') { - return '완료'; + return objective.summary || '완료'; } if (objective.status === 'failed') { - return '실패'; + return objective.failureReason ? '미달' : objective.summary || '실패'; } - if (objective.id === 'liu-bei') { - return '유지'; - } - if (objective.id === 'quick') { - return '가능'; - } - return '진행'; + return objective.summary || '진행'; } private objectiveStatusColor(objective: BattleObjectiveState) { @@ -15619,6 +15834,12 @@ export class BattleScene extends Phaser.Scene { if (objective.status === 'failed') { return '#ffb6a6'; } + if (objective.summary === '교전 중') { + return '#ffd78a'; + } + if (objective.summary === '미확보') { + return '#9fb0bf'; + } return '#f4dfad'; } diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index beec0cc..11d7ba5 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -6,7 +6,12 @@ export type BattleObjectiveSnapshot = { id: string; label: string; achieved: boolean; + status?: 'active' | 'done' | 'failed'; detail: string; + category?: 'primary' | 'required' | 'bonus'; + summary?: string; + failureReason?: string; + targetTile?: { x: number; y: number; radius?: number }; rewardGold: number; };