Polish battle interaction feedback
This commit is contained in:
@@ -3030,6 +3030,11 @@ export class BattleScene extends Phaser.Scene {
|
||||
private cameraTileX = 0;
|
||||
private cameraTileY = 0;
|
||||
private edgeScrollElapsed = 0;
|
||||
private pointerFeedbackMarker?: Phaser.GameObjects.Rectangle;
|
||||
private pointerFeedbackTile?: { x: number; y: number };
|
||||
private pointerFeedbackHintBg?: Phaser.GameObjects.Rectangle;
|
||||
private pointerFeedbackHintText?: Phaser.GameObjects.Text;
|
||||
private lastPointerFeedbackKey?: string;
|
||||
private suppressNextLeftClick = false;
|
||||
private approachPathCostCache = new Map<string, Map<string, number>>();
|
||||
private launchSortieUnitIds: string[] = [];
|
||||
@@ -3092,6 +3097,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.handleLeftClick(pointer);
|
||||
}
|
||||
});
|
||||
this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => this.updatePointerFeedback(pointer));
|
||||
this.input.keyboard?.on('keydown-ENTER', () => {
|
||||
this.confirmLockedTargetPreview();
|
||||
});
|
||||
@@ -3847,6 +3853,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
battleUnits.forEach((unit) => this.positionUnitView(unit));
|
||||
this.updateMiniMap();
|
||||
this.refreshFacingIndicator();
|
||||
this.updatePointerFeedback(this.input.activePointer, true);
|
||||
}
|
||||
|
||||
private updateMiniMap() {
|
||||
@@ -3949,6 +3956,259 @@ export class BattleScene extends Phaser.Scene {
|
||||
);
|
||||
}
|
||||
|
||||
private updatePointerFeedback(pointer = this.input.activePointer, force = false) {
|
||||
if (
|
||||
this.battleOutcome ||
|
||||
this.phase === 'animating' ||
|
||||
this.phase === 'resolved' ||
|
||||
this.commandMenuObjects.length > 0 ||
|
||||
this.mapMenuObjects.length > 0 ||
|
||||
this.turnPromptObjects.length > 0 ||
|
||||
this.combatCutInObjects.length > 0 ||
|
||||
this.resultObjects.length > 0
|
||||
) {
|
||||
this.clearPointerFeedback();
|
||||
return;
|
||||
}
|
||||
|
||||
const tile = this.pointerToTile(pointer);
|
||||
if (!tile || !this.isTileVisible(tile.x, tile.y)) {
|
||||
this.clearPointerFeedback();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.phase === 'moving' && this.selectedUnit) {
|
||||
this.updateMovePointerFeedback(this.selectedUnit, tile, force);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.phase === 'targeting' && this.selectedUnit && this.targetingAction) {
|
||||
this.updateTargetingPointerFeedback(this.selectedUnit, tile, force);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.phase === 'idle') {
|
||||
const unit = this.unitAtTile(tile.x, tile.y);
|
||||
if (unit) {
|
||||
this.updateUnitPointerFeedback(unit, tile, force);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.clearPointerFeedback();
|
||||
}
|
||||
|
||||
private updateUnitPointerFeedback(unit: UnitData, tile: { x: number; y: number }, force: boolean) {
|
||||
const key = `unit:${unit.id}:${tile.x},${tile.y}:${this.activeFaction}:${this.actedUnitIds.has(unit.id)}`;
|
||||
if (!force && this.lastPointerFeedbackKey === key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const acted = this.actedUnitIds.has(unit.id);
|
||||
const selectable = unit.faction === 'ally' && this.activeFaction === 'ally' && !acted;
|
||||
const tone = selectable ? palette.blue : unit.faction === 'enemy' ? 0xd8732c : 0x647485;
|
||||
const text = selectable
|
||||
? `${unit.name}: 선택 가능 · 클릭하면 이동 범위 표시`
|
||||
: unit.faction === 'enemy'
|
||||
? `${unit.name}: 적 부대 · 클릭하면 위협 범위 확인`
|
||||
: `${unit.name}: 행동 완료`;
|
||||
|
||||
this.showPointerFeedback(tile, tone, text, key, 0x1c7ed6, 0.1, 0.8);
|
||||
}
|
||||
|
||||
private updateMovePointerFeedback(unit: UnitData, tile: { x: number; y: number }, force: boolean) {
|
||||
const destination = this.unitAtTile(tile.x, tile.y, unit.id);
|
||||
const canMove = this.canMoveTo(unit, tile.x, tile.y);
|
||||
const key = `move:${unit.id}:${tile.x},${tile.y}:${canMove}:${destination?.id ?? 'empty'}`;
|
||||
if (!force && this.lastPointerFeedbackKey === key) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canMove) {
|
||||
const distance = this.movementTileDistance(unit, tile.x, tile.y);
|
||||
const text =
|
||||
distance === 0
|
||||
? `${unit.name}: 제자리에서 명령 선택`
|
||||
: `${unit.name}: 이동 가능 · ${distance}칸 · 클릭해 확정`;
|
||||
this.showPointerFeedback(tile, palette.blue, text, key, 0x1c7ed6, 0.12, 0.9);
|
||||
return;
|
||||
}
|
||||
|
||||
const reason = destination ? `${destination.name}이 있는 칸` : '이동 범위 밖';
|
||||
this.showPointerFeedback(tile, 0xb64a45, `이동 불가: ${reason}`, key, 0xb64a45, 0.08, 0.75);
|
||||
}
|
||||
|
||||
private updateTargetingPointerFeedback(unit: UnitData, tile: { x: number; y: number }, force: boolean) {
|
||||
const usable = this.selectedUsable;
|
||||
if (usable && usable.effect !== 'damage') {
|
||||
this.updateSupportPointerFeedback(unit, usable, tile, force);
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateDamagePointerFeedback(unit, tile, force);
|
||||
}
|
||||
|
||||
private updateDamagePointerFeedback(unit: UnitData, tile: { x: number; y: number }, force: boolean) {
|
||||
const action = this.targetingAction;
|
||||
if (!action) {
|
||||
this.clearPointerFeedback();
|
||||
return;
|
||||
}
|
||||
|
||||
const usable = this.selectedUsable;
|
||||
const target = this.unitAtTile(tile.x, tile.y);
|
||||
const range = this.actionRange(unit, action, usable);
|
||||
const distance = this.tileDistanceTo(unit.x, unit.y, tile.x, tile.y);
|
||||
const canTarget = target ? this.canUseDamageCommand(unit, target, action, usable) : false;
|
||||
const key = `damage:${unit.id}:${action}:${usable?.id ?? 'attack'}:${tile.x},${tile.y}:${target?.id ?? 'empty'}:${canTarget}`;
|
||||
if (!force && this.lastPointerFeedbackKey === key) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target && canTarget) {
|
||||
const preview = this.combatPreview(unit, target, action, usable);
|
||||
const locked = this.isLockedDamageTarget(unit, target, action, usable);
|
||||
const counter = preview.counterAvailable ? '반격 주의' : '반격 없음';
|
||||
this.showPointerFeedback(
|
||||
tile,
|
||||
this.previewAccentColor(preview),
|
||||
`${target.name}: 피해 ${preview.damage} · ${this.previewSuccessLabel(preview)} ${preview.hitRate}% · ${counter}`,
|
||||
key,
|
||||
0xd9503f,
|
||||
0.16,
|
||||
1
|
||||
);
|
||||
this.renderAttackPreview(preview, locked);
|
||||
return;
|
||||
}
|
||||
|
||||
const label = this.actionTargetingLabel(action, usable);
|
||||
const reason = target
|
||||
? this.damageTargetFailureMessage(unit, target, action, usable)
|
||||
: distance > range
|
||||
? `${label}: 사거리 밖 · ${distance}/${range}칸`
|
||||
: `${label}: 이 칸에는 적 대상이 없습니다`;
|
||||
this.showPointerFeedback(tile, 0xb64a45, this.singleLineHint(reason), key, 0xb64a45, 0.08, 0.72);
|
||||
this.renderCurrentTargetingGuideNotice(this.singleLineHint(reason));
|
||||
}
|
||||
|
||||
private updateSupportPointerFeedback(unit: UnitData, usable: BattleUsable, tile: { x: number; y: number }, force: boolean) {
|
||||
const target = this.unitAtTile(tile.x, tile.y);
|
||||
const distance = this.tileDistanceTo(unit.x, unit.y, tile.x, tile.y);
|
||||
const canTarget = target ? this.canUseSupportCommand(unit, target, usable) : false;
|
||||
const key = `support:${unit.id}:${usable.id}:${tile.x},${tile.y}:${target?.id ?? 'empty'}:${canTarget}`;
|
||||
if (!force && this.lastPointerFeedbackKey === key) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target && canTarget) {
|
||||
const preview = this.supportPreview(unit, target, usable);
|
||||
const locked = this.isLockedSupportTarget(unit, target, usable);
|
||||
const isHeal = usable.effect === 'heal';
|
||||
const text = isHeal
|
||||
? `${target.name}: 회복 +${preview.healAmount} · HP ${target.hp} → ${preview.projectedHp}`
|
||||
: `${target.name}: 강화 · 공격 +${preview.attackBonus} · 명중 +${preview.hitBonus}`;
|
||||
this.showPointerFeedback(tile, isHeal ? 0x59d18c : palette.blue, text, key, isHeal ? 0x45b875 : 0x4f86d9, 0.16, 1);
|
||||
this.renderSupportPreview(preview, locked);
|
||||
return;
|
||||
}
|
||||
|
||||
const label = this.actionTargetingLabel(usable.command, usable);
|
||||
const reason = target
|
||||
? this.supportTargetFailureMessage(unit, target, usable)
|
||||
: distance > usable.range
|
||||
? `${label}: 사거리 밖 · ${distance}/${usable.range}칸`
|
||||
: `${label}: 이 칸에는 ${this.usableTargetLabel(usable)} 대상이 없습니다`;
|
||||
const tone = usable.effect === 'heal' ? 0x80cfa1 : 0x8ea9d9;
|
||||
this.showPointerFeedback(tile, tone, this.singleLineHint(reason), key, tone, 0.08, 0.72);
|
||||
this.renderCurrentTargetingGuideNotice(this.singleLineHint(reason), tone);
|
||||
}
|
||||
|
||||
private showPointerFeedback(
|
||||
tile: { x: number; y: number },
|
||||
tone: number,
|
||||
text: string,
|
||||
key: string,
|
||||
fillColor: number,
|
||||
fillAlpha: number,
|
||||
strokeAlpha: number
|
||||
) {
|
||||
const marker = this.ensurePointerFeedbackMarker();
|
||||
marker.setData('tileX', tile.x);
|
||||
marker.setData('tileY', tile.y);
|
||||
marker.setFillStyle(fillColor, fillAlpha);
|
||||
marker.setStrokeStyle(2, tone, strokeAlpha);
|
||||
this.pointerFeedbackTile = tile;
|
||||
this.positionTileObject(marker, tile.x, tile.y);
|
||||
marker.setVisible(true);
|
||||
|
||||
this.renderPointerFeedbackHint(text, tone);
|
||||
this.lastPointerFeedbackKey = key;
|
||||
}
|
||||
|
||||
private ensurePointerFeedbackMarker() {
|
||||
if (this.pointerFeedbackMarker) {
|
||||
return this.pointerFeedbackMarker;
|
||||
}
|
||||
|
||||
const marker = this.add.rectangle(0, 0, this.layout.tileSize, this.layout.tileSize, palette.blue, 0.1);
|
||||
marker.setName('pointer-feedback-tile');
|
||||
marker.setOrigin(0);
|
||||
marker.setDepth(5.08);
|
||||
if (this.mapMask) {
|
||||
marker.setMask(this.mapMask);
|
||||
}
|
||||
marker.setVisible(false);
|
||||
this.pointerFeedbackMarker = marker;
|
||||
return marker;
|
||||
}
|
||||
|
||||
private renderPointerFeedbackHint(text: string, tone: number) {
|
||||
const left = this.layout.panelX + 18;
|
||||
const top = this.sideContentTop() - 38;
|
||||
const width = this.layout.panelWidth - 36;
|
||||
const height = 32;
|
||||
if (!this.pointerFeedbackHintBg) {
|
||||
this.pointerFeedbackHintBg = this.add.rectangle(left, top, width, height, 0x0b1118, 0.96);
|
||||
this.pointerFeedbackHintBg.setOrigin(0);
|
||||
this.pointerFeedbackHintBg.setDepth(28);
|
||||
}
|
||||
if (!this.pointerFeedbackHintText) {
|
||||
this.pointerFeedbackHintText = this.add.text(left + 12, top + 8, '', {
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: '12px',
|
||||
color: '#f2e3bf',
|
||||
fontStyle: '700',
|
||||
fixedWidth: width - 24
|
||||
});
|
||||
this.pointerFeedbackHintText.setDepth(29);
|
||||
}
|
||||
|
||||
this.pointerFeedbackHintBg.setPosition(left, top);
|
||||
this.pointerFeedbackHintBg.setSize(width, height);
|
||||
this.pointerFeedbackHintBg.setStrokeStyle(1, tone, 0.62);
|
||||
this.pointerFeedbackHintBg.setVisible(true);
|
||||
this.pointerFeedbackHintText.setPosition(left + 12, top + 8);
|
||||
this.pointerFeedbackHintText.setText(this.truncateUiText(text, 38));
|
||||
this.pointerFeedbackHintText.setVisible(true);
|
||||
}
|
||||
|
||||
private clearPointerFeedback() {
|
||||
this.pointerFeedbackMarker?.setVisible(false);
|
||||
this.pointerFeedbackTile = undefined;
|
||||
this.pointerFeedbackHintBg?.setVisible(false);
|
||||
this.pointerFeedbackHintText?.setVisible(false);
|
||||
this.lastPointerFeedbackKey = undefined;
|
||||
}
|
||||
|
||||
private unitAtTile(x: number, y: number, exceptUnitId?: string) {
|
||||
return battleUnits.find((unit) => unit.id !== exceptUnitId && unit.hp > 0 && unit.x === x && unit.y === y);
|
||||
}
|
||||
|
||||
private singleLineHint(text: string) {
|
||||
return text.replace(/\n/g, ' · ');
|
||||
}
|
||||
|
||||
private selectUnit(unit: UnitData) {
|
||||
this.hideMapMenu();
|
||||
this.hideSaveSlotPanel();
|
||||
@@ -4247,6 +4507,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.markers.forEach((marker) => marker.destroy());
|
||||
this.markers = [];
|
||||
this.lockedTargetPreview = undefined;
|
||||
this.clearPointerFeedback();
|
||||
}
|
||||
|
||||
private showEnemyThreatRange() {
|
||||
|
||||
Reference in New Issue
Block a user