From 2eb7258065aa056a2ee21e22996d2c76bbdad3a5 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Thu, 2 Jul 2026 22:02:04 +0900 Subject: [PATCH] Improve battle target selection feedback --- src/game/scenes/BattleScene.ts | 285 ++++++++++++++++++++++++++++++--- 1 file changed, 263 insertions(+), 22 deletions(-) diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index a697bd0..09f321c 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -3670,18 +3670,16 @@ export class BattleScene extends Phaser.Scene { void this.tryResolveSupportTarget(this.selectedUnit, unit, this.selectedUsable); return; } - if (unit.faction !== this.selectedUnit.faction) { - void this.tryResolveDamageTarget(this.selectedUnit, unit, this.targetingAction, this.selectedUsable); - return; - } - } - - if (this.selectedUsable) { - this.renderUnitDetail(this.selectedUnit, `${this.selectedUsable.name} 대상으로 삼을 부대를 선택하세요. 우클릭하면 명령 선택으로 돌아갑니다.`); + void this.tryResolveDamageTarget(this.selectedUnit, unit, this.targetingAction, this.selectedUsable); return; } - this.renderUnitDetail(this.selectedUnit, '대상으로 삼을 적 부대를 선택하세요. 우클릭하면 명령 선택으로 돌아갑니다.'); + if (this.selectedUsable) { + this.renderUnitDetail(this.selectedUnit, `${this.selectedUsable.name} 대상이 아닙니다. 표시된 부대를 선택하세요. 우클릭하면 명령 선택으로 돌아갑니다.`); + return; + } + + this.renderUnitDetail(this.selectedUnit, '공격 대상이 아닙니다. 붉게 표시된 적 부대를 선택하세요. 우클릭하면 명령 선택으로 돌아갑니다.'); return; } @@ -4194,6 +4192,7 @@ export class BattleScene extends Phaser.Scene { return; } if (choicePointer.leftButtonDown()) { + this.suppressNextLeftClick = true; this.hideTurnEndPrompt(); if (command === 'strategy' || command === 'item') { this.showUsableMenu(unit, command, choicePointer.x, choicePointer.y); @@ -4351,10 +4350,20 @@ export class BattleScene extends Phaser.Scene { return [ `${usable.name} / ${this.usableChannelLabel(usable)} / ${this.usableEffectLabel(usable)} / ${this.usableTargetLabel(usable)}`, `${this.usablePowerLabel(usable)} · 사거리 ${usable.range} · 대상 ${targetCount}명`, - usable.description + targetCount > 0 ? usable.description : `사용 불가: ${this.usableUnavailableReason(user, usable)}` ].join('\n'); } + private usableUnavailableReason(user: UnitData, usable: BattleUsable) { + if (usable.command === 'item' && this.itemStock(user.id, usable.id) <= 0) { + return '보유 수량이 없습니다.'; + } + if (usable.effect === 'damage') { + return this.damageTargetUnavailableReason(user, usable.command, usable); + } + return this.supportTargetUnavailableReason(user, usable); + } + private renderUsableMenuPill(x: number, y: number, width: number, icon: BattleUiIconKey, label: string, tone: number) { const pill = this.add.rectangle(x, y, width, 26, 0x0b1118, 0.92); pill.setOrigin(0); @@ -4423,10 +4432,12 @@ export class BattleScene extends Phaser.Scene { usables.forEach((usable, index) => { const rowTop = top + padding + titleHeight + index * rowHeight; const accent = this.usableAccentColor(usable); + const targetCount = this.usableTargetCount(unit, usable); + const isAvailable = targetCount > 0 && (usable.command !== 'item' || this.itemStock(unit.id, usable.id) > 0); const bg = this.add.rectangle(left + 8, rowTop, menuWidth - 16, rowHeight - 7, 0x1a2630, 0.95); bg.setOrigin(0); bg.setDepth(15); - bg.setStrokeStyle(1, accent, 0.68); + bg.setStrokeStyle(1, isAvailable ? accent : 0x6c7480, isAvailable ? 0.68 : 0.52); bg.setInteractive({ useHandCursor: true }); this.commandMenuObjects.push(bg); @@ -4462,11 +4473,13 @@ export class BattleScene extends Phaser.Scene { const summary = this.add.text( left + 68, rowTop + 34, - `${this.usableChannelLabel(usable)} · ${this.usableEffectLabel(usable)} · ${this.usableTargetLabel(usable)} · 대상 ${this.usableTargetCount(unit, usable)}명`, + targetCount > 0 + ? `${this.usableChannelLabel(usable)} · ${this.usableEffectLabel(usable)} · ${this.usableTargetLabel(usable)} · 대상 ${targetCount}명` + : `대상 0명 · ${this.usableUnavailableReason(unit, usable)}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', - color: '#aeb7c2', + color: targetCount > 0 ? '#aeb7c2' : '#ffcf9a', fixedWidth: menuWidth - 88 } ); @@ -4483,11 +4496,12 @@ export class BattleScene extends Phaser.Scene { return; } if (choicePointer.leftButtonDown()) { + this.suppressNextLeftClick = true; this.beginUsableTargeting(unit, usable); } }; bg.on('pointerover', () => { - bg.setFillStyle(0x283947, 0.98); + bg.setFillStyle(isAvailable ? 0x283947 : 0x27303a, 0.98); this.renderUnitDetail(unit, this.usableDetailText(unit, usable)); }); bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.94)); @@ -4604,6 +4618,190 @@ export class BattleScene extends Phaser.Scene { this.finishUnitAction(unit, this.commandResultMessage(unit, command)); } + private actionTargetingLabel(action: DamageCommand, usable?: BattleUsable) { + return usable?.name ?? commandLabels[action]; + } + + private damageTargetCandidates(attacker: UnitData) { + return battleUnits.filter((target) => target.faction !== attacker.faction && target.hp > 0); + } + + private supportTargetCandidates(user: UnitData, usable: BattleUsable) { + return battleUnits.filter((target) => { + if (target.hp <= 0) { + return false; + } + if (usable.target === 'self') { + return target.id === user.id; + } + if (usable.target === 'ally') { + return target.faction === user.faction; + } + return target.faction !== user.faction; + }); + } + + private nearestUnitDistance(origin: UnitData, candidates: UnitData[]) { + return candidates + .map((target) => ({ target, distance: this.tileDistance(origin, target) })) + .sort((a, b) => a.distance - b.distance)[0]; + } + + private damageTargetUnavailableReason(attacker: UnitData, action: DamageCommand, usable?: BattleUsable) { + const candidates = this.damageTargetCandidates(attacker); + if (candidates.length === 0) { + return '대상으로 삼을 적군이 없습니다.'; + } + + const range = this.actionRange(attacker, action, usable); + const nearest = this.nearestUnitDistance(attacker, candidates); + if (nearest && nearest.distance > range) { + return `${nearest.target.name}까지 ${nearest.distance}칸 / 사거리 ${range}칸입니다.`; + } + return '사거리 안에 실행 가능한 적군이 없습니다.'; + } + + private supportTargetUnavailableReason(user: UnitData, usable: BattleUsable) { + const candidates = this.supportTargetCandidates(user, usable); + if (candidates.length === 0) { + return `${this.usableTargetLabel(usable)} 대상이 없습니다.`; + } + + const usableCandidates = usable.effect === 'heal' ? candidates.filter((target) => target.hp < target.maxHp) : candidates; + if (usableCandidates.length === 0) { + return `${this.usableTargetLabel(usable)}의 HP가 이미 가득합니다.`; + } + + const nearest = this.nearestUnitDistance(user, usableCandidates); + if (nearest && nearest.distance > usable.range) { + return `${nearest.target.name}까지 ${nearest.distance}칸 / 사거리 ${usable.range}칸입니다.`; + } + + return '현재 조건에서 선택 가능한 대상이 없습니다.'; + } + + private damageTargetFailureMessage(attacker: UnitData, target: UnitData, action: DamageCommand, usable?: BattleUsable) { + const label = this.actionTargetingLabel(action, usable); + if (target.hp <= 0) { + return `${label}: 이미 퇴각한 부대입니다.`; + } + if (attacker.faction === target.faction) { + return `${label}: 아군은 공격 대상이 아닙니다. 붉게 표시된 적군을 선택하세요.`; + } + + const range = this.actionRange(attacker, action, usable); + const distance = this.tileDistance(attacker, target); + if (distance > range) { + return `${label}: ${target.name}은 사거리 밖입니다.\n현재 ${distance}칸 / 사거리 ${range}칸.`; + } + + return `${label}: 선택할 수 없는 대상입니다. 붉게 표시된 적군을 선택하세요.`; + } + + private supportTargetFailureMessage(user: UnitData, target: UnitData, usable: BattleUsable) { + if (target.hp <= 0) { + return `${usable.name}: 이미 퇴각한 부대입니다.`; + } + if (usable.target === 'self' && target.id !== user.id) { + return `${usable.name}: 자신에게만 사용할 수 있습니다.`; + } + if (usable.target === 'ally' && target.faction !== user.faction) { + return `${usable.name}: 아군 대상 책략입니다. 푸르게 표시된 아군을 선택하세요.`; + } + if (usable.target === 'enemy' && target.faction === user.faction) { + return `${usable.name}: 적군 대상 책략입니다. 표시된 적군을 선택하세요.`; + } + if (usable.effect === 'heal' && target.hp >= target.maxHp) { + return `${usable.name}: ${target.name}의 HP가 이미 가득합니다.`; + } + + const distance = this.tileDistance(user, target); + if (distance > usable.range) { + return `${usable.name}: ${target.name}은 사거리 밖입니다.\n현재 ${distance}칸 / 사거리 ${usable.range}칸.`; + } + + return `${usable.name}: 선택할 수 없는 대상입니다. 표시된 대상을 선택하세요.`; + } + + private damageTargetingInstruction(attacker: UnitData, action: DamageCommand, targets: UnitData[], usable?: BattleUsable) { + const label = this.actionTargetingLabel(action, usable); + const range = this.actionRange(attacker, action, usable); + return [ + `${label} 대상 선택`, + `사거리 ${range}칸 · 가능 대상 ${targets.length}명`, + '옅은 칸은 사거리, 짙은 붉은 칸은 실행 가능한 적입니다.', + '우클릭하면 명령 선택으로 돌아갑니다.' + ].join('\n'); + } + + private supportTargetingInstruction(user: UnitData, usable: BattleUsable, targets: UnitData[]) { + return [ + `${usable.name} 대상 선택`, + `사거리 ${usable.range}칸 · 가능 대상 ${targets.length}명`, + '옅은 칸은 사거리, 밝게 표시된 칸은 실행 가능한 대상입니다.', + '우클릭하면 명령 선택으로 돌아갑니다.' + ].join('\n'); + } + + private showTargetingRangeTiles(unit: UnitData, range: number, fillColor: number, strokeColor: number) { + this.tilesWithinDistance(unit.x, unit.y, range).forEach(({ x, y }) => { + const marker = this.add.rectangle( + this.tileTopLeftX(x), + this.tileTopLeftY(y), + this.layout.tileSize, + this.layout.tileSize, + fillColor, + 0.08 + ); + marker.setData('tileX', x); + marker.setData('tileY', y); + marker.setOrigin(0); + marker.setStrokeStyle(1, strokeColor, 0.22); + marker.setDepth(4.72); + if (this.mapMask) { + marker.setMask(this.mapMask); + } + marker.setVisible(this.isTileVisible(x, y)); + this.markers.push(marker); + }); + } + + private showUnavailableTargetMarker(target: UnitData, tone: number) { + const marker = this.add.rectangle( + this.tileTopLeftX(target.x), + this.tileTopLeftY(target.y), + this.layout.tileSize, + this.layout.tileSize, + 0x0b1118, + 0.06 + ); + marker.setData('tileX', target.x); + marker.setData('tileY', target.y); + marker.setOrigin(0); + marker.setStrokeStyle(1, tone, 0.52); + marker.setDepth(5.02); + if (this.mapMask) { + marker.setMask(this.mapMask); + } + marker.setVisible(this.isTileVisible(target.x, target.y)); + this.markers.push(marker); + } + + private showUnavailableDamageTargetMarkers(attacker: UnitData, targets: UnitData[]) { + const validIds = new Set(targets.map((target) => target.id)); + this.damageTargetCandidates(attacker) + .filter((target) => !validIds.has(target.id)) + .forEach((target) => this.showUnavailableTargetMarker(target, 0xb76d62)); + } + + private showUnavailableSupportTargetMarkers(user: UnitData, usable: BattleUsable, targets: UnitData[]) { + const validIds = new Set(targets.map((target) => target.id)); + const tone = usable.effect === 'heal' ? 0x80cfa1 : 0x8ea9d9; + this.supportTargetCandidates(user, usable) + .filter((target) => !validIds.has(target.id)) + .forEach((target) => this.showUnavailableTargetMarker(target, tone)); + } + private beginDamageTargeting(unit: UnitData, action: DamageCommand, usable?: BattleUsable) { if (this.phase !== 'command' || this.selectedUnit?.id !== unit.id || this.actedUnitIds.has(unit.id)) { return; @@ -4611,7 +4809,10 @@ export class BattleScene extends Phaser.Scene { const targets = this.damageableTargets(unit, action, usable); if (targets.length === 0) { - this.renderUnitDetail(unit, `현재 위치에서 ${commandLabels[action]} 가능한 적이 없습니다. 다른 명령을 선택하거나 우클릭으로 이동을 취소하세요.`); + this.renderUnitDetail( + unit, + `현재 위치에서 ${this.actionTargetingLabel(action, usable)} 가능한 적이 없습니다.\n${this.damageTargetUnavailableReason(unit, action, usable)}\n다른 명령을 선택하거나 우클릭으로 이동을 취소하세요.` + ); return; } @@ -4621,7 +4822,9 @@ export class BattleScene extends Phaser.Scene { this.selectedUsable = usable; this.hideCommandMenu(); this.clearMarkers(); - this.renderUnitDetail(unit, `붉은 칸의 적을 선택하면 ${usable?.name ?? commandLabels[action]}합니다. 우클릭하면 명령 선택으로 돌아갑니다.`); + this.renderUnitDetail(unit, this.damageTargetingInstruction(unit, action, targets, usable)); + this.showTargetingRangeTiles(unit, this.actionRange(unit, action, usable), usable ? this.usableAccentColor(usable) : 0xc93b30, 0xffd27a); + this.showUnavailableDamageTargetMarkers(unit, targets); this.showDamageTargets(unit, action, targets, usable); soundDirector.playSelect(); } @@ -4639,8 +4842,7 @@ export class BattleScene extends Phaser.Scene { const targets = this.supportTargets(unit, usable); if (targets.length === 0) { - const reason = usable.effect === 'heal' ? '회복할 수 있는 아군이 없습니다.' : '대상으로 삼을 수 있는 아군이 없습니다.'; - this.renderUnitDetail(unit, `${usable.name}\n${reason}`); + this.renderUnitDetail(unit, `${usable.name}\n${this.supportTargetUnavailableReason(unit, usable)}`); return; } @@ -4650,7 +4852,9 @@ export class BattleScene extends Phaser.Scene { this.selectedUsable = usable; this.hideCommandMenu(); this.clearMarkers(); - this.renderUnitDetail(unit, `푸른 칸의 아군을 선택하면 ${usable.name}을 사용합니다. 우클릭하면 명령 선택으로 돌아갑니다.`); + this.renderUnitDetail(unit, this.supportTargetingInstruction(unit, usable, targets)); + this.showTargetingRangeTiles(unit, usable.range, usable.effect === 'heal' ? 0x45b875 : 0x4f86d9, usable.effect === 'heal' ? 0xb6ffd2 : 0xd9e8ff); + this.showUnavailableSupportTargetMarkers(unit, usable, targets); this.showSupportTargets(unit, usable, targets); soundDirector.playSelect(); } @@ -4773,7 +4977,7 @@ export class BattleScene extends Phaser.Scene { } if (!this.canUseDamageCommand(attacker, target, action, usable)) { - this.renderUnitDetail(attacker, '사거리 밖의 적입니다. 붉은 칸으로 표시된 적을 선택하세요.'); + this.renderUnitDetail(attacker, this.damageTargetFailureMessage(attacker, target, action, usable)); return; } @@ -4805,7 +5009,7 @@ export class BattleScene extends Phaser.Scene { } if (!this.canUseSupportCommand(user, target, usable)) { - this.renderUnitDetail(user, '사거리 밖의 대상입니다. 푸른 칸으로 표시된 대상을 선택하세요.'); + this.renderUnitDetail(user, this.supportTargetFailureMessage(user, target, usable)); return; } @@ -6975,6 +7179,10 @@ export class BattleScene extends Phaser.Scene { return; } + if (this.phase === 'targeting' && this.handleTargetingEmptyTileClick(pointer)) { + return; + } + if (this.phase !== 'idle' || !this.isPointerOnEmptyMapTile(pointer)) { return; } @@ -6996,6 +7204,39 @@ export class BattleScene extends Phaser.Scene { this.renderTerrainDetail(tile.x, tile.y); } + private handleTargetingEmptyTileClick(pointer: Phaser.Input.Pointer) { + if (!this.selectedUnit || !this.targetingAction || !this.isPointerOnEmptyMapTile(pointer)) { + return false; + } + + const tile = this.pointerToTile(pointer); + if (!tile) { + return false; + } + + const unit = this.selectedUnit; + const usable = this.selectedUsable; + const range = this.actionRange(unit, this.targetingAction, usable); + const distance = this.tileDistanceTo(unit.x, unit.y, tile.x, tile.y); + const label = this.actionTargetingLabel(this.targetingAction, usable); + const targetLabel = usable && usable.effect !== 'damage' ? this.usableTargetLabel(usable) : '적군'; + const markedLabel = usable && usable.effect !== 'damage' ? '밝게 표시된 대상' : '붉게 표시된 적군'; + + if (distance > range) { + this.renderUnitDetail( + unit, + `${label}: 사거리 밖의 빈 칸입니다.\n선택한 칸 ${distance}칸 / 사거리 ${range}칸.\n${markedLabel}을 선택하거나 우클릭으로 명령 선택으로 돌아가세요.` + ); + return true; + } + + this.renderUnitDetail( + unit, + `${label}: 사거리 안이지만 ${targetLabel} 대상이 없는 칸입니다.\n${markedLabel}을 선택하거나 우클릭으로 명령 선택으로 돌아가세요.` + ); + return true; + } + private isPointerOnEmptyMapTile(pointer: Phaser.Input.Pointer) { const tile = this.pointerToTile(pointer); if (!tile) { @@ -10039,7 +10280,7 @@ export class BattleScene extends Phaser.Scene { this.clearMarkers(); soundDirector.playSelect(); this.showCommandMenu(unit, view?.sprite.x ?? this.tileCenterX(unit.x), view?.sprite.y ?? this.tileCenterY(unit.y)); - this.renderUnitDetail(unit, '대상 선택을 취소했습니다. 명령을 다시 선택하세요.'); + this.renderUnitDetail(unit, '대상 선택을 취소했습니다.\n공격, 책략, 도구, 대기 중 하나를 다시 선택하세요.\n우클릭하면 이동 취소로 돌아갑니다.'); return true; }