Improve battle target selection feedback

This commit is contained in:
2026-07-02 22:02:04 +09:00
parent 4979740abf
commit 2eb7258065

View File

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