Add enemy threat range overlay
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user