Confirm battle targets from preview

This commit is contained in:
2026-07-02 23:02:07 +09:00
parent 2a4070d4f5
commit eee2c52b39

View File

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