Improve battle targeting UX
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user