feat(game): 게임 시스템 전면 개편 및 다국어 지원 확장

## 스킬 시스템 개선
- skill_data.dart: 스킬 데이터 구조 전면 개편 (+1176 라인)
- skill_service.dart: 스킬 발동 로직 확장 및 버프 시스템 연동
- skill.dart: 스킬 모델 개선, 쿨다운/효과 타입 추가

## Canvas 애니메이션 리팩토링
- battle_composer.dart 삭제 (레거시 위젯 기반 렌더러)
- monster_colors.dart 삭제 (AsciiCell 색상 시스템으로 통합)
- canvas_battle_composer.dart: z-index 정렬 (몬스터 z=1, 캐릭터 z=2, 이펙트 z=3)
- ascii_cell.dart, ascii_layer.dart: 코드 정리

## UI/UX 개선
- hp_mp_bar.dart: l10n 적용, 몬스터 HP 바 컴팩트화
- death_overlay.dart: 사망 화면 개선
- equipment_stats_panel.dart: 장비 스탯 표시 확장
- active_buff_panel.dart: 버프 패널 개선
- notification_overlay.dart: 알림 시스템 개선

## 다국어 지원 확장
- game_text_l10n.dart: 게임 텍스트 통합 (+758 라인)
- 한국어/일본어/영어/중국어 번역 업데이트
- ARB 파일 동기화

## 게임 로직 개선
- progress_service.dart: 진행 로직 리팩토링
- combat_calculator.dart: 전투 계산 로직 개선
- stat_calculator.dart: 스탯 계산 시스템 개선
- story_service.dart: 스토리 진행 로직 개선

