diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 19dc821..d437bef 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1434,6 +1434,12 @@ type TargetingGuideCandidate = { tone: number; }; +type TargetingGuideNotice = { + icon: BattleUiIconKey; + text: string; + tone: number; +}; + type SavedUnitState = Pick & { 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; }