From 0b7047e8bf76e90b8712fe5893b660ede0dac999 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Mon, 22 Jun 2026 19:31:58 +0900 Subject: [PATCH] Add enemy threat range overlay --- scripts/verify-flow.mjs | 14 +- src/game/scenes/BattleScene.ts | 285 ++++++++++++++++++++++++++++++++- 2 files changed, 289 insertions(+), 10 deletions(-) diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index 5009b31..6eab1d0 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -108,7 +108,17 @@ try { await page.waitForTimeout(700); await page.mouse.click(260, 300, { button: 'right' }); await page.waitForTimeout(120); - await page.mouse.click(182, 213); + await page.mouse.click(182, 196); + await page.waitForTimeout(160); + + const threatState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + if (!threatState?.threatMarkerCount || threatState.threatMarkerCount <= 0) { + throw new Error(`Expected enemy threat markers from map menu: ${JSON.stringify(threatState)}`); + } + + await page.mouse.click(260, 300, { button: 'right' }); + await page.waitForTimeout(120); + await page.mouse.click(182, 230); await page.waitForTimeout(120); await page.mouse.click(400, 213); await page.waitForTimeout(180); @@ -123,7 +133,7 @@ try { await page.mouse.click(260, 300, { button: 'right' }); await page.waitForTimeout(120); - await page.mouse.click(182, 247); + await page.mouse.click(182, 264); await page.waitForTimeout(120); await page.mouse.click(400, 213); await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.scene === 'BattleScene'); diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 5b1e322..a215718 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -104,7 +104,7 @@ type UsableEffect = 'damage' | 'heal' | 'focus'; type UsableTarget = 'enemy' | 'ally' | 'self'; type RosterTab = 'ally' | 'enemy'; type ActiveFaction = 'ally' | 'enemy'; -type MapMenuAction = 'endTurn' | 'save' | 'load' | 'roster' | 'bond' | 'situation' | 'bgm' | 'close'; +type MapMenuAction = 'endTurn' | 'threat' | 'save' | 'load' | 'roster' | 'bond' | 'situation' | 'bgm' | 'close'; type EnemyAiBehavior = 'aggressive' | 'guard' | 'hold'; type BattleOutcome = 'victory' | 'defeat'; type SaveSlotMode = 'save' | 'load'; @@ -144,6 +144,22 @@ type UnitBattleStats = { support: number; }; +type ThreatTile = { + x: number; + y: number; + enemies: UnitData[]; + strongestBehavior: EnemyAiBehavior; +}; + +type MapMenuLayout = { + left: number; + top: number; + width: number; + buttonHeight: number; + padding: number; + actions: MapMenuAction[]; +}; + type BattleObjectiveResult = { id: string; label: string; @@ -467,6 +483,7 @@ const terrainDisplayLabels: Record = { const mapMenuLabels: Partial> = { endTurn: '턴 종료', + threat: '위험 범위', roster: '부대 일람', bond: '공명', situation: '전황', @@ -527,6 +544,7 @@ export class BattleScene extends Phaser.Scene { private commandButtons: CommandButton[] = []; private commandMenuObjects: Array = []; private mapMenuObjects: Array = []; + private mapMenuLayout?: MapMenuLayout; private alertObjects: Phaser.GameObjects.GameObject[] = []; private alertDismissEvent?: Phaser.Time.TimerEvent; private battleEventObjects: Phaser.GameObjects.GameObject[] = []; @@ -557,6 +575,7 @@ export class BattleScene extends Phaser.Scene { private cameraTileX = 0; private cameraTileY = 0; private edgeScrollElapsed = 0; + private suppressNextLeftClick = false; constructor() { super('BattleScene'); @@ -1043,7 +1062,7 @@ export class BattleScene extends Phaser.Scene { this.phase = 'idle'; this.clearMarkers(); this.hideCommandMenu(); - this.renderUnitDetail(unit, '적 부대 정보입니다.'); + this.renderUnitDetail(unit, this.enemyDetailMessage(unit)); return; } @@ -1221,6 +1240,133 @@ export class BattleScene extends Phaser.Scene { this.markers = []; } + private showEnemyThreatRange() { + this.clearMarkers(); + const threatTiles = this.enemyThreatTiles(); + threatTiles.forEach((tile) => this.renderThreatMarker(tile)); + const aggressiveCount = threatTiles.filter((tile) => tile.strongestBehavior === 'aggressive').length; + const guardCount = threatTiles.filter((tile) => tile.strongestBehavior === 'guard').length; + const holdCount = threatTiles.filter((tile) => tile.strongestBehavior === 'hold').length; + this.renderSituationPanel( + [ + `위험 범위 ${threatTiles.length}칸 표시`, + aggressiveCount > 0 ? `추격 ${aggressiveCount}칸` : '', + guardCount > 0 ? `경계 ${guardCount}칸` : '', + holdCount > 0 ? `고수 ${holdCount}칸` : '' + ] + .filter(Boolean) + .join('\n') + ); + } + + private enemyThreatTiles() { + const threats = new Map(); + battleUnits + .filter((enemy) => enemy.faction === 'enemy' && enemy.hp > 0) + .forEach((enemy) => { + const behavior = this.enemyBehavior(enemy); + const origins = + behavior === 'hold' + ? [{ x: enemy.x, y: enemy.y, cost: 0 }] + : [{ x: enemy.x, y: enemy.y, cost: 0 }, ...this.reachableTiles(enemy)]; + const range = this.attackRange(enemy); + + origins.forEach((origin) => { + this.tilesWithinDistance(origin.x, origin.y, range).forEach(({ x, y }) => { + if (!this.isThreatTargetTile(x, y, enemy.id)) { + return; + } + + const key = this.tileKey(x, y); + const existing = threats.get(key); + if (!existing) { + threats.set(key, { + x, + y, + enemies: [enemy], + strongestBehavior: behavior + }); + return; + } + + if (!existing.enemies.some((candidate) => candidate.id === enemy.id)) { + existing.enemies.push(enemy); + } + if (this.threatBehaviorRank(behavior) > this.threatBehaviorRank(existing.strongestBehavior)) { + existing.strongestBehavior = behavior; + } + }); + }); + }); + + return Array.from(threats.values()).sort((a, b) => a.y - b.y || a.x - b.x); + } + + private renderThreatMarker(tile: ThreatTile) { + const color = this.threatColor(tile.strongestBehavior); + const alpha = Phaser.Math.Clamp(0.18 + tile.enemies.length * 0.07, 0.2, 0.48); + const marker = this.add.rectangle(this.tileTopLeftX(tile.x), this.tileTopLeftY(tile.y), this.layout.tileSize, this.layout.tileSize, color, alpha); + marker.setData('tileX', tile.x); + marker.setData('tileY', tile.y); + marker.setData('markerType', 'threat'); + marker.setOrigin(0); + marker.setStrokeStyle(1, 0xffd1a1, tile.strongestBehavior === 'aggressive' ? 0.66 : 0.42); + marker.setDepth(4.5); + if (this.mapMask) { + marker.setMask(this.mapMask); + } + marker.setVisible(this.isTileVisible(tile.x, tile.y)); + marker.setInteractive({ useHandCursor: true }); + marker.on('pointerover', () => { + marker.setFillStyle(color, Phaser.Math.Clamp(alpha + 0.16, 0.3, 0.64)); + this.renderThreatDetail(tile); + }); + marker.on('pointerout', () => marker.setFillStyle(color, alpha)); + marker.on('pointerdown', () => this.renderThreatDetail(tile)); + this.markers.push(marker); + } + + private tilesWithinDistance(originX: number, originY: number, distance: number) { + const tiles: Array<{ x: number; y: number }> = []; + for (let y = originY - distance; y <= originY + distance; y += 1) { + for (let x = originX - distance; x <= originX + distance; x += 1) { + if (!this.isInBounds(x, y) || Math.abs(originX - x) + Math.abs(originY - y) > distance) { + continue; + } + tiles.push({ x, y }); + } + } + return tiles; + } + + private isThreatTargetTile(x: number, y: number, enemyId: string) { + const terrainRule = getTerrainRule(battleMap.terrain[y][x]); + if (terrainRule.passable === false) { + return false; + } + return !battleUnits.some((unit) => unit.id !== enemyId && unit.faction === 'enemy' && unit.hp > 0 && unit.x === x && unit.y === y); + } + + private threatBehaviorRank(behavior: EnemyAiBehavior) { + if (behavior === 'aggressive') { + return 3; + } + if (behavior === 'guard') { + return 2; + } + return 1; + } + + private threatColor(behavior: EnemyAiBehavior) { + if (behavior === 'aggressive') { + return 0xc93b30; + } + if (behavior === 'guard') { + return 0xd8732c; + } + return 0x9e3e58; + } + private showCommandMenu(unit: UnitData, pointerX: number, pointerY: number) { this.hideCommandMenu(); @@ -2813,6 +2959,17 @@ export class BattleScene extends Phaser.Scene { return; } + if (this.suppressNextLeftClick) { + this.suppressNextLeftClick = false; + return; + } + + const mapMenuAction = this.mapMenuActionAt(pointer.x, pointer.y); + if (mapMenuAction) { + this.runMapMenuAction(mapMenuAction); + return; + } + if (this.phase !== 'idle' || !this.isPointerOnEmptyMapTile(pointer)) { return; } @@ -2865,13 +3022,14 @@ export class BattleScene extends Phaser.Scene { this.pendingMove = undefined; this.phase = 'idle'; - const actions: MapMenuAction[] = ['endTurn', 'save', 'load', 'roster', 'bond', 'situation', 'bgm', 'close']; + const actions: MapMenuAction[] = ['endTurn', 'threat', 'save', 'load', 'roster', 'bond', 'situation', 'bgm', 'close']; const menuWidth = 148; const buttonHeight = 34; const padding = 8; const menuHeight = padding * 2 + actions.length * buttonHeight; const left = this.layout.mapX + 84; const top = Phaser.Math.Clamp(pointerY - menuHeight / 2, this.layout.mapY + 16, this.layout.mapY + this.layout.mapHeight - menuHeight - 16); + this.mapMenuLayout = { left, top, width: menuWidth, buttonHeight, padding, actions }; const panel = this.add.rectangle(left, top, menuWidth, menuHeight, 0x101821, 0.97); panel.setOrigin(0); @@ -2881,10 +3039,7 @@ export class BattleScene extends Phaser.Scene { actions.forEach((action, index) => { const y = top + padding + index * buttonHeight; - const disabled = - (action === 'endTurn' && this.activeFaction !== 'ally') || - ((action === 'save' || action === 'load') && this.activeFaction !== 'ally') || - (action === 'load' && !this.hasBattleSave()); + const disabled = this.isMapMenuActionDisabled(action); const bg = this.add.rectangle(left + 8, y, menuWidth - 16, buttonHeight - 4, disabled ? 0x121820 : 0x1a2630, disabled ? 0.72 : 0.94); bg.setOrigin(0); bg.setDepth(21); @@ -2902,7 +3057,10 @@ export class BattleScene extends Phaser.Scene { this.mapMenuObjects.push(text); if (!disabled) { - const run = () => this.runMapMenuAction(action); + const run = () => { + this.suppressNextLeftClick = true; + this.runMapMenuAction(action); + }; bg.setInteractive({ useHandCursor: true }); bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98)); bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.94)); @@ -2921,6 +3079,10 @@ export class BattleScene extends Phaser.Scene { this.endAllyTurn(); return; } + if (action === 'threat') { + this.showEnemyThreatRange(); + return; + } if (action === 'save') { this.showSaveSlotPanel('save'); return; @@ -2950,6 +3112,31 @@ export class BattleScene extends Phaser.Scene { private hideMapMenu() { this.mapMenuObjects.forEach((object) => object.destroy()); this.mapMenuObjects = []; + this.mapMenuLayout = undefined; + } + + private mapMenuActionAt(x: number, y: number) { + if (!this.mapMenuLayout) { + return undefined; + } + + const { left, top, width, buttonHeight, padding, actions } = this.mapMenuLayout; + if (x < left || x > left + width || y < top + padding || y > top + padding + actions.length * buttonHeight) { + return undefined; + } + + const index = Math.floor((y - top - padding) / buttonHeight); + const action = actions[index]; + return action && !this.isMapMenuActionDisabled(action) ? action : undefined; + } + + private isMapMenuActionDisabled(action: MapMenuAction) { + return ( + (action === 'endTurn' && this.activeFaction !== 'ally') || + (action === 'threat' && this.activeFaction !== 'ally') || + ((action === 'save' || action === 'load') && this.activeFaction !== 'ally') || + (action === 'load' && !this.hasBattleSave()) + ); } private mapMenuLabel(action: MapMenuAction) { @@ -3461,6 +3648,31 @@ export class BattleScene extends Phaser.Scene { return '경계'; } + private enemyDetailMessage(enemy: UnitData) { + const behavior = this.enemyBehavior(enemy); + const nearest = this.nearestAliveUnit(enemy, 'ally'); + const distance = nearest ? Math.abs(enemy.x - nearest.x) + Math.abs(enemy.y - nearest.y) : undefined; + const distanceText = nearest ? `\n가장 가까운 아군: ${nearest.name} ${distance}칸` : ''; + return [ + `${this.enemyBehaviorLabel(behavior)} 태세 · 이동 ${enemy.move} · 사거리 ${this.attackRange(enemy)}`, + this.enemyBehaviorDescription(behavior), + `공격력 ${this.actionPower(enemy, 'attack')} · 방어 ${this.equipmentDefenseBonus(enemy)} · 명중 보정 ${this.hitBuff(enemy)}`, + distanceText.trim() + ] + .filter(Boolean) + .join('\n'); + } + + private enemyBehaviorDescription(behavior: EnemyAiBehavior) { + if (behavior === 'aggressive') { + return '계속 접근하여 공격 대상을 만들려 합니다.'; + } + if (behavior === 'hold') { + return '자리를 지키며 사거리 안의 대상만 노립니다.'; + } + return `초기 위치 주변 ${guardDetectionRange}칸 안에 들어온 아군을 추격합니다.`; + } + private formatEnemyCombatResult(result: CombatResult, behavior: EnemyAiBehavior) { const outcome = result.hit ? `${result.critical ? '치명타 ' : ''}${result.damage} 피해` : '회피됨'; const defeated = result.defeated ? ` / ${result.defender.name} 퇴각` : ` / ${result.defender.name} HP ${result.defender.hp}/${result.defender.maxHp}`; @@ -5307,6 +5519,61 @@ export class BattleScene extends Phaser.Scene { return palette.gold; } + private renderThreatDetail(tile: ThreatTile) { + this.clearSidePanelContent(); + const terrain = battleMap.terrain[tile.y][tile.x]; + const terrainRule = getTerrainRule(terrain); + const { panelX, panelWidth } = this.layout; + const left = panelX + 24; + const width = panelWidth - 48; + const top = this.sideContentTop(); + const strongest = [...tile.enemies].sort((a, b) => this.actionPower(b, 'attack') - this.actionPower(a, 'attack'))[0]; + + this.trackSideObject(this.add.text(left, top, '위험 범위', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '24px', + color: '#f2e3bf', + fontStyle: '700' + })); + + this.renderSituationLine('좌표', `${tile.x + 1}, ${tile.y + 1}`, left, top + 48, width); + this.renderSituationLine('지형', `${terrainRule.label} / 방어 ${this.formatSignedValue(terrainRule.defenseBonus)}`, left, top + 86, width); + this.renderSituationLine('위협 수', `${tile.enemies.length}`, left, top + 124, width); + this.renderSituationLine('최대 태세', this.enemyBehaviorLabel(tile.strongestBehavior), left, top + 162, width); + + this.trackSideObject(this.add.text(left, top + 206, '위협 부대', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#f2e3bf', + fontStyle: '700' + })); + + tile.enemies.slice(0, 4).forEach((enemy, index) => { + const rowY = top + 232 + index * 28; + const unitClass = getUnitClass(enemy.classKey); + const bg = this.trackSideObject(this.add.rectangle(left, rowY, width, 24, 0x101820, 0.86)); + bg.setOrigin(0); + bg.setStrokeStyle(1, this.threatColor(this.enemyBehavior(enemy)), 0.52); + this.trackSideObject(this.add.text(left + 10, rowY + 5, `${enemy.name} · ${unitClass.name}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: '#d4dce6', + fontStyle: '700' + })); + const value = this.trackSideObject(this.add.text(left + width - 10, rowY + 5, `${this.enemyBehaviorLabel(this.enemyBehavior(enemy))} / 공 ${this.actionPower(enemy, 'attack')} / 사 ${this.attackRange(enemy)}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: '#f4dfad' + })); + value.setOrigin(1, 0); + }); + + const message = strongest + ? `${strongest.name} 최대 위협. 방어 보정 지형을 활용하세요.` + : '현재 이 칸을 위협하는 적이 없습니다.'; + this.renderPanelMessage(message, left, top + 348, width, 46); + } + private renderTerrainDetail(x: number, y: number) { this.clearSidePanelContent(); const terrain = battleMap.terrain[y][x]; @@ -5759,6 +6026,8 @@ export class BattleScene extends Phaser.Scene { phase: this.phase, battleOutcome: this.battleOutcome ?? null, resultVisible: this.resultObjects.length > 0, + markerCount: this.markers.length, + threatMarkerCount: this.markers.filter((marker) => marker.getData('markerType') === 'threat').length, camera: { x: this.cameraTileX, y: this.cameraTileY,