diff --git a/docs/terrain-ui-prod-after-start-check.png b/docs/terrain-ui-prod-after-start-check.png new file mode 100644 index 0000000..cb82778 Binary files /dev/null and b/docs/terrain-ui-prod-after-start-check.png differ diff --git a/docs/terrain-ui-prod-attack-preview-check.png b/docs/terrain-ui-prod-attack-preview-check.png new file mode 100644 index 0000000..6d2523b Binary files /dev/null and b/docs/terrain-ui-prod-attack-preview-check.png differ diff --git a/docs/terrain-ui-prod-hover-check.png b/docs/terrain-ui-prod-hover-check.png new file mode 100644 index 0000000..a676cdd Binary files /dev/null and b/docs/terrain-ui-prod-hover-check.png differ diff --git a/docs/terrain-ui-prod-unit-detail-check.png b/docs/terrain-ui-prod-unit-detail-check.png new file mode 100644 index 0000000..6dea1af Binary files /dev/null and b/docs/terrain-ui-prod-unit-detail-check.png differ diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 80b92a6..c4f5018 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1341,6 +1341,7 @@ type CombatPreview = { criticalRate: number; terrainDefenseBonus: number; terrainHitPenalty: number; + terrainAffinity: number; counterAvailable: boolean; terrainLabel: string; matchupLabel: string; @@ -2920,18 +2921,6 @@ const factionLabels: Record = { enemy: '적군' }; -const terrainDisplayLabels: Record = { - plain: '평지', - road: '길', - forest: '숲', - hill: '언덕', - village: '마을', - fort: '요새', - camp: '진영', - river: '강', - cliff: '절벽' -}; - const mapMenuLabels: Partial> = { endTurn: '턴 종료', threat: '위험 범위', @@ -4136,13 +4125,16 @@ export class BattleScene extends Phaser.Scene { this.updateUnitPointerFeedback(unit, tile, force); return; } + this.updateTerrainPointerFeedback(tile, force); + return; } this.clearPointerFeedback(); } 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) { return; } @@ -4150,34 +4142,58 @@ export class BattleScene extends Phaser.Scene { const acted = this.actedUnitIds.has(unit.id); const selectable = unit.faction === 'ally' && this.activeFaction === 'ally' && !acted; const tone = selectable ? palette.blue : unit.faction === 'enemy' ? 0xd8732c : 0x647485; + const terrainHint = this.terrainEffectText(terrain, unit, 'compact'); const text = selectable - ? `${unit.name}: 선택 가능 · 클릭하면 이동 범위 표시` + ? `${unit.name}: 선택 가능 · ${terrainHint}` : unit.faction === 'enemy' - ? `${unit.name}: 적 부대 · 클릭하면 위협 범위 확인` - : `${unit.name}: 행동 완료`; + ? `${unit.name}: 적 부대 · ${terrainHint}` + : `${unit.name}: 행동 완료 · ${terrainHint}`; 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) { const destination = this.unitAtTile(tile.x, tile.y, unit.id); 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) { return; } 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 = - distance === 0 - ? `${unit.name}: 제자리에서 명령 선택` - : `${unit.name}: 이동 가능 · ${distance}칸 · 클릭해 확정`; + spent === 0 + ? `${unit.name}: 제자리 명령 · ${terrainHint}` + : `${unit.name}: 이동 가능 · 소모 ${spent}/${unit.move} · 남은 ${remaining} · ${terrainHint}`; this.showPointerFeedback(tile, palette.blue, text, key, 0x1c7ed6, 0.12, 0.9); 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); } @@ -4212,10 +4228,11 @@ export class BattleScene extends Phaser.Scene { const preview = this.combatPreview(unit, target, action, usable); const locked = this.isLockedDamageTarget(unit, target, action, usable); const counter = preview.counterAvailable ? '반격 주의' : '반격 없음'; + const terrain = `${preview.terrainLabel} ${this.combatTerrainSummary(preview)}`; this.showPointerFeedback( tile, 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, 0xd9503f, 0.16, @@ -4445,27 +4462,30 @@ export class BattleScene extends Phaser.Scene { } 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( this.tileTopLeftX(x), this.tileTopLeftY(y), this.layout.tileSize, this.layout.tileSize, palette.blue, - 0.34 + fillAlpha ); marker.setData('tileX', x); marker.setData('tileY', y); marker.setOrigin(0); - marker.setStrokeStyle(2, palette.blue, 0.84); + marker.setStrokeStyle(cost === 0 ? 2 : 1, palette.blue, strokeAlpha); marker.setDepth(4); if (this.mapMask) { marker.setMask(this.mapMask); } marker.setVisible(this.isTileVisible(x, y)); marker.setInteractive({ useHandCursor: true }); - marker.on('pointerover', () => marker.setFillStyle(palette.blue, 0.48)); - marker.on('pointerout', () => marker.setFillStyle(palette.blue, 0.34)); + marker.on('pointerover', () => marker.setFillStyle(palette.blue, Math.min(fillAlpha + 0.12, 0.5))); + marker.on('pointerout', () => marker.setFillStyle(palette.blue, fillAlpha)); marker.on('pointerdown', (pointer: Phaser.Input.Pointer) => { if (pointer.leftButtonDown()) { this.moveSelectedUnit(x, y, pointer); @@ -4605,6 +4625,10 @@ export class BattleScene extends Phaser.Scene { 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) { const terrainRule = getTerrainRule(terrain); if (terrainRule.passable === false) { @@ -4614,6 +4638,97 @@ export class BattleScene extends Phaser.Scene { 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) { + 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) { return x >= 0 && y >= 0 && x < battleMap.width && y < battleMap.height; } @@ -5223,7 +5338,7 @@ export class BattleScene extends Phaser.Scene { } if (occupant) { - this.deploymentNotice = `${occupant.name}이 있는 칸입니다. 시작 구역 안의 빈 칸을 선택하세요.`; + this.deploymentNotice = `${occupant.name} 배치 칸입니다. 시작 구역 안의 빈 칸을 선택하세요.`; this.renderDeploymentPanel(); return; } @@ -7444,8 +7559,11 @@ export class BattleScene extends Phaser.Scene { const buff = this.battleBuffs.get(preview.attacker.id); const equipmentText = this.equipmentPreviewBonusText(preview); const terrainParts = [ - preview.terrainDefenseBonus > 0 ? `${preview.action === 'strategy' ? '저항' : '방'}+${preview.terrainDefenseBonus}` : '', - preview.terrainHitPenalty > 0 ? `${preview.action === 'strategy' ? '성공' : '명'}-${preview.terrainHitPenalty}` : '' + preview.terrainDefenseBonus !== 0 + ? `${preview.action === 'strategy' ? '저항' : '방'}${this.formatSignedValue(preview.terrainDefenseBonus)}` + : '', + preview.terrainHitPenalty > 0 ? `${preview.action === 'strategy' ? '성공' : '명'}-${preview.terrainHitPenalty}` : '', + preview.terrainAffinity !== 100 ? `적성${preview.terrainAffinity}%` : '' ].filter(Boolean); 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, '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) { 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)); card.setOrigin(0); 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); - this.trackSideIcon(cardX + 21, y + 29, summary.icon, 34); - this.trackSideObject(this.add.text(cardX + 44, y + 7, summary.label, { + this.trackSideIcon(cardX + 18, y + 29, summary.icon, 30); + this.trackSideObject(this.add.text(cardX + 38, y + 7, summary.label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '10px', color: '#9fb0bf', 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', fontSize: '12px', color: '#f2e3bf', fontStyle: '700', - fixedWidth: cardWidth - 52 + fixedWidth: cardWidth - 44 })); }); @@ -9487,6 +9605,7 @@ export class BattleScene extends Phaser.Scene { criticalRate, terrainDefenseBonus: terrainRule.defenseBonus, terrainHitPenalty, + terrainAffinity: defenderTerrainRating, counterAvailable: this.canCounterAttack(defender, attacker, action), terrainLabel: terrainRule.label, matchupLabel: this.combatMatchupLabel(attacker, defender, action) @@ -15463,6 +15582,7 @@ export class BattleScene extends Phaser.Scene { private renderTerrainDetail(x: number, y: number) { this.clearSidePanelContent(); + this.setMiniMapVisible(true); const terrain = battleMap.terrain[y][x]; const terrainRule = getTerrainRule(terrain); const { panelX, panelWidth } = this.layout; @@ -15471,34 +15591,53 @@ export class BattleScene extends Phaser.Scene { const top = this.sideContentTop(); const attackDefense = Math.floor(terrainRule.defenseBonus / 3); 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', - 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', fontStyle: '700' })); - this.renderSituationLine('좌표', `${x + 1}, ${y + 1}`, left, top + 48, width); - this.renderSituationLine('지형', terrainDisplayLabels[terrain], left, top + 92, width); - this.renderSituationLine('이동 비용', terrainRule.passable === false ? '진입 불가' : `${terrainRule.moveCost}`, left, top + 136, width); - this.renderSituationLine('방어 보정', this.formatSignedValue(terrainRule.defenseBonus), left, top + 180, width); - this.renderSituationLine('공격 방어', this.formatSignedValue(attackDefense), left, top + 224, width); - this.renderSituationLine('책략 저항', this.formatSignedValue(strategyDefense), left, top + 268, width); - this.renderSituationLine('도구 피해', this.formatSignedValue(itemDefense), left, top + 312, width); - - const recovery = - terrainRule.recoveryHp || terrainRule.moraleBonus - ? `\n턴 시작 시 병력 +${terrainRule.recoveryHp ?? 0}, 사기 +${terrainRule.moraleBonus ?? 0} 효과를 받습니다.` - : ''; - const message = - terrainRule.passable === false - ? '이 지형은 이동할 수 없습니다. 길목과 다리, 완만한 평지를 찾아 우회해야 합니다.' - : terrainRule.defenseBonus > 0 - ? '이 지형에 있는 부대는 방어 보정으로 받는 피해가 줄어듭니다.' - : '이 지형은 기본 방어 보정이 없습니다.'; - this.renderPanelMessage(`${message}${recovery}`, left, top + 370, width, 76); + this.renderSituationLine('좌표', `${x + 1}, ${y + 1}`, left, top + 76, width); + this.renderSituationLine('이동', terrainRule.passable === false ? '진입 불가' : `기본 비용 ${terrainRule.moveCost}`, left, top + 120, width); + this.renderSituationLine( + '전투', + terrainRule.defenseBonus === 0 + ? '보정 없음' + : `방어 ${this.formatSignedValue(terrainRule.defenseBonus)} / 명중 -${hitPenalty}`, + left, + top + 164, + width + ); + this.renderSituationLine( + '피해 완화', + terrainRule.defenseBonus === 0 ? '없음' : `공격 -${attackDefense} / 책략 -${strategyDefense}`, + left, + top + 208, + width + ); + 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) { @@ -15789,11 +15928,17 @@ export class BattleScene extends Phaser.Scene { private unitEffectSummaries(unit: UnitData): UnitEffectSummary[] { const effects: UnitEffectSummary[] = []; const buff = this.battleBuffs.get(unit.id); - const terrainRule = getTerrainRule(battleMap.terrain[unit.y][unit.x]); - const turnStartParts = [ - terrainRule.recoveryHp ? `병력 +${terrainRule.recoveryHp}` : '', - terrainRule.moraleBonus ? `사기 +${terrainRule.moraleBonus}` : '' - ].filter(Boolean); + const terrain = battleMap.terrain[unit.y][unit.x]; + const terrainRule = getTerrainRule(terrain); + const terrainValue = this.terrainEffectText(terrain, unit, 'card'); + + effects.push({ + icon: 'terrain', + label: `${terrainRule.label} 지형`, + value: terrainValue, + tone: this.terrainTone(terrain), + polarity: 'terrain' + }); this.unitStatuses(unit).forEach((status) => { effects.push({ @@ -15815,15 +15960,6 @@ export class BattleScene extends Phaser.Scene { polarity: 'buff' }); } - if (turnStartParts.length > 0) { - effects.push({ - icon: 'terrain', - label: `${terrainRule.label} 턴 시작`, - value: turnStartParts.join(' / '), - tone: 0x59d18c, - polarity: 'terrain' - }); - } return effects; } @@ -15849,7 +15985,7 @@ export class BattleScene extends Phaser.Scene { fontStyle: '700', 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', fontSize: '13px', color: '#f2e3bf',