Improve battle targeting UX

This commit is contained in:
2026-07-03 05:46:50 +09:00
parent 4c3fd6d435
commit 6ff336969a

View File

@@ -1434,6 +1434,12 @@ type TargetingGuideCandidate = {
tone: number;
};
type TargetingGuideNotice = {
icon: BattleUiIconKey;
text: string;
tone: number;
};
type SavedUnitState = Pick<UnitData, 'id' | 'level' | 'exp' | 'hp' | 'maxHp' | 'attack' | 'move' | 'x' | 'y'> & {
equipment: UnitData['equipment'];
direction?: UnitDirection;
@@ -3230,10 +3236,55 @@ export class BattleScene extends Phaser.Scene {
);
battleUnits.splice(0, battleUnits.length, ...applySortieDeploymentPlan(nextUnits, deploymentPlan));
battleBonds.splice(0, battleBonds.length, ...initialBattleBonds.map((bond) => this.applyCampaignBondProgress(bond, campaign)));
this.applyDebugBattleSetup();
initialBattleUnits = battleUnits.map(cloneUnitData);
initialBattleBonds = battleBonds.map(cloneBattleBond);
}
private applyDebugBattleSetup() {
if (this.debugBattleSetupKey() !== 'attack-preview') {
return;
}
const attacker = battleUnits.find((unit) => unit.id === 'guan-yu' && unit.hp > 0) ?? battleUnits.find((unit) => unit.faction === 'ally' && unit.hp > 0);
const target = battleUnits.find((unit) => unit.id === 'rebel-a' && unit.hp > 0) ?? battleUnits.find((unit) => unit.faction === 'enemy' && unit.hp > 0);
if (!attacker || !target) {
return;
}
const targetTile = this.debugAdjacentOpenTile(attacker, target.id);
if (!targetTile) {
return;
}
target.x = targetTile.x;
target.y = targetTile.y;
target.hp = Math.max(1, target.hp);
}
private debugBattleSetupKey() {
if (!this.debugToolsEnabled() || typeof window === 'undefined') {
return undefined;
}
return new URLSearchParams(window.location.search).get('debugBattleSetup')?.trim();
}
private debugAdjacentOpenTile(origin: UnitData, movingUnitId: string) {
const offsets = [
{ x: 1, y: 0 },
{ x: 0, y: -1 },
{ x: 0, y: 1 },
{ x: -1, y: 0 },
{ x: 2, y: 0 },
{ x: 1, y: -1 },
{ x: 1, y: 1 }
];
return offsets
.map((offset) => ({ x: origin.x + offset.x, y: origin.y + offset.y }))
.find((tile) => this.isInBounds(tile.x, tile.y) && !this.isOccupied(tile.x, tile.y, movingUnitId));
}
private normalizeLaunchSortieUnitIds(unitIds?: string[]) {
return [...new Set((unitIds ?? []).map((unitId) => unitId.trim()).filter(Boolean))];
}
@@ -3909,11 +3960,11 @@ export class BattleScene extends Phaser.Scene {
}
if (this.selectedUsable) {
this.renderUnitDetail(this.selectedUnit, `${this.selectedUsable.name} 대상이 아닙니다. 표시된 부대를 선택하세요. 우클릭하면 명령 선택으로 돌아갑니다.`);
this.renderCurrentTargetingGuideNotice(`${this.selectedUsable.name}: 표시된 대상만 선택할 수 있습니다.`);
return;
}
this.renderUnitDetail(this.selectedUnit, '공격 대상이 아닙니다. 붉게 표시된 적 부대를 선택하세요. 우클릭하면 명령 선택으로 돌아갑니다.');
this.renderCurrentTargetingGuideNotice('공격 대상이 아닙니다. 붉게 표시된 적 부대를 선택하세요.');
return;
}
@@ -4402,7 +4453,7 @@ export class BattleScene extends Phaser.Scene {
return {
available: false,
status: '대상 없음',
detail: '현재 사거리 안 적 없음'
detail: this.truncateUiText(this.damageTargetUnavailableReason(unit, 'attack'), 24)
};
}
@@ -4433,7 +4484,7 @@ export class BattleScene extends Phaser.Scene {
detail:
availableCount > 0
? `${firstReady.usable.name} · ${firstReady.forecast.valueLabel} ${firstReady.forecast.value}`
: `${firstReady.usable.name} · ${firstReady.forecast.status}`
: `${firstReady.usable.name} · ${this.truncateUiText(firstReady.forecast.detail, 18)}`
};
}
@@ -5329,7 +5380,87 @@ export class BattleScene extends Phaser.Scene {
return `${usable.name}: 선택할 수 없는 대상입니다. 표시된 대상을 선택하세요.`;
}
private renderDamageTargetingGuide(unit: UnitData, action: DamageCommand, targets: UnitData[], usable?: BattleUsable) {
private targetingNearestMetric(origin: UnitData, candidates: UnitData[], range: number, tone: number): TargetingGuideMetric {
const nearest = this.nearestUnitDistance(origin, candidates);
if (!nearest) {
return { icon: 'move', label: '가장 가까움', value: '없음', tone };
}
return { icon: 'move', label: '가장 가까움', value: `${nearest.distance}/${range}`, tone };
}
private renderDamageTargetingUnavailable(unit: UnitData, action: DamageCommand, usable?: BattleUsable) {
const range = this.actionRange(unit, action, usable);
const label = this.actionTargetingLabel(action, usable);
const candidates = this.damageTargetCandidates(unit);
const accent = usable ? this.usableAccentColor(usable) : palette.gold;
this.renderTargetingUnavailableCard({
unit,
title: `${label} 대상 없음`,
subtitle: `${unit.name} · ${this.targetingActionTypeLabel(action, usable)}`,
icon: usable ? this.usableIcon(usable) : this.commandIcon('attack', unit),
accent,
reason: this.damageTargetUnavailableReason(unit, action, usable),
metrics: [
{ icon: 'move', label: '사거리', value: `${range}`, tone: accent },
{ icon: 'advantage', label: '후보', value: `${candidates.length}`, tone: 0xb64a45 },
this.targetingNearestMetric(unit, candidates, range, accent)
],
hint: '다른 명령을 고르거나, 우클릭으로 이동을 취소하세요.'
});
}
private renderSupportTargetingUnavailable(unit: UnitData, usable: BattleUsable) {
const candidates = this.supportTargetCandidates(unit, usable);
const accent = this.usableAccentColor(usable);
this.renderTargetingUnavailableCard({
unit,
title: `${usable.name} 대상 없음`,
subtitle: `${this.usableChannelLabel(usable)} · ${this.usableEffectLabel(usable)} · ${this.usableTargetLabel(usable)}`,
icon: this.usableIcon(usable),
accent,
reason: this.supportTargetUnavailableReason(unit, usable),
metrics: [
{ icon: this.usableTargetIcon(usable), label: '사거리', value: usable.range === 0 ? '자신' : `${usable.range}`, tone: accent },
{ icon: 'success', label: '후보', value: `${candidates.length}`, tone: usable.effect === 'heal' ? 0x59d18c : palette.blue },
this.targetingNearestMetric(unit, candidates, usable.range, accent)
],
hint: '다른 책략/도구를 고르거나, 우클릭으로 명령 선택으로 돌아가세요.'
});
}
private renderCurrentTargetingGuideNotice(text: string, tone = 0xb64a45) {
if (!this.selectedUnit || !this.targetingAction) {
return;
}
const unit = this.selectedUnit;
const usable = this.selectedUsable;
const notice: TargetingGuideNotice = { icon: 'counter', text: this.truncateUiText(text.replace(/\n/g, ' · '), 46), tone };
if (usable && usable.effect !== 'damage') {
const targets = this.supportTargets(unit, usable);
if (targets.length > 0) {
this.renderSupportTargetingGuide(unit, usable, targets, notice);
return;
}
this.renderSupportTargetingUnavailable(unit, usable);
return;
}
const targets = this.damageableTargets(unit, this.targetingAction, usable);
if (targets.length > 0) {
this.renderDamageTargetingGuide(unit, this.targetingAction, targets, usable, notice);
return;
}
this.renderDamageTargetingUnavailable(unit, this.targetingAction, usable);
}
private renderDamageTargetingGuide(
unit: UnitData,
action: DamageCommand,
targets: UnitData[],
usable?: BattleUsable,
notice?: TargetingGuideNotice
) {
const range = this.actionRange(unit, action, usable);
const label = this.actionTargetingLabel(action, usable);
const actionIcon = usable ? this.usableIcon(usable) : this.commandIcon('attack', unit);
@@ -5361,11 +5492,17 @@ export class BattleScene extends Phaser.Scene {
],
candidates,
overflowCount: Math.max(0, targets.length - candidates.length),
footer: '표시된 적 선택 · 확정 전 예측 확인'
footer: '표시된 적 선택 · 확정 전 예측 확인',
notice
});
}
private renderSupportTargetingGuide(unit: UnitData, usable: BattleUsable, targets: UnitData[]) {
private renderSupportTargetingGuide(
unit: UnitData,
usable: BattleUsable,
targets: UnitData[],
notice?: TargetingGuideNotice
) {
const accent = this.usableAccentColor(usable);
const candidates = targets.slice(0, 3).map((target): TargetingGuideCandidate => {
const preview = this.supportPreview(unit, target, usable);
@@ -5399,10 +5536,108 @@ export class BattleScene extends Phaser.Scene {
],
candidates,
overflowCount: Math.max(0, targets.length - candidates.length),
footer: '표시된 대상 선택 · 확정 전 예측 확인'
footer: '표시된 대상 선택 · 확정 전 예측 확인',
notice
});
}
private renderTargetingUnavailableCard(options: {
unit: UnitData;
title: string;
subtitle: string;
icon: BattleUiIconKey;
accent: number;
reason: string;
metrics: TargetingGuideMetric[];
hint: string;
}) {
this.clearSidePanelContent();
this.setMiniMapVisible(false);
const { panelX, panelWidth } = this.layout;
const left = panelX + 24;
const width = panelWidth - 48;
const top = this.sideContentTop();
const height = 416;
const bg = this.trackSideObject(this.add.rectangle(left, top, width, height, 0x101820, 0.98));
bg.setOrigin(0);
bg.setStrokeStyle(2, options.accent, 0.78);
const iconFrame = this.trackSideObject(this.add.rectangle(left + 40, top + 42, 72, 72, 0x0a0f14, 0.94));
iconFrame.setStrokeStyle(1, options.accent, 0.76);
this.trackSideIcon(left + 40, top + 42, options.icon, 64);
this.trackSideObject(this.add.text(left + 86, top + 11, options.title, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '20px',
color: '#f4dfad',
fontStyle: '700',
fixedWidth: width - 96
}));
this.trackSideObject(this.add.text(left + 86, top + 39, options.subtitle, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '12px',
color: '#9fb0bf',
fontStyle: '700',
fixedWidth: width - 96
}));
this.trackSideObject(this.add.text(left + 86, top + 57, `사용자 ${options.unit.name}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '12px',
color: '#c8d2dd',
fixedWidth: width - 96
}));
const metricTop = top + 92;
const metricGap = 6;
const metricWidth = (width - metricGap * 2) / 3;
options.metrics.slice(0, 3).forEach((metric, index) => {
this.renderTargetingGuideMetric(left + index * (metricWidth + metricGap), metricTop, metricWidth, metric);
});
const reasonTop = top + 172;
const reasonBg = this.trackSideObject(this.add.rectangle(left, reasonTop, width, 120, 0x121a22, 0.98));
reasonBg.setOrigin(0);
reasonBg.setStrokeStyle(1, 0xb64a45, 0.74);
const reasonIconFrame = this.trackSideObject(this.add.rectangle(left + 38, reasonTop + 40, 58, 58, 0x0a0f14, 0.94));
reasonIconFrame.setStrokeStyle(1, 0xb64a45, 0.7);
this.trackSideIcon(left + 38, reasonTop + 40, 'counter', 52);
this.trackSideObject(this.add.text(left + 76, reasonTop + 18, '지금은 대상 없음', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '19px',
color: '#ffcf9a',
fontStyle: '700',
fixedWidth: width - 92
}));
this.trackSideObject(this.add.text(left + 76, reasonTop + 48, options.reason, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '13px',
color: '#d4dce6',
fontStyle: '700',
wordWrap: { width: width - 94, useAdvancedWrap: true },
lineSpacing: 3
}));
const hintTop = top + 314;
const hintBg = this.trackSideObject(this.add.rectangle(left, hintTop, width, 66, 0x0b1118, 0.96));
hintBg.setOrigin(0);
hintBg.setStrokeStyle(1, options.accent, 0.54);
this.trackSideIcon(left + 25, hintTop + 33, 'move', 38);
this.trackSideObject(this.add.text(left + 52, hintTop + 12, '다음 선택', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '11px',
color: '#9fb0bf',
fontStyle: '700'
}));
this.trackSideObject(this.add.text(left + 52, hintTop + 31, options.hint, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '13px',
color: '#f2e3bf',
fontStyle: '700',
fixedWidth: width - 64
}));
}
private renderTargetingGuideCard(options: {
unit: UnitData;
title: string;
@@ -5413,6 +5648,7 @@ export class BattleScene extends Phaser.Scene {
candidates: TargetingGuideCandidate[];
overflowCount: number;
footer: string;
notice?: TargetingGuideNotice;
}) {
this.clearSidePanelContent();
this.setMiniMapVisible(false);
@@ -5474,13 +5710,15 @@ export class BattleScene extends Phaser.Scene {
const footerTop = top + 368;
const footer = this.trackSideObject(this.add.rectangle(left, footerTop, width, 34, 0x0b1118, 0.95));
footer.setOrigin(0);
footer.setStrokeStyle(1, options.accent, 0.52);
this.trackSideIcon(left + 18, footerTop + 17, 'success', 26);
const footerTone = options.notice?.tone ?? options.accent;
footer.setStrokeStyle(1, footerTone, options.notice ? 0.72 : 0.52);
this.trackSideIcon(left + 18, footerTop + 17, options.notice?.icon ?? 'success', 26);
const overflowText = options.overflowCount > 0 ? ` · 외 ${options.overflowCount}` : '';
this.trackSideObject(this.add.text(left + 38, footerTop + 8, `${options.footer}${overflowText}`, {
const footerText = options.notice?.text ?? `${options.footer}${overflowText}`;
this.trackSideObject(this.add.text(left + 38, footerTop + 8, footerText, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '12px',
color: '#d4dce6',
color: options.notice ? '#ffcf9a' : '#d4dce6',
fontStyle: '700',
fixedWidth: width - 48
}));
@@ -5611,10 +5849,7 @@ export class BattleScene extends Phaser.Scene {
const targets = this.damageableTargets(unit, action, usable);
if (targets.length === 0) {
this.renderUnitDetail(
unit,
`현재 위치에서 ${this.actionTargetingLabel(action, usable)} 가능한 적이 없습니다.\n${this.damageTargetUnavailableReason(unit, action, usable)}\n다른 명령을 선택하거나 우클릭으로 이동을 취소하세요.`
);
this.renderDamageTargetingUnavailable(unit, action, usable);
return;
}
@@ -5644,7 +5879,7 @@ export class BattleScene extends Phaser.Scene {
const targets = this.supportTargets(unit, usable);
if (targets.length === 0) {
this.renderUnitDetail(unit, `${usable.name}\n${this.supportTargetUnavailableReason(unit, usable)}`);
this.renderSupportTargetingUnavailable(unit, usable);
return;
}
@@ -5733,7 +5968,7 @@ export class BattleScene extends Phaser.Scene {
) {
if (!this.canUseDamageCommand(attacker, target, action, usable)) {
this.clearLockedTargetPreview();
this.renderUnitDetail(attacker, this.damageTargetFailureMessage(attacker, target, action, usable));
this.renderCurrentTargetingGuideNotice(this.damageTargetFailureMessage(attacker, target, action, usable));
return;
}
@@ -5748,7 +5983,7 @@ export class BattleScene extends Phaser.Scene {
private chooseSupportTarget(user: UnitData, target: UnitData, usable: BattleUsable, preview?: SupportPreview) {
if (!this.canUseSupportCommand(user, target, usable)) {
this.clearLockedTargetPreview();
this.renderUnitDetail(user, this.supportTargetFailureMessage(user, target, usable));
this.renderCurrentTargetingGuideNotice(this.supportTargetFailureMessage(user, target, usable));
return;
}
@@ -5954,7 +6189,7 @@ export class BattleScene extends Phaser.Scene {
if (!this.canUseDamageCommand(attacker, target, action, usable)) {
this.clearLockedTargetPreview();
this.renderUnitDetail(attacker, this.damageTargetFailureMessage(attacker, target, action, usable));
this.renderCurrentTargetingGuideNotice(this.damageTargetFailureMessage(attacker, target, action, usable));
return;
}
@@ -5988,7 +6223,7 @@ export class BattleScene extends Phaser.Scene {
if (!this.canUseSupportCommand(user, target, usable)) {
this.clearLockedTargetPreview();
this.renderUnitDetail(user, this.supportTargetFailureMessage(user, target, usable));
this.renderCurrentTargetingGuideNotice(this.supportTargetFailureMessage(user, target, usable));
return;
}
@@ -8713,17 +8948,11 @@ export class BattleScene extends Phaser.Scene {
const markedLabel = usable && usable.effect !== 'damage' ? '밝게 표시된 대상' : '붉게 표시된 적군';
if (distance > range) {
this.renderUnitDetail(
unit,
`${label}: 사거리 밖의 빈 칸입니다.\n선택한 칸 ${distance}칸 / 사거리 ${range}칸.\n${markedLabel}을 선택하거나 우클릭으로 명령 선택으로 돌아가세요.`
);
this.renderCurrentTargetingGuideNotice(`${label}: 사거리 밖입니다. 선택한 칸 ${distance}/${range}칸 · ${markedLabel}을 선택하세요.`);
return true;
}
this.renderUnitDetail(
unit,
`${label}: 사거리 안이지만 ${targetLabel} 대상이 없는 칸입니다.\n${markedLabel}을 선택하거나 우클릭으로 명령 선택으로 돌아가세요.`
);
this.renderCurrentTargetingGuideNotice(`${label}: 이 칸에는 ${targetLabel} 대상이 없습니다. ${markedLabel}을 선택하세요.`);
return true;
}