Add enemy threat range overlay

This commit is contained in:
2026-06-22 19:31:58 +09:00
parent e063154e26
commit 0b7047e8bf
2 changed files with 289 additions and 10 deletions

View File

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

View File

@@ -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<TerrainType, string> = {
const mapMenuLabels: Partial<Record<MapMenuAction, string>> = {
endTurn: '턴 종료',
threat: '위험 범위',
roster: '부대 일람',
bond: '공명',
situation: '전황',
@@ -527,6 +544,7 @@ export class BattleScene extends Phaser.Scene {
private commandButtons: CommandButton[] = [];
private commandMenuObjects: Array<Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text> = [];
private mapMenuObjects: Array<Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text> = [];
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<string, ThreatTile>();
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,