Surface terrain effects in battle UI

This commit is contained in:
2026-07-04 11:37:19 +09:00
parent 6281776c94
commit a6b47fcbe6
5 changed files with 211 additions and 75 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View File

@@ -1341,6 +1341,7 @@ type CombatPreview = {
criticalRate: number; criticalRate: number;
terrainDefenseBonus: number; terrainDefenseBonus: number;
terrainHitPenalty: number; terrainHitPenalty: number;
terrainAffinity: number;
counterAvailable: boolean; counterAvailable: boolean;
terrainLabel: string; terrainLabel: string;
matchupLabel: string; matchupLabel: string;
@@ -2920,18 +2921,6 @@ const factionLabels: Record<ActiveFaction, string> = {
enemy: '적군' enemy: '적군'
}; };
const terrainDisplayLabels: Record<TerrainType, string> = {
plain: '평지',
road: '길',
forest: '숲',
hill: '언덕',
village: '마을',
fort: '요새',
camp: '진영',
river: '강',
cliff: '절벽'
};
const mapMenuLabels: Partial<Record<MapMenuAction, string>> = { const mapMenuLabels: Partial<Record<MapMenuAction, string>> = {
endTurn: '턴 종료', endTurn: '턴 종료',
threat: '위험 범위', threat: '위험 범위',
@@ -4136,13 +4125,16 @@ export class BattleScene extends Phaser.Scene {
this.updateUnitPointerFeedback(unit, tile, force); this.updateUnitPointerFeedback(unit, tile, force);
return; return;
} }
this.updateTerrainPointerFeedback(tile, force);
return;
} }
this.clearPointerFeedback(); this.clearPointerFeedback();
} }
private updateUnitPointerFeedback(unit: UnitData, tile: { x: number; y: number }, force: boolean) { 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)}`; const terrain = battleMap.terrain[tile.y][tile.x];
const key = `unit:${unit.id}:${tile.x},${tile.y}:${this.activeFaction}:${this.actedUnitIds.has(unit.id)}:${terrain}`;
if (!force && this.lastPointerFeedbackKey === key) { if (!force && this.lastPointerFeedbackKey === key) {
return; return;
} }
@@ -4150,34 +4142,58 @@ export class BattleScene extends Phaser.Scene {
const acted = this.actedUnitIds.has(unit.id); const acted = this.actedUnitIds.has(unit.id);
const selectable = unit.faction === 'ally' && this.activeFaction === 'ally' && !acted; const selectable = unit.faction === 'ally' && this.activeFaction === 'ally' && !acted;
const tone = selectable ? palette.blue : unit.faction === 'enemy' ? 0xd8732c : 0x647485; const tone = selectable ? palette.blue : unit.faction === 'enemy' ? 0xd8732c : 0x647485;
const terrainHint = this.terrainEffectText(terrain, unit, 'compact');
const text = selectable const text = selectable
? `${unit.name}: 선택 가능 · 클릭하면 이동 범위 표시` ? `${unit.name}: 선택 가능 · ${terrainHint}`
: unit.faction === 'enemy' : unit.faction === 'enemy'
? `${unit.name}: 적 부대 · 클릭하면 위협 범위 확인` ? `${unit.name}: 적 부대 · ${terrainHint}`
: `${unit.name}: 행동 완료`; : `${unit.name}: 행동 완료 · ${terrainHint}`;
this.showPointerFeedback(tile, tone, text, key, 0x1c7ed6, 0.1, 0.8); this.showPointerFeedback(tile, tone, text, key, 0x1c7ed6, 0.1, 0.8);
} }
private updateTerrainPointerFeedback(tile: { x: number; y: number }, force: boolean) {
const terrain = battleMap.terrain[tile.y][tile.x];
const key = `terrain:${tile.x},${tile.y}:${terrain}`;
if (!force && this.lastPointerFeedbackKey === key) {
return;
}
const terrainRule = getTerrainRule(terrain);
const tone = this.terrainTone(terrain);
const text = `${terrainRule.label}: ${this.terrainEffectText(terrain, undefined, 'compact')} · 클릭하면 상세`;
this.showPointerFeedback(tile, tone, text, key, terrainRule.color, terrainRule.passable === false ? 0.16 : 0.1, 0.68);
this.renderTerrainDetail(tile.x, tile.y);
}
private updateMovePointerFeedback(unit: UnitData, tile: { x: number; y: number }, force: boolean) { private updateMovePointerFeedback(unit: UnitData, tile: { x: number; y: number }, force: boolean) {
const destination = this.unitAtTile(tile.x, tile.y, unit.id); const destination = this.unitAtTile(tile.x, tile.y, unit.id);
const canMove = this.canMoveTo(unit, tile.x, tile.y); const canMove = this.canMoveTo(unit, tile.x, tile.y);
const key = `move:${unit.id}:${tile.x},${tile.y}:${canMove}:${destination?.id ?? 'empty'}`; const terrain = battleMap.terrain[tile.y][tile.x];
const moveCost = this.movementCostTo(unit, tile.x, tile.y);
const key = `move:${unit.id}:${tile.x},${tile.y}:${canMove}:${destination?.id ?? 'empty'}:${moveCost ?? 'x'}:${terrain}`;
if (!force && this.lastPointerFeedbackKey === key) { if (!force && this.lastPointerFeedbackKey === key) {
return; return;
} }
if (canMove) { if (canMove) {
const distance = this.movementTileDistance(unit, tile.x, tile.y); const spent = moveCost ?? 0;
const remaining = Math.max(0, unit.move - spent);
const terrainHint = this.terrainEffectText(terrain, unit, 'move');
const text = const text =
distance === 0 spent === 0
? `${unit.name}: 제자리에서 명령 선택` ? `${unit.name}: 제자리 명령 · ${terrainHint}`
: `${unit.name}: 이동 가능 · ${distance} · 클릭해 확정`; : `${unit.name}: 이동 가능 · 소모 ${spent}/${unit.move} · 남은 ${remaining} · ${terrainHint}`;
this.showPointerFeedback(tile, palette.blue, text, key, 0x1c7ed6, 0.12, 0.9); this.showPointerFeedback(tile, palette.blue, text, key, 0x1c7ed6, 0.12, 0.9);
return; return;
} }
const reason = destination ? `${destination.name}이 있는 칸` : '이동 범위 밖'; const terrainRule = getTerrainRule(terrain);
const reason = destination
? `${destination.name} 배치 칸`
: terrainRule.passable === false
? `${terrainRule.label} 진입 불가`
: `이동 범위 밖 · ${this.terrainEffectText(terrain, unit, 'move')}`;
this.showPointerFeedback(tile, 0xb64a45, `이동 불가: ${reason}`, key, 0xb64a45, 0.08, 0.75); this.showPointerFeedback(tile, 0xb64a45, `이동 불가: ${reason}`, key, 0xb64a45, 0.08, 0.75);
} }
@@ -4212,10 +4228,11 @@ export class BattleScene extends Phaser.Scene {
const preview = this.combatPreview(unit, target, action, usable); const preview = this.combatPreview(unit, target, action, usable);
const locked = this.isLockedDamageTarget(unit, target, action, usable); const locked = this.isLockedDamageTarget(unit, target, action, usable);
const counter = preview.counterAvailable ? '반격 주의' : '반격 없음'; const counter = preview.counterAvailable ? '반격 주의' : '반격 없음';
const terrain = `${preview.terrainLabel} ${this.combatTerrainSummary(preview)}`;
this.showPointerFeedback( this.showPointerFeedback(
tile, tile,
this.previewAccentColor(preview), this.previewAccentColor(preview),
`${target.name}: 피해 ${preview.damage} · ${this.previewSuccessLabel(preview)} ${preview.hitRate}% · ${counter}`, `${target.name}: 피해 ${preview.damage} · ${this.previewSuccessLabel(preview)} ${preview.hitRate}% · ${counter} · ${terrain}`,
key, key,
0xd9503f, 0xd9503f,
0.16, 0.16,
@@ -4445,27 +4462,30 @@ export class BattleScene extends Phaser.Scene {
} }
private showMoveRange(unit: UnitData) { private showMoveRange(unit: UnitData) {
this.reachableTiles(unit).forEach(({ x, y }) => { this.reachableTiles(unit).forEach(({ x, y, cost }) => {
const remaining = Math.max(0, unit.move - cost);
const fillAlpha = Phaser.Math.Clamp(0.2 + remaining / Math.max(1, unit.move) * 0.18, 0.2, 0.38);
const strokeAlpha = cost === 0 ? 0.94 : Phaser.Math.Clamp(0.48 + remaining / Math.max(1, unit.move) * 0.34, 0.48, 0.82);
const marker = this.add.rectangle( const marker = this.add.rectangle(
this.tileTopLeftX(x), this.tileTopLeftX(x),
this.tileTopLeftY(y), this.tileTopLeftY(y),
this.layout.tileSize, this.layout.tileSize,
this.layout.tileSize, this.layout.tileSize,
palette.blue, palette.blue,
0.34 fillAlpha
); );
marker.setData('tileX', x); marker.setData('tileX', x);
marker.setData('tileY', y); marker.setData('tileY', y);
marker.setOrigin(0); marker.setOrigin(0);
marker.setStrokeStyle(2, palette.blue, 0.84); marker.setStrokeStyle(cost === 0 ? 2 : 1, palette.blue, strokeAlpha);
marker.setDepth(4); marker.setDepth(4);
if (this.mapMask) { if (this.mapMask) {
marker.setMask(this.mapMask); marker.setMask(this.mapMask);
} }
marker.setVisible(this.isTileVisible(x, y)); marker.setVisible(this.isTileVisible(x, y));
marker.setInteractive({ useHandCursor: true }); marker.setInteractive({ useHandCursor: true });
marker.on('pointerover', () => marker.setFillStyle(palette.blue, 0.48)); marker.on('pointerover', () => marker.setFillStyle(palette.blue, Math.min(fillAlpha + 0.12, 0.5)));
marker.on('pointerout', () => marker.setFillStyle(palette.blue, 0.34)); marker.on('pointerout', () => marker.setFillStyle(palette.blue, fillAlpha));
marker.on('pointerdown', (pointer: Phaser.Input.Pointer) => { marker.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
if (pointer.leftButtonDown()) { if (pointer.leftButtonDown()) {
this.moveSelectedUnit(x, y, pointer); this.moveSelectedUnit(x, y, pointer);
@@ -4605,6 +4625,10 @@ export class BattleScene extends Phaser.Scene {
return Array.from(reachable.values()); return Array.from(reachable.values());
} }
private movementCostTo(unit: UnitData, x: number, y: number) {
return this.reachableTiles(unit).find((tile) => tile.x === x && tile.y === y)?.cost;
}
private movementCost(unit: UnitData, terrain: TerrainType) { private movementCost(unit: UnitData, terrain: TerrainType) {
const terrainRule = getTerrainRule(terrain); const terrainRule = getTerrainRule(terrain);
if (terrainRule.passable === false) { if (terrainRule.passable === false) {
@@ -4614,6 +4638,97 @@ export class BattleScene extends Phaser.Scene {
return unitClass.movementCosts?.[terrain] ?? terrainRule.moveCost; return unitClass.movementCosts?.[terrain] ?? terrainRule.moveCost;
} }
private terrainTone(terrain: TerrainType) {
const terrainRule = getTerrainRule(terrain);
if (terrainRule.passable === false) {
return 0x8b6b64;
}
if (terrainRule.recoveryHp || terrainRule.moraleBonus) {
return 0x59d18c;
}
if (terrainRule.defenseBonus >= 15) {
return palette.gold;
}
if (terrain === 'road') {
return 0xd8b15f;
}
if (terrain === 'forest' || terrain === 'hill') {
return 0x82c486;
}
return 0x9fb0bf;
}
private terrainEffectText(terrain: TerrainType, unit?: UnitData, mode: 'compact' | 'move' | 'detail' | 'card' = 'compact') {
const terrainRule = getTerrainRule(terrain);
const parts: string[] = [];
if (terrainRule.passable === false) {
parts.push('진입 불가');
} else {
const moveCost = unit ? this.movementCost(unit, terrain) : terrainRule.moveCost;
parts.push(mode === 'move' ? `지형 이동 ${moveCost}` : mode === 'card' ? `이동${moveCost}` : `이동 ${moveCost}`);
}
if (terrainRule.defenseBonus !== 0) {
parts.push(`${mode === 'detail' ? '방어 ' : '방'}${this.formatSignedValue(terrainRule.defenseBonus)}`);
}
if (unit) {
const affinity = getUnitClass(unit.classKey).terrainRatings[terrain] ?? 100;
if (affinity !== 100 || mode === 'detail') {
parts.push(mode === 'card' ? `적성${affinity}%` : `적성 ${affinity}%`);
}
}
if (mode === 'card' && terrainRule.recoveryHp && terrainRule.moraleBonus) {
parts.push(
terrainRule.recoveryHp === terrainRule.moraleBonus
? `보급+${terrainRule.recoveryHp}`
: `보급+${terrainRule.recoveryHp}/${terrainRule.moraleBonus}`
);
} else {
if (terrainRule.recoveryHp) {
parts.push(mode === 'card' ? `병력+${terrainRule.recoveryHp}` : `병력 +${terrainRule.recoveryHp}`);
}
if (terrainRule.moraleBonus) {
parts.push(mode === 'card' ? `사기+${terrainRule.moraleBonus}` : `사기 +${terrainRule.moraleBonus}`);
}
}
return parts.join(' · ') || '기본 지형';
}
private terrainTacticalNote(terrain: TerrainType) {
switch (terrain) {
case 'road':
return '길은 방어 이득은 적지만 전열을 빠르게 재배치하기 좋습니다.';
case 'forest':
return '숲은 방어가 올라가지만 기병은 이동이 둔해집니다.';
case 'hill':
return '언덕은 궁병과 책사가 버티기 좋고 접근로를 좁힙니다.';
case 'village':
return '마을은 방어와 턴 시작 회복을 함께 주는 거점입니다.';
case 'fort':
return '요새는 피해를 크게 줄여 전열 고정에 좋습니다.';
case 'camp':
return '진영은 방어와 사기 보급을 동시에 기대할 수 있습니다.';
case 'river':
return '강은 건널 수 없습니다. 길목이나 얕은 우회로를 찾아야 합니다.';
case 'cliff':
return '절벽은 진입할 수 없는 차단 지형입니다.';
default:
return '평지는 이동과 전투 모두 기본 기준이 되는 지형입니다.';
}
}
private combatTerrainSummary(preview: Pick<CombatPreview, 'terrainDefenseBonus' | 'terrainHitPenalty' | 'terrainAffinity'>) {
const parts = [
preview.terrainDefenseBonus !== 0 ? `${this.formatSignedValue(preview.terrainDefenseBonus)}` : '',
preview.terrainHitPenalty > 0 ? `명-${preview.terrainHitPenalty}` : '',
preview.terrainAffinity !== 100 ? `적성${preview.terrainAffinity}%` : ''
].filter(Boolean);
return parts.join(' / ') || '기본';
}
private isInBounds(x: number, y: number) { private isInBounds(x: number, y: number) {
return x >= 0 && y >= 0 && x < battleMap.width && y < battleMap.height; return x >= 0 && y >= 0 && x < battleMap.width && y < battleMap.height;
} }
@@ -5223,7 +5338,7 @@ export class BattleScene extends Phaser.Scene {
} }
if (occupant) { if (occupant) {
this.deploymentNotice = `${occupant.name}이 있는 칸입니다. 시작 구역 안의 빈 칸을 선택하세요.`; this.deploymentNotice = `${occupant.name} 배치 칸입니다. 시작 구역 안의 빈 칸을 선택하세요.`;
this.renderDeploymentPanel(); this.renderDeploymentPanel();
return; return;
} }
@@ -7444,8 +7559,11 @@ export class BattleScene extends Phaser.Scene {
const buff = this.battleBuffs.get(preview.attacker.id); const buff = this.battleBuffs.get(preview.attacker.id);
const equipmentText = this.equipmentPreviewBonusText(preview); const equipmentText = this.equipmentPreviewBonusText(preview);
const terrainParts = [ const terrainParts = [
preview.terrainDefenseBonus > 0 ? `${preview.action === 'strategy' ? '저항' : '방'}+${preview.terrainDefenseBonus}` : '', preview.terrainDefenseBonus !== 0
preview.terrainHitPenalty > 0 ? `${preview.action === 'strategy' ? '성공' : ''}-${preview.terrainHitPenalty}` : '' ? `${preview.action === 'strategy' ? '저항' : ''}${this.formatSignedValue(preview.terrainDefenseBonus)}`
: '',
preview.terrainHitPenalty > 0 ? `${preview.action === 'strategy' ? '성공' : '명'}-${preview.terrainHitPenalty}` : '',
preview.terrainAffinity !== 100 ? `적성${preview.terrainAffinity}%` : ''
].filter(Boolean); ].filter(Boolean);
if (preview.action === 'strategy') { if (preview.action === 'strategy') {
@@ -7677,7 +7795,7 @@ export class BattleScene extends Phaser.Scene {
} }
badgeX = this.renderPreviewBadge(badgeX, badgeY, 'critical', `치명 ${preview.criticalRate}%`, maxBadgeRight); badgeX = this.renderPreviewBadge(badgeX, badgeY, 'critical', `치명 ${preview.criticalRate}%`, maxBadgeRight);
badgeX = this.renderPreviewBadge(badgeX, badgeY, 'advantage', preview.matchupLabel, maxBadgeRight); badgeX = this.renderPreviewBadge(badgeX, badgeY, 'advantage', preview.matchupLabel, maxBadgeRight);
let detailBadgeX = this.renderPreviewBadge(badgeX, badgeY, 'terrain', preview.terrainLabel, maxBadgeRight); let detailBadgeX = this.renderPreviewBadge(badgeX, badgeY, 'terrain', `${preview.terrainLabel} ${this.combatTerrainSummary(preview)}`, maxBadgeRight);
if (preview.bondDamageBonus > 0 && preview.bondLabel) { if (preview.bondDamageBonus > 0 && preview.bondLabel) {
detailBadgeX = this.renderPreviewBadge(detailBadgeX, badgeY, 'leadership', `공명 +${preview.bondDamageBonus}%`, maxBadgeRight); detailBadgeX = this.renderPreviewBadge(detailBadgeX, badgeY, 'leadership', `공명 +${preview.bondDamageBonus}%`, maxBadgeRight);
} }
@@ -7859,22 +7977,22 @@ export class BattleScene extends Phaser.Scene {
const card = this.trackSideObject(this.add.rectangle(cardX, y, cardWidth, 58, 0x0b1118, 0.96)); const card = this.trackSideObject(this.add.rectangle(cardX, y, cardWidth, 58, 0x0b1118, 0.96));
card.setOrigin(0); card.setOrigin(0);
card.setStrokeStyle(1, summary.tone, 0.62); card.setStrokeStyle(1, summary.tone, 0.62);
const iconFrame = this.trackSideObject(this.add.rectangle(cardX + 21, y + 29, 38, 38, 0x16212d, 0.92)); const iconFrame = this.trackSideObject(this.add.rectangle(cardX + 18, y + 29, 34, 34, 0x16212d, 0.92));
iconFrame.setStrokeStyle(1, summary.tone, 0.5); iconFrame.setStrokeStyle(1, summary.tone, 0.5);
this.trackSideIcon(cardX + 21, y + 29, summary.icon, 34); this.trackSideIcon(cardX + 18, y + 29, summary.icon, 30);
this.trackSideObject(this.add.text(cardX + 44, y + 7, summary.label, { this.trackSideObject(this.add.text(cardX + 38, y + 7, summary.label, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '10px', fontSize: '10px',
color: '#9fb0bf', color: '#9fb0bf',
fontStyle: '700', fontStyle: '700',
fixedWidth: cardWidth - 52 fixedWidth: cardWidth - 44
})); }));
this.trackSideObject(this.add.text(cardX + 44, y + 24, this.truncateUiText(summary.value, 14), { this.trackSideObject(this.add.text(cardX + 38, y + 24, this.truncateUiText(summary.value, 14), {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '12px', fontSize: '12px',
color: '#f2e3bf', color: '#f2e3bf',
fontStyle: '700', fontStyle: '700',
fixedWidth: cardWidth - 52 fixedWidth: cardWidth - 44
})); }));
}); });
@@ -9487,6 +9605,7 @@ export class BattleScene extends Phaser.Scene {
criticalRate, criticalRate,
terrainDefenseBonus: terrainRule.defenseBonus, terrainDefenseBonus: terrainRule.defenseBonus,
terrainHitPenalty, terrainHitPenalty,
terrainAffinity: defenderTerrainRating,
counterAvailable: this.canCounterAttack(defender, attacker, action), counterAvailable: this.canCounterAttack(defender, attacker, action),
terrainLabel: terrainRule.label, terrainLabel: terrainRule.label,
matchupLabel: this.combatMatchupLabel(attacker, defender, action) matchupLabel: this.combatMatchupLabel(attacker, defender, action)
@@ -15463,6 +15582,7 @@ export class BattleScene extends Phaser.Scene {
private renderTerrainDetail(x: number, y: number) { private renderTerrainDetail(x: number, y: number) {
this.clearSidePanelContent(); this.clearSidePanelContent();
this.setMiniMapVisible(true);
const terrain = battleMap.terrain[y][x]; const terrain = battleMap.terrain[y][x];
const terrainRule = getTerrainRule(terrain); const terrainRule = getTerrainRule(terrain);
const { panelX, panelWidth } = this.layout; const { panelX, panelWidth } = this.layout;
@@ -15471,34 +15591,53 @@ export class BattleScene extends Phaser.Scene {
const top = this.sideContentTop(); const top = this.sideContentTop();
const attackDefense = Math.floor(terrainRule.defenseBonus / 3); const attackDefense = Math.floor(terrainRule.defenseBonus / 3);
const strategyDefense = Math.floor(terrainRule.defenseBonus / 4); const strategyDefense = Math.floor(terrainRule.defenseBonus / 4);
const itemDefense = Math.floor(terrainRule.defenseBonus / 5); const hitPenalty = Math.max(0, Math.floor(terrainRule.defenseBonus / 2));
const turnEffect = [
terrainRule.recoveryHp ? `병력 +${terrainRule.recoveryHp}` : '',
terrainRule.moraleBonus ? `사기 +${terrainRule.moraleBonus}` : ''
].filter(Boolean).join(' / ') || '없음';
this.trackSideObject(this.add.text(left, top, '지형 정보', { const header = this.trackSideObject(this.add.rectangle(left, top, width, 58, 0x101820, 0.94));
header.setOrigin(0);
header.setStrokeStyle(1, this.terrainTone(terrain), 0.72);
const iconFrame = this.trackSideObject(this.add.rectangle(left + 28, top + 29, 44, 44, 0x0a0f14, 0.92));
iconFrame.setStrokeStyle(1, this.terrainTone(terrain), 0.58);
this.trackSideIcon(left + 28, top + 29, 'terrain', 40);
this.trackSideObject(this.add.text(left + 58, top + 7, '지형 정보', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '24px', fontSize: '13px',
color: '#9fb0bf',
fontStyle: '700'
}));
this.trackSideObject(this.add.text(left + 58, top + 27, terrainRule.label, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '22px',
color: '#f2e3bf', color: '#f2e3bf',
fontStyle: '700' fontStyle: '700'
})); }));
this.renderSituationLine('좌표', `${x + 1}, ${y + 1}`, left, top + 48, width); this.renderSituationLine('좌표', `${x + 1}, ${y + 1}`, left, top + 76, width);
this.renderSituationLine('지형', terrainDisplayLabels[terrain], left, top + 92, width); this.renderSituationLine('이동', terrainRule.passable === false ? '진입 불가' : `기본 비용 ${terrainRule.moveCost}`, left, top + 120, width);
this.renderSituationLine('이동 비용', terrainRule.passable === false ? '진입 불가' : `${terrainRule.moveCost}`, left, top + 136, width); this.renderSituationLine(
this.renderSituationLine('방어 보정', this.formatSignedValue(terrainRule.defenseBonus), left, top + 180, width); '전투',
this.renderSituationLine('공격 방어', this.formatSignedValue(attackDefense), left, top + 224, width); terrainRule.defenseBonus === 0
this.renderSituationLine('책략 저항', this.formatSignedValue(strategyDefense), left, top + 268, width); ? '보정 없음'
this.renderSituationLine('도구 피해', this.formatSignedValue(itemDefense), left, top + 312, width); : `방어 ${this.formatSignedValue(terrainRule.defenseBonus)} / 명중 -${hitPenalty}`,
left,
const recovery = top + 164,
terrainRule.recoveryHp || terrainRule.moraleBonus width
? `\n턴 시작 시 병력 +${terrainRule.recoveryHp ?? 0}, 사기 +${terrainRule.moraleBonus ?? 0} 효과를 받습니다.` );
: ''; this.renderSituationLine(
const message = '피해 완화',
terrainRule.passable === false terrainRule.defenseBonus === 0 ? '없음' : `공격 -${attackDefense} / 책략 -${strategyDefense}`,
? '이 지형은 이동할 수 없습니다. 길목과 다리, 완만한 평지를 찾아 우회해야 합니다.' left,
: terrainRule.defenseBonus > 0 top + 208,
? '이 지형에 있는 부대는 방어 보정으로 받는 피해가 줄어듭니다.' width
: '이 지형은 기본 방어 보정이 없습니다.'; );
this.renderPanelMessage(`${message}${recovery}`, left, top + 370, width, 76); this.renderSituationLine('턴 시작', turnEffect, left, top + 252, width);
this.renderSituationLine('핵심 효과', this.terrainEffectText(terrain, undefined, 'compact'), left, top + 296, width);
this.renderPanelMessage(this.terrainTacticalNote(terrain), left, top + 350, width, 64);
} }
private formatSignedValue(value: number) { private formatSignedValue(value: number) {
@@ -15789,11 +15928,17 @@ export class BattleScene extends Phaser.Scene {
private unitEffectSummaries(unit: UnitData): UnitEffectSummary[] { private unitEffectSummaries(unit: UnitData): UnitEffectSummary[] {
const effects: UnitEffectSummary[] = []; const effects: UnitEffectSummary[] = [];
const buff = this.battleBuffs.get(unit.id); const buff = this.battleBuffs.get(unit.id);
const terrainRule = getTerrainRule(battleMap.terrain[unit.y][unit.x]); const terrain = battleMap.terrain[unit.y][unit.x];
const turnStartParts = [ const terrainRule = getTerrainRule(terrain);
terrainRule.recoveryHp ? `병력 +${terrainRule.recoveryHp}` : '', const terrainValue = this.terrainEffectText(terrain, unit, 'card');
terrainRule.moraleBonus ? `사기 +${terrainRule.moraleBonus}` : ''
].filter(Boolean); effects.push({
icon: 'terrain',
label: `${terrainRule.label} 지형`,
value: terrainValue,
tone: this.terrainTone(terrain),
polarity: 'terrain'
});
this.unitStatuses(unit).forEach((status) => { this.unitStatuses(unit).forEach((status) => {
effects.push({ effects.push({
@@ -15815,15 +15960,6 @@ export class BattleScene extends Phaser.Scene {
polarity: 'buff' polarity: 'buff'
}); });
} }
if (turnStartParts.length > 0) {
effects.push({
icon: 'terrain',
label: `${terrainRule.label} 턴 시작`,
value: turnStartParts.join(' / '),
tone: 0x59d18c,
polarity: 'terrain'
});
}
return effects; return effects;
} }
@@ -15849,7 +15985,7 @@ export class BattleScene extends Phaser.Scene {
fontStyle: '700', fontStyle: '700',
fixedWidth: Math.max(36, cardWidth - 56) fixedWidth: Math.max(36, cardWidth - 56)
})); }));
this.trackSideObject(this.add.text(cardX + 47, y + 25, this.truncateUiText(effect.value, 15), { this.trackSideObject(this.add.text(cardX + 47, y + 25, this.truncateUiText(effect.value, visible.length === 1 ? 30 : 15), {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '13px', fontSize: '13px',
color: '#f2e3bf', color: '#f2e3bf',