diff --git a/lib/game/data/item_table.dart b/lib/game/data/item_table.dart index eb0b256..49e8f28 100644 --- a/lib/game/data/item_table.dart +++ b/lib/game/data/item_table.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'package:flutter/services.dart'; import '../model/item.dart'; -import '../model/status_effect.dart'; +import '../enums.dart'; class ItemTemplate { final String name; diff --git a/lib/game/enums.dart b/lib/game/enums.dart new file mode 100644 index 0000000..8dde3e1 --- /dev/null +++ b/lib/game/enums.dart @@ -0,0 +1,26 @@ +enum ActionType { attack, defend } + +enum RiskLevel { safe, normal, risky } + +enum EnemyActionType { attack, defend } + +enum StatusEffectType { + stun, // Cannot act this turn + vulnerable, // Takes 50% more damage + bleed, // Takes damage at start/end of turn + defenseForbidden, // Cannot use Defend action +} + +/// 스탯에 적용될 수 있는 수정자(Modifier)의 타입 정의. +/// Flat: 기본 값에 직접 더해지는 값. +/// Percent: 기본 값에 비율로 곱해지는 값. +enum ModifierType { flat, percent } + +enum StageType { + battle, // Normal battle + elite, // Stronger enemy + shop, // Buy/Sell items + rest, // Heal or repair +} + +enum EquipmentSlot { weapon, armor, shield, accessory } diff --git a/lib/game/model/damage_event.dart b/lib/game/model/damage_event.dart new file mode 100644 index 0000000..a54a969 --- /dev/null +++ b/lib/game/model/damage_event.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; // Color 사용을 위해 import + +enum DamageTarget { player, enemy } + +class DamageEvent { + final int damage; + final DamageTarget target; + final Color color; // 데미지 타입에 따른 색상 (예: 일반 공격, 치명타 등) + + DamageEvent({ + required this.damage, + required this.target, + this.color = Colors.red, // 기본 색상은 빨강 + }); +} diff --git a/lib/game/model/effect_event.dart b/lib/game/model/effect_event.dart new file mode 100644 index 0000000..2a153e9 --- /dev/null +++ b/lib/game/model/effect_event.dart @@ -0,0 +1,15 @@ +import '../enums.dart'; + +enum EffectTarget { player, enemy } + +class EffectEvent { + final ActionType type; // attack, defend + final RiskLevel risk; + final EffectTarget target; // 이펙트가 표시될 위치의 대상 + + EffectEvent({ + required this.type, + required this.risk, + required this.target, + }); +} diff --git a/lib/game/model/entity.dart b/lib/game/model/entity.dart index e34b0c4..00d8da1 100644 --- a/lib/game/model/entity.dart +++ b/lib/game/model/entity.dart @@ -1,5 +1,6 @@ import 'item.dart'; import 'status_effect.dart'; +import '../enums.dart'; class Character { String name; diff --git a/lib/game/model/item.dart b/lib/game/model/item.dart index a48ba3e..4719589 100644 --- a/lib/game/model/item.dart +++ b/lib/game/model/item.dart @@ -1,6 +1,4 @@ -import 'status_effect.dart'; - -enum EquipmentSlot { weapon, armor, shield, accessory } +import '../enums.dart'; /// Defines an effect that an item can apply (e.g., 10% chance to Stun for 1 turn) class ItemEffect { diff --git a/lib/game/model/stage.dart b/lib/game/model/stage.dart index 622a29c..44c8836 100644 --- a/lib/game/model/stage.dart +++ b/lib/game/model/stage.dart @@ -1,21 +1,12 @@ import 'entity.dart'; import 'item.dart'; -enum StageType { - battle, // Normal battle - elite, // Stronger enemy - shop, // Buy/Sell items - rest, // Heal or repair -} +import '../enums.dart'; class StageModel { final StageType type; final Character? enemy; // For battle/elite final List shopItems; // For shop - StageModel({ - required this.type, - this.enemy, - this.shopItems = const [], - }); + StageModel({required this.type, this.enemy, this.shopItems = const []}); } diff --git a/lib/game/model/stat.dart b/lib/game/model/stat.dart index d9e6eca..8c0222a 100644 --- a/lib/game/model/stat.dart +++ b/lib/game/model/stat.dart @@ -1,12 +1,6 @@ // lib/game/model/stat.dart -/// 스탯에 적용될 수 있는 수정자(Modifier)의 타입 정의. -/// Flat: 기본 값에 직접 더해지는 값. -/// Percent: 기본 값에 비율로 곱해지는 값. -enum ModifierType { - flat, - percent, -} +import '../enums.dart'; /// 스탯 수정자 클래스. /// 특정 스탯에 적용되어 최종 값을 변경한다. diff --git a/lib/game/model/status_effect.dart b/lib/game/model/status_effect.dart index 185ee45..d968837 100644 --- a/lib/game/model/status_effect.dart +++ b/lib/game/model/status_effect.dart @@ -1,18 +1,9 @@ -enum StatusEffectType { - stun, // Cannot act this turn - vulnerable, // Takes 50% more damage - bleed, // Takes damage at start/end of turn - defenseForbidden, // Cannot use Defend action -} +import '../enums.dart'; class StatusEffect { final StatusEffectType type; int duration; // Turns remaining final int value; // Intensity (e.g., bleed damage amount) - StatusEffect({ - required this.type, - required this.duration, - this.value = 0, - }); + StatusEffect({required this.type, required this.duration, this.value = 0}); } diff --git a/lib/providers/battle_provider.dart b/lib/providers/battle_provider.dart index 3975a8e..3fb337d 100644 --- a/lib/providers/battle_provider.dart +++ b/lib/providers/battle_provider.dart @@ -1,19 +1,17 @@ +import 'dart:async'; // StreamController 사용을 위해 import import 'dart:math'; -import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import '../game/model/entity.dart'; import '../game/model/item.dart'; import '../game/model/status_effect.dart'; -import '../game/model/stage.dart'; // Import StageModel +import '../game/model/stage.dart'; import '../game/data/item_table.dart'; import '../game/data/enemy_table.dart'; import '../utils/game_math.dart'; - -enum ActionType { attack, defend } - -enum RiskLevel { safe, normal, risky } - -enum EnemyActionType { attack, defend } +import '../game/enums.dart'; +import '../game/model/damage_event.dart'; // DamageEvent import +import '../game/model/effect_event.dart'; // EffectEvent import class EnemyIntent { final EnemyActionType type; @@ -43,10 +41,25 @@ class BattleProvider with ChangeNotifier { List rewardOptions = []; bool showRewardPopup = false; + // Damage Event Stream + final _damageEventController = StreamController.broadcast(); + Stream get damageStream => _damageEventController.stream; + + // Effect Event Stream + final _effectEventController = StreamController.broadcast(); + Stream get effectStream => _effectEventController.stream; + BattleProvider() { // initializeBattle(); // Do not auto-start logic } + @override + void dispose() { + _damageEventController.close(); // StreamController 닫기 + _effectEventController.close(); + super.dispose(); + } + void initializeBattle() { stage = 1; player = Character( @@ -254,20 +267,61 @@ class BattleProvider with ChangeNotifier { break; } - if (success) { - if (type == ActionType.attack) { - int damage = (player.totalAtk * efficiency).toInt(); - _applyDamage(enemy, damage); - _addLog("Player dealt $damage damage to Enemy."); + if (success) { - // Try applying status effects from items - _tryApplyStatusEffects(player, enemy); - } else { - int armorGained = (player.totalDefense * efficiency).toInt(); - player.armor += armorGained; - _addLog("Player gained $armorGained armor."); - } - } else { + if (type == ActionType.attack) { + + int damage = (player.totalAtk * efficiency).toInt(); + + + + _effectEventController.sink.add(EffectEvent( + + type: ActionType.attack, + + risk: risk, + + target: EffectTarget.enemy, + + )); + + + + _applyDamage(enemy, damage, targetType: DamageTarget.enemy); // Add targetType + + _addLog("Player dealt $damage damage to Enemy."); + + + + // Try applying status effects from items + + _tryApplyStatusEffects(player, enemy); + + } else { + + _effectEventController.sink.add(EffectEvent( + + type: ActionType.defend, + + risk: risk, + + target: EffectTarget.player, + + )); + + + + int armorGained = (player.totalDefense * efficiency).toInt(); + + player.armor += armorGained; + + _addLog("Player gained $armorGained armor."); + + } + + } + + else { _addLog("Player's action missed!"); } @@ -305,28 +359,38 @@ class BattleProvider with ChangeNotifier { if (enemy.isDead) { _onVictory(); return; + // return; // Already handled by _processStartTurnEffects if damage applied } if (canAct && currentEnemyIntent != null) { final intent = currentEnemyIntent!; - // Check Success Rate based on Risk - final random = Random(); - bool success = false; - switch (intent.risk) { - case RiskLevel.safe: - success = random.nextDouble() < 1.0; - break; - case RiskLevel.normal: - success = random.nextDouble() < 0.8; - break; - case RiskLevel.risky: - success = random.nextDouble() < 0.4; - break; - } + if (intent.type == EnemyActionType.defend) { + // Already handled in _generateEnemyIntent + _addLog("Enemy maintains defensive stance."); + } else { + // Attack Logic + final random = Random(); + bool success = false; + switch (intent.risk) { + case RiskLevel.safe: + success = random.nextDouble() < 1.0; + break; + case RiskLevel.normal: + success = random.nextDouble() < 0.8; + break; + case RiskLevel.risky: + success = random.nextDouble() < 0.4; + break; + } + + if (success) { + _effectEventController.sink.add(EffectEvent( + type: ActionType.attack, + risk: intent.risk, + target: EffectTarget.player, + )); - if (success) { - if (intent.type == EnemyActionType.attack) { int incomingDamage = intent.value; int damageToHp = 0; @@ -346,16 +410,12 @@ class BattleProvider with ChangeNotifier { } if (damageToHp > 0) { - _applyDamage(player, damageToHp); + _applyDamage(player, damageToHp, targetType: DamageTarget.player); _addLog("Enemy dealt $damageToHp damage to Player HP."); } - } else if (intent.type == EnemyActionType.defend) { - int armorGained = intent.value; - enemy.armor += armorGained; - _addLog("Enemy gained $armorGained armor."); + } else { + _addLog("Enemy's ${intent.risk.name} attack missed!"); } - } else { - _addLog("Enemy's ${intent.risk.name} action missed!"); } } else if (!canAct) { _addLog("Enemy is stunned and cannot act!"); @@ -394,9 +454,25 @@ class BattleProvider with ChangeNotifier { .toList(); if (bleedEffects.isNotEmpty) { int totalBleed = bleedEffects.fold(0, (sum, e) => sum + e.value); + int previousHp = character.hp; // Record HP before damage character.hp -= totalBleed; if (character.hp < 0) character.hp = 0; _addLog("${character.name} takes $totalBleed bleed damage!"); + + // Emit DamageEvent for bleed + if (character == player) { + _damageEventController.sink.add(DamageEvent( + damage: totalBleed, + target: DamageTarget.player, + color: Colors.purpleAccent, // Bleed damage color + )); + } else if (character == enemy) { + _damageEventController.sink.add(DamageEvent( + damage: totalBleed, + target: DamageTarget.enemy, + color: Colors.purpleAccent, // Bleed damage color + )); + } } // 2. Stun Check @@ -429,15 +505,22 @@ class BattleProvider with ChangeNotifier { } } - void _applyDamage(Character target, int damage) { + void _applyDamage(Character target, int damage, {required DamageTarget targetType, Color color = Colors.red}) { // Check Vulnerable if (target.hasStatus(StatusEffectType.vulnerable)) { damage = (damage * 1.5).toInt(); _addLog("Vulnerable! Damage increased to $damage."); + color = Colors.orange; // Vulnerable damage color } target.hp -= damage; if (target.hp < 0) target.hp = 0; + + _damageEventController.sink.add(DamageEvent( + damage: damage, + target: targetType, + color: color, + )); } void _addLog(String message) { @@ -589,7 +672,33 @@ class BattleProvider with ChangeNotifier { risk: risk, description: "Defends for $armor (${risk.name})", ); + + // [Changed] Apply defense immediately for pre-emptive defense + bool success = false; + switch (risk) { + case RiskLevel.safe: + success = random.nextDouble() < 1.0; + break; + case RiskLevel.normal: + success = random.nextDouble() < 0.8; + break; + case RiskLevel.risky: + success = random.nextDouble() < 0.4; + break; + } + + if (success) { + enemy.armor += armor; + _addLog("Enemy prepares defense! (+$armor Armor)"); + _effectEventController.sink.add(EffectEvent( + type: ActionType.defend, + risk: risk, + target: EffectTarget.enemy, + )); + } else { + _addLog("Enemy tried to defend but fumbled!"); + } } notifyListeners(); } -} +} \ No newline at end of file diff --git a/lib/screens/battle_screen.dart b/lib/screens/battle_screen.dart index b372e7c..4941bcd 100644 --- a/lib/screens/battle_screen.dart +++ b/lib/screens/battle_screen.dart @@ -1,9 +1,13 @@ import 'package:flutter/material.dart'; import 'package:game_test/game/model/item.dart'; -import 'package:game_test/game/model/stage.dart'; // Import StageModel import 'package:provider/provider.dart'; import '../providers/battle_provider.dart'; import '../game/model/entity.dart'; +import '../game/enums.dart'; + +import '../game/model/damage_event.dart'; +import '../game/model/effect_event.dart'; +import 'dart:async'; // StreamSubscription class BattleScreen extends StatefulWidget { const BattleScreen({super.key}); @@ -14,6 +18,12 @@ class BattleScreen extends StatefulWidget { class _BattleScreenState extends State { final ScrollController _scrollController = ScrollController(); + final List<_DamageTextData> _floatingDamageTexts = []; + final List<_FloatingEffectData> _floatingEffects = []; + StreamSubscription? _damageSubscription; + StreamSubscription? _effectSubscription; + final GlobalKey _playerKey = GlobalKey(); + final GlobalKey _enemyKey = GlobalKey(); @override void initState() { @@ -24,14 +34,163 @@ class _BattleScreenState extends State { _scrollController.jumpTo(_scrollController.position.maxScrollExtent); } }); + + // Subscribe to Damage Stream + final battleProvider = context.read(); + _damageSubscription = battleProvider.damageStream.listen( + _addFloatingDamageText, + ); + _effectSubscription = battleProvider.effectStream.listen( + _addFloatingEffect, + ); } @override void dispose() { _scrollController.dispose(); + _damageSubscription?.cancel(); + _effectSubscription?.cancel(); super.dispose(); } + void _addFloatingDamageText(DamageEvent event) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + + GlobalKey targetKey = event.target == DamageTarget.player + ? _playerKey + : _enemyKey; + + if (targetKey.currentContext == null) return; + RenderBox? renderBox = + targetKey.currentContext!.findRenderObject() as RenderBox?; + if (renderBox == null) return; + + Offset position = renderBox.localToGlobal(Offset.zero); + + RenderBox? stackRenderBox = context.findRenderObject() as RenderBox?; + if (stackRenderBox != null) { + Offset stackOffset = stackRenderBox.localToGlobal(Offset.zero); + position = position - stackOffset; + } + + // 중앙 정렬 보정 및 위쪽으로 이동 + position = position + Offset(renderBox.size.width / 2 - 20, -20); + + final String id = UniqueKey().toString(); + + setState(() { + _floatingDamageTexts.add( + _DamageTextData( + id: id, + widget: Positioned( + left: position.dx, + top: position.dy, + child: _FloatingDamageText( + key: ValueKey(id), + damage: event.damage.toString(), + color: event.color, + onRemove: () { + if (mounted) { + setState(() { + _floatingDamageTexts.removeWhere((e) => e.id == id); + }); + } + }, + ), + ), + ), + ); + }); + }); + } + + void _addFloatingEffect(EffectEvent event) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + + GlobalKey targetKey = event.target == EffectTarget.player + ? _playerKey + : _enemyKey; + if (targetKey.currentContext == null) return; + + RenderBox? renderBox = + targetKey.currentContext!.findRenderObject() as RenderBox?; + if (renderBox == null) return; + + Offset position = renderBox.localToGlobal(Offset.zero); + RenderBox? stackRenderBox = context.findRenderObject() as RenderBox?; + if (stackRenderBox != null) { + Offset stackOffset = stackRenderBox.localToGlobal(Offset.zero); + position = position - stackOffset; + } + + // 중앙 정렬 + position = + position + + Offset(renderBox.size.width / 2 - 30, renderBox.size.height / 2 - 30); + + IconData icon; + Color color; + double size; + + if (event.type == ActionType.attack) { + if (event.risk == RiskLevel.risky) { + icon = Icons.whatshot; + color = Colors.redAccent; + size = 60.0; + } else if (event.risk == RiskLevel.normal) { + icon = Icons.flash_on; + color = Colors.orangeAccent; + size = 40.0; + } else { + icon = Icons.close; + color = Colors.grey; + size = 30.0; + } + } else { + icon = Icons.shield; + if (event.risk == RiskLevel.risky) { + 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); + }); + } + }, + ), + ), + ), + ); + }); + }); + } + void _showRiskLevelSelection(BuildContext context, ActionType actionType) { final player = context.read().player; final baseValue = actionType == ActionType.attack @@ -192,10 +351,12 @@ class _BattleScreenState extends State { _buildCharacterStatus( battleProvider.enemy, isEnemy: true, + key: _enemyKey, ), _buildCharacterStatus( battleProvider.player, isEnemy: false, + key: _playerKey, ), ], ), @@ -285,6 +446,8 @@ class _BattleScreenState extends State { ), ), ), + ..._floatingDamageTexts.map((e) => e.widget), + ..._floatingEffects.map((e) => e.widget), ], ); }, @@ -325,8 +488,13 @@ class _BattleScreenState extends State { ); } - Widget _buildCharacterStatus(Character character, {bool isEnemy = false}) { + Widget _buildCharacterStatus( + Character character, { + bool isEnemy = false, + Key? key, + }) { return Column( + key: key, children: [ Text( "${character.name}: HP ${character.hp}/${character.totalMaxHp}", @@ -454,3 +622,200 @@ class _BattleScreenState extends State { ); } } + +class _FloatingDamageText extends StatefulWidget { + final String damage; + final Color color; + final VoidCallback onRemove; + + const _FloatingDamageText({ + Key? key, + required this.damage, + required this.color, + required this.onRemove, + }) : super(key: key); + + @override + __FloatingDamageTextState createState() => __FloatingDamageTextState(); +} + +class __FloatingDamageTextState extends State<_FloatingDamageText> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _offsetAnimation; + late Animation _opacityAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 1000), + vsync: this, + ); + + _offsetAnimation = Tween( + begin: const Offset(0.0, 0.0), + end: const Offset(0.0, -1.5), // 위로 띄울 높이 + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + + _opacityAnimation = Tween(begin: 1.0, end: 0.0).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval( + 0.5, + 1.0, + curve: Curves.easeOut, + ), // 절반 이후부터 투명도 감소 + ), + ); + + _controller.forward().then((_) { + if (mounted) { + widget.onRemove(); // 애니메이션 완료 후 콜백 호출하여 위젯 제거 요청 + // _controller.dispose(); // 제거: dispose() 메서드에서 처리됨 + } + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return FractionalTranslation( + translation: _offsetAnimation.value, + child: Opacity( + opacity: _opacityAnimation.value, + child: Material( + color: Colors.transparent, + child: Text( + widget.damage, + style: TextStyle( + color: widget.color, + fontSize: 20, + fontWeight: FontWeight.bold, + shadows: const [ + Shadow( + blurRadius: 2.0, + color: Colors.black, + offset: Offset(1.0, 1.0), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} + +class _DamageTextData { + final String id; + + final Widget widget; + + _DamageTextData({required this.id, required this.widget}); +} + +class _FloatingEffect extends StatefulWidget { + final IconData icon; + final Color color; + final double size; + final VoidCallback onRemove; + + const _FloatingEffect({ + Key? key, + + required this.icon, + + required this.color, + + required this.size, + + required this.onRemove, + }) : super(key: key); + + @override + __FloatingEffectState createState() => __FloatingEffectState(); +} + +class __FloatingEffectState extends State<_FloatingEffect> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + late Animation _scaleAnimation; + + late Animation _opacityAnimation; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + duration: const Duration(milliseconds: 800), + + vsync: this, + ); + + _scaleAnimation = Tween( + begin: 0.5, + end: 1.5, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.elasticOut)); + + _opacityAnimation = Tween(begin: 1.0, end: 0.0).animate( + CurvedAnimation( + parent: _controller, + + curve: const Interval(0.5, 1.0, curve: Curves.easeOut), + ), + ); + + _controller.forward().then((_) { + if (mounted) { + widget.onRemove(); + } + }); + } + + @override + void dispose() { + _controller.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + + builder: (context, child) { + return Transform.scale( + scale: _scaleAnimation.value, + + child: Opacity( + opacity: _opacityAnimation.value, + + child: Icon(widget.icon, color: widget.color, size: widget.size), + ), + ); + }, + ); + } +} + +class _FloatingEffectData { + final String id; + + final Widget widget; + + _FloatingEffectData({required this.id, required this.widget}); +} diff --git a/lib/screens/inventory_screen.dart b/lib/screens/inventory_screen.dart index e9422f3..01f1fa4 100644 --- a/lib/screens/inventory_screen.dart +++ b/lib/screens/inventory_screen.dart @@ -2,8 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/battle_provider.dart'; import '../game/model/item.dart'; -import '../game/model/entity.dart'; -import '../game/model/stage.dart'; // Import StageModel +import '../game/enums.dart'; class InventoryScreen extends StatelessWidget { const InventoryScreen({super.key}); @@ -41,8 +40,12 @@ class InventoryScreen extends StatelessWidget { ), _buildStatItem("ATK", "${player.totalAtk}"), _buildStatItem("DEF", "${player.totalDefense}"), - _buildStatItem("Shield", "${player.armor}"), - _buildStatItem("Gold", "${player.gold} G", color: Colors.amber), + _buildStatItem("Shield", "${player.armor}"), + _buildStatItem( + "Gold", + "${player.gold} G", + color: Colors.amber, + ), ], ), ], @@ -74,7 +77,11 @@ class InventoryScreen extends StatelessWidget { return Expanded( child: InkWell( onTap: item != null - ? () => _showUnequipConfirmationDialog(context, battleProvider, item) + ? () => _showUnequipConfirmationDialog( + context, + battleProvider, + item, + ) : null, child: Card( color: item != null @@ -232,8 +239,10 @@ class InventoryScreen extends StatelessWidget { /// Shows a menu with actions for the selected item (Equip, Discard, etc.) void _showItemActionDialog( - BuildContext context, BattleProvider provider, Item item) { - + BuildContext context, + BattleProvider provider, + Item item, + ) { bool isShop = provider.currentStage.type == StageType.shop; showDialog( @@ -546,4 +555,4 @@ class InventoryScreen extends StatelessWidget { ], ); } -} \ No newline at end of file +} diff --git a/prompt/24_refactor_enums.md b/prompt/24_refactor_enums.md new file mode 100644 index 0000000..9b4723b --- /dev/null +++ b/prompt/24_refactor_enums.md @@ -0,0 +1,41 @@ +# 24. Enum 리팩토링 (Refactor Enums) + +## 1. 배경 (Background) + +현재 프로젝트 내 여러 파일에 `enum`들이 산재해 있어 관리가 어렵고, 의존성이 복잡해질 우려가 있습니다. 이를 하나의 파일로 통합하여 관리 효율성을 높이고자 합니다. + +## 2. 목표 (Objective) + +- 프로젝트 전반에 흩어져 있는 `enum` 정의들을 `lib/game/enums.dart` 파일 하나로 통합합니다. +- 기존 파일들에서 `enum` 정의를 제거하고, 새로운 파일을 import 하여 사용하도록 수정합니다. + +## 3. 대상 Enum 목록 (Target Enums) + +다음 파일들에 정의된 Enum들을 이동합니다: + +1. **`lib/providers/battle_provider.dart`** + - `ActionType` + - `RiskLevel` + - `EnemyActionType` +2. **`lib/game/model/status_effect.dart`** + - `StatusEffectType` +3. **`lib/game/model/stat.dart`** + - `ModifierType` +4. **`lib/game/model/stage.dart`** + - `StageType` +5. **`lib/game/model/item.dart`** + - `EquipmentSlot` + +## 4. 작업 상세 (Implementation Details) + +1. **새 파일 생성:** `lib/game/enums.dart` +2. **Enum 이동:** 위 목록의 Enum들을 새 파일로 복사합니다. +3. **기존 코드 수정:** + - 원래 파일에서 Enum 정의 삭제. + - 해당 Enum을 사용하는 모든 파일에 `import 'package:game/game/enums.dart';` (또는 상대 경로) 추가. + - `battle_provider.dart`의 `EnemyIntent` 클래스는 `battle_provider.dart`에 남겨두거나, 필요하다면 별도 모델 파일로 분리 고려 (이번 작업에서는 Enum만 이동). + +## 5. 기대 효과 (Expected Outcome) + +- Enum 정의가 한곳에 모여 있어 찾기 쉽고 수정이 용이해짐. +- 순환 참조 문제 예방 및 코드 구조 개선. diff --git a/prompt/25_battle_visual_effects.md b/prompt/25_battle_visual_effects.md new file mode 100644 index 0000000..841662f --- /dev/null +++ b/prompt/25_battle_visual_effects.md @@ -0,0 +1,45 @@ +# 25. 전투 시각 효과 및 로직 개선 (Battle Visual Effects & Logic) + +## 1. 개요 (Overview) +이 작업은 텍스트 로그에만 의존하던 전투 시스템에 시각적 피드백을 추가하여 타격감과 상황 인지력을 높이는 것을 목표로 했습니다. 데미지 수치와 공격/방어 행동에 따른 이펙트를 화면상의 캐릭터 위치에 표시합니다. 또한 적의 방어 로직을 선제적으로 적용하여 전략성을 강화했습니다. + +## 2. 변경 사항 (Changes) + +### A. 데이터 모델 (Data Models) +* **`lib/game/model/damage_event.dart` (신규):** 데미지 발생 이벤트를 정의 (데미지 양, 대상, 색상). +* **`lib/game/model/effect_event.dart` (신규):** 행동 이펙트 이벤트를 정의 (행동 타입, 리스크 레벨, 대상). + +### B. 상태 관리 (State Management - `BattleProvider`) +* **`StreamController` 도입:** `damageStream`과 `effectStream`을 통해 `BattleScreen`으로 비동기 이벤트를 전달. +* **이벤트 발행:** + * `playerAction`: 플레이어의 공격/방어 성공 시 적절한 `EffectEvent` 발행. + * `_enemyTurn`: 적의 행동 시 `EffectEvent` 발행. + * `_applyDamage`: 데미지 적용 시 `DamageEvent` 발행. + +### C. UI 구현 (`BattleScreen`) +* **플로팅 위젯 (`_FloatingDamageText`, `_FloatingEffect`):** + * `AnimationController`를 사용하여 위로 떠오르거나(`DamageText`), 확대/축소되는(`Effect`) 애니메이션 구현. + * 애니메이션 종료 시 자동으로 리스트에서 제거되도록 `onRemove` 콜백 구현. +* **위치 계산 및 렌더링:** + * `GlobalKey`를 사용하여 캐릭터(`RenderBox`)의 화면상 위치를 파악. + * `WidgetsBinding.instance.addPostFrameCallback`을 사용하여 빌드 완료 후 안전하게 위치를 계산하고 `setState` 호출 (빌드 에러 방지). + * `Stack` 위젯 내에 `Positioned`로 이펙트 위젯들을 오버레이. +* **이펙트 다양화:** + * **Attack:** 리스크 레벨에 따라 아이콘 변경 (Safe: `close`, Normal: `flash_on`, Risky: `whatshot`) 및 색상/크기 차별화. + * **Defend:** 리스크 레벨에 따라 방패 아이콘(`shield`)의 색상 및 크기 변경. + +### D. 전투 로직 개선 (Logic Improvements) +* **선제 방어 (Pre-emptive Defense):** + * 적의 방어 행동(`Defend`)은 플레이어 턴이 시작되기 전(`_generateEnemyIntent`)에 미리 적용되도록 변경. + * 이로 인해 플레이어는 적의 증가된 방어도를 보고 전략을 세울 수 있으며, 공격 시 방어도가 정상적으로 적용됨. + * `_enemyTurn`에서는 방어 행동을 다시 수행하지 않고 로그만 출력하도록 수정. + +## 3. 핵심 로직 (Core Logic) +* **Stream 통신:** Provider는 로직만 처리하고 UI(이펙트)는 Stream을 통해 `BattleScreen`이 수동적으로 반응하도록 설계하여 결합도를 낮춤. +* **Safe Rendering:** 비동기 이벤트 수신 시 UI 갱신 타이밍 문제(`setState` during build)를 해결하기 위해 `addPostFrameCallback` 패턴 적용. +* **Pre-emptive Action:** 적의 방어는 의도 생성 시점에 즉시 반영하여 턴제 전투의 전략성을 보강. + +## 4. 결과 (Result) +* 캐릭터가 데미지를 입으면 붉은색(일반) 또는 보라색(출혈) 숫자가 캐릭터 위로 떠오름. +* 공격 및 방어 시 행동의 강도(Risk Level)에 따라 다른 시각적 이펙트가 캐릭터 위에 애니메이션으로 표시됨. +* 적이 방어 행동을 선택하면 즉시 방어도가 올라가고 방어 이펙트가 출력됨. \ No newline at end of file diff --git a/test/character_test.dart b/test/character_test.dart index 9de2853..65e128a 100644 --- a/test/character_test.dart +++ b/test/character_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:game_test/game/model/entity.dart'; import 'package:game_test/game/model/item.dart'; +import 'package:game_test/game/enums.dart'; void main() { group('Character Equipment & HP Logic', () { diff --git a/test/enemy_intent_test.dart b/test/enemy_intent_test.dart index f5702bf..f333a9c 100644 --- a/test/enemy_intent_test.dart +++ b/test/enemy_intent_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:game_test/providers/battle_provider.dart'; import 'package:game_test/game/data/enemy_table.dart'; import 'package:game_test/game/data/item_table.dart'; +import 'package:game_test/game/enums.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized();