diff --git a/docs/interaction-feedback-local-move-invalid.png b/docs/interaction-feedback-local-move-invalid.png new file mode 100644 index 0000000..20025ab Binary files /dev/null and b/docs/interaction-feedback-local-move-invalid.png differ diff --git a/docs/interaction-feedback-local-move-valid.png b/docs/interaction-feedback-local-move-valid.png new file mode 100644 index 0000000..b34d9c8 Binary files /dev/null and b/docs/interaction-feedback-local-move-valid.png differ diff --git a/docs/interaction-feedback-local-target-hover.png b/docs/interaction-feedback-local-target-hover.png new file mode 100644 index 0000000..946c42c Binary files /dev/null and b/docs/interaction-feedback-local-target-hover.png differ diff --git a/docs/interaction-feedback-local-target-preview.png b/docs/interaction-feedback-local-target-preview.png new file mode 100644 index 0000000..f5a38b5 Binary files /dev/null and b/docs/interaction-feedback-local-target-preview.png differ diff --git a/docs/interaction-feedback-local-unit-hover.png b/docs/interaction-feedback-local-unit-hover.png new file mode 100644 index 0000000..040f97e Binary files /dev/null and b/docs/interaction-feedback-local-unit-hover.png differ diff --git a/docs/interaction-feedback-prod-move-invalid.png b/docs/interaction-feedback-prod-move-invalid.png new file mode 100644 index 0000000..08f0fe9 Binary files /dev/null and b/docs/interaction-feedback-prod-move-invalid.png differ diff --git a/docs/interaction-feedback-prod-move-valid.png b/docs/interaction-feedback-prod-move-valid.png new file mode 100644 index 0000000..c6040ac Binary files /dev/null and b/docs/interaction-feedback-prod-move-valid.png differ diff --git a/docs/interaction-feedback-prod-target-hover.png b/docs/interaction-feedback-prod-target-hover.png new file mode 100644 index 0000000..91b6723 Binary files /dev/null and b/docs/interaction-feedback-prod-target-hover.png differ diff --git a/docs/interaction-feedback-prod-target-preview.png b/docs/interaction-feedback-prod-target-preview.png new file mode 100644 index 0000000..fd317e0 Binary files /dev/null and b/docs/interaction-feedback-prod-target-preview.png differ diff --git a/docs/interaction-feedback-prod-unit-hover.png b/docs/interaction-feedback-prod-unit-hover.png new file mode 100644 index 0000000..0488191 Binary files /dev/null and b/docs/interaction-feedback-prod-unit-hover.png differ diff --git a/docs/interaction-feedback-report.md b/docs/interaction-feedback-report.md new file mode 100644 index 0000000..bbe15b2 --- /dev/null +++ b/docs/interaction-feedback-report.md @@ -0,0 +1,40 @@ +# Battle Interaction Feedback Verification + +Date: 2026-07-03 + +## Scope + +- Added tile-centered pointer feedback for idle unit hover, movement selection, and attack/support targeting. +- Kept the previous tile/foot-based click model intact. +- Avoided unit bases, selection rings, and large badges over unit sprites. +- Used thin tile highlights plus a compact side-panel hint line. + +## Local Verification + +- URL: `http://127.0.0.1:4194/heros_web/?debug&debugBattleSetup=attack-preview&v=interaction-feedback-local2` +- Build: `pnpm build` passed. +- Checked unit hover, valid movement tile, invalid occupied tile, attack command menu, target hover, and target preview. +- Console errors: 0. + +Screenshots: + +- `docs/interaction-feedback-local-unit-hover.png` +- `docs/interaction-feedback-local-move-valid.png` +- `docs/interaction-feedback-local-move-invalid.png` +- `docs/interaction-feedback-local-target-hover.png` +- `docs/interaction-feedback-local-target-preview.png` + +## Deployed Verification + +- URL: `https://comtropy.synology.me/heros_web/?debug&debugBattleSetup=attack-preview&v=interaction-feedback-20260703` +- Deploy: `pnpm deploy:nas` passed and deployed to `/volume1/web/heros_web`. +- Checked the same unit hover, movement selection, and target selection flow on the deployed site. +- Console errors: 0. + +Screenshots: + +- `docs/interaction-feedback-prod-unit-hover.png` +- `docs/interaction-feedback-prod-move-valid.png` +- `docs/interaction-feedback-prod-move-invalid.png` +- `docs/interaction-feedback-prod-target-hover.png` +- `docs/interaction-feedback-prod-target-preview.png` diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 4f793ea..9ea1d6f 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -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>(); 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() {