From eee2c52b39f14725d4ea578b36acbecdc2124c5a Mon Sep 17 00:00:00 2001 From: Wickedness Date: Thu, 2 Jul 2026 23:02:07 +0900 Subject: [PATCH] Confirm battle targets from preview --- src/game/scenes/BattleScene.ts | 310 ++++++++++++++++++++++++++++++--- 1 file changed, 284 insertions(+), 26 deletions(-) diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 270eb83..615efba 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1341,6 +1341,14 @@ type SupportPreview = { duration: number; }; +type LockedTargetPreview = { + kind: 'damage' | 'support'; + userId: string; + targetId: string; + action: DamageCommand; + usableId?: string; +}; + type SupportResult = { usable: BattleUsable; user: UnitData; @@ -2895,6 +2903,7 @@ export class BattleScene extends Phaser.Scene { private selectedUnit?: UnitData; private targetingAction?: DamageCommand; private selectedUsable?: BattleUsable; + private lockedTargetPreview?: LockedTargetPreview; private turnText?: Phaser.GameObjects.Text; private objectiveTrackerText?: Phaser.GameObjects.Text; private objectiveTrackerSubText?: Phaser.GameObjects.Text; @@ -2982,6 +2991,7 @@ export class BattleScene extends Phaser.Scene { this.pendingMove = undefined; this.targetingAction = undefined; this.selectedUsable = undefined; + this.lockedTargetPreview = undefined; this.battleOutcome = undefined; this.phase = 'idle'; soundDirector.playMusic('battle-prep'); @@ -2997,6 +3007,12 @@ export class BattleScene extends Phaser.Scene { this.handleLeftClick(pointer); } }); + this.input.keyboard?.on('keydown-ENTER', () => { + this.confirmLockedTargetPreview(); + }); + this.input.keyboard?.on('keydown-ESC', () => { + this.cancelAttackTargeting(); + }); this.installDebugHotkeys(); this.add.rectangle(0, 0, width, height, 0x080b0d).setOrigin(0); @@ -3676,10 +3692,10 @@ export class BattleScene extends Phaser.Scene { if (this.phase === 'targeting' && this.selectedUnit) { if (unit.hp > 0 && this.targetingAction) { if (this.selectedUsable && this.selectedUsable.effect !== 'damage') { - void this.tryResolveSupportTarget(this.selectedUnit, unit, this.selectedUsable); + this.chooseSupportTarget(this.selectedUnit, unit, this.selectedUsable); return; } - void this.tryResolveDamageTarget(this.selectedUnit, unit, this.targetingAction, this.selectedUsable); + this.chooseDamageTarget(this.selectedUnit, unit, this.targetingAction, this.selectedUsable); return; } @@ -3933,6 +3949,7 @@ export class BattleScene extends Phaser.Scene { private clearMarkers() { this.markers.forEach((marker) => marker.destroy()); this.markers = []; + this.lockedTargetPreview = undefined; } private showEnemyThreatRange() { @@ -4868,21 +4885,192 @@ export class BattleScene extends Phaser.Scene { soundDirector.playSelect(); } + private isLockedDamageTarget(attacker: UnitData, target: UnitData, action: DamageCommand, usable?: BattleUsable) { + const locked = this.lockedTargetPreview; + return Boolean( + locked && + locked.kind === 'damage' && + locked.userId === attacker.id && + locked.targetId === target.id && + locked.action === action && + locked.usableId === usable?.id + ); + } + + private isLockedSupportTarget(user: UnitData, target: UnitData, usable: BattleUsable) { + const locked = this.lockedTargetPreview; + return Boolean( + locked && + locked.kind === 'support' && + locked.userId === user.id && + locked.targetId === target.id && + locked.action === usable.command && + locked.usableId === usable.id + ); + } + + private clearLockedTargetPreview(refreshMarkers = true) { + this.lockedTargetPreview = undefined; + if (refreshMarkers) { + this.refreshTargetMarkerStyles(); + } + } + + private lockDamageTargetPreview( + attacker: UnitData, + target: UnitData, + action: DamageCommand, + usable?: BattleUsable, + preview = this.combatPreview(attacker, target, action, usable) + ) { + this.lockedTargetPreview = { + kind: 'damage', + userId: attacker.id, + targetId: target.id, + action, + usableId: usable?.id + }; + this.refreshTargetMarkerStyles(); + this.renderAttackPreview(preview, true); + soundDirector.playSelect(); + } + + private lockSupportTargetPreview(user: UnitData, target: UnitData, usable: BattleUsable, preview = this.supportPreview(user, target, usable)) { + this.lockedTargetPreview = { + kind: 'support', + userId: user.id, + targetId: target.id, + action: usable.command, + usableId: usable.id + }; + this.refreshTargetMarkerStyles(); + this.renderSupportPreview(preview, true); + soundDirector.playSelect(); + } + + private chooseDamageTarget( + attacker: UnitData, + target: UnitData, + action: DamageCommand, + usable?: BattleUsable, + preview?: CombatPreview + ) { + if (!this.canUseDamageCommand(attacker, target, action, usable)) { + this.clearLockedTargetPreview(); + this.renderUnitDetail(attacker, this.damageTargetFailureMessage(attacker, target, action, usable)); + return; + } + + if (this.isLockedDamageTarget(attacker, target, action, usable)) { + void this.tryResolveDamageTarget(attacker, target, action, usable); + return; + } + + this.lockDamageTargetPreview(attacker, target, action, usable, preview ?? this.combatPreview(attacker, target, action, usable)); + } + + 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)); + return; + } + + if (this.isLockedSupportTarget(user, target, usable)) { + void this.tryResolveSupportTarget(user, target, usable); + return; + } + + this.lockSupportTargetPreview(user, target, usable, preview ?? this.supportPreview(user, target, usable)); + } + + private confirmLockedTargetPreview() { + const locked = this.lockedTargetPreview; + if (!locked || this.phase !== 'targeting' || !this.selectedUnit || this.selectedUnit.id !== locked.userId) { + return false; + } + + const target = battleUnits.find((unit) => unit.id === locked.targetId); + if (!target || target.hp <= 0) { + this.clearLockedTargetPreview(); + return false; + } + + soundDirector.playSelect(); + if (locked.kind === 'support') { + if (!this.selectedUsable || this.selectedUsable.id !== locked.usableId) { + this.clearLockedTargetPreview(); + return false; + } + void this.tryResolveSupportTarget(this.selectedUnit, target, this.selectedUsable); + return true; + } + + const usable = this.selectedUsable?.id === locked.usableId ? this.selectedUsable : undefined; + void this.tryResolveDamageTarget(this.selectedUnit, target, locked.action, usable); + return true; + } + + private setTargetMarkerData( + marker: Phaser.GameObjects.Rectangle, + kind: LockedTargetPreview['kind'], + userId: string, + targetId: string, + action: DamageCommand, + usableId: string | undefined, + baseColor: number, + strokeColor: number + ) { + marker.setData('targetMarkerKind', kind); + marker.setData('targetUserId', userId); + marker.setData('targetTargetId', targetId); + marker.setData('targetAction', action); + marker.setData('targetUsableId', usableId); + marker.setData('targetBaseColor', baseColor); + marker.setData('targetStrokeColor', strokeColor); + } + + private refreshTargetMarkerStyles() { + this.markers.forEach((marker) => { + const kind = marker.getData('targetMarkerKind') as LockedTargetPreview['kind'] | undefined; + if (!kind) { + return; + } + + const baseColor = marker.getData('targetBaseColor') as number; + const strokeColor = marker.getData('targetStrokeColor') as number; + const locked = this.lockedTargetPreview; + const isLocked = + locked !== undefined && + locked.kind === kind && + locked.userId === marker.getData('targetUserId') && + locked.targetId === marker.getData('targetTargetId') && + locked.action === marker.getData('targetAction') && + locked.usableId === marker.getData('targetUsableId'); + + marker.setFillStyle(isLocked ? 0xf2e3bf : baseColor, isLocked ? 0.36 : 0.2); + marker.setStrokeStyle(isLocked ? 3 : 2, isLocked ? 0xffffff : strokeColor, isLocked ? 1 : 0.9); + }); + } + private showDamageTargets(attacker: UnitData, action: DamageCommand, targets = this.damageableTargets(attacker, action), usable?: BattleUsable) { targets.forEach((target) => { const preview = this.combatPreview(attacker, target, action, usable); + const baseColor = 0xc93b30; + const strokeColor = 0xffd27a; const marker = this.add.rectangle( this.tileTopLeftX(target.x), this.tileTopLeftY(target.y), this.layout.tileSize, this.layout.tileSize, - 0xc93b30, + baseColor, 0.2 ); marker.setData('tileX', target.x); marker.setData('tileY', target.y); + this.setTargetMarkerData(marker, 'damage', attacker.id, target.id, action, usable?.id, baseColor, strokeColor); marker.setOrigin(0); - marker.setStrokeStyle(2, 0xffd27a, 0.9); + marker.setStrokeStyle(2, strokeColor, 0.9); marker.setDepth(5.05); if (this.mapMask) { marker.setMask(this.mapMask); @@ -4907,17 +5095,17 @@ export class BattleScene extends Phaser.Scene { hitArea.setVisible(this.isTileVisible(target.x, target.y)); hitArea.setInteractive({ useHandCursor: true }); hitArea.on('pointerover', () => { - marker.setFillStyle(0xd9503f, 0.34); - marker.setStrokeStyle(3, 0xffe19a, 1); - this.renderAttackPreview(preview); + const locked = this.isLockedDamageTarget(attacker, target, action, usable); + marker.setFillStyle(locked ? 0xf2e3bf : 0xd9503f, locked ? 0.36 : 0.34); + marker.setStrokeStyle(3, locked ? 0xffffff : 0xffe19a, 1); + this.renderAttackPreview(preview, locked); }); hitArea.on('pointerout', () => { - marker.setFillStyle(0xc93b30, 0.2); - marker.setStrokeStyle(2, 0xffd27a, 0.9); + this.refreshTargetMarkerStyles(); }); hitArea.on('pointerdown', (pointer: Phaser.Input.Pointer) => { if (pointer.leftButtonDown()) { - void this.tryResolveDamageTarget(attacker, target, action, usable); + this.chooseDamageTarget(attacker, target, action, usable, preview); } }); this.markers.push(marker, hitArea); @@ -4927,18 +5115,21 @@ export class BattleScene extends Phaser.Scene { private showSupportTargets(user: UnitData, usable: BattleUsable, targets = this.supportTargets(user, usable)) { targets.forEach((target) => { const preview = this.supportPreview(user, target, usable); + const baseColor = usable.effect === 'heal' ? 0x45b875 : 0x4f86d9; + const strokeColor = usable.effect === 'heal' ? 0xb6ffd2 : 0xd9e8ff; const marker = this.add.rectangle( this.tileTopLeftX(target.x), this.tileTopLeftY(target.y), this.layout.tileSize, this.layout.tileSize, - usable.effect === 'heal' ? 0x45b875 : 0x4f86d9, + baseColor, 0.2 ); marker.setData('tileX', target.x); marker.setData('tileY', target.y); + this.setTargetMarkerData(marker, 'support', user.id, target.id, usable.command, usable.id, baseColor, strokeColor); marker.setOrigin(0); - marker.setStrokeStyle(2, usable.effect === 'heal' ? 0xb6ffd2 : 0xd9e8ff, 0.9); + marker.setStrokeStyle(2, strokeColor, 0.9); marker.setDepth(5.05); if (this.mapMask) { marker.setMask(this.mapMask); @@ -4963,17 +5154,17 @@ export class BattleScene extends Phaser.Scene { hitArea.setVisible(this.isTileVisible(target.x, target.y)); hitArea.setInteractive({ useHandCursor: true }); hitArea.on('pointerover', () => { - marker.setFillStyle(usable.effect === 'heal' ? 0x45b875 : 0x4f86d9, 0.34); - marker.setStrokeStyle(3, usable.effect === 'heal' ? 0xd9ffe8 : 0xf2f7ff, 1); - this.renderSupportPreview(preview); + const locked = this.isLockedSupportTarget(user, target, usable); + marker.setFillStyle(locked ? 0xf2e3bf : baseColor, locked ? 0.36 : 0.34); + marker.setStrokeStyle(3, locked ? 0xffffff : usable.effect === 'heal' ? 0xd9ffe8 : 0xf2f7ff, 1); + this.renderSupportPreview(preview, locked); }); hitArea.on('pointerout', () => { - marker.setFillStyle(usable.effect === 'heal' ? 0x45b875 : 0x4f86d9, 0.2); - marker.setStrokeStyle(2, usable.effect === 'heal' ? 0xb6ffd2 : 0xd9e8ff, 0.9); + this.refreshTargetMarkerStyles(); }); hitArea.on('pointerdown', (pointer: Phaser.Input.Pointer) => { if (pointer.leftButtonDown()) { - void this.tryResolveSupportTarget(user, target, usable); + this.chooseSupportTarget(user, target, usable, preview); } }); this.markers.push(marker, hitArea); @@ -4986,11 +5177,13 @@ export class BattleScene extends Phaser.Scene { } if (!this.canUseDamageCommand(attacker, target, action, usable)) { + this.clearLockedTargetPreview(); this.renderUnitDetail(attacker, this.damageTargetFailureMessage(attacker, target, action, usable)); return; } if (usable?.command === 'item' && !this.consumeItem(attacker.id, usable.id)) { + this.clearLockedTargetPreview(); this.showUnavailableCommandAlert('item'); return; } @@ -5018,11 +5211,13 @@ export class BattleScene extends Phaser.Scene { } if (!this.canUseSupportCommand(user, target, usable)) { + this.clearLockedTargetPreview(); this.renderUnitDetail(user, this.supportTargetFailureMessage(user, target, usable)); return; } if (usable.command === 'item' && !this.consumeItem(user.id, usable.id)) { + this.clearLockedTargetPreview(); this.showUnavailableCommandAlert('item'); return; } @@ -5034,9 +5229,9 @@ export class BattleScene extends Phaser.Scene { this.finishUnitAction(user, this.formatSupportResult(result)); } - private renderAttackPreview(preview: CombatPreview) { + private renderAttackPreview(preview: CombatPreview, locked = false) { this.renderUnitDetail(preview.defender); - this.renderCombatPreviewCard(preview); + this.renderCombatPreviewCard(preview, locked); } private supportPreview(user: UnitData, target: UnitData, usable: BattleUsable): SupportPreview { @@ -5054,9 +5249,9 @@ export class BattleScene extends Phaser.Scene { }; } - private renderSupportPreview(preview: SupportPreview) { + private renderSupportPreview(preview: SupportPreview, locked = false) { this.renderUnitDetail(preview.target); - this.renderSupportPreviewCard(preview); + this.renderSupportPreviewCard(preview, locked); } private equipmentPreviewBonusText(preview: CombatPreview) { @@ -5167,11 +5362,11 @@ export class BattleScene extends Phaser.Scene { return '도구'; } - private renderCombatPreviewCard(preview: CombatPreview) { + private renderCombatPreviewCard(preview: CombatPreview, locked = false) { const { panelX, panelWidth } = this.layout; const left = panelX + 24; const width = panelWidth - 48; - const height = 264; + const height = locked ? 300 : 264; const y = Math.max(this.sideContentTop(), Math.min(this.sideContentTop() + 344, this.sideContentBottom(10) - height)); const accent = this.previewAccentColor(preview); const defenderClass = getUnitClass(preview.defender.classKey); @@ -5244,13 +5439,16 @@ export class BattleScene extends Phaser.Scene { if (preview.equipmentEffectLabels.length > 0) { this.renderPreviewBadge(badgeX, badgeY, 'mastery', '장비 보정', maxBadgeRight); } + if (locked) { + this.renderPreviewConfirmBar(left + 10, y + 266, width - 20, accent, `${preview.defender.name} 선택됨`); + } } - private renderSupportPreviewCard(preview: SupportPreview) { + private renderSupportPreviewCard(preview: SupportPreview, locked = false) { const { panelX, panelWidth } = this.layout; const left = panelX + 24; const width = panelWidth - 48; - const height = 264; + const height = locked ? 300 : 264; const y = Math.max(this.sideContentTop(), Math.min(this.sideContentTop() + 344, this.sideContentBottom(10) - height)); const accent = this.usableAccentColor(preview.usable); @@ -5326,6 +5524,62 @@ export class BattleScene extends Phaser.Scene { if (preview.usable.effect === 'focus') { this.renderPreviewBadge(badgeX, badgeY, 'leadership', `${preview.duration}턴`, maxBadgeRight); } + if (locked) { + this.renderPreviewConfirmBar(left + 10, y + 266, width - 20, accent, `${preview.target.name} 선택됨`); + } + } + + private renderPreviewConfirmBar(x: number, y: number, width: number, accent: number, label: string) { + const row = this.trackSideObject(this.add.rectangle(x, y, width, 28, 0x0b1118, 0.97)); + row.setOrigin(0); + row.setStrokeStyle(1, accent, 0.7); + this.trackSideObject(this.add.text(x + 10, y + 7, label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: '#f2e3bf', + fontStyle: '700', + fixedWidth: width - 158 + })); + this.renderPreviewActionButton(x + width - 148, y + 4, 70, 20, '실행', accent, () => { + this.confirmLockedTargetPreview(); + }); + this.renderPreviewActionButton(x + width - 72, y + 4, 62, 20, '취소', 0x647485, () => { + this.cancelAttackTargeting(); + }); + } + + private renderPreviewActionButton( + x: number, + y: number, + width: number, + height: number, + label: string, + tone: number, + onClick: () => void + ) { + const bg = this.trackSideObject(this.add.rectangle(x, y, width, height, 0x16212d, 0.98)); + bg.setOrigin(0); + bg.setStrokeStyle(1, tone, 0.82); + bg.setInteractive({ useHandCursor: true }); + bg.on('pointerover', () => { + bg.setFillStyle(tone, 0.95); + }); + bg.on('pointerout', () => { + bg.setFillStyle(0x16212d, 0.98); + }); + bg.on('pointerdown', (pointer: Phaser.Input.Pointer) => { + if (pointer.leftButtonDown()) { + onClick(); + } + }); + + const text = this.trackSideObject(this.add.text(x + width / 2, y + height / 2, label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: '#f5efe2', + fontStyle: '700' + })); + text.setOrigin(0.5); } private renderPreviewEquipmentChip( @@ -5490,6 +5744,7 @@ export class BattleScene extends Phaser.Scene { this.pendingMove = undefined; this.targetingAction = undefined; this.selectedUsable = undefined; + this.lockedTargetPreview = undefined; this.hideCommandMenu(); this.hideTurnEndPrompt(); this.applyActedStyle(unit); @@ -7344,6 +7599,7 @@ export class BattleScene extends Phaser.Scene { return false; } + this.clearLockedTargetPreview(); const unit = this.selectedUnit; const usable = this.selectedUsable; const range = this.actionRange(unit, this.targetingAction, usable); @@ -7748,6 +8004,8 @@ export class BattleScene extends Phaser.Scene { this.selectedUnit = undefined; this.pendingMove = undefined; this.targetingAction = undefined; + this.selectedUsable = undefined; + this.lockedTargetPreview = undefined; this.battleOutcome = undefined; this.phase = 'idle';