Compare commits

..

3 Commits

Author SHA1 Message Date
JiWoong Sul
525e231c06 style(ui): victory_overlay ASCII 아트 정렬 개선
- 트로피 ASCII 좌우 공백 균형 조정
- THE END 텍스트 FittedBox로 자동 스케일링
- 폰트 크기 14→12 조정
2026-01-15 21:34:30 +09:00
JiWoong Sul
58cc1fddb5 feat(ui): 스킬 상세 정보 ExpansionTile 구현
- _SkillRow → _SkillTile (ExpansionTile 기반)
- 타입별 스탯 그리드 표시 (공격/회복/버프/디버프)
- 메타 행: 티어(로마숫자), 타입명, 속성명
- RetroColors 적용, 타입/속성별 컬러 구분
- 한/영/일 3개 언어 지원
2026-01-15 21:34:24 +09:00
JiWoong Sul
60db6b2ec9 feat(l10n): 스킬 상세 정보 라벨 추가
- 공통: tier, mpCost, cooldown, seconds
- 공격: power, hits, dot, lifesteal, defPen, selfDmg
- 회복: healFixed, healPercent, mpHeal
- 버프/디버프: duration, atkMod, defMod, criMod, evaMod
- 타입 이름 4개, 속성 이름 8개
2026-01-15 21:34:18 +09:00
3 changed files with 517 additions and 45 deletions

View File

@@ -1106,3 +1106,52 @@ String get uiError =>
String get uiSaved => _l('Saved', '저장됨', '保存しました');
String get uiSaveBattleLog =>
_l('Save Battle Log', '배틀로그 저장', 'バトルログ保存');
// ============================================================================
// 스킬 상세 정보 라벨 (Skill Detail Labels)
// ============================================================================
// 공통 라벨
String get skillTier => _l('Tier', '티어', 'ティア');
String get skillMpCost => _l('MP', 'MP', 'MP');
String get skillCooldown => _l('CD', '쿨타임', 'CT');
String get skillSeconds => _l('s', '', '');
// 공격 스킬 라벨
String get skillPower => _l('Power', '위력', '威力');
String get skillHits => _l('Hits', '타격', 'ヒット');
String get skillDot => _l('DOT', '지속피해', 'DOT');
String get skillLifesteal => _l('Lifesteal', 'HP흡수', 'HP吸収');
String get skillDefPen => _l('DEF Pen', '방어무시', '防御貫通');
String get skillSelfDmg => _l('Self Dmg', '자해', '自傷');
// 회복 스킬 라벨
String get skillHealFixed => _l('Heal', '회복', '回復');
String get skillHealPercent => _l('HP%', 'HP%', 'HP%');
String get skillMpHeal => _l('MP Heal', 'MP회복', 'MP回復');
// 버프/디버프 라벨
String get skillBuffDuration => _l('Duration', '지속', '持続');
String get skillAtkMod => _l('ATK', '공격', '攻撃');
String get skillDefMod => _l('DEF', '방어', '防御');
String get skillCriMod => _l('CRI', '치명', 'クリ');
String get skillEvaMod => _l('EVA', '회피', '回避');
// 스킬 타입 이름
String get skillTypeAttack => _l('Attack', '공격', '攻撃');
String get skillTypeHeal => _l('Heal', '회복', '回復');
String get skillTypeBuff => _l('Buff', '버프', 'バフ');
String get skillTypeDebuff => _l('Debuff', '디버프', 'デバフ');
// 속성 이름 (SkillElement)
String get elementLogic => _l('Logic', '논리', 'ロジック');
String get elementMemory => _l('Memory', '메모리', 'メモリ');
String get elementNetwork => _l('Network', '네트워크', 'ネットワーク');
String get elementFire => _l('Fire', '화염', '火炎');
String get elementIce => _l('Ice', '빙결', '氷結');
String get elementLightning => _l('Lightning', '전기', '電撃');
String get elementVoid => _l('Void', '공허', 'ヴォイド');
String get elementChaos => _l('Chaos', '혼돈', 'カオス');
// 스킬 상세 정보 없음
String get skillNoDetails => _l('No details', '상세 정보 없음', '詳細情報なし');

View File

@@ -7,6 +7,7 @@ import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/skill.dart';
import 'package:asciineverdie/src/features/game/widgets/active_buff_panel.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
/// 스킬 페이지 (캐로셀)
///
@@ -72,7 +73,7 @@ class SkillsPage extends StatelessWidget {
return ListView.builder(
itemCount: skillBook.skills.length,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
itemBuilder: (context, index) {
final skillEntry = skillBook.skills[index];
final skill = SkillData.getSkillBySpellName(skillEntry.name);
@@ -86,7 +87,7 @@ class SkillsPage extends StatelessWidget {
skillState != null &&
!skillState.isReady(skillSystem.elapsedMs, skill!.cooldownMs);
return _SkillRow(
return _SkillTile(
skillName: skillName,
rank: skillEntry.rank,
skill: skill,
@@ -97,9 +98,9 @@ class SkillsPage extends StatelessWidget {
}
}
/// 스킬 위젯
class _SkillRow extends StatelessWidget {
const _SkillRow({
/// 스킬 타일 위젯 (ExpansionTile 기반)
class _SkillTile extends StatelessWidget {
const _SkillTile({
required this.skillName,
required this.rank,
required this.skill,
@@ -111,16 +112,83 @@ class _SkillRow extends StatelessWidget {
final Skill? skill;
final bool isOnCooldown;
@override
Widget build(BuildContext context) {
// 스킬 데이터가 없으면 단순 행으로 표시
if (skill == null) {
return _SimpleSkillRow(
skillName: skillName,
rank: rank,
isOnCooldown: isOnCooldown,
);
}
final typeColor = _getTypeColor(skill!.type);
return ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 8),
childrenPadding: const EdgeInsets.only(left: 16, right: 8, bottom: 8),
dense: true,
title: Row(
children: [
_SkillTypeIcon(type: skill!.type),
const SizedBox(width: 8),
Expanded(
child: Text(
skillName,
style: TextStyle(
fontSize: 18,
color: isOnCooldown ? Colors.grey : typeColor,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
if (isOnCooldown)
const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(Icons.hourglass_empty, size: 14, color: Colors.orange),
),
_RankBadge(rank: rank),
],
),
children: [
_SkillStatsGrid(skill: skill!),
const SizedBox(height: 4),
_SkillMetaRow(skill: skill!),
],
);
}
Color _getTypeColor(SkillType type) {
return switch (type) {
SkillType.attack => Colors.red.shade300,
SkillType.heal => Colors.green.shade300,
SkillType.buff => Colors.blue.shade300,
SkillType.debuff => Colors.purple.shade300,
};
}
}
/// 스킬 데이터가 없는 경우의 단순 행
class _SimpleSkillRow extends StatelessWidget {
const _SimpleSkillRow({
required this.skillName,
required this.rank,
required this.isOnCooldown,
});
final String skillName;
final String rank;
final bool isOnCooldown;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
padding: const EdgeInsets.symmetric(vertical: 3, horizontal: 8),
child: Row(
children: [
// 스킬 타입 아이콘
_buildTypeIcon(),
const SizedBox(width: 8),
// 스킬 이름
const SizedBox(width: 24),
Expanded(
child: Text(
skillName,
@@ -131,11 +199,9 @@ class _SkillRow extends StatelessWidget {
overflow: TextOverflow.ellipsis,
),
),
// 쿨타임 표시
if (isOnCooldown)
const Icon(Icons.hourglass_empty, size: 14, color: Colors.orange),
const SizedBox(width: 8),
// 랭크
Text(
rank,
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
@@ -144,19 +210,375 @@ class _SkillRow extends StatelessWidget {
),
);
}
}
Widget _buildTypeIcon() {
if (skill == null) {
return const SizedBox(width: 16);
}
/// 스킬 타입 아이콘
class _SkillTypeIcon extends StatelessWidget {
const _SkillTypeIcon({required this.type});
final (IconData icon, Color color) = switch (skill!.type) {
final SkillType type;
@override
Widget build(BuildContext context) {
final (IconData icon, Color color) = switch (type) {
SkillType.attack => (Icons.flash_on, Colors.red),
SkillType.heal => (Icons.favorite, Colors.green),
SkillType.buff => (Icons.arrow_upward, Colors.blue),
SkillType.debuff => (Icons.arrow_downward, Colors.purple),
};
return Icon(icon, size: 16, color: color);
return Icon(icon, size: 18, color: color);
}
}
/// 랭크 배지
class _RankBadge extends StatelessWidget {
const _RankBadge({required this.rank});
final String rank;
@override
Widget build(BuildContext context) {
final gold = RetroColors.goldOf(context);
final surface = RetroColors.surfaceOf(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: surface,
border: Border.all(color: gold, width: 1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
rank,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: gold,
),
),
);
}
}
/// 스킬 스탯 그리드 (타입별 분기)
class _SkillStatsGrid extends StatelessWidget {
const _SkillStatsGrid({required this.skill});
final Skill skill;
@override
Widget build(BuildContext context) {
final entries = <_StatEntry>[];
// 공통: MP, 쿨타임
entries.add(_StatEntry(l10n.skillMpCost, '${skill.mpCost}'));
entries.add(_StatEntry(
l10n.skillCooldown,
'${(skill.cooldownMs / 1000).toStringAsFixed(1)}${l10n.skillSeconds}',
));
// 타입별 스탯 추가
switch (skill.type) {
case SkillType.attack:
_addAttackStats(entries);
case SkillType.heal:
_addHealStats(entries);
case SkillType.buff:
_addBuffStats(entries);
case SkillType.debuff:
_addDebuffStats(entries);
}
return Wrap(
spacing: 8,
runSpacing: 4,
children: entries.map((e) => _StatChip(entry: e)).toList(),
);
}
void _addAttackStats(List<_StatEntry> entries) {
// 위력 (power × multiplier)
final power = (skill.power * skill.damageMultiplier).round();
entries.add(_StatEntry(l10n.skillPower, '$power'));
// 타격 횟수 (1보다 클 때만)
if (skill.hitCount > 1) {
entries.add(_StatEntry(l10n.skillHits, '${skill.hitCount}'));
}
// DOT 정보
if (skill.isDot && skill.baseDotDamage != null) {
final dotDps = skill.baseDotDamage! *
(skill.baseDotDurationMs! / skill.baseDotTickMs!);
entries.add(_StatEntry(l10n.skillDot, '${dotDps.round()}'));
}
// HP 흡수
if (skill.lifestealPercent > 0) {
entries.add(_StatEntry(
l10n.skillLifesteal,
'${(skill.lifestealPercent * 100).round()}%',
));
}
// 방어 무시
if (skill.targetDefReduction > 0) {
entries.add(_StatEntry(
l10n.skillDefPen,
'${(skill.targetDefReduction * 100).round()}%',
));
}
// 자해 데미지
if (skill.selfDamagePercent > 0) {
entries.add(_StatEntry(
l10n.skillSelfDmg,
'${(skill.selfDamagePercent * 100).round()}%',
));
}
}
void _addHealStats(List<_StatEntry> entries) {
// 고정 회복
if (skill.healAmount > 0) {
entries.add(_StatEntry(l10n.skillHealFixed, '+${skill.healAmount}'));
}
// % 회복
if (skill.healPercent > 0) {
entries.add(_StatEntry(
l10n.skillHealPercent,
'${(skill.healPercent * 100).round()}%',
));
}
// MP 회복
if (skill.mpHealAmount > 0) {
entries.add(_StatEntry(l10n.skillMpHeal, '+${skill.mpHealAmount}'));
}
// 부가 버프
if (skill.buff != null) {
final buff = skill.buff!;
entries.add(_StatEntry(
l10n.skillBuffDuration,
'${(buff.durationMs / 1000).round()}${l10n.skillSeconds}',
));
}
}
void _addBuffStats(List<_StatEntry> entries) {
if (skill.buff == null) return;
final buff = skill.buff!;
// 지속시간
entries.add(_StatEntry(
l10n.skillBuffDuration,
'${(buff.durationMs / 1000).round()}${l10n.skillSeconds}',
));
// 각 보정치
if (buff.atkModifier != 0) {
final sign = buff.atkModifier > 0 ? '+' : '';
entries.add(_StatEntry(
l10n.skillAtkMod,
'$sign${(buff.atkModifier * 100).round()}%',
));
}
if (buff.defModifier != 0) {
final sign = buff.defModifier > 0 ? '+' : '';
entries.add(_StatEntry(
l10n.skillDefMod,
'$sign${(buff.defModifier * 100).round()}%',
));
}
if (buff.criRateModifier != 0) {
final sign = buff.criRateModifier > 0 ? '+' : '';
entries.add(_StatEntry(
l10n.skillCriMod,
'$sign${(buff.criRateModifier * 100).round()}%',
));
}
if (buff.evasionModifier != 0) {
final sign = buff.evasionModifier > 0 ? '+' : '';
entries.add(_StatEntry(
l10n.skillEvaMod,
'$sign${(buff.evasionModifier * 100).round()}%',
));
}
}
void _addDebuffStats(List<_StatEntry> entries) {
if (skill.buff == null) return;
final buff = skill.buff!;
// 지속시간
entries.add(_StatEntry(
l10n.skillBuffDuration,
'${(buff.durationMs / 1000).round()}${l10n.skillSeconds}',
));
// 디버프 효과 (보통 음수)
if (buff.atkModifier != 0) {
entries.add(_StatEntry(
l10n.skillAtkMod,
'${(buff.atkModifier * 100).round()}%',
));
}
if (buff.defModifier != 0) {
entries.add(_StatEntry(
l10n.skillDefMod,
'${(buff.defModifier * 100).round()}%',
));
}
}
}
/// 스탯 엔트리
class _StatEntry {
const _StatEntry(this.label, this.value);
final String label;
final String value;
}
/// 스탯 칩 (레트로 스타일)
class _StatChip extends StatelessWidget {
const _StatChip({required this.entry});
final _StatEntry entry;
@override
Widget build(BuildContext context) {
final surface = RetroColors.surfaceOf(context);
final border = RetroColors.borderOf(context);
final textMuted = RetroColors.textMutedOf(context);
final textPrimary = RetroColors.textPrimaryOf(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: surface,
border: Border.all(color: border, width: 1),
borderRadius: BorderRadius.circular(2),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${entry.label}: ',
style: TextStyle(fontSize: 15, color: textMuted),
),
Text(
entry.value,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: textPrimary,
),
),
],
),
);
}
}
/// 스킬 메타 정보 행
class _SkillMetaRow extends StatelessWidget {
const _SkillMetaRow({required this.skill});
final Skill skill;
@override
Widget build(BuildContext context) {
final textMuted = RetroColors.textMutedOf(context);
final typeColor = _getTypeColor(skill.type);
return Row(
children: [
// 티어
Text(
'${l10n.skillTier} ${_tierToRoman(skill.tier)}',
style: TextStyle(fontSize: 15, color: textMuted),
),
const SizedBox(width: 8),
// 타입
Text(
_getTypeName(skill.type),
style: TextStyle(
fontSize: 15,
color: typeColor,
fontWeight: FontWeight.bold,
),
),
// 속성 (있는 경우)
if (skill.element != null) ...[
const SizedBox(width: 8),
Text(
_getElementName(skill.element!),
style: TextStyle(
fontSize: 15,
color: _getElementColor(skill.element!),
fontWeight: FontWeight.w500,
),
),
],
],
);
}
Color _getTypeColor(SkillType type) {
return switch (type) {
SkillType.attack => Colors.red,
SkillType.heal => Colors.green,
SkillType.buff => Colors.blue,
SkillType.debuff => Colors.purple,
};
}
Color _getElementColor(SkillElement element) {
return switch (element) {
SkillElement.logic => Colors.cyan,
SkillElement.memory => Colors.teal,
SkillElement.network => Colors.indigo,
SkillElement.fire => Colors.orange,
SkillElement.ice => Colors.lightBlue,
SkillElement.lightning => Colors.yellow.shade700,
SkillElement.voidElement => Colors.grey,
SkillElement.chaos => Colors.pink,
};
}
String _tierToRoman(int tier) {
return switch (tier) {
1 => 'I',
2 => 'II',
3 => 'III',
4 => 'IV',
5 => 'V',
_ => '$tier',
};
}
String _getTypeName(SkillType type) {
return switch (type) {
SkillType.attack => l10n.skillTypeAttack,
SkillType.heal => l10n.skillTypeHeal,
SkillType.buff => l10n.skillTypeBuff,
SkillType.debuff => l10n.skillTypeDebuff,
};
}
String _getElementName(SkillElement element) {
return switch (element) {
SkillElement.logic => l10n.elementLogic,
SkillElement.memory => l10n.elementMemory,
SkillElement.network => l10n.elementNetwork,
SkillElement.fire => l10n.elementFire,
SkillElement.ice => l10n.elementIce,
SkillElement.lightning => l10n.elementLightning,
SkillElement.voidElement => l10n.elementVoid,
SkillElement.chaos => l10n.elementChaos,
};
}
}

View File

@@ -583,17 +583,18 @@ class _VictoryOverlayState extends State<VictoryOverlay>
}
Widget _buildTrophyAsciiArt(Color gold) {
// 중앙 정렬을 위해 각 줄 좌우 공백 균형 맞춤
const trophy = '''
___________
'._==_==_=_.'
.-\\: /-.
| (|:. |) |
'-|:. |-'
\\::. /
'::. .'
) (
_.' '._
'-------' ''';
____________
'._==_==_=_.'
.-\\: /-.
| (|:. |) |
'-|:. |-'
\\::. /
'::. .'
) (
_.' '._
'-------' ''';
return Text(
trophy,
@@ -649,25 +650,25 @@ class _VictoryOverlayState extends State<VictoryOverlay>
Widget _buildTheEnd(BuildContext context, Color gold) {
const theEnd = '''
████████╗██╗ ██╗███████╗ ███████╗███╗ ██╗██████╗
╚══██╔══╝██║ ██║██╔════╝ ██╔════╝████╗ ██║██╔══██╗
██║ ███████║█████╗ █████╗ ██╔██╗ ██║██║ ██║
██║ ██╔══██║██╔══╝ ██╔══╝ ██║╚██╗██║██║ ██║
██║ ██║ ██║███████╗ ███████╗██║ ╚████║██████╔╝
╚═╝ ╚═╝ ╚═╝╚══════╝ ╚══════╝╚═╝ ╚═══╝╚═════╝ ''';
╚══██╔══╝██║ ██║██╔════╝ ██╔════╝████╗ ██║██╔══██╗
██║ ███████║█████╗ █████╗ ██╔██╗ ██║██║ ██║
██║ ██╔══██║██╔══╝ ██╔══╝ ██║╚██╗██║██║ ██║
██║ ██║ ██║███████╗ ███████╗██║ ╚████║██████╔╝
╚═╝ ╚═╝ ╚═╝╚══════╝ ╚══════╝╚═╝ ╚═══╝╚═════╝ ''';
return Column(
children: [
Text(
theEnd,
style: TextStyle(
fontFamily: 'JetBrainsMono',
fontSize: 14,
color: gold,
height: 1.0,
),
textAlign: TextAlign.center,
// FittedBox로 감싸서 화면 너비에 맞게 자동 스케일링
return FittedBox(
fit: BoxFit.scaleDown,
child: Text(
theEnd,
style: TextStyle(
fontFamily: 'JetBrainsMono',
fontSize: 12,
color: gold,
height: 1.0,
),
],
textAlign: TextAlign.center,
),
);
}