Surface terrain effects in battle UI
This commit is contained in:
BIN
docs/terrain-ui-prod-after-start-check.png
Normal file
BIN
docs/terrain-ui-prod-after-start-check.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/terrain-ui-prod-attack-preview-check.png
Normal file
BIN
docs/terrain-ui-prod-attack-preview-check.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
BIN
docs/terrain-ui-prod-hover-check.png
Normal file
BIN
docs/terrain-ui-prod-hover-check.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
BIN
docs/terrain-ui-prod-unit-detail-check.png
Normal file
BIN
docs/terrain-ui-prod-unit-detail-check.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user