## 기타
- theme_preferences.dart 삭제 (미사용)
- 테스트 파일 업데이트
- class_data.dart: 클래스 데이터 정리
This commit is contained in:
JiWoong Sul
2025-12-22 19:00:58 +09:00
parent f606fca063
commit 99f5b74802
63 changed files with 3403 additions and 2740 deletions

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/data/game_text_l10n.dart' as l10n;
import 'package:askiineverdie/src/core/model/skill.dart';
/// 활성 버프 패널 위젯
@@ -18,10 +19,10 @@ class ActiveBuffPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (activeBuffs.isEmpty) {
return const Center(
return Center(
child: Text(
'No active buffs',
style: TextStyle(
l10n.uiNoActiveBuffs,
style: const TextStyle(
fontSize: 11,
color: Colors.grey,
fontStyle: FontStyle.italic,
@@ -43,10 +44,7 @@ class ActiveBuffPanel extends StatelessWidget {
/// 개별 버프 행 위젯
class _BuffRow extends StatelessWidget {
const _BuffRow({
required this.buff,
required this.currentMs,
});
const _BuffRow({required this.buff, required this.currentMs});
final ActiveBuff buff;
final int currentMs;
@@ -66,11 +64,7 @@ class _BuffRow extends StatelessWidget {
Row(
children: [
// 버프 아이콘
const Icon(
Icons.trending_up,
size: 14,
color: Colors.lightBlue,
),
const Icon(Icons.trending_up, size: 14, color: Colors.lightBlue),
const SizedBox(width: 4),
// 버프 이름
@@ -92,8 +86,9 @@ class _BuffRow extends StatelessWidget {
style: TextStyle(
fontSize: 10,
color: remainingMs < 3000 ? Colors.orange : Colors.grey,
fontWeight:
remainingMs < 3000 ? FontWeight.bold : FontWeight.normal,
fontWeight: remainingMs < 3000
? FontWeight.bold
: FontWeight.normal,
),
),
],
@@ -117,11 +112,7 @@ class _BuffRow extends StatelessWidget {
// 효과 목록
if (modifiers.isNotEmpty) ...[
const SizedBox(height: 2),
Wrap(
spacing: 6,
runSpacing: 2,
children: modifiers,
),
Wrap(spacing: 6, runSpacing: 2, children: modifiers),
],
],
),
@@ -134,35 +125,43 @@ class _BuffRow extends StatelessWidget {
final effect = buff.effect;
if (effect.atkModifier != 0) {
modifiers.add(_ModifierChip(
label: 'ATK',
value: effect.atkModifier,
isPositive: effect.atkModifier > 0,
));
modifiers.add(
_ModifierChip(
label: 'ATK',
value: effect.atkModifier,
isPositive: effect.atkModifier > 0,
),
);
}
if (effect.defModifier != 0) {
modifiers.add(_ModifierChip(
label: 'DEF',
value: effect.defModifier,
isPositive: effect.defModifier > 0,
));
modifiers.add(
_ModifierChip(
label: 'DEF',
value: effect.defModifier,
isPositive: effect.defModifier > 0,
),
);
}
if (effect.criRateModifier != 0) {
modifiers.add(_ModifierChip(
label: 'CRI',
value: effect.criRateModifier,
isPositive: effect.criRateModifier > 0,
));
modifiers.add(
_ModifierChip(
label: 'CRI',
value: effect.criRateModifier,
isPositive: effect.criRateModifier > 0,
),
);
}
if (effect.evasionModifier != 0) {
modifiers.add(_ModifierChip(
label: 'EVA',
value: effect.evasionModifier,
isPositive: effect.evasionModifier > 0,
));
modifiers.add(
_ModifierChip(
label: 'EVA',
value: effect.evasionModifier,
isPositive: effect.evasionModifier > 0,
),
);
}
return modifiers;

View File

@@ -117,6 +117,9 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
// 전투 이벤트 동기화용 (Phase 5)
int? _lastEventTimestamp;
bool _showCriticalEffect = false;
bool _showBlockEffect = false;
bool _showParryEffect = false;
bool _showSkillEffect = false;
// 특수 애니메이션 프레임 수는 ascii_animation_type.dart의
// specialAnimationFrameCounts 상수 사용
@@ -177,33 +180,114 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
// 전투 모드가 아니면 무시
if (_animationMode != AnimationMode.battle) return;
// 이벤트 타입에 따라 페이즈 강제 전환
final (targetPhase, isCritical) = switch (event.type) {
// 이벤트 타입에 따라 페이즈 및 효과 결정
final (
targetPhase,
isCritical,
isBlock,
isParry,
isSkill,
) = switch (event.type) {
// 플레이어 공격 → attack 페이즈
CombatEventType.playerAttack => (BattlePhase.attack, event.isCritical),
CombatEventType.playerSkill => (BattlePhase.attack, event.isCritical),
CombatEventType.playerAttack => (
BattlePhase.attack,
event.isCritical,
false,
false,
false,
),
// 스킬 사용 → attack 페이즈 + 스킬 이펙트
CombatEventType.playerSkill => (
BattlePhase.attack,
event.isCritical,
false,
false,
true,
),
// 몬스터 공격/플레이어 피격 → hit 페이즈
CombatEventType.monsterAttack => (BattlePhase.hit, false),
CombatEventType.playerBlock => (BattlePhase.hit, false),
CombatEventType.playerParry => (BattlePhase.hit, false),
// 몬스터 공격 → hit 페이즈
CombatEventType.monsterAttack => (
BattlePhase.hit,
false,
false,
false,
false,
),
// 블록 → hit 페이즈 + 블록 이펙트
CombatEventType.playerBlock => (
BattlePhase.hit,
false,
true,
false,
false,
),
// 패리 → hit 페이즈 + 패리 이펙트
CombatEventType.playerParry => (
BattlePhase.hit,
false,
false,
true,
false,
),
// 회피 → recover 페이즈 (빠른 회피 동작)
CombatEventType.playerEvade => (BattlePhase.recover, false),
CombatEventType.monsterEvade => (BattlePhase.idle, false),
CombatEventType.playerEvade => (
BattlePhase.recover,
false,
false,
false,
false,
),
CombatEventType.monsterEvade => (
BattlePhase.idle,
false,
false,
false,
false,
),
// 회복/버프 → idle 페이즈 유지
CombatEventType.playerHeal => (BattlePhase.idle, false),
CombatEventType.playerBuff => (BattlePhase.idle, false),
CombatEventType.playerHeal => (
BattlePhase.idle,
false,
false,
false,
false,
),
CombatEventType.playerBuff => (
BattlePhase.idle,
false,
false,
false,
false,
),
// DOT 틱 → attack 페이즈 (지속 피해)
CombatEventType.dotTick => (BattlePhase.attack, false),
CombatEventType.dotTick => (
BattlePhase.attack,
false,
false,
false,
false,
),
// 물약 사용 → idle 페이즈 유지
CombatEventType.playerPotion => (BattlePhase.idle, false),
CombatEventType.playerPotion => (
BattlePhase.idle,
false,
false,
false,
false,
),
// 물약 드랍 → idle 페이즈 유지
CombatEventType.potionDrop => (BattlePhase.idle, false),
CombatEventType.potionDrop => (
BattlePhase.idle,
false,
false,
false,
false,
),
};
setState(() {
@@ -211,6 +295,9 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
_battleSubFrame = 0;
_phaseFrameCount = 0;
_showCriticalEffect = isCritical;
_showBlockEffect = isBlock;
_showParryEffect = isParry;
_showSkillEffect = isSkill;
// 페이즈 인덱스 동기화
_phaseIndex = _battlePhaseSequence.indexWhere((p) => p.$1 == targetPhase);
@@ -322,8 +409,11 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
_phaseIndex = (_phaseIndex + 1) % _battlePhaseSequence.length;
_phaseFrameCount = 0;
_battleSubFrame = 0;
// 크리티컬 이펙트 리셋 (페이즈 전환 시)
// 이펙트 리셋 (페이즈 전환 시)
_showCriticalEffect = false;
_showBlockEffect = false;
_showParryEffect = false;
_showSkillEffect = false;
} else {
_battleSubFrame++;
}
@@ -340,21 +430,22 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
/// 현재 애니메이션 레이어 생성
List<AsciiLayer> _composeLayers() {
return switch (_animationMode) {
AnimationMode.battle => _battleComposer?.composeLayers(
_battlePhase,
_battleSubFrame,
widget.monsterBaseName,
_environment,
_globalTick,
) ??
[AsciiLayer.empty()],
AnimationMode.battle =>
_battleComposer?.composeLayers(
_battlePhase,
_battleSubFrame,
widget.monsterBaseName,
_environment,
_globalTick,
) ??
[AsciiLayer.empty()],
AnimationMode.walking => _walkingComposer.composeLayers(_globalTick),
AnimationMode.town => _townComposer.composeLayers(_globalTick),
AnimationMode.special => _specialComposer.composeLayers(
_currentSpecialAnimation ?? AsciiAnimationType.levelUp,
_currentFrame,
_globalTick,
),
_currentSpecialAnimation ?? AsciiAnimationType.levelUp,
_currentFrame,
_globalTick,
),
};
}
@@ -363,17 +454,38 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
// Phase 7: 고정 4색 팔레트 사용 (colorTheme 무시)
const bgColor = AsciiColors.background;
// 테두리 효과 결정 (특수 애니메이션 또는 크리티컬 히트)
// 테두리 효과 결정 (전투 이벤트 또는 특수 애니메이션)
final isSpecial = _currentSpecialAnimation != null;
Border? borderEffect;
if (_showCriticalEffect) {
// 크리티컬 히트: 노란색 테두리 (Phase 5)
borderEffect =
Border.all(color: Colors.yellow.withValues(alpha: 0.8), width: 2);
// 크리티컬 히트: 노란색 테두리
borderEffect = Border.all(
color: Colors.yellow.withValues(alpha: 0.8),
width: 2,
);
} else if (_showBlockEffect) {
// 블록 (방패 방어): 파란색 테두리
borderEffect = Border.all(
color: Colors.blue.withValues(alpha: 0.8),
width: 2,
);
} else if (_showParryEffect) {
// 패리 (무기 쳐내기): 주황색 테두리
borderEffect = Border.all(
color: Colors.orange.withValues(alpha: 0.8),
width: 2,
);
} else if (_showSkillEffect) {
// 스킬 사용: 마젠타 테두리
borderEffect = Border.all(
color: Colors.purple.withValues(alpha: 0.8),
width: 2,
);
} else if (isSpecial) {
// 특수 애니메이션: 시안 테두리
borderEffect =
Border.all(color: AsciiColors.positive.withValues(alpha: 0.5));
borderEffect = Border.all(
color: AsciiColors.positive.withValues(alpha: 0.5),
);
}
return Container(

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:askiineverdie/data/game_text_l10n.dart' as l10n;
import 'package:askiineverdie/data/story_data.dart';
/// 시네마틱 뷰 위젯 (Phase 9: Cinematic UI)
@@ -162,12 +163,9 @@ class _CinematicViewState extends State<CinematicView>
right: 16,
child: TextButton(
onPressed: _skip,
child: const Text(
'SKIP',
style: TextStyle(
color: Colors.white54,
fontSize: 14,
),
child: Text(
l10n.uiSkip,
style: const TextStyle(color: Colors.white54, fontSize: 14),
),
),
),
@@ -178,7 +176,7 @@ class _CinematicViewState extends State<CinematicView>
left: 0,
right: 0,
child: Text(
'Tap to continue',
l10n.uiTapToContinue,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.3),
fontSize: 12,
@@ -246,8 +244,8 @@ class _ProgressDots extends StatelessWidget {
color: isActive
? Colors.cyan
: isPast
? Colors.cyan.withValues(alpha: 0.5)
: Colors.white.withValues(alpha: 0.2),
? Colors.cyan.withValues(alpha: 0.5)
: Colors.white.withValues(alpha: 0.2),
),
);
}),

View File

@@ -148,7 +148,10 @@ class _LogEntryTile extends StatelessWidget {
(Color?, IconData?) _getStyleForType(CombatLogType type) {
return switch (type) {
CombatLogType.normal => (null, null),
CombatLogType.damage => (Colors.red.shade300, Icons.local_fire_department),
CombatLogType.damage => (
Colors.red.shade300,
Icons.local_fire_department,
),
CombatLogType.heal => (Colors.green.shade300, Icons.healing),
CombatLogType.levelUp => (Colors.amber, Icons.arrow_upward),
CombatLogType.questComplete => (Colors.blue.shade300, Icons.check_circle),
@@ -158,7 +161,10 @@ class _LogEntryTile extends StatelessWidget {
CombatLogType.evade => (Colors.cyan.shade300, Icons.directions_run),
CombatLogType.block => (Colors.blueGrey.shade300, Icons.shield),
CombatLogType.parry => (Colors.teal.shade300, Icons.sports_kabaddi),
CombatLogType.monsterAttack => (Colors.deepOrange.shade300, Icons.dangerous),
CombatLogType.monsterAttack => (
Colors.deepOrange.shade300,
Icons.dangerous,
),
CombatLogType.buff => (Colors.lightBlue.shade300, Icons.trending_up),
CombatLogType.dotTick => (Colors.deepPurple.shade300, Icons.whatshot),
CombatLogType.potion => (Colors.pink.shade300, Icons.local_drink),

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/data/game_text_l10n.dart' as l10n;
import 'package:askiineverdie/src/core/l10n/game_data_l10n.dart';
import 'package:askiineverdie/src/core/model/combat_event.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
@@ -109,7 +111,7 @@ class DeathOverlay extends StatelessWidget {
),
const SizedBox(height: 16),
Text(
'YOU DIED',
l10n.deathYouDied,
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
@@ -133,7 +135,7 @@ class DeathOverlay extends StatelessWidget {
),
const SizedBox(height: 4),
Text(
'Level ${deathInfo.levelAtDeath} ${traits.klass}',
'Level ${deathInfo.levelAtDeath} ${GameDataL10n.getKlassName(context, traits.klass)}',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@@ -174,9 +176,9 @@ class DeathOverlay extends StatelessWidget {
String _getDeathCauseText() {
return switch (deathInfo.cause) {
DeathCause.monster => 'Killed by ${deathInfo.killerName}',
DeathCause.selfDamage => 'Self-inflicted damage',
DeathCause.environment => 'Environmental hazard',
DeathCause.monster => l10n.deathKilledBy(deathInfo.killerName),
DeathCause.selfDamage => l10n.deathSelfInflicted,
DeathCause.environment => l10n.deathEnvironmentalHazard,
};
}
@@ -210,7 +212,7 @@ class DeathOverlay extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Sacrificed to Resurrect',
l10n.deathSacrificedToResurrect,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@@ -234,8 +236,8 @@ class DeathOverlay extends StatelessWidget {
_buildInfoRow(
context,
icon: Icons.check_circle_outline,
label: 'Equipment',
value: 'No sacrifice needed',
label: l10n.deathEquipment,
value: l10n.deathNoSacrificeNeeded,
isNegative: false,
),
const SizedBox(height: 8),
@@ -243,7 +245,7 @@ class DeathOverlay extends StatelessWidget {
_buildInfoRow(
context,
icon: Icons.monetization_on_outlined,
label: 'Gold Remaining',
label: l10n.deathGoldRemaining,
value: _formatGold(deathInfo.goldAtDeath),
isNegative: false,
),
@@ -306,7 +308,7 @@ class DeathOverlay extends StatelessWidget {
child: FilledButton.icon(
onPressed: onResurrect,
icon: const Icon(Icons.replay),
label: const Text('Resurrect'),
label: Text(l10n.deathResurrect),
style: FilledButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
padding: const EdgeInsets.symmetric(vertical: 16),
@@ -324,7 +326,7 @@ class DeathOverlay extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Combat Log',
l10n.deathCombatLog,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
@@ -378,69 +380,70 @@ class DeathOverlay extends StatelessWidget {
/// 전투 이벤트를 아이콘, 색상, 메시지로 포맷
(IconData, Color, String) _formatCombatEvent(CombatEvent event) {
final target = event.targetName ?? '';
return switch (event.type) {
CombatEventType.playerAttack => (
event.isCritical ? Icons.flash_on : Icons.local_fire_department,
event.isCritical ? Colors.yellow.shade300 : Colors.green.shade300,
event.isCritical
? 'CRITICAL! ${event.damage} damage to ${event.targetName}'
: 'Hit ${event.targetName} for ${event.damage} damage',
),
event.isCritical ? Icons.flash_on : Icons.local_fire_department,
event.isCritical ? Colors.yellow.shade300 : Colors.green.shade300,
event.isCritical
? l10n.combatCritical(event.damage, target)
: l10n.combatYouHit(target, event.damage),
),
CombatEventType.monsterAttack => (
Icons.dangerous,
Colors.red.shade300,
'${event.targetName} hits you for ${event.damage} damage',
),
Icons.dangerous,
Colors.red.shade300,
l10n.combatMonsterHitsYou(target, event.damage),
),
CombatEventType.playerEvade => (
Icons.directions_run,
Colors.cyan.shade300,
'Evaded attack from ${event.targetName}',
),
Icons.directions_run,
Colors.cyan.shade300,
l10n.combatEvadedAttackFrom(target),
),
CombatEventType.monsterEvade => (
Icons.directions_run,
Colors.orange.shade300,
'${event.targetName} evaded your attack',
),
Icons.directions_run,
Colors.orange.shade300,
l10n.combatMonsterEvaded(target),
),
CombatEventType.playerBlock => (
Icons.shield,
Colors.blueGrey.shade300,
'Blocked ${event.targetName}\'s attack (${event.damage} reduced)',
),
Icons.shield,
Colors.blueGrey.shade300,
l10n.combatBlockedAttack(target, event.damage),
),
CombatEventType.playerParry => (
Icons.sports_kabaddi,
Colors.teal.shade300,
'Parried ${event.targetName}\'s attack (${event.damage} reduced)',
),
Icons.sports_kabaddi,
Colors.teal.shade300,
l10n.combatParriedAttack(target, event.damage),
),
CombatEventType.playerSkill => (
Icons.auto_fix_high,
Colors.purple.shade300,
'${event.skillName} deals ${event.damage} damage',
),
Icons.auto_fix_high,
Colors.purple.shade300,
l10n.combatSkillDamage(event.skillName ?? '', event.damage),
),
CombatEventType.playerHeal => (
Icons.healing,
Colors.green.shade300,
'Healed for ${event.healAmount} HP',
),
Icons.healing,
Colors.green.shade300,
l10n.combatHealedFor(event.healAmount),
),
CombatEventType.playerBuff => (
Icons.trending_up,
Colors.lightBlue.shade300,
'${event.skillName} activated',
),
Icons.trending_up,
Colors.lightBlue.shade300,
l10n.combatBuffActivated(event.skillName ?? ''),
),
CombatEventType.dotTick => (
Icons.whatshot,
Colors.deepOrange.shade300,
'${event.skillName} ticks for ${event.damage} damage',
),
Icons.whatshot,
Colors.deepOrange.shade300,
l10n.combatDotTick(event.skillName ?? '', event.damage),
),
CombatEventType.playerPotion => (
Icons.local_drink,
Colors.lightGreen.shade300,
'${event.skillName}: +${event.healAmount} ${event.targetName}',
),
Icons.local_drink,
Colors.lightGreen.shade300,
l10n.combatPotionUsed(event.skillName ?? '', event.healAmount, target),
),
CombatEventType.potionDrop => (
Icons.card_giftcard,
Colors.lime.shade300,
'Dropped: ${event.skillName}',
),
Icons.card_giftcard,
Colors.lime.shade300,
l10n.combatPotionDrop(event.skillName ?? ''),
),
};
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/data/game_text_l10n.dart' as l10n;
import 'package:askiineverdie/src/core/engine/item_service.dart';
import 'package:askiineverdie/src/core/model/equipment_item.dart';
import 'package:askiineverdie/src/core/model/equipment_slot.dart';
@@ -135,7 +136,7 @@ class _EmptySlotTile extends StatelessWidget {
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
leading: _SlotIcon(slot: slot, isEmpty: true),
title: Text(
'[${_getSlotName(slot)}] (empty)',
'[${_getSlotName(slot)}] ${l10n.uiEmpty}',
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade600,
@@ -222,10 +223,7 @@ class _TotalScoreHeader extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.blueGrey.shade700,
Colors.blueGrey.shade600,
],
colors: [Colors.blueGrey.shade700, Colors.blueGrey.shade600],
),
borderRadius: BorderRadius.circular(8),
boxShadow: [
@@ -239,11 +237,7 @@ class _TotalScoreHeader extends StatelessWidget {
child: Row(
children: [
// 장비 아이콘
const Icon(
Icons.shield,
size: 20,
color: Colors.white70,
),
const Icon(Icons.shield, size: 20, color: Colors.white70),
const SizedBox(width: 8),
// 총합 점수
@@ -251,12 +245,9 @@ class _TotalScoreHeader extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Equipment Score',
style: TextStyle(
fontSize: 10,
color: Colors.white70,
),
Text(
l10n.uiEquipmentScore,
style: const TextStyle(fontSize: 10, color: Colors.white70),
),
Text(
'$totalScore',
@@ -304,46 +295,80 @@ class _StatsGrid extends StatelessWidget {
final entries = <_StatEntry>[];
// 공격 스탯
if (stats.atk > 0) entries.add(_StatEntry('ATK', '+${stats.atk}'));
if (stats.magAtk > 0) entries.add(_StatEntry('MATK', '+${stats.magAtk}'));
if (stats.atk > 0) entries.add(_StatEntry(l10n.statAtk, '+${stats.atk}'));
if (stats.magAtk > 0) {
entries.add(_StatEntry(l10n.statMAtk, '+${stats.magAtk}'));
}
if (stats.criRate > 0) {
entries.add(_StatEntry('CRI', '${(stats.criRate * 100).toStringAsFixed(1)}%'));
entries.add(
_StatEntry(l10n.statCri, '${(stats.criRate * 100).toStringAsFixed(1)}%'),
);
}
if (stats.parryRate > 0) {
entries.add(_StatEntry('PARRY', '${(stats.parryRate * 100).toStringAsFixed(1)}%'));
entries.add(
_StatEntry(
l10n.statParry,
'${(stats.parryRate * 100).toStringAsFixed(1)}%',
),
);
}
// 방어 스탯
if (stats.def > 0) entries.add(_StatEntry('DEF', '+${stats.def}'));
if (stats.magDef > 0) entries.add(_StatEntry('MDEF', '+${stats.magDef}'));
if (stats.def > 0) entries.add(_StatEntry(l10n.statDef, '+${stats.def}'));
if (stats.magDef > 0) {
entries.add(_StatEntry(l10n.statMDef, '+${stats.magDef}'));
}
if (stats.blockRate > 0) {
entries.add(_StatEntry('BLOCK', '${(stats.blockRate * 100).toStringAsFixed(1)}%'));
entries.add(
_StatEntry(
l10n.statBlock,
'${(stats.blockRate * 100).toStringAsFixed(1)}%',
),
);
}
if (stats.evasion > 0) {
entries.add(_StatEntry('EVA', '${(stats.evasion * 100).toStringAsFixed(1)}%'));
entries.add(
_StatEntry(l10n.statEva, '${(stats.evasion * 100).toStringAsFixed(1)}%'),
);
}
// 자원 스탯
if (stats.hpBonus > 0) entries.add(_StatEntry('HP', '+${stats.hpBonus}'));
if (stats.mpBonus > 0) entries.add(_StatEntry('MP', '+${stats.mpBonus}'));
if (stats.hpBonus > 0) {
entries.add(_StatEntry(l10n.statHp, '+${stats.hpBonus}'));
}
if (stats.mpBonus > 0) {
entries.add(_StatEntry(l10n.statMp, '+${stats.mpBonus}'));
}
// 능력치 보너스
if (stats.strBonus > 0) entries.add(_StatEntry('STR', '+${stats.strBonus}'));
if (stats.conBonus > 0) entries.add(_StatEntry('CON', '+${stats.conBonus}'));
if (stats.dexBonus > 0) entries.add(_StatEntry('DEX', '+${stats.dexBonus}'));
if (stats.intBonus > 0) entries.add(_StatEntry('INT', '+${stats.intBonus}'));
if (stats.wisBonus > 0) entries.add(_StatEntry('WIS', '+${stats.wisBonus}'));
if (stats.chaBonus > 0) entries.add(_StatEntry('CHA', '+${stats.chaBonus}'));
if (stats.strBonus > 0) {
entries.add(_StatEntry(l10n.statStr, '+${stats.strBonus}'));
}
if (stats.conBonus > 0) {
entries.add(_StatEntry(l10n.statCon, '+${stats.conBonus}'));
}
if (stats.dexBonus > 0) {
entries.add(_StatEntry(l10n.statDex, '+${stats.dexBonus}'));
}
if (stats.intBonus > 0) {
entries.add(_StatEntry(l10n.statInt, '+${stats.intBonus}'));
}
if (stats.wisBonus > 0) {
entries.add(_StatEntry(l10n.statWis, '+${stats.wisBonus}'));
}
if (stats.chaBonus > 0) {
entries.add(_StatEntry(l10n.statCha, '+${stats.chaBonus}'));
}
// 무기 공속
if (slot == EquipmentSlot.weapon && stats.attackSpeed > 0) {
entries.add(_StatEntry('SPEED', '${stats.attackSpeed}ms'));
entries.add(_StatEntry(l10n.statSpeed, '${stats.attackSpeed}ms'));
}
if (entries.isEmpty) {
return const Text(
'No bonus stats',
style: TextStyle(fontSize: 10, color: Colors.grey),
return Text(
l10n.uiNoBonusStats,
style: const TextStyle(fontSize: 10, color: Colors.grey),
);
}
@@ -406,7 +431,7 @@ class _ItemMetaRow extends StatelessWidget {
return Row(
children: [
Text(
'Lv.${item.level}',
l10n.uiLevel(item.level),
style: const TextStyle(fontSize: 9, color: Colors.grey),
),
const SizedBox(width: 8),
@@ -420,7 +445,7 @@ class _ItemMetaRow extends StatelessWidget {
),
const SizedBox(width: 8),
Text(
'Wt.${item.weight}',
l10n.uiWeight(item.weight),
style: const TextStyle(fontSize: 9, color: Colors.grey),
),
],
@@ -441,16 +466,16 @@ class _ItemMetaRow extends StatelessWidget {
/// 슬롯 이름 반환
String _getSlotName(EquipmentSlot slot) {
return switch (slot) {
EquipmentSlot.weapon => 'Weapon',
EquipmentSlot.shield => 'Shield',
EquipmentSlot.helm => 'Helm',
EquipmentSlot.hauberk => 'Hauberk',
EquipmentSlot.brassairts => 'Brassairts',
EquipmentSlot.vambraces => 'Vambraces',
EquipmentSlot.gauntlets => 'Gauntlets',
EquipmentSlot.gambeson => 'Gambeson',
EquipmentSlot.cuisses => 'Cuisses',
EquipmentSlot.greaves => 'Greaves',
EquipmentSlot.sollerets => 'Sollerets',
EquipmentSlot.weapon => l10n.slotWeapon,
EquipmentSlot.shield => l10n.slotShield,
EquipmentSlot.helm => l10n.slotHelm,
EquipmentSlot.hauberk => l10n.slotHauberk,
EquipmentSlot.brassairts => l10n.slotBrassairts,
EquipmentSlot.vambraces => l10n.slotVambraces,
EquipmentSlot.gauntlets => l10n.slotGauntlets,
EquipmentSlot.gambeson => l10n.slotGambeson,
EquipmentSlot.cuisses => l10n.slotCuisses,
EquipmentSlot.greaves => l10n.slotGreaves,
EquipmentSlot.sollerets => l10n.slotSollerets,
};
}

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/data/game_text_l10n.dart' as l10n;
/// HP/MP 바 위젯 (Phase 8: 변화 시 시각 효과)
///
/// - HP가 20% 미만일 때 빨간색 깜빡임
@@ -151,7 +153,8 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
final hpRatio = widget.hpMax > 0 ? widget.hpCurrent / widget.hpMax : 0.0;
final mpRatio = widget.mpMax > 0 ? widget.mpCurrent / widget.mpMax : 0.0;
final hasMonster = widget.monsterHpCurrent != null &&
final hasMonster =
widget.monsterHpCurrent != null &&
widget.monsterHpMax != null &&
widget.monsterHpMax! > 0;
@@ -162,7 +165,7 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
children: [
// HP 바 (플래시 효과 포함)
_buildAnimatedBar(
label: 'HP',
label: l10n.statHp,
current: widget.hpCurrent,
max: widget.hpMax,
ratio: hpRatio,
@@ -176,7 +179,7 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
// MP 바 (플래시 효과 포함)
_buildAnimatedBar(
label: 'MP',
label: l10n.statMp,
current: widget.mpCurrent,
max: widget.mpMax,
ratio: mpRatio,
@@ -188,10 +191,7 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
),
// 몬스터 HP 바 (전투 중일 때만)
if (hasMonster) ...[
const SizedBox(height: 8),
_buildMonsterBar(),
],
if (hasMonster) ...[const SizedBox(height: 8), _buildMonsterBar()],
],
),
);
@@ -228,7 +228,13 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
),
child: Stack(
children: [
_buildBar(label: label, current: current, max: max, ratio: ratio, color: color),
_buildBar(
label: label,
current: current,
max: max,
ratio: ratio,
color: color,
),
// 플로팅 변화량 텍스트 (위로 떠오르며 사라짐)
if (change != 0 && flashController.value > 0.05)
@@ -340,8 +346,9 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
child: LinearProgressIndicator(
value: ratio.clamp(0.0, 1.0),
backgroundColor: Colors.orange.withValues(alpha: 0.2),
valueColor:
const AlwaysStoppedAnimation<Color>(Colors.orange),
valueColor: const AlwaysStoppedAnimation<Color>(
Colors.orange,
),
minHeight: 8,
),
),

View File

@@ -40,20 +40,21 @@ class _NotificationOverlayState extends State<NotificationOverlay>
vsync: this,
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, -1),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutBack,
));
_slideAnimation =
Tween<Offset>(begin: const Offset(0, -1), end: Offset.zero).animate(
CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutBack,
),
);
_fadeAnimation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeIn),
);
_notificationSub =
widget.notificationService.notifications.listen(_onNotification);
_notificationSub = widget.notificationService.notifications.listen(
_onNotification,
);
_dismissSub = widget.notificationService.dismissals.listen(_onDismiss);
}
@@ -184,35 +185,35 @@ class _NotificationCard extends StatelessWidget {
(Color, IconData, Color) _getStyleForType(NotificationType type) {
return switch (type) {
NotificationType.levelUp => (
const Color(0xFF1565C0),
Icons.trending_up,
Colors.amber,
),
const Color(0xFF1565C0),
Icons.trending_up,
Colors.amber,
),
NotificationType.questComplete => (
const Color(0xFF2E7D32),
Icons.check_circle,
Colors.lightGreen,
),
const Color(0xFF2E7D32),
Icons.check_circle,
Colors.lightGreen,
),
NotificationType.actComplete => (
const Color(0xFF6A1B9A),
Icons.flag,
Colors.purpleAccent,
),
const Color(0xFF6A1B9A),
Icons.flag,
Colors.purpleAccent,
),
NotificationType.newSpell => (
const Color(0xFF4527A0),
Icons.auto_fix_high,
Colors.deepPurpleAccent,
),
const Color(0xFF4527A0),
Icons.auto_fix_high,
Colors.deepPurpleAccent,
),
NotificationType.newEquipment => (
const Color(0xFFE65100),
Icons.shield,
Colors.orange,
),
const Color(0xFFE65100),
Icons.shield,
Colors.orange,
),
NotificationType.bossDefeat => (
const Color(0xFFC62828),
Icons.whatshot,
Colors.redAccent,
),
const Color(0xFFC62828),
Icons.whatshot,
Colors.redAccent,
),
};
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/data/game_text_l10n.dart' as l10n;
import 'package:askiineverdie/data/potion_data.dart';
import 'package:askiineverdie/src/core/model/potion.dart';
@@ -22,10 +23,10 @@ class PotionInventoryPanel extends StatelessWidget {
final potionEntries = _buildPotionEntries();
if (potionEntries.isEmpty) {
return const Center(
return Center(
child: Text(
'No potions',
style: TextStyle(
l10n.uiNoPotions,
style: const TextStyle(
fontSize: 11,
color: Colors.grey,
fontStyle: FontStyle.italic,
@@ -146,11 +147,7 @@ class _PotionRow extends StatelessWidget {
// 전투 중 사용 불가 표시
if (isUsedThisBattle) ...[
const SizedBox(width: 4),
const Icon(
Icons.block,
size: 12,
color: Colors.grey,
),
const Icon(Icons.block, size: 12, color: Colors.grey),
],
],
),
@@ -213,10 +210,7 @@ class _HealBadge extends StatelessWidget {
),
child: Text(
healText,
style: TextStyle(
fontSize: 9,
color: Colors.grey.shade700,
),
style: TextStyle(fontSize: 9, color: Colors.grey.shade700),
),
);
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/data/game_text_l10n.dart' as l10n;
import 'package:askiineverdie/data/skill_data.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/core/model/skill.dart';
@@ -90,8 +91,8 @@ class _SkillPanelState extends State<SkillPanel>
final skillStates = widget.skillSystem.skillStates;
if (skillStates.isEmpty) {
return const Center(
child: Text('No skills', style: TextStyle(fontSize: 11)),
return Center(
child: Text(l10n.uiNoSkills, style: const TextStyle(fontSize: 11)),
);
}
@@ -143,7 +144,7 @@ class _SkillRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final cooldownText = isReady
? 'Ready'
? l10n.uiReady
: '${(remainingMs / 1000).toStringAsFixed(1)}s';
final skillIcon = _getSkillIcon(skill.type);
@@ -192,9 +193,9 @@ class _SkillRow extends StatelessWidget {
color: elementColor.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(3),
),
child: const Text(
'DOT',
style: TextStyle(fontSize: 7, color: Colors.white70),
child: Text(
l10n.uiDot,
style: const TextStyle(fontSize: 7, color: Colors.white70),
),
),
const SizedBox(width: 4),
@@ -202,7 +203,7 @@ class _SkillRow extends StatelessWidget {
// 랭크
Text(
'Lv.$rank',
l10n.uiLevel(rank),
style: const TextStyle(fontSize: 9, color: Colors.grey),
),
const SizedBox(width: 4),
@@ -233,7 +234,9 @@ class _SkillRow extends StatelessWidget {
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: skillColor.withValues(alpha: glowAnimation.value * 0.5),
color: skillColor.withValues(
alpha: glowAnimation.value * 0.5,
),
blurRadius: 8 * glowAnimation.value,
spreadRadius: 2 * glowAnimation.value,
),
@@ -332,11 +335,7 @@ class _ElementBadge extends StatelessWidget {
? Border.all(color: color.withValues(alpha: 0.7), width: 1)
: null,
),
child: Icon(
icon,
size: 10,
color: color,
),
child: Icon(icon, size: 10, color: color),
);
}
}

View File

@@ -137,8 +137,9 @@ class TaskProgressPanel extends StatelessWidget {
child: Text(
'${speedMultiplier}x',
style: TextStyle(
fontWeight:
speedMultiplier > 1 ? FontWeight.bold : FontWeight.normal,
fontWeight: speedMultiplier > 1
? FontWeight.bold
: FontWeight.normal,
color: speedMultiplier > 1
? Theme.of(context).colorScheme.primary
: null,
@@ -157,8 +158,9 @@ class TaskProgressPanel extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: LinearProgressIndicator(
value: progressValue,
backgroundColor:
Theme.of(context).colorScheme.primary.withValues(alpha: 0.2),
backgroundColor: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),