update
This commit is contained in:
parent
d3fd9680c2
commit
d7cd938403
|
|
@ -155,22 +155,24 @@
|
||||||
{
|
{
|
||||||
"id": "old_ring",
|
"id": "old_ring",
|
||||||
"name": "Old Ring",
|
"name": "Old Ring",
|
||||||
"description": "A tarnished ring.",
|
"description": "A tarnished ring. Might bring a little luck.",
|
||||||
"baseAtk": 1,
|
"baseAtk": 1,
|
||||||
"baseHp": 5,
|
"baseHp": 5,
|
||||||
"slot": "accessory",
|
"slot": "accessory",
|
||||||
"price": 25,
|
"price": 25,
|
||||||
"image": "assets/images/items/old_ring.png"
|
"image": "assets/images/items/old_ring.png",
|
||||||
|
"luck": 5
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "copper_ring",
|
"id": "copper_ring",
|
||||||
"name": "Copper Ring",
|
"name": "Copper Ring",
|
||||||
"description": "A simple ring",
|
"description": "A simple ring.",
|
||||||
"baseAtk": 1,
|
"baseAtk": 1,
|
||||||
"baseHp": 5,
|
"baseHp": 5,
|
||||||
"slot": "accessory",
|
"slot": "accessory",
|
||||||
"price": 25,
|
"price": 25,
|
||||||
"image": "assets/images/items/copper_ring.png"
|
"image": "assets/images/items/copper_ring.png",
|
||||||
|
"luck": 3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "ruby_amulet",
|
"id": "ruby_amulet",
|
||||||
|
|
@ -180,7 +182,8 @@
|
||||||
"baseHp": 15,
|
"baseHp": 15,
|
||||||
"slot": "accessory",
|
"slot": "accessory",
|
||||||
"price": 80,
|
"price": 80,
|
||||||
"image": "assets/images/items/ruby_amulet.png"
|
"image": "assets/images/items/ruby_amulet.png",
|
||||||
|
"luck": 7
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "heros_badge",
|
"id": "heros_badge",
|
||||||
|
|
@ -191,7 +194,19 @@
|
||||||
"baseArmor": 1,
|
"baseArmor": 1,
|
||||||
"slot": "accessory",
|
"slot": "accessory",
|
||||||
"price": 150,
|
"price": 150,
|
||||||
"image": "assets/images/items/heros_badge.png"
|
"image": "assets/images/items/heros_badge.png",
|
||||||
|
"luck": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lucky_charm",
|
||||||
|
"name": "Lucky Charm",
|
||||||
|
"description": "A four-leaf clover encased in amber.",
|
||||||
|
"baseAtk": 0,
|
||||||
|
"baseHp": 10,
|
||||||
|
"slot": "accessory",
|
||||||
|
"price": 200,
|
||||||
|
"image": "assets/images/items/lucky_charm.png",
|
||||||
|
"luck": 25
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../enums.dart';
|
||||||
|
|
||||||
|
class AnimationConfig {
|
||||||
|
// Durations
|
||||||
|
static const Duration floatingTextDuration = Duration(milliseconds: 1000);
|
||||||
|
static const Duration floatingEffectDuration = Duration(milliseconds: 800);
|
||||||
|
static const Duration fadeDuration = Duration(milliseconds: 200);
|
||||||
|
|
||||||
|
// Attack Animations
|
||||||
|
static const Duration attackSafe = Duration(milliseconds: 200);
|
||||||
|
static const Duration attackNormal = Duration(milliseconds: 400);
|
||||||
|
static const Duration attackRiskyTotal = Duration(milliseconds: 1100);
|
||||||
|
static const Duration attackRiskyScale = Duration(milliseconds: 600);
|
||||||
|
static const Duration attackRiskyDash = Duration(milliseconds: 500);
|
||||||
|
|
||||||
|
// Curves
|
||||||
|
static const Curve floatingTextCurve = Curves.easeOut;
|
||||||
|
static const Curve floatingEffectScaleCurve = Curves.elasticOut;
|
||||||
|
static const Curve attackSafeCurve = Curves.elasticIn;
|
||||||
|
static const Curve attackNormalCurve = Curves.easeOutQuad;
|
||||||
|
static const Curve attackRiskyDashCurve = Curves.easeInExpo;
|
||||||
|
|
||||||
|
static Duration getAttackDuration(RiskLevel risk) {
|
||||||
|
switch (risk) {
|
||||||
|
case RiskLevel.safe:
|
||||||
|
return attackSafe;
|
||||||
|
case RiskLevel.normal:
|
||||||
|
return attackNormal;
|
||||||
|
case RiskLevel.risky:
|
||||||
|
return attackRiskyTotal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../enums.dart';
|
||||||
|
|
||||||
|
class BattleConfig {
|
||||||
|
// Icons
|
||||||
|
static const IconData attackIcon = Icons.flash_on;
|
||||||
|
static const IconData defendIcon = Icons.shield;
|
||||||
|
|
||||||
|
// Colors
|
||||||
|
static const Color riskyColor = Colors.redAccent;
|
||||||
|
static const Color normalColor = Colors.orangeAccent;
|
||||||
|
static const Color safeColor = Colors.grey;
|
||||||
|
|
||||||
|
static const Color defendRiskyColor = Colors.deepPurpleAccent;
|
||||||
|
static const Color defendNormalColor = Colors.blueAccent;
|
||||||
|
static const Color defendSafeColor = Colors.greenAccent;
|
||||||
|
|
||||||
|
// Sizes
|
||||||
|
static const double sizeRisky = 80.0; // User increased this in previous edit
|
||||||
|
static const double sizeNormal = 60.0;
|
||||||
|
static const double sizeSafe = 40.0;
|
||||||
|
|
||||||
|
static IconData getIcon(ActionType type) {
|
||||||
|
switch (type) {
|
||||||
|
case ActionType.attack:
|
||||||
|
return attackIcon;
|
||||||
|
case ActionType.defend:
|
||||||
|
return defendIcon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Color getColor(ActionType type, RiskLevel risk) {
|
||||||
|
if (type == ActionType.attack) {
|
||||||
|
switch (risk) {
|
||||||
|
case RiskLevel.risky:
|
||||||
|
return riskyColor;
|
||||||
|
case RiskLevel.normal:
|
||||||
|
return normalColor;
|
||||||
|
case RiskLevel.safe:
|
||||||
|
return safeColor;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch (risk) {
|
||||||
|
case RiskLevel.risky:
|
||||||
|
return defendRiskyColor;
|
||||||
|
case RiskLevel.normal:
|
||||||
|
return defendNormalColor;
|
||||||
|
case RiskLevel.safe:
|
||||||
|
return defendSafeColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static double getSize(RiskLevel risk) {
|
||||||
|
switch (risk) {
|
||||||
|
case RiskLevel.risky:
|
||||||
|
return sizeRisky;
|
||||||
|
case RiskLevel.normal:
|
||||||
|
return sizeNormal;
|
||||||
|
case RiskLevel.safe:
|
||||||
|
return sizeSafe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class ThemeConfig {
|
||||||
|
// Stat Colors
|
||||||
|
static const Color statHpColor = Colors.red;
|
||||||
|
static const Color statHpPlayerColor = Colors.green;
|
||||||
|
static const Color statHpEnemyColor = Colors.red;
|
||||||
|
static const Color statAtkColor = Colors.blueAccent;
|
||||||
|
static const Color statDefColor =
|
||||||
|
Colors.green; // Or Blue depending on context
|
||||||
|
static const Color statLuckColor = Colors.green;
|
||||||
|
static const Color statGoldColor = Colors.amber;
|
||||||
|
|
||||||
|
// UI Colors
|
||||||
|
static const Color textColorWhite = Colors.white;
|
||||||
|
static const Color textColorGrey = Colors.grey;
|
||||||
|
static const Color cardBgColor = Colors.black54;
|
||||||
|
static const Color inventoryCardBg = Color(
|
||||||
|
0xFF455A64,
|
||||||
|
); // Colors.blueGrey[700]
|
||||||
|
static const Color equipmentCardBg = Color(
|
||||||
|
0xFF546E7A,
|
||||||
|
); // Colors.blueGrey[600]
|
||||||
|
static const Color emptySlotBg = Color(0xFF424242); // Colors.grey[800]
|
||||||
|
|
||||||
|
// Feedback Colors
|
||||||
|
static const Color damageTextDefault = Colors.red;
|
||||||
|
static const Color healText = Colors.green;
|
||||||
|
static const Color missText = Colors.grey;
|
||||||
|
static const Color failedText = Colors.redAccent;
|
||||||
|
static const Color feedbackShadow = Colors.black;
|
||||||
|
|
||||||
|
// Status Effect Colors
|
||||||
|
static const Color effectBg = Colors.deepOrange;
|
||||||
|
static const Color effectText = Colors.white;
|
||||||
|
}
|
||||||
|
|
@ -42,9 +42,9 @@ class ItemTemplate {
|
||||||
id: json['id'],
|
id: json['id'],
|
||||||
name: json['name'],
|
name: json['name'],
|
||||||
description: json['description'],
|
description: json['description'],
|
||||||
atkBonus: json['atkBonus'] ?? 0,
|
atkBonus: json['atkBonus'] ?? json['baseAtk'] ?? 0,
|
||||||
hpBonus: json['hpBonus'] ?? 0,
|
hpBonus: json['hpBonus'] ?? json['baseHp'] ?? 0,
|
||||||
armorBonus: json['armorBonus'] ?? 0,
|
armorBonus: json['armorBonus'] ?? json['baseArmor'] ?? 0,
|
||||||
slot: EquipmentSlot.values.firstWhere((e) => e.name == json['slot']),
|
slot: EquipmentSlot.values.firstWhere((e) => e.name == json['slot']),
|
||||||
effects: effectsList,
|
effects: effectsList,
|
||||||
price: json['price'] ?? 10,
|
price: json['price'] ?? 10,
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@ import '../enums.dart';
|
||||||
enum EffectTarget { player, enemy }
|
enum EffectTarget { player, enemy }
|
||||||
|
|
||||||
class EffectEvent {
|
class EffectEvent {
|
||||||
|
final String id;
|
||||||
final ActionType type; // attack, defend
|
final ActionType type; // attack, defend
|
||||||
final RiskLevel risk;
|
final RiskLevel risk;
|
||||||
final EffectTarget target; // 이펙트가 표시될 위치의 대상
|
final EffectTarget target; // 이펙트가 표시될 위치의 대상
|
||||||
final BattleFeedbackType? feedbackType; // 새로운 피드백 타입
|
final BattleFeedbackType? feedbackType; // 새로운 피드백 타입
|
||||||
|
|
||||||
EffectEvent({
|
EffectEvent({
|
||||||
|
required this.id,
|
||||||
required this.type,
|
required this.type,
|
||||||
required this.risk,
|
required this.risk,
|
||||||
required this.target,
|
required this.target,
|
||||||
|
|
|
||||||
|
|
@ -243,7 +243,7 @@ class BattleProvider with ChangeNotifier {
|
||||||
|
|
||||||
/// Handle player's action choice
|
/// Handle player's action choice
|
||||||
|
|
||||||
void playerAction(ActionType type, RiskLevel risk) {
|
Future<void> playerAction(ActionType type, RiskLevel risk) async {
|
||||||
if (!isPlayerTurn || player.isDead || enemy.isDead || showRewardPopup)
|
if (!isPlayerTurn || player.isDead || enemy.isDead || showRewardPopup)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
|
@ -303,8 +303,12 @@ class BattleProvider with ChangeNotifier {
|
||||||
if (type == ActionType.attack) {
|
if (type == ActionType.attack) {
|
||||||
int damage = (player.totalAtk * efficiency).toInt();
|
int damage = (player.totalAtk * efficiency).toInt();
|
||||||
|
|
||||||
|
final eventId =
|
||||||
|
DateTime.now().millisecondsSinceEpoch.toString() +
|
||||||
|
Random().nextInt(1000).toString();
|
||||||
_effectEventController.sink.add(
|
_effectEventController.sink.add(
|
||||||
EffectEvent(
|
EffectEvent(
|
||||||
|
id: eventId,
|
||||||
type: ActionType.attack,
|
type: ActionType.attack,
|
||||||
risk: risk,
|
risk: risk,
|
||||||
target: EffectTarget.enemy,
|
target: EffectTarget.enemy,
|
||||||
|
|
@ -312,6 +316,15 @@ class BattleProvider with ChangeNotifier {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Animation Delays to sync with Impact
|
||||||
|
if (risk == RiskLevel.safe) {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
} else if (risk == RiskLevel.normal) {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 400));
|
||||||
|
} else if (risk == RiskLevel.risky) {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 1100));
|
||||||
|
}
|
||||||
|
|
||||||
int damageToHp = 0;
|
int damageToHp = 0;
|
||||||
if (enemy.armor > 0) {
|
if (enemy.armor > 0) {
|
||||||
if (enemy.armor >= damage) {
|
if (enemy.armor >= damage) {
|
||||||
|
|
@ -339,6 +352,9 @@ class BattleProvider with ChangeNotifier {
|
||||||
} else {
|
} else {
|
||||||
_effectEventController.sink.add(
|
_effectEventController.sink.add(
|
||||||
EffectEvent(
|
EffectEvent(
|
||||||
|
id:
|
||||||
|
DateTime.now().millisecondsSinceEpoch.toString() +
|
||||||
|
Random().nextInt(1000).toString(),
|
||||||
type: ActionType.defend,
|
type: ActionType.defend,
|
||||||
risk: risk,
|
risk: risk,
|
||||||
target: EffectTarget.player,
|
target: EffectTarget.player,
|
||||||
|
|
@ -355,6 +371,9 @@ class BattleProvider with ChangeNotifier {
|
||||||
_addLog("Player's attack missed!");
|
_addLog("Player's attack missed!");
|
||||||
_effectEventController.sink.add(
|
_effectEventController.sink.add(
|
||||||
EffectEvent(
|
EffectEvent(
|
||||||
|
id:
|
||||||
|
DateTime.now().millisecondsSinceEpoch.toString() +
|
||||||
|
Random().nextInt(1000).toString(),
|
||||||
type: type,
|
type: type,
|
||||||
risk: risk,
|
risk: risk,
|
||||||
target: EffectTarget.enemy, // 공격 실패는 적 위치에 MISS
|
target: EffectTarget.enemy, // 공격 실패는 적 위치에 MISS
|
||||||
|
|
@ -365,6 +384,9 @@ class BattleProvider with ChangeNotifier {
|
||||||
_addLog("Player's defense failed!");
|
_addLog("Player's defense failed!");
|
||||||
_effectEventController.sink.add(
|
_effectEventController.sink.add(
|
||||||
EffectEvent(
|
EffectEvent(
|
||||||
|
id:
|
||||||
|
DateTime.now().millisecondsSinceEpoch.toString() +
|
||||||
|
Random().nextInt(1000).toString(),
|
||||||
type: type,
|
type: type,
|
||||||
risk: risk,
|
risk: risk,
|
||||||
target: EffectTarget.player, // 방어 실패는 내 위치에 FAILED
|
target: EffectTarget.player, // 방어 실패는 내 위치에 FAILED
|
||||||
|
|
@ -429,6 +451,9 @@ class BattleProvider with ChangeNotifier {
|
||||||
if (intent.isSuccess) {
|
if (intent.isSuccess) {
|
||||||
_effectEventController.sink.add(
|
_effectEventController.sink.add(
|
||||||
EffectEvent(
|
EffectEvent(
|
||||||
|
id:
|
||||||
|
DateTime.now().millisecondsSinceEpoch.toString() +
|
||||||
|
Random().nextInt(1000).toString(),
|
||||||
type: ActionType.attack,
|
type: ActionType.attack,
|
||||||
risk: intent.risk,
|
risk: intent.risk,
|
||||||
target: EffectTarget.player,
|
target: EffectTarget.player,
|
||||||
|
|
@ -465,6 +490,9 @@ class BattleProvider with ChangeNotifier {
|
||||||
_addLog("Enemy's ${intent.risk.name} attack missed!");
|
_addLog("Enemy's ${intent.risk.name} attack missed!");
|
||||||
_effectEventController.sink.add(
|
_effectEventController.sink.add(
|
||||||
EffectEvent(
|
EffectEvent(
|
||||||
|
id:
|
||||||
|
DateTime.now().millisecondsSinceEpoch.toString() +
|
||||||
|
Random().nextInt(1000).toString(),
|
||||||
type: ActionType.attack, // 적의 공격이므로 ActionType.attack
|
type: ActionType.attack, // 적의 공격이므로 ActionType.attack
|
||||||
risk: intent.risk,
|
risk: intent.risk,
|
||||||
target: EffectTarget.player, // 플레이어가 회피했으므로 플레이어 위치에 이펙트
|
target: EffectTarget.player, // 플레이어가 회피했으므로 플레이어 위치에 이펙트
|
||||||
|
|
@ -777,6 +805,9 @@ class BattleProvider with ChangeNotifier {
|
||||||
_addLog("Enemy prepares defense! (+$armor Armor)");
|
_addLog("Enemy prepares defense! (+$armor Armor)");
|
||||||
_effectEventController.sink.add(
|
_effectEventController.sink.add(
|
||||||
EffectEvent(
|
EffectEvent(
|
||||||
|
id:
|
||||||
|
DateTime.now().millisecondsSinceEpoch.toString() +
|
||||||
|
Random().nextInt(1000).toString(),
|
||||||
type: ActionType.defend,
|
type: ActionType.defend,
|
||||||
risk: risk,
|
risk: risk,
|
||||||
target: EffectTarget.enemy,
|
target: EffectTarget.enemy,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../providers/battle_provider.dart';
|
import '../providers/battle_provider.dart';
|
||||||
|
|
||||||
|
|
@ -13,7 +14,11 @@ import '../widgets/battle/character_status_card.dart';
|
||||||
import '../widgets/battle/battle_log_overlay.dart';
|
import '../widgets/battle/battle_log_overlay.dart';
|
||||||
import '../widgets/battle/floating_battle_texts.dart';
|
import '../widgets/battle/floating_battle_texts.dart';
|
||||||
import '../widgets/battle/stage_ui.dart';
|
import '../widgets/battle/stage_ui.dart';
|
||||||
|
import '../widgets/battle/shake_widget.dart';
|
||||||
|
import '../widgets/battle/battle_animation_widget.dart';
|
||||||
|
import '../widgets/battle/explosion_widget.dart';
|
||||||
import 'main_menu_screen.dart';
|
import 'main_menu_screen.dart';
|
||||||
|
import '../game/config/battle_config.dart';
|
||||||
|
|
||||||
class BattleScreen extends StatefulWidget {
|
class BattleScreen extends StatefulWidget {
|
||||||
const BattleScreen({super.key});
|
const BattleScreen({super.key});
|
||||||
|
|
@ -31,7 +36,13 @@ class _BattleScreenState extends State<BattleScreen> {
|
||||||
final GlobalKey _playerKey = GlobalKey();
|
final GlobalKey _playerKey = GlobalKey();
|
||||||
final GlobalKey _enemyKey = GlobalKey();
|
final GlobalKey _enemyKey = GlobalKey();
|
||||||
final GlobalKey _stackKey = GlobalKey();
|
final GlobalKey _stackKey = GlobalKey();
|
||||||
|
final GlobalKey<ShakeWidgetState> _shakeKey = GlobalKey<ShakeWidgetState>();
|
||||||
|
final GlobalKey<BattleAnimationWidgetState> _playerAnimKey =
|
||||||
|
GlobalKey<BattleAnimationWidgetState>();
|
||||||
|
final GlobalKey<ExplosionWidgetState> _explosionKey =
|
||||||
|
GlobalKey<ExplosionWidgetState>();
|
||||||
bool _showLogs = true;
|
bool _showLogs = true;
|
||||||
|
bool _isPlayerAttacking = false; // Player Attack Animation State
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -104,7 +115,17 @@ class _BattleScreenState extends State<BattleScreen> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final Set<String> _processedEffectIds = {};
|
||||||
|
|
||||||
void _addFloatingEffect(EffectEvent event) {
|
void _addFloatingEffect(EffectEvent event) {
|
||||||
|
if (_processedEffectIds.contains(event.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_processedEffectIds.add(event.id);
|
||||||
|
if (_processedEffectIds.length > 20) {
|
||||||
|
_processedEffectIds.remove(_processedEffectIds.first);
|
||||||
|
}
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
|
@ -130,40 +151,78 @@ class _BattleScreenState extends State<BattleScreen> {
|
||||||
position +
|
position +
|
||||||
Offset(renderBox.size.width / 2 - 30, renderBox.size.height / 2 - 30);
|
Offset(renderBox.size.width / 2 - 30, renderBox.size.height / 2 - 30);
|
||||||
|
|
||||||
// feedbackType이 존재하면 해당 텍스트를 표시하고 기존 이펙트 아이콘은 건너뜜
|
// 0. Prepare Effect Function
|
||||||
if (event.feedbackType != null) {
|
void showEffect() {
|
||||||
String feedbackText;
|
if (!mounted) return;
|
||||||
Color feedbackColor;
|
|
||||||
switch (event.feedbackType) {
|
// feedbackType이 존재하면 해당 텍스트를 표시하고 기존 이펙트 아이콘은 건너뜜
|
||||||
case BattleFeedbackType.miss:
|
if (event.feedbackType != null) {
|
||||||
feedbackText = "MISS";
|
String feedbackText;
|
||||||
feedbackColor = Colors.grey;
|
Color feedbackColor;
|
||||||
break;
|
switch (event.feedbackType) {
|
||||||
case BattleFeedbackType.failed:
|
case BattleFeedbackType.miss:
|
||||||
feedbackText = "FAILED";
|
feedbackText = "MISS";
|
||||||
feedbackColor = Colors.redAccent;
|
feedbackColor = Colors.grey;
|
||||||
break;
|
break;
|
||||||
default:
|
case BattleFeedbackType.failed:
|
||||||
feedbackText = ""; // Should not happen with current enums
|
feedbackText = "FAILED";
|
||||||
feedbackColor = Colors.white;
|
feedbackColor = Colors.redAccent;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
feedbackText = ""; // Should not happen with current enums
|
||||||
|
feedbackColor = Colors.white;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id = UniqueKey().toString();
|
||||||
|
setState(() {
|
||||||
|
_floatingFeedbackTexts.add(
|
||||||
|
FeedbackTextData(
|
||||||
|
id: id,
|
||||||
|
widget: Positioned(
|
||||||
|
left: position.dx,
|
||||||
|
top: position.dy,
|
||||||
|
child: FloatingFeedbackText(
|
||||||
|
key: ValueKey(id),
|
||||||
|
feedback: feedbackText,
|
||||||
|
color: feedbackColor,
|
||||||
|
onRemove: () {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_floatingFeedbackTexts.removeWhere((e) => e.id == id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return; // feedbackType이 있으면 아이콘 이펙트는 표시하지 않음
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use BattleConfig for Icon, Color, and Size
|
||||||
|
IconData icon = BattleConfig.getIcon(event.type);
|
||||||
|
Color color = BattleConfig.getColor(event.type, event.risk);
|
||||||
|
double size = BattleConfig.getSize(event.risk);
|
||||||
|
|
||||||
final String id = UniqueKey().toString();
|
final String id = UniqueKey().toString();
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_floatingFeedbackTexts.add(
|
_floatingEffects.add(
|
||||||
FeedbackTextData(
|
FloatingEffectData(
|
||||||
id: id,
|
id: id,
|
||||||
widget: Positioned(
|
widget: Positioned(
|
||||||
left: position.dx,
|
left: position.dx,
|
||||||
top: position.dy,
|
top: position.dy,
|
||||||
child: FloatingFeedbackText(
|
child: FloatingEffect(
|
||||||
key: ValueKey(id),
|
key: ValueKey(id),
|
||||||
feedback: feedbackText,
|
icon: icon,
|
||||||
color: feedbackColor,
|
color: color,
|
||||||
|
size: size,
|
||||||
onRemove: () {
|
onRemove: () {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_floatingFeedbackTexts.removeWhere((e) => e.id == id);
|
_floatingEffects.removeWhere((e) => e.id == id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -172,67 +231,63 @@ class _BattleScreenState extends State<BattleScreen> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return; // feedbackType이 있으면 아이콘 이펙트는 표시하지 않음
|
|
||||||
}
|
}
|
||||||
|
|
||||||
IconData icon;
|
// 1. Attack Animation Trigger (All Risk Levels)
|
||||||
Color color;
|
if (event.type == ActionType.attack &&
|
||||||
double size;
|
event.target == EffectTarget.enemy &&
|
||||||
|
event.feedbackType == null) {
|
||||||
|
// Calculate target position (Enemy) relative to Player
|
||||||
|
final RenderBox? playerBox =
|
||||||
|
_playerKey.currentContext?.findRenderObject() as RenderBox?;
|
||||||
|
final RenderBox? enemyBox =
|
||||||
|
_enemyKey.currentContext?.findRenderObject() as RenderBox?;
|
||||||
|
|
||||||
if (event.type == ActionType.attack) {
|
if (playerBox != null && enemyBox != null) {
|
||||||
if (event.risk == RiskLevel.risky) {
|
final playerPos = playerBox.localToGlobal(Offset.zero);
|
||||||
icon = Icons.whatshot;
|
final enemyPos = enemyBox.localToGlobal(Offset.zero);
|
||||||
color = Colors.redAccent;
|
|
||||||
size = 60.0;
|
final offset = enemyPos - playerPos;
|
||||||
} else if (event.risk == RiskLevel.normal) {
|
|
||||||
icon = Icons.flash_on;
|
// Start Animation: Hide Stats
|
||||||
color = Colors.orangeAccent;
|
setState(() {
|
||||||
size = 40.0;
|
_isPlayerAttacking = true;
|
||||||
} else {
|
});
|
||||||
icon = Icons.close;
|
|
||||||
color = Colors.grey;
|
_playerAnimKey.currentState
|
||||||
size = 30.0;
|
?.animateAttack(offset, () {
|
||||||
|
showEffect(); // Show Effect at Impact!
|
||||||
|
// Shake and Explosion ONLY for Risky
|
||||||
|
if (event.risk == RiskLevel.risky) {
|
||||||
|
_shakeKey.currentState?.shake();
|
||||||
|
|
||||||
|
RenderBox? stackBox =
|
||||||
|
_stackKey.currentContext?.findRenderObject()
|
||||||
|
as RenderBox?;
|
||||||
|
if (stackBox != null) {
|
||||||
|
Offset localEnemyPos = stackBox.globalToLocal(enemyPos);
|
||||||
|
// Center of the enemy card roughly
|
||||||
|
localEnemyPos += Offset(
|
||||||
|
enemyBox.size.width / 2,
|
||||||
|
enemyBox.size.height / 2,
|
||||||
|
);
|
||||||
|
_explosionKey.currentState?.explode(localEnemyPos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, event.risk)
|
||||||
|
.then((_) {
|
||||||
|
// End Animation: Show Stats
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isPlayerAttacking = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
icon = Icons.shield;
|
// Not a player attack, show immediately
|
||||||
if (event.risk == RiskLevel.risky) {
|
showEffect();
|
||||||
color = Colors.deepPurpleAccent;
|
|
||||||
size = 60.0;
|
|
||||||
} else if (event.risk == RiskLevel.normal) {
|
|
||||||
color = Colors.blueAccent;
|
|
||||||
size = 40.0;
|
|
||||||
} else {
|
|
||||||
color = Colors.greenAccent;
|
|
||||||
size = 30.0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final String id = UniqueKey().toString();
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_floatingEffects.add(
|
|
||||||
FloatingEffectData(
|
|
||||||
id: id,
|
|
||||||
widget: Positioned(
|
|
||||||
left: position.dx,
|
|
||||||
top: position.dy,
|
|
||||||
child: FloatingEffect(
|
|
||||||
key: ValueKey(id),
|
|
||||||
icon: icon,
|
|
||||||
color: color,
|
|
||||||
size: size,
|
|
||||||
onRemove: () {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_floatingEffects.removeWhere((e) => e.id == id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -330,254 +385,265 @@ class _BattleScreenState extends State<BattleScreen> {
|
||||||
return RestUI(battleProvider: battleProvider);
|
return RestUI(battleProvider: battleProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Stack(
|
return ShakeWidget(
|
||||||
key: _stackKey,
|
key: _shakeKey,
|
||||||
children: [
|
child: Stack(
|
||||||
// 1. Background (Black)
|
key: _stackKey,
|
||||||
Container(color: Colors.black87),
|
children: [
|
||||||
|
// 1. Background (Black)
|
||||||
|
Container(color: Colors.black87),
|
||||||
|
|
||||||
// 2. Battle Content (Top Bar + Characters)
|
// 2. Battle Content (Top Bar + Characters)
|
||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
// Top Bar
|
// Top Bar
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Flexible(
|
Flexible(
|
||||||
child: FittedBox(
|
child: FittedBox(
|
||||||
fit: BoxFit.scaleDown,
|
fit: BoxFit.scaleDown,
|
||||||
child: Text(
|
child: Text(
|
||||||
"Stage ${battleProvider.stage}",
|
"Stage ${battleProvider.stage}",
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
Flexible(
|
||||||
Flexible(
|
child: FittedBox(
|
||||||
child: FittedBox(
|
fit: BoxFit.scaleDown,
|
||||||
fit: BoxFit.scaleDown,
|
child: Text(
|
||||||
child: Text(
|
"Turn ${battleProvider.turnCount}",
|
||||||
"Turn ${battleProvider.turnCount}",
|
style: const TextStyle(
|
||||||
style: const TextStyle(
|
color: Colors.white,
|
||||||
color: Colors.white,
|
fontSize: 18,
|
||||||
fontSize: 18,
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Battle Area (Characters) - Expanded to fill available space
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
// Enemy (Top Right)
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
child: CharacterStatusCard(
|
||||||
|
character: battleProvider.enemy,
|
||||||
|
isPlayer: false,
|
||||||
|
isTurn: !battleProvider.isPlayerTurn,
|
||||||
|
key: _enemyKey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Player (Bottom Left)
|
||||||
|
Positioned(
|
||||||
|
bottom: 80, // Space for FABs
|
||||||
|
left: 0,
|
||||||
|
child: CharacterStatusCard(
|
||||||
|
character: battleProvider.player,
|
||||||
|
isPlayer: true,
|
||||||
|
isTurn: battleProvider.isPlayerTurn,
|
||||||
|
key: _playerKey,
|
||||||
|
animationKey: _playerAnimKey,
|
||||||
|
hideStats: _isPlayerAttacking,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// 3. Logs Overlay
|
||||||
|
if (_showLogs && battleProvider.logs.isNotEmpty)
|
||||||
|
Positioned(
|
||||||
|
top: 60,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
height: 150,
|
||||||
|
child: BattleLogOverlay(logs: battleProvider.logs),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 4. Floating Action Buttons (Bottom Right)
|
||||||
|
Positioned(
|
||||||
|
bottom: 20,
|
||||||
|
right: 20,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildFloatingActionButton(
|
||||||
|
context,
|
||||||
|
"ATK",
|
||||||
|
Icons.whatshot,
|
||||||
|
Colors.redAccent,
|
||||||
|
ActionType.attack,
|
||||||
|
battleProvider.isPlayerTurn &&
|
||||||
|
!battleProvider.player.isDead &&
|
||||||
|
!battleProvider.enemy.isDead &&
|
||||||
|
!battleProvider.showRewardPopup,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildFloatingActionButton(
|
||||||
|
context,
|
||||||
|
"DEF",
|
||||||
|
Icons.shield,
|
||||||
|
Colors.blueAccent,
|
||||||
|
ActionType.defend,
|
||||||
|
battleProvider.isPlayerTurn &&
|
||||||
|
!battleProvider.player.isDead &&
|
||||||
|
!battleProvider.enemy.isDead &&
|
||||||
|
!battleProvider.showRewardPopup,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 5. Log Toggle Button (Bottom Left)
|
||||||
|
Positioned(
|
||||||
|
bottom: 20,
|
||||||
|
left: 20,
|
||||||
|
child: FloatingActionButton(
|
||||||
|
heroTag: "logToggle",
|
||||||
|
mini: true,
|
||||||
|
backgroundColor: Colors.grey[800],
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_showLogs = !_showLogs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Icon(
|
||||||
|
_showLogs ? Icons.visibility_off : Icons.visibility,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Reward Popup
|
||||||
|
if (battleProvider.showRewardPopup)
|
||||||
|
Container(
|
||||||
|
color: Colors.black54,
|
||||||
|
child: Center(
|
||||||
|
child: SimpleDialog(
|
||||||
|
title: const Text("Victory! Choose a Reward"),
|
||||||
|
children: battleProvider.rewardOptions.map((item) {
|
||||||
|
return SimpleDialogOption(
|
||||||
|
onPressed: () {
|
||||||
|
battleProvider.selectReward(item);
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blueGrey[700],
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
border: Border.all(color: Colors.grey),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
ItemUtils.getIcon(item.slot),
|
||||||
|
color: ItemUtils.getColor(item.slot),
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
item.name,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_buildItemStatText(item),
|
||||||
|
Text(
|
||||||
|
item.description,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Battle Area (Characters) - Expanded to fill available space
|
// Floating Effects
|
||||||
Expanded(
|
..._floatingDamageTexts.map((e) => e.widget),
|
||||||
child: Padding(
|
..._floatingEffects.map((e) => e.widget),
|
||||||
padding: const EdgeInsets.all(16.0),
|
..._floatingFeedbackTexts.map((e) => e.widget),
|
||||||
child: Stack(
|
|
||||||
|
// Explosion Layer
|
||||||
|
ExplosionWidget(key: _explosionKey),
|
||||||
|
|
||||||
|
// Game Over Overlay
|
||||||
|
if (battleProvider.player.isDead)
|
||||||
|
Container(
|
||||||
|
color: Colors.black87,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Enemy (Top Right)
|
const Text(
|
||||||
Positioned(
|
"DEFEAT",
|
||||||
top: 0,
|
style: TextStyle(
|
||||||
right: 0,
|
color: Colors.red,
|
||||||
child: CharacterStatusCard(
|
fontSize: 48,
|
||||||
character: battleProvider.enemy,
|
fontWeight: FontWeight.bold,
|
||||||
isPlayer: false,
|
letterSpacing: 4.0,
|
||||||
isTurn: !battleProvider.isPlayerTurn,
|
|
||||||
key: _enemyKey,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Player (Bottom Left)
|
const SizedBox(height: 32),
|
||||||
Positioned(
|
ElevatedButton(
|
||||||
bottom: 80, // Space for FABs
|
style: ElevatedButton.styleFrom(
|
||||||
left: 0,
|
backgroundColor: Colors.grey[800],
|
||||||
child: CharacterStatusCard(
|
padding: const EdgeInsets.symmetric(
|
||||||
character: battleProvider.player,
|
horizontal: 32,
|
||||||
isPlayer: true,
|
vertical: 16,
|
||||||
isTurn: battleProvider.isPlayerTurn,
|
),
|
||||||
key: _playerKey,
|
),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pushAndRemoveUntil(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const MainMenuScreen(),
|
||||||
|
),
|
||||||
|
(route) => false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text(
|
||||||
|
"Return to Main Menu",
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// 3. Logs Overlay
|
|
||||||
if (_showLogs && battleProvider.logs.isNotEmpty)
|
|
||||||
Positioned(
|
|
||||||
top: 60,
|
|
||||||
left: 16,
|
|
||||||
right: 16,
|
|
||||||
height: 150,
|
|
||||||
child: BattleLogOverlay(logs: battleProvider.logs),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 4. Floating Action Buttons (Bottom Right)
|
|
||||||
Positioned(
|
|
||||||
bottom: 20,
|
|
||||||
right: 20,
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
_buildFloatingActionButton(
|
|
||||||
context,
|
|
||||||
"ATK",
|
|
||||||
Icons.whatshot,
|
|
||||||
Colors.redAccent,
|
|
||||||
ActionType.attack,
|
|
||||||
battleProvider.isPlayerTurn &&
|
|
||||||
!battleProvider.player.isDead &&
|
|
||||||
!battleProvider.enemy.isDead &&
|
|
||||||
!battleProvider.showRewardPopup,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_buildFloatingActionButton(
|
|
||||||
context,
|
|
||||||
"DEF",
|
|
||||||
Icons.shield,
|
|
||||||
Colors.blueAccent,
|
|
||||||
ActionType.defend,
|
|
||||||
battleProvider.isPlayerTurn &&
|
|
||||||
!battleProvider.player.isDead &&
|
|
||||||
!battleProvider.enemy.isDead &&
|
|
||||||
!battleProvider.showRewardPopup,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 5. Log Toggle Button (Bottom Left)
|
|
||||||
Positioned(
|
|
||||||
bottom: 20,
|
|
||||||
left: 20,
|
|
||||||
child: FloatingActionButton(
|
|
||||||
heroTag: "logToggle",
|
|
||||||
mini: true,
|
|
||||||
backgroundColor: Colors.grey[800],
|
|
||||||
onPressed: () {
|
|
||||||
setState(() {
|
|
||||||
_showLogs = !_showLogs;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
child: Icon(
|
|
||||||
_showLogs ? Icons.visibility_off : Icons.visibility,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Reward Popup
|
|
||||||
if (battleProvider.showRewardPopup)
|
|
||||||
Container(
|
|
||||||
color: Colors.black54,
|
|
||||||
child: Center(
|
|
||||||
child: SimpleDialog(
|
|
||||||
title: const Text("Victory! Choose a Reward"),
|
|
||||||
children: battleProvider.rewardOptions.map((item) {
|
|
||||||
return SimpleDialogOption(
|
|
||||||
onPressed: () {
|
|
||||||
battleProvider.selectReward(item);
|
|
||||||
},
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.blueGrey[700],
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
border: Border.all(color: Colors.grey),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
ItemUtils.getIcon(item.slot),
|
|
||||||
color: ItemUtils.getColor(item.slot),
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(
|
|
||||||
item.name,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
_buildItemStatText(item),
|
|
||||||
Text(
|
|
||||||
item.description,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Floating Effects
|
|
||||||
..._floatingDamageTexts.map((e) => e.widget),
|
|
||||||
..._floatingEffects.map((e) => e.widget),
|
|
||||||
..._floatingFeedbackTexts.map((e) => e.widget),
|
|
||||||
|
|
||||||
// Game Over Overlay
|
|
||||||
if (battleProvider.player.isDead)
|
|
||||||
Container(
|
|
||||||
color: Colors.black87,
|
|
||||||
child: Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
"DEFEAT",
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.red,
|
|
||||||
fontSize: 48,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
letterSpacing: 4.0,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
ElevatedButton(
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.grey[800],
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 32,
|
|
||||||
vertical: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.of(context).pushAndRemoveUntil(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => const MainMenuScreen(),
|
|
||||||
),
|
|
||||||
(route) => false,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: const Text(
|
|
||||||
"Return to Main Menu",
|
|
||||||
style: TextStyle(color: Colors.white, fontSize: 18),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -589,6 +655,7 @@ class _BattleScreenState extends State<BattleScreen> {
|
||||||
if (item.atkBonus > 0) stats.add("+${item.atkBonus} ATK");
|
if (item.atkBonus > 0) stats.add("+${item.atkBonus} ATK");
|
||||||
if (item.hpBonus > 0) stats.add("+${item.hpBonus} HP");
|
if (item.hpBonus > 0) stats.add("+${item.hpBonus} HP");
|
||||||
if (item.armorBonus > 0) stats.add("+${item.armorBonus} DEF");
|
if (item.armorBonus > 0) stats.add("+${item.armorBonus} DEF");
|
||||||
|
if (item.luck > 0) stats.add("+${item.luck} Luck");
|
||||||
|
|
||||||
List<String> effectTexts = item.effects.map((e) => e.description).toList();
|
List<String> effectTexts = item.effects.map((e) => e.description).toList();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -460,6 +460,11 @@ class InventoryScreen extends StatelessWidget {
|
||||||
_buildStatChangeRow("Current HP", currentHp, newHp),
|
_buildStatChangeRow("Current HP", currentHp, newHp),
|
||||||
_buildStatChangeRow("ATK", currentAtk, newAtk),
|
_buildStatChangeRow("ATK", currentAtk, newAtk),
|
||||||
_buildStatChangeRow("DEF", currentDef, newDef),
|
_buildStatChangeRow("DEF", currentDef, newDef),
|
||||||
|
_buildStatChangeRow(
|
||||||
|
"LUCK",
|
||||||
|
player.totalLuck,
|
||||||
|
player.totalLuck - (oldItem?.luck ?? 0) + newItem.luck,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../game/enums.dart';
|
||||||
|
|
||||||
|
class BattleAnimationWidget extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const BattleAnimationWidget({super.key, required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
BattleAnimationWidgetState createState() => BattleAnimationWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class BattleAnimationWidgetState extends State<BattleAnimationWidget>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
late AnimationController _scaleController;
|
||||||
|
late AnimationController _translateController;
|
||||||
|
late Animation<double> _scaleAnimation;
|
||||||
|
late Animation<Offset> _translateAnimation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scaleController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 800),
|
||||||
|
);
|
||||||
|
_translateController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 1000),
|
||||||
|
);
|
||||||
|
|
||||||
|
_scaleAnimation = Tween<double>(
|
||||||
|
begin: 1.0,
|
||||||
|
end: 1.2,
|
||||||
|
).animate(CurvedAnimation(parent: _scaleController, curve: Curves.easeOut));
|
||||||
|
|
||||||
|
// Default translation, will be updated on animateAttack
|
||||||
|
_translateAnimation = Tween<Offset>(
|
||||||
|
begin: Offset.zero,
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(_translateController);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scaleController.dispose();
|
||||||
|
_translateController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> animateAttack(
|
||||||
|
Offset targetOffset,
|
||||||
|
VoidCallback onImpact,
|
||||||
|
RiskLevel risk,
|
||||||
|
) async {
|
||||||
|
if (risk == RiskLevel.safe || risk == RiskLevel.normal) {
|
||||||
|
// Safe & Normal: Dash/Wobble without scale
|
||||||
|
final isSafe = risk == RiskLevel.safe;
|
||||||
|
final duration = isSafe ? 500 : 400;
|
||||||
|
final offsetFactor = isSafe ? 0.2 : 0.5;
|
||||||
|
|
||||||
|
_translateController.duration = Duration(milliseconds: duration);
|
||||||
|
_translateAnimation =
|
||||||
|
Tween<Offset>(
|
||||||
|
begin: Offset.zero,
|
||||||
|
end: targetOffset * offsetFactor,
|
||||||
|
).animate(
|
||||||
|
CurvedAnimation(
|
||||||
|
parent: _translateController,
|
||||||
|
curve: Curves.easeOutQuad,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _translateController.forward();
|
||||||
|
if (!mounted) return;
|
||||||
|
onImpact();
|
||||||
|
await _translateController.reverse();
|
||||||
|
} else {
|
||||||
|
// Risky: Scale + Heavy Dash
|
||||||
|
_scaleController.duration = const Duration(milliseconds: 600);
|
||||||
|
_translateController.duration = const Duration(milliseconds: 500);
|
||||||
|
|
||||||
|
// 1. Scale Up (Preparation)
|
||||||
|
await _scaleController.forward();
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// 2. Dash to Target (Impact)
|
||||||
|
_translateAnimation = Tween<Offset>(begin: Offset.zero, end: targetOffset)
|
||||||
|
.animate(
|
||||||
|
CurvedAnimation(
|
||||||
|
parent: _translateController,
|
||||||
|
curve: Curves.easeInExpo, // Heavy impact curve
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _translateController.forward();
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// 3. Impact Callback (Shake)
|
||||||
|
onImpact();
|
||||||
|
|
||||||
|
// 4. Return (Reset)
|
||||||
|
_scaleController.reverse();
|
||||||
|
_translateController.reverse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: Listenable.merge([_scaleController, _translateController]),
|
||||||
|
builder: (context, child) {
|
||||||
|
return Transform.translate(
|
||||||
|
offset: _translateAnimation.value,
|
||||||
|
child: Transform.scale(
|
||||||
|
scale: _scaleAnimation.value,
|
||||||
|
child: widget.child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,101 +3,124 @@ import 'package:provider/provider.dart';
|
||||||
import '../../game/model/entity.dart';
|
import '../../game/model/entity.dart';
|
||||||
import '../../game/enums.dart';
|
import '../../game/enums.dart';
|
||||||
import '../../providers/battle_provider.dart';
|
import '../../providers/battle_provider.dart';
|
||||||
|
import 'battle_animation_widget.dart';
|
||||||
|
import '../../game/config/theme_config.dart';
|
||||||
|
import '../../game/config/animation_config.dart';
|
||||||
|
|
||||||
class CharacterStatusCard extends StatelessWidget {
|
class CharacterStatusCard extends StatelessWidget {
|
||||||
final Character character;
|
final Character character;
|
||||||
final bool isPlayer;
|
final bool isPlayer;
|
||||||
final bool isTurn;
|
final bool isTurn;
|
||||||
|
final GlobalKey<BattleAnimationWidgetState>? animationKey;
|
||||||
|
final bool hideStats;
|
||||||
|
|
||||||
const CharacterStatusCard({
|
const CharacterStatusCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.character,
|
required this.character,
|
||||||
this.isPlayer = false,
|
this.isPlayer = false,
|
||||||
this.isTurn = false,
|
this.isTurn = false,
|
||||||
|
this.animationKey,
|
||||||
|
this.hideStats = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
FittedBox(
|
AnimatedOpacity(
|
||||||
fit: BoxFit.scaleDown,
|
opacity: hideStats ? 0.0 : 1.0,
|
||||||
child: Text(
|
duration: AnimationConfig.fadeDuration,
|
||||||
"Armor: ${character.armor}",
|
child: Column(
|
||||||
style: const TextStyle(color: Colors.white),
|
children: [
|
||||||
|
FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Text(
|
||||||
|
"Armor: ${character.armor}",
|
||||||
|
style: const TextStyle(color: ThemeConfig.textColorWhite),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Text(
|
||||||
|
"${character.name}: HP ${character.hp}/${character.totalMaxHp}",
|
||||||
|
style: TextStyle(
|
||||||
|
color: character.isDead
|
||||||
|
? ThemeConfig.statHpEnemyColor
|
||||||
|
: ThemeConfig.textColorWhite,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 100,
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: character.totalMaxHp > 0
|
||||||
|
? character.hp / character.totalMaxHp
|
||||||
|
: 0,
|
||||||
|
color: !isPlayer
|
||||||
|
? ThemeConfig.statHpEnemyColor
|
||||||
|
: ThemeConfig.statHpPlayerColor,
|
||||||
|
backgroundColor: ThemeConfig.textColorGrey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (character.statusEffects.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4.0),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 4.0,
|
||||||
|
children: character.statusEffects.map((effect) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: ThemeConfig.effectBg,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
"${effect.type.name.toUpperCase()} (${effect.duration})",
|
||||||
|
style: const TextStyle(
|
||||||
|
color: ThemeConfig.effectText,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text("ATK: ${character.totalAtk}"),
|
||||||
|
Text("DEF: ${character.totalDefense}"),
|
||||||
|
Text("LUCK: ${character.totalLuck}"),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
FittedBox(
|
|
||||||
fit: BoxFit.scaleDown,
|
|
||||||
child: Text(
|
|
||||||
"${character.name}: HP ${character.hp}/${character.totalMaxHp}",
|
|
||||||
style: TextStyle(
|
|
||||||
color: character.isDead ? Colors.red : Colors.white,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(
|
|
||||||
width: 100,
|
|
||||||
child: LinearProgressIndicator(
|
|
||||||
value: character.totalMaxHp > 0
|
|
||||||
? character.hp / character.totalMaxHp
|
|
||||||
: 0,
|
|
||||||
color: !isPlayer ? Colors.red : Colors.green,
|
|
||||||
backgroundColor: Colors.grey,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (character.statusEffects.isNotEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 4.0),
|
|
||||||
child: Wrap(
|
|
||||||
spacing: 4.0,
|
|
||||||
children: character.statusEffects.map((effect) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 6,
|
|
||||||
vertical: 2,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.deepOrange,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
"${effect.type.name.toUpperCase()} (${effect.duration})",
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text("ATK: ${character.totalAtk}"),
|
|
||||||
Text("DEF: ${character.totalDefense}"),
|
|
||||||
// 캐릭터 아이콘/이미지 영역 추가
|
// 캐릭터 아이콘/이미지 영역 추가
|
||||||
Container(
|
BattleAnimationWidget(
|
||||||
width: 100, // 임시 크기
|
key: animationKey,
|
||||||
height: 100, // 임시 크기
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
width: 100, // 임시 크기
|
||||||
color: isPlayer
|
height: 100, // 임시 크기
|
||||||
? Colors.lightBlue
|
decoration: BoxDecoration(
|
||||||
: Colors.deepOrange, // 플레이어/적 구분 색상
|
color: isPlayer
|
||||||
borderRadius: BorderRadius.circular(8),
|
? Colors.lightBlue
|
||||||
),
|
: Colors.deepOrange, // 플레이어/적 구분 색상
|
||||||
child: Center(
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: isPlayer
|
),
|
||||||
? const Icon(
|
child: Center(
|
||||||
Icons.person,
|
child: isPlayer
|
||||||
size: 60,
|
? const Icon(
|
||||||
color: Colors.white,
|
Icons.person,
|
||||||
) // 플레이어 아이콘
|
size: 60,
|
||||||
: const Icon(
|
color: ThemeConfig.textColorWhite,
|
||||||
Icons.psychology,
|
) // 플레이어 아이콘
|
||||||
size: 60,
|
: const Icon(
|
||||||
color: Colors.white,
|
Icons.psychology,
|
||||||
), // 적 아이콘 (몬스터 대신)
|
size: 60,
|
||||||
|
color: ThemeConfig.textColorWhite,
|
||||||
|
), // 적 아이콘 (몬스터 대신)
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8), // 아이콘과 정보 사이 간격
|
const SizedBox(height: 8), // 아이콘과 정보 사이 간격
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
import 'dart:math';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class Particle {
|
||||||
|
Offset position;
|
||||||
|
Offset velocity;
|
||||||
|
Color color;
|
||||||
|
double size;
|
||||||
|
double life; // 1.0 to 0.0
|
||||||
|
double decay;
|
||||||
|
|
||||||
|
Particle({
|
||||||
|
required this.position,
|
||||||
|
required this.velocity,
|
||||||
|
required this.color,
|
||||||
|
required this.size,
|
||||||
|
required this.life,
|
||||||
|
required this.decay,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExplosionWidget extends StatefulWidget {
|
||||||
|
const ExplosionWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ExplosionWidgetState createState() => ExplosionWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExplosionWidgetState extends State<ExplosionWidget>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
final List<Particle> _particles = [];
|
||||||
|
final Random _random = Random();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 1000),
|
||||||
|
);
|
||||||
|
_controller.addListener(_updateParticles);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.removeListener(_updateParticles);
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateParticles() {
|
||||||
|
if (_particles.isEmpty) return;
|
||||||
|
|
||||||
|
for (var i = _particles.length - 1; i >= 0; i--) {
|
||||||
|
final p = _particles[i];
|
||||||
|
p.position += p.velocity;
|
||||||
|
p.velocity += Offset(0, 0.5); // Gravity
|
||||||
|
p.life -= p.decay;
|
||||||
|
if (p.life <= 0) {
|
||||||
|
_particles.removeAt(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_particles.isEmpty) {
|
||||||
|
_controller.stop();
|
||||||
|
}
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void explode(Offset position) {
|
||||||
|
// Clear old particles if any (optional, or just add more)
|
||||||
|
// _particles.clear();
|
||||||
|
|
||||||
|
// Create new particles
|
||||||
|
for (int i = 0; i < 30; i++) {
|
||||||
|
final double angle = _random.nextDouble() * 2 * pi;
|
||||||
|
final double speed = _random.nextDouble() * 5 + 2;
|
||||||
|
final double dx = cos(angle) * speed;
|
||||||
|
final double dy = sin(angle) * speed;
|
||||||
|
|
||||||
|
// Random colors for fire/explosion effect
|
||||||
|
Color color;
|
||||||
|
final r = _random.nextDouble();
|
||||||
|
if (r < 0.33) {
|
||||||
|
color = Colors.redAccent;
|
||||||
|
} else if (r < 0.66) {
|
||||||
|
color = Colors.orangeAccent;
|
||||||
|
} else {
|
||||||
|
color = Colors.yellowAccent;
|
||||||
|
}
|
||||||
|
|
||||||
|
_particles.add(
|
||||||
|
Particle(
|
||||||
|
position: position,
|
||||||
|
velocity: Offset(dx, dy),
|
||||||
|
color: color,
|
||||||
|
size: _random.nextDouble() * 4 + 2,
|
||||||
|
life: 1.0,
|
||||||
|
decay: _random.nextDouble() * 0.02 + 0.01,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_controller.isAnimating) {
|
||||||
|
_controller.repeat(); // Use repeat to keep loop running until empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return IgnorePointer(
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: ExplosionPainter(_particles),
|
||||||
|
size: Size.infinite,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExplosionPainter extends CustomPainter {
|
||||||
|
final List<Particle> particles;
|
||||||
|
|
||||||
|
ExplosionPainter(this.particles);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
for (final p in particles) {
|
||||||
|
final paint = Paint()
|
||||||
|
..color = p.color.withOpacity(p.life.clamp(0.0, 1.0))
|
||||||
|
..style = PaintingStyle.fill;
|
||||||
|
|
||||||
|
canvas.drawCircle(p.position, p.size, paint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant ExplosionPainter oldDelegate) {
|
||||||
|
return true; // Always repaint when animating
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../game/config/theme_config.dart';
|
||||||
|
import '../../game/config/animation_config.dart';
|
||||||
|
|
||||||
class FloatingDamageText extends StatefulWidget {
|
class FloatingDamageText extends StatefulWidget {
|
||||||
final String damage;
|
final String damage;
|
||||||
final Color color;
|
final Color color;
|
||||||
|
|
@ -26,14 +29,20 @@ class FloatingDamageTextState extends State<FloatingDamageText>
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_controller = AnimationController(
|
_controller = AnimationController(
|
||||||
duration: const Duration(milliseconds: 1000),
|
duration: AnimationConfig.floatingTextDuration,
|
||||||
vsync: this,
|
vsync: this,
|
||||||
);
|
);
|
||||||
|
|
||||||
_offsetAnimation = Tween<Offset>(
|
_offsetAnimation =
|
||||||
begin: const Offset(0.0, 0.0),
|
Tween<Offset>(
|
||||||
end: const Offset(0.0, -1.5),
|
begin: const Offset(0.0, 0.0),
|
||||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
|
end: const Offset(0.0, -1.5),
|
||||||
|
).animate(
|
||||||
|
CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: AnimationConfig.floatingTextCurve,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
|
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
|
||||||
CurvedAnimation(
|
CurvedAnimation(
|
||||||
|
|
@ -75,7 +84,7 @@ class FloatingDamageTextState extends State<FloatingDamageText>
|
||||||
shadows: const [
|
shadows: const [
|
||||||
Shadow(
|
Shadow(
|
||||||
blurRadius: 2.0,
|
blurRadius: 2.0,
|
||||||
color: Colors.black,
|
color: ThemeConfig.feedbackShadow,
|
||||||
offset: Offset(1.0, 1.0),
|
offset: Offset(1.0, 1.0),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -124,14 +133,16 @@ class FloatingEffectState extends State<FloatingEffect>
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_controller = AnimationController(
|
_controller = AnimationController(
|
||||||
duration: const Duration(milliseconds: 800),
|
duration: AnimationConfig.floatingEffectDuration,
|
||||||
vsync: this,
|
vsync: this,
|
||||||
);
|
);
|
||||||
|
|
||||||
_scaleAnimation = Tween<double>(
|
_scaleAnimation = Tween<double>(begin: 0.5, end: 1.5).animate(
|
||||||
begin: 0.5,
|
CurvedAnimation(
|
||||||
end: 1.5,
|
parent: _controller,
|
||||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.elasticOut));
|
curve: AnimationConfig.floatingEffectScaleCurve,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
|
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
|
||||||
CurvedAnimation(
|
CurvedAnimation(
|
||||||
|
|
@ -203,14 +214,20 @@ class FloatingFeedbackTextState extends State<FloatingFeedbackText>
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_controller = AnimationController(
|
_controller = AnimationController(
|
||||||
duration: const Duration(milliseconds: 1000),
|
duration: AnimationConfig.floatingTextDuration,
|
||||||
vsync: this,
|
vsync: this,
|
||||||
);
|
);
|
||||||
|
|
||||||
_offsetAnimation = Tween<Offset>(
|
_offsetAnimation =
|
||||||
begin: const Offset(0.0, 0.0),
|
Tween<Offset>(
|
||||||
end: const Offset(0.0, -1.5),
|
begin: const Offset(0.0, 0.0),
|
||||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
|
end: const Offset(0.0, -1.5),
|
||||||
|
).animate(
|
||||||
|
CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: AnimationConfig.floatingTextCurve,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
|
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
|
||||||
CurvedAnimation(
|
CurvedAnimation(
|
||||||
|
|
@ -252,7 +269,7 @@ class FloatingFeedbackTextState extends State<FloatingFeedbackText>
|
||||||
shadows: const [
|
shadows: const [
|
||||||
Shadow(
|
Shadow(
|
||||||
blurRadius: 2.0,
|
blurRadius: 2.0,
|
||||||
color: Colors.black,
|
color: ThemeConfig.feedbackShadow,
|
||||||
offset: Offset(1.0, 1.0),
|
offset: Offset(1.0, 1.0),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import 'dart:math';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class ShakeWidget extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
final double shakeOffset;
|
||||||
|
final int shakeCount;
|
||||||
|
final Duration duration;
|
||||||
|
|
||||||
|
const ShakeWidget({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.shakeOffset = 10.0,
|
||||||
|
this.shakeCount = 3,
|
||||||
|
this.duration = const Duration(milliseconds: 400),
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ShakeWidgetState createState() => ShakeWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class ShakeWidgetState extends State<ShakeWidget>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(vsync: this, duration: widget.duration);
|
||||||
|
_controller.addStatusListener((status) {
|
||||||
|
if (status == AnimationStatus.completed) {
|
||||||
|
_controller.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void shake() {
|
||||||
|
_controller.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _controller,
|
||||||
|
builder: (context, child) {
|
||||||
|
final double sineValue = sin(
|
||||||
|
widget.shakeCount * 2 * pi * _controller.value,
|
||||||
|
);
|
||||||
|
return Transform.translate(
|
||||||
|
offset: Offset(sineValue * widget.shakeOffset, 0),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: widget.child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -39,6 +39,10 @@
|
||||||
- **Floating Text:** 데미지 발생 시 캐릭터 위에 데미지 수치가 떠오름 (일반: 빨강, 출혈: 보라, 취약: 주황).
|
- **Floating Text:** 데미지 발생 시 캐릭터 위에 데미지 수치가 떠오름 (일반: 빨강, 출혈: 보라, 취약: 주황).
|
||||||
- **Action Feedback:** 공격 빗나감(MISS - 적 위치/회색) 및 방어 실패(FAILED - 내 위치/빨강) 텍스트 오버레이.
|
- **Action Feedback:** 공격 빗나감(MISS - 적 위치/회색) 및 방어 실패(FAILED - 내 위치/빨강) 텍스트 오버레이.
|
||||||
- **Effect Icons:** 공격/방어 및 리스크 레벨에 따른 아이콘 애니메이션 출력.
|
- **Effect Icons:** 공격/방어 및 리스크 레벨에 따른 아이콘 애니메이션 출력.
|
||||||
|
- **Advanced Animations:**
|
||||||
|
- **Risk-Based:** Safe(Wobble), Normal(Dash), Risky(Scale Up + Heavy Dash + Shake + Explosion).
|
||||||
|
- **Icon-Only:** 공격 시 캐릭터 아이콘만 이동하며, 스탯 정보(HP/Armor)는 일시적으로 숨김 처리.
|
||||||
|
- **Impact Sync:** 타격 이펙트와 데미지 텍스트가 애니메이션 타격 시점에 정확히 동기화됨.
|
||||||
- **상태이상:** `Stun`, `Bleed`, `Vulnerable`, `DefenseForbidden`.
|
- **상태이상:** `Stun`, `Bleed`, `Vulnerable`, `DefenseForbidden`.
|
||||||
- **행운 시스템 (Luck System):**
|
- **행운 시스템 (Luck System):**
|
||||||
- 아이템 옵션으로 `luck` 스탯 제공.
|
- 아이템 옵션으로 `luck` 스탯 제공.
|
||||||
|
|
@ -80,7 +84,9 @@
|
||||||
- **Streams:** `damageStream`, `effectStream`을 통해 UI(`BattleScreen`)에 비동기 이벤트 전달.
|
- **Streams:** `damageStream`, `effectStream`을 통해 UI(`BattleScreen`)에 비동기 이벤트 전달.
|
||||||
- **`lib/game/enums.dart`:** 프로젝트 전반의 Enum 통합 관리 (`ActionType`, `RiskLevel`, `StageType` 등).
|
- **`lib/game/enums.dart`:** 프로젝트 전반의 Enum 통합 관리 (`ActionType`, `RiskLevel`, `StageType` 등).
|
||||||
- **`lib/utils/item_utils.dart`:** 아이템 타입별 아이콘 및 색상 로직 중앙화.
|
- **`lib/utils/item_utils.dart`:** 아이템 타입별 아이콘 및 색상 로직 중앙화.
|
||||||
- **`lib/widgets/battle/`:** `BattleScreen`에서 분리된 재사용 가능한 위젯들 (`CharacterStatusCard`, `BattleLogOverlay`, `FloatingBattleTexts`, `StageUI`).
|
- **`lib/widgets/battle/`:** `BattleScreen`에서 분리된 재사용 가능한 위젯들.
|
||||||
|
- **UI Components:** `CharacterStatusCard`, `BattleLogOverlay`, `FloatingBattleTexts`, `StageUI`.
|
||||||
|
- **Effects:** `BattleAnimationWidget` (공격 애니메이션), `ExplosionWidget` (파티클), `ShakeWidget` (화면 흔들림).
|
||||||
- **`lib/widgets/responsive_container.dart`:** 반응형 레이아웃 컨테이너.
|
- **`lib/widgets/responsive_container.dart`:** 반응형 레이아웃 컨테이너.
|
||||||
- **`lib/game/model/`:**
|
- **`lib/game/model/`:**
|
||||||
- `damage_event.dart`, `effect_event.dart`: 이벤트 모델.
|
- `damage_event.dart`, `effect_event.dart`: 이벤트 모델.
|
||||||
|
|
@ -107,9 +113,9 @@
|
||||||
- [ ] **출혈 상태 이상 조건 변경:** 공격 시 상대방의 방어도에 의해 공격이 완전히 막힐 경우, 출혈 상태 이상이 적용되지 않도록 로직 변경.
|
- [ ] **출혈 상태 이상 조건 변경:** 공격 시 상대방의 방어도에 의해 공격이 완전히 막힐 경우, 출혈 상태 이상이 적용되지 않도록 로직 변경.
|
||||||
- [ ] **장비 분해 시스템 (적 장비):** 플레이어 장비의 옵션으로 상대방의 장비를 분해하여 '언암드' 상태로 만들 수 있는 시스템 구현.
|
- [ ] **장비 분해 시스템 (적 장비):** 플레이어 장비의 옵션으로 상대방의 장비를 분해하여 '언암드' 상태로 만들 수 있는 시스템 구현.
|
||||||
- [ ] **플레이어 공격 데미지 분산(Variance) 적용 여부 검토:** 현재 적에게만 적용된 +/- 20% 데미지 분산을 플레이어에게도 적용할지 결정.
|
- [ ] **플레이어 공격 데미지 분산(Variance) 적용 여부 검토:** 현재 적에게만 적용된 +/- 20% 데미지 분산을 플레이어에게도 적용할지 결정.
|
||||||
- [ ] **애니메이션 및 타격감 고도화:**
|
- [x] **애니메이션 및 타격감 고도화:**
|
||||||
- 캐릭터별 이미지 추가 및 하스스톤 스타일의 공격 모션(대상에게 돌진 후 타격) 구현.
|
- 캐릭터별 이미지 추가 및 하스스톤 스타일의 공격 모션(대상에게 돌진 후 타격) 구현 완료 (Icon-Only Animation).
|
||||||
- **Risky 공격:** 하스스톤의 7데미지 이상 타격감(화면 흔들림, 강렬한 파티클 및 임팩트) 재현.
|
- **Risky 공격:** 하스스톤의 7데미지 이상 타격감(화면 흔들림, 강렬한 파티클 및 임팩트) 구현 완료.
|
||||||
- [ ] **체력 조건부 특수 능력:** 캐릭터의 체력이 30% 미만일 때 발동 가능한 특수 능력 시스템 구현.
|
- [ ] **체력 조건부 특수 능력:** 캐릭터의 체력이 30% 미만일 때 발동 가능한 특수 능력 시스템 구현.
|
||||||
- [ ] **영구 스탯 수정자 로직 적용 (필수):**
|
- [ ] **영구 스탯 수정자 로직 적용 (필수):**
|
||||||
- 현재 `Character` 클래스에 `permanentModifiers` 필드만 선언되어 있음.
|
- 현재 `Character` 클래스에 `permanentModifiers` 필드만 선언되어 있음.
|
||||||
|
|
@ -118,7 +124,16 @@
|
||||||
- Firebase Auth 등을 활용한 구글 로그인 구현.
|
- Firebase Auth 등을 활용한 구글 로그인 구현.
|
||||||
- Firestore 또는 Realtime Database에 유저 계정 정보(진행 상황, 재화 등) 저장 및 불러오기 기능 추가.
|
- Firestore 또는 Realtime Database에 유저 계정 정보(진행 상황, 재화 등) 저장 및 불러오기 기능 추가.
|
||||||
- _Note: 이 기능은 게임의 핵심 로직이 안정화된 후, 완전 나중에 진행할 예정입니다._
|
- _Note: 이 기능은 게임의 핵심 로직이 안정화된 후, 완전 나중에 진행할 예정입니다._
|
||||||
|
- [ ] **설정 페이지 (Settings Page) 구현 (Priority: Very Low):**
|
||||||
|
- **이펙트 강도 조절 (Effect Intensity):** 1 ~ 999 범위로 설정 가능.
|
||||||
|
- **Easter Egg:** 강도를 999로 설정하고 Risky 공격 성공 시, "심각한 오류로 프로세스가 종료되었습니다" 같은 페이크 시스템 팝업 출력.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**이 프롬프트를 읽은 AI 에이전트는 위 내용을 바탕으로 즉시 개발을 이어가십시오.**
|
**이 프롬프트를 읽은 AI 에이전트는 위 내용을 바탕으로 즉시 개발을 이어가십시오.**
|
||||||
|
|
||||||
|
## 7. 프롬프트 히스토리 (Prompt History)
|
||||||
|
|
||||||
|
- [x] 39_luck_system.md
|
||||||
|
- [x] 40_ui_update_summary.md
|
||||||
|
- [x] 41_refactoring_presets.md
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
# 40. UI Update Summary (Risky Attack Visual Effects)
|
||||||
|
|
||||||
|
39번 (Luck System) 이후 작업된 UI 및 시각 효과 관련 변경 사항 정리입니다.
|
||||||
|
|
||||||
|
## 1. Attack Animation & Visual Effects
|
||||||
|
|
||||||
|
공격 유형(Risk Level)에 따라 차별화된 애니메이션과 시각 효과를 구현했습니다.
|
||||||
|
|
||||||
|
### BattleAnimationWidget (`lib/widgets/battle/battle_animation_widget.dart`)
|
||||||
|
|
||||||
|
`animateAttack` 메서드가 `RiskLevel`을 인자로 받아 각기 다른 동작을 수행합니다.
|
||||||
|
|
||||||
|
- **Safe Attack**:
|
||||||
|
- **동작**: 제자리에서 좌우로 살짝 흔들리는(Wobble) 애니메이션.
|
||||||
|
- **느낌**: 신중함, 머뭇거림.
|
||||||
|
- **구현**: `Curves.elasticIn`을 사용한 짧은 X축 이동 (200ms).
|
||||||
|
- **Normal Attack**:
|
||||||
|
- **동작**: 적에게 다가가서 가볍게 부딪히는(Dash) 애니메이션.
|
||||||
|
- **느낌**: 일반적인 타격.
|
||||||
|
- **구현**: 확대(Scale Up) 없이 `Curves.easeOutQuad`로 이동 후 복귀 (400ms).
|
||||||
|
- **Risky Attack**:
|
||||||
|
- **동작**: 몸을 크게 부풀린 후(Scale Up) 강하게 돌진(Heavy Dash).
|
||||||
|
- **느낌**: 강력한 한 방, 높은 리스크.
|
||||||
|
- **구현**: 1.2배 확대(600ms) -> `Curves.easeInExpo` 가속 돌진(500ms) -> 타격 -> 복귀.
|
||||||
|
|
||||||
|
### ExplosionWidget (`lib/widgets/battle/explosion_widget.dart`) [NEW]
|
||||||
|
|
||||||
|
- **기능**: 타격 지점에서 파편(Particle)이 사방으로 튀는 효과.
|
||||||
|
- **구현**: `CustomPainter`를 사용하여 다수의 파티클을 효율적으로 렌더링.
|
||||||
|
- **트리거**: **Risky Attack** 적중 시에만 발동.
|
||||||
|
|
||||||
|
### ShakeWidget (`lib/widgets/battle/shake_widget.dart`)
|
||||||
|
|
||||||
|
- **기능**: 화면(또는 위젯)을 흔드는 효과.
|
||||||
|
- **적용**: **Risky Attack** 타격 시에만 발동.
|
||||||
|
|
||||||
|
## 2. BattleScreen Integration (`lib/screens/battle_screen.dart`)
|
||||||
|
|
||||||
|
- `BattleAnimationWidget`, `ExplosionWidget`, `ShakeWidget`을 조합하여 전투 연출 구성.
|
||||||
|
- `_addFloatingEffect`에서 공격 이벤트 수신 시 `RiskLevel`에 따라 적절한 애니메이션 메서드 호출.
|
||||||
|
- **Risky Attack**의 경우: `Scale Up` -> `Dash` -> `Impact` (Shake + Explosion) -> `Return`의 시퀀스로 동작.
|
||||||
|
- **Timing Sync**: 데미지 텍스트 표시 타이밍을 애니메이션 타격 시점(Safe: 200ms, Normal: 400ms, Risky: 1100ms)에 맞게 조정.
|
||||||
|
- **Icon-Only Animation**: 공격 시 전체 카드가 아닌 **캐릭터 아이콘만** 적에게 날아가도록 변경.
|
||||||
|
- **Hide Stats**: 공격 애니메이션 진행 중에는 HP, Armor 등 스탯 정보를 숨겨 시각적 혼란 방지.
|
||||||
|
|
||||||
|
## 3. 기타 UI 개선
|
||||||
|
|
||||||
|
- **Floating Effects**: 데미지 텍스트 및 이펙트 아이콘 위치 조정.
|
||||||
|
- **Risk Level Selection**: 다이얼로그 UI 개선.
|
||||||
|
- **CharacterStatusCard**: 애니메이션 키(`animationKey`)와 스탯 숨김(`hideStats`) 속성 추가.
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
# 41. Refactoring: Presets & Configs (리팩토링: 설정 중앙화)
|
||||||
|
|
||||||
|
## 개요 (Overview)
|
||||||
|
|
||||||
|
프로젝트 전반에 산재되어 있던 하드코딩된 값들(색상, 아이콘, 애니메이션 시간 등)을 중앙 집중식 설정 파일(`Config`)로 분리하여 유지보수성과 일관성을 향상시켰습니다.
|
||||||
|
|
||||||
|
## 변경 사항 (Changes)
|
||||||
|
|
||||||
|
### 1. 설정 파일 생성 (New Config Files)
|
||||||
|
|
||||||
|
`lib/game/config/` 디렉토리에 다음 파일들을 생성했습니다.
|
||||||
|
|
||||||
|
- **`battle_config.dart`**: 전투 관련 아이콘, 색상, 크기 정의 (공격/방어, 리스크 레벨별).
|
||||||
|
- **`theme_config.dart`**: UI 전반의 색상 테마 정의.
|
||||||
|
- **Stat Colors**: HP(Player/Enemy), ATK, DEF, LUCK, Gold 등.
|
||||||
|
- **UI Colors**: 카드 배경, 텍스트(White/Grey), 등급별 색상 등.
|
||||||
|
- **Feedback Colors**: 데미지, 회복, 미스 텍스트 색상 및 그림자.
|
||||||
|
- **Effect Colors**: 상태이상 배지 배경 및 텍스트.
|
||||||
|
- **`animation_config.dart`**: 애니메이션 관련 상수 정의.
|
||||||
|
- **Durations**: Floating Text(1000ms), Fade(200ms), Attack(Risk Level별 상이).
|
||||||
|
- **Curves**: `easeOut`, `elasticOut`, `elasticIn` 등 애니메이션 커브.
|
||||||
|
|
||||||
|
### 2. 코드 리팩토링 (Refactoring)
|
||||||
|
|
||||||
|
기존 하드코딩된 값을 `Config` 클래스의 상수로 대체했습니다.
|
||||||
|
|
||||||
|
- **`lib/screens/battle_screen.dart`**:
|
||||||
|
- `BattleConfig`를 사용하여 공격/방어 아이콘 및 이펙트 색상 결정.
|
||||||
|
- **`lib/widgets/battle/character_status_card.dart`**:
|
||||||
|
- `ThemeConfig`를 사용하여 HP/Armor/Stat 텍스트 및 게이지 색상 적용.
|
||||||
|
- `AnimationConfig`를 사용하여 스탯 숨김/표시 Fade 애니메이션 시간 적용.
|
||||||
|
- **`lib/widgets/battle/floating_battle_texts.dart`**:
|
||||||
|
- `ThemeConfig`를 사용하여 데미지 텍스트 그림자 색상 적용.
|
||||||
|
- `AnimationConfig`를 사용하여 텍스트 부양 및 아이콘 스케일 애니메이션의 시간과 커브 적용.
|
||||||
|
|
||||||
|
## 기대 효과 (Benefits)
|
||||||
|
|
||||||
|
- **유지보수 용이성**: 색상이나 애니메이션 속도를 변경할 때 단일 파일만 수정하면 프로젝트 전체에 일괄 적용됩니다.
|
||||||
|
- **일관성 유지**: UI 요소 간의 색상 및 동작 통일성을 보장합니다.
|
||||||
|
- **확장성**: 추후 '다크 모드'나 '테마 변경', '게임 속도 조절' 등의 기능을 구현하기 위한 기반이 마련되었습니다.
|
||||||
Loading…
Reference in New Issue