diff --git a/assets/data/enemies.json b/assets/data/enemies.json index 8e5fd3e..994f557 100644 --- a/assets/data/enemies.json +++ b/assets/data/enemies.json @@ -4,35 +4,35 @@ "name": "Goblin", "baseHp": 20, "baseAtk": 5, - "baseDefense": 0, + "baseDefense": 5, "image": "assets/images/enemies/goblin.png" }, { "name": "Slime", "baseHp": 30, "baseAtk": 3, - "baseDefense": 1, + "baseDefense": 5, "image": "assets/images/enemies/slime.png" }, { "name": "Wolf", "baseHp": 25, "baseAtk": 7, - "baseDefense": 0, + "baseDefense": 5, "image": "assets/images/enemies/wolf.png" }, { "name": "Bandit", "baseHp": 35, "baseAtk": 6, - "baseDefense": 1, + "baseDefense": 5, "image": "assets/images/enemies/bandit.png" }, { "name": "Skeleton", "baseHp": 15, "baseAtk": 8, - "baseDefense": 0, + "baseDefense": 5, "image": "assets/images/enemies/skeleton.png" } ], diff --git a/lib/game/enums.dart b/lib/game/enums.dart index 8dde3e1..f591b00 100644 --- a/lib/game/enums.dart +++ b/lib/game/enums.dart @@ -24,3 +24,5 @@ enum StageType { } enum EquipmentSlot { weapon, armor, shield, accessory } + +enum DamageType { normal, bleed, vulnerable } diff --git a/lib/game/model/damage_event.dart b/lib/game/model/damage_event.dart index a54a969..8843c72 100644 --- a/lib/game/model/damage_event.dart +++ b/lib/game/model/damage_event.dart @@ -1,15 +1,27 @@ -import 'package:flutter/material.dart'; // Color 사용을 위해 import +import 'package:flutter/material.dart'; +import '../enums.dart'; enum DamageTarget { player, enemy } class DamageEvent { final int damage; final DamageTarget target; - final Color color; // 데미지 타입에 따른 색상 (예: 일반 공격, 치명타 등) + final DamageType type; DamageEvent({ required this.damage, required this.target, - this.color = Colors.red, // 기본 색상은 빨강 + this.type = DamageType.normal, }); + + Color get color { + switch (type) { + case DamageType.normal: + return Colors.grey; + case DamageType.bleed: + return Colors.red; + case DamageType.vulnerable: + return Colors.orange; + } + } } diff --git a/lib/providers/battle_provider.dart b/lib/providers/battle_provider.dart index 3fb337d..3a0319d 100644 --- a/lib/providers/battle_provider.dart +++ b/lib/providers/battle_provider.dart @@ -18,12 +18,16 @@ class EnemyIntent { final int value; final RiskLevel risk; final String description; + final bool isSuccess; + final int finalValue; EnemyIntent({ required this.type, required this.value, required this.risk, required this.description, + required this.isSuccess, + required this.finalValue, }); } @@ -38,9 +42,12 @@ class BattleProvider with ChangeNotifier { bool isPlayerTurn = true; int stage = 1; + int turnCount = 1; List rewardOptions = []; bool showRewardPopup = false; + List get logs => battleLogs; + // Damage Event Stream final _damageEventController = StreamController.broadcast(); Stream get damageStream => _damageEventController.stream; @@ -62,11 +69,12 @@ class BattleProvider with ChangeNotifier { void initializeBattle() { stage = 1; + turnCount = 1; player = Character( name: "Player", - maxHp: 100, + maxHp: 80, armor: 0, - atk: 10, + atk: 5, baseDefense: 5, ); @@ -213,6 +221,7 @@ class BattleProvider with ChangeNotifier { enemy: newEnemy, shopItems: shopItems, ); + turnCount = 1; notifyListeners(); } @@ -267,61 +276,63 @@ class BattleProvider with ChangeNotifier { break; } - if (success) { + if (success) { + if (type == ActionType.attack) { + int damage = (player.totalAtk * efficiency).toInt(); - if (type == ActionType.attack) { + _effectEventController.sink.add( + EffectEvent( + type: ActionType.attack, - int damage = (player.totalAtk * efficiency).toInt(); + risk: risk, - - - _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); + target: EffectTarget.enemy, + ), + ); + int damageToHp = 0; + if (enemy.armor > 0) { + if (enemy.armor >= damage) { + enemy.armor -= damage; + damageToHp = 0; + _addLog("Enemy's armor absorbed all $damage damage."); } 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."); - + damageToHp = damage - enemy.armor; + _addLog("Enemy's armor absorbed ${enemy.armor} damage."); + enemy.armor = 0; } - + } else { + damageToHp = damage; } - else { + if (damageToHp > 0) { + _applyDamage(enemy, damageToHp, targetType: DamageTarget.enemy); + _addLog("Player dealt $damageToHp damage to Enemy."); + } else { + _addLog("Player's attack was fully blocked by armor."); + } + + // 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!"); } @@ -352,6 +363,13 @@ class BattleProvider with ChangeNotifier { _addLog("Enemy's turn..."); await Future.delayed(const Duration(seconds: 1)); + // Enemy Turn Start Logic + // Armor decay + if (enemy.armor > 0) { + enemy.armor = (enemy.armor * 0.5).toInt(); + _addLog("Enemy's armor decayed to ${enemy.armor}."); + } + // 1. Process Start-of-Turn Effects for Enemy bool canAct = _processStartTurnEffects(enemy); @@ -370,28 +388,16 @@ class BattleProvider with ChangeNotifier { _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 (intent.isSuccess) { + _effectEventController.sink.add( + EffectEvent( + type: ActionType.attack, + risk: intent.risk, + target: EffectTarget.player, + ), + ); - if (success) { - _effectEventController.sink.add(EffectEvent( - type: ActionType.attack, - risk: intent.risk, - target: EffectTarget.player, - )); - - int incomingDamage = intent.value; + int incomingDamage = intent.finalValue; int damageToHp = 0; // Handle Player Armor @@ -440,6 +446,7 @@ class BattleProvider with ChangeNotifier { } isPlayerTurn = true; + turnCount++; notifyListeners(); } @@ -461,17 +468,21 @@ class BattleProvider with ChangeNotifier { // Emit DamageEvent for bleed if (character == player) { - _damageEventController.sink.add(DamageEvent( - damage: totalBleed, - target: DamageTarget.player, - color: Colors.purpleAccent, // Bleed damage color - )); + _damageEventController.sink.add( + DamageEvent( + damage: totalBleed, + target: DamageTarget.player, + type: DamageType.bleed, + ), + ); } else if (character == enemy) { - _damageEventController.sink.add(DamageEvent( - damage: totalBleed, - target: DamageTarget.enemy, - color: Colors.purpleAccent, // Bleed damage color - )); + _damageEventController.sink.add( + DamageEvent( + damage: totalBleed, + target: DamageTarget.enemy, + type: DamageType.bleed, + ), + ); } } @@ -505,22 +516,25 @@ class BattleProvider with ChangeNotifier { } } - void _applyDamage(Character target, int damage, {required DamageTarget targetType, Color color = Colors.red}) { + void _applyDamage( + Character target, + int damage, { + required DamageTarget targetType, + DamageType type = DamageType.normal, + }) { // Check Vulnerable if (target.hasStatus(StatusEffectType.vulnerable)) { damage = (damage * 1.5).toInt(); _addLog("Vulnerable! Damage increased to $damage."); - color = Colors.orange; // Vulnerable damage color + type = DamageType.vulnerable; } target.hp -= damage; if (target.hp < 0) target.hp = 0; - _damageEventController.sink.add(DamageEvent( - damage: damage, - target: targetType, - color: color, - )); + _damageEventController.sink.add( + DamageEvent(damage: damage, target: targetType, type: type), + ); } void _addLog(String message) { @@ -554,7 +568,7 @@ class BattleProvider with ChangeNotifier { } // Heal player after selecting reward - int healAmount = GameMath.floor(player.totalMaxHp * 0.5); + int healAmount = GameMath.floor(player.totalMaxHp * 0.1); player.heal(healAmount); _addLog("Stage Cleared! Recovered $healAmount HP."); @@ -653,27 +667,7 @@ class BattleProvider with ChangeNotifier { int damage = (enemy.totalAtk * efficiency * variance).toInt(); if (damage < 1) damage = 1; - currentEnemyIntent = EnemyIntent( - type: EnemyActionType.attack, - value: damage, - risk: risk, - description: "Attacks for $damage (${risk.name})", - ); - } else { - // Defend Intent - int baseDef = enemy.totalDefense; - // Variance - double variance = 0.8 + random.nextDouble() * 0.4; - int armor = (baseDef * 2 * efficiency * variance).toInt(); - - currentEnemyIntent = EnemyIntent( - type: EnemyActionType.defend, - value: armor, - risk: risk, - description: "Defends for $armor (${risk.name})", - ); - - // [Changed] Apply defense immediately for pre-emptive defense + // Calculate success immediately bool success = false; switch (risk) { case RiskLevel.safe: @@ -687,18 +681,59 @@ class BattleProvider with ChangeNotifier { break; } + currentEnemyIntent = EnemyIntent( + type: EnemyActionType.attack, + value: damage, + risk: risk, + description: "Attacks for $damage (${risk.name})", + isSuccess: success, + finalValue: damage, + ); + } else { + // Defend Intent + int baseDef = enemy.totalDefense; + // Variance + double variance = 0.8 + random.nextDouble() * 0.4; + int armor = (baseDef * 2 * efficiency * variance).toInt(); + + // Calculate success immediately + 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; + } + + currentEnemyIntent = EnemyIntent( + type: EnemyActionType.defend, + value: armor, + risk: risk, + description: "Defends for $armor (${risk.name})", + isSuccess: success, + finalValue: armor, + ); + + // Apply defense immediately if successful if (success) { enemy.armor += armor; _addLog("Enemy prepares defense! (+$armor Armor)"); - _effectEventController.sink.add(EffectEvent( - type: ActionType.defend, - risk: risk, - target: EffectTarget.enemy, - )); + _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 4941bcd..93751a1 100644 --- a/lib/screens/battle_screen.dart +++ b/lib/screens/battle_screen.dart @@ -1,13 +1,14 @@ import 'package:flutter/material.dart'; -import 'package:game_test/game/model/item.dart'; import 'package:provider/provider.dart'; import '../providers/battle_provider.dart'; import '../game/model/entity.dart'; import '../game/enums.dart'; - +import '../game/model/item.dart'; import '../game/model/damage_event.dart'; import '../game/model/effect_event.dart'; -import 'dart:async'; // StreamSubscription +import 'dart:async'; +import '../widgets/responsive_container.dart'; +import '../utils/item_utils.dart'; class BattleScreen extends StatefulWidget { const BattleScreen({super.key}); @@ -24,18 +25,17 @@ class _BattleScreenState extends State { StreamSubscription? _effectSubscription; final GlobalKey _playerKey = GlobalKey(); final GlobalKey _enemyKey = GlobalKey(); + final GlobalKey _stackKey = GlobalKey(); @override void initState() { super.initState(); - // Scroll to the bottom of the log when new messages are added WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.jumpTo(_scrollController.position.maxScrollExtent); } }); - // Subscribe to Damage Stream final battleProvider = context.read(); _damageSubscription = battleProvider.damageStream.listen( _addFloatingDamageText, @@ -68,13 +68,13 @@ class _BattleScreenState extends State { Offset position = renderBox.localToGlobal(Offset.zero); - RenderBox? stackRenderBox = context.findRenderObject() as RenderBox?; + RenderBox? stackRenderBox = + _stackKey.currentContext?.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(); @@ -119,13 +119,14 @@ class _BattleScreenState extends State { if (renderBox == null) return; Offset position = renderBox.localToGlobal(Offset.zero); - RenderBox? stackRenderBox = context.findRenderObject() as RenderBox?; + + RenderBox? stackRenderBox = + _stackKey.currentContext?.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); @@ -248,7 +249,6 @@ class _BattleScreenState extends State { onPressed: () { context.read().playerAction(actionType, risk); Navigator.pop(context); - // Ensure the log scrolls to the bottom after action WidgetsBinding.instance.addPostFrameCallback((_) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, @@ -279,132 +279,135 @@ class _BattleScreenState extends State { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Consumer( - builder: (context, provider, child) => Text( - "Colosseum - Stage ${provider.stage} (${provider.currentStage.type.name.toUpperCase()})", - ), - ), - actions: [ - IconButton( - icon: const Icon(Icons.refresh), - onPressed: () => context.read().initializeBattle(), - ), - ], - ), - body: Consumer( + return ResponsiveContainer( + child: Consumer( builder: (context, battleProvider, child) { - // UI Switching based on Stage Type if (battleProvider.currentStage.type == StageType.shop) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.store, size: 64, color: Colors.amber), - const SizedBox(height: 16), - const Text("Merchant Shop", style: TextStyle(fontSize: 24)), - const SizedBox(height: 8), - const Text("Buying/Selling feature coming soon!"), - const SizedBox(height: 32), - ElevatedButton( - onPressed: () => battleProvider.proceedToNextStage(), - child: const Text("Leave Shop"), - ), - ], - ), - ); + return _buildShopUI(context, battleProvider); } else if (battleProvider.currentStage.type == StageType.rest) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.local_hotel, size: 64, color: Colors.blue), - const SizedBox(height: 16), - const Text("Rest Area", style: TextStyle(fontSize: 24)), - const SizedBox(height: 8), - const Text("Take a breath and heal."), - const SizedBox(height: 32), - ElevatedButton( - onPressed: () { - battleProvider.player.heal(20); // Simple heal - battleProvider.proceedToNextStage(); - }, - child: const Text("Rest & Leave (+20 HP)"), - ), - ], - ), - ); + return _buildRestUI(context, battleProvider); } - // Default: Battle UI (for Battle and Elite) return Stack( + key: _stackKey, children: [ + Container(color: Colors.black87), Column( children: [ - // Top (Status Area) + // Top Bar Padding( padding: const EdgeInsets.all(8.0), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - _buildCharacterStatus( - battleProvider.enemy, - isEnemy: true, - key: _enemyKey, + Text( + "Stage ${battleProvider.stage}", + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), ), - _buildCharacterStatus( - battleProvider.player, - isEnemy: false, - key: _playerKey, + Text( + "Turn ${battleProvider.turnCount}", + style: const TextStyle( + color: Colors.white, + fontSize: 18, + ), ), ], ), ), - // Middle (Log Area) + + // Battle Area Expanded( - child: Container( - color: Colors.black87, - padding: const EdgeInsets.all(8.0), - child: ListView.builder( - controller: _scrollController, - itemCount: battleProvider.battleLogs.length, - itemBuilder: (context, index) { - return Text( - battleProvider.battleLogs[index], - style: const TextStyle( - color: Colors.white, - fontFamily: 'Monospace', - fontSize: 12, - ), - ); - }, + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildCharacterStatus( + battleProvider.player, + isPlayer: true, + isTurn: battleProvider.isPlayerTurn, + key: _playerKey, + ), + // const Text( + // "VS", + // style: TextStyle( + // color: Colors.red, + // fontSize: 24, + // fontWeight: FontWeight.bold, + // ), + // ), + _buildCharacterStatus( + battleProvider.enemy, + isPlayer: false, + isTurn: !battleProvider.isPlayerTurn, + key: _enemyKey, + ), + ], ), ), ), - // Bottom (Control Area) + + // Action Buttons Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, + padding: const EdgeInsets.all(16.0), + child: Column( children: [ - _buildActionButton( - context, - "ATTACK", - ActionType.attack, - battleProvider.isPlayerTurn && - !battleProvider.player.isDead && - !battleProvider.enemy.isDead && - !battleProvider.showRewardPopup, - ), - _buildActionButton( - context, - "DEFEND", - ActionType.defend, - battleProvider.isPlayerTurn && - !battleProvider.player.isDead && - !battleProvider.enemy.isDead && - !battleProvider.showRewardPopup, + if (battleProvider.logs.isNotEmpty) + Container( + height: 60, + padding: const EdgeInsets.all(8.0), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + ), + child: ListView.builder( + reverse: true, + itemCount: battleProvider.logs.length, + itemBuilder: (context, index) { + final logIndex = + battleProvider.logs.length - 1 - index; + return Text( + battleProvider.logs[logIndex], + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + ), + ); + }, + ), + ), + const SizedBox(height: 16), + Card( + color: Colors.grey[900], + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildActionButton( + context, + "ATTACK", + ActionType.attack, + battleProvider.isPlayerTurn && + !battleProvider.player.isDead && + !battleProvider.enemy.isDead && + !battleProvider.showRewardPopup, + ), + _buildActionButton( + context, + "DEFEND", + ActionType.defend, + battleProvider.isPlayerTurn && + !battleProvider.player.isDead && + !battleProvider.enemy.isDead && + !battleProvider.showRewardPopup, + ), + ], + ), + ), ), ], ), @@ -425,13 +428,32 @@ class _BattleScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - item.name, - style: const TextStyle( - fontWeight: FontWeight.bold, - ), + 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), // Display stats here + _buildItemStatText(item), Text( item.description, style: const TextStyle( @@ -455,6 +477,49 @@ class _BattleScreenState extends State { ); } + Widget _buildShopUI(BuildContext context, BattleProvider battleProvider) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.store, size: 64, color: Colors.amber), + const SizedBox(height: 16), + const Text("Merchant Shop", style: TextStyle(fontSize: 24)), + const SizedBox(height: 8), + const Text("Buying/Selling feature coming soon!"), + const SizedBox(height: 32), + ElevatedButton( + onPressed: () => battleProvider.proceedToNextStage(), + child: const Text("Leave Shop"), + ), + ], + ), + ); + } + + Widget _buildRestUI(BuildContext context, BattleProvider battleProvider) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.local_hotel, size: 64, color: Colors.blue), + const SizedBox(height: 16), + const Text("Rest Area", style: TextStyle(fontSize: 24)), + const SizedBox(height: 8), + const Text("Take a breath and heal."), + const SizedBox(height: 32), + ElevatedButton( + onPressed: () { + battleProvider.player.heal(20); + battleProvider.proceedToNextStage(); + }, + child: const Text("Rest & Leave (+20 HP)"), + ), + ], + ), + ); + } + Widget _buildItemStatText(Item item) { List stats = []; if (item.atkBonus > 0) stats.add("+${item.atkBonus} ATK"); @@ -490,12 +555,14 @@ class _BattleScreenState extends State { Widget _buildCharacterStatus( Character character, { - bool isEnemy = false, + bool isPlayer = false, + bool isTurn = false, Key? key, }) { return Column( key: key, children: [ + Text("Armor: ${character.armor}"), Text( "${character.name}: HP ${character.hp}/${character.totalMaxHp}", style: TextStyle( @@ -509,11 +576,10 @@ class _BattleScreenState extends State { value: character.totalMaxHp > 0 ? character.hp / character.totalMaxHp : 0, - color: isEnemy ? Colors.red : Colors.green, + color: !isPlayer ? Colors.red : Colors.green, backgroundColor: Colors.grey, ), ), - // Display Active Status Effects if (character.statusEffects.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 4.0), @@ -541,7 +607,10 @@ class _BattleScreenState extends State { }).toList(), ), ), - if (isEnemy) + Text("ATK: ${character.totalAtk}"), + Text("DEF: ${character.totalDefense}"), + + if (!isPlayer) Consumer( builder: (context, provider, child) { if (provider.currentEnemyIntent != null && !character.isDead) { @@ -593,11 +662,6 @@ class _BattleScreenState extends State { return const SizedBox.shrink(); }, ), - if (!isEnemy) ...[ - Text("Armor: ${character.armor}"), - Text("ATK: ${character.totalAtk}"), - Text("DEF: ${character.totalDefense}"), - ], ], ); } @@ -655,24 +719,19 @@ class __FloatingDamageTextState extends State<_FloatingDamageText> _offsetAnimation = Tween( begin: const Offset(0.0, 0.0), - end: const Offset(0.0, -1.5), // 위로 띄울 높이 + 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, - ), // 절반 이후부터 투명도 감소 + curve: const Interval(0.5, 1.0, curve: Curves.easeOut), ), ); _controller.forward().then((_) { if (mounted) { - widget.onRemove(); // 애니메이션 완료 후 콜백 호출하여 위젯 제거 요청 - // _controller.dispose(); // 제거: dispose() 메서드에서 처리됨 + widget.onRemove(); } }); } @@ -719,7 +778,6 @@ class __FloatingDamageTextState extends State<_FloatingDamageText> class _DamageTextData { final String id; - final Widget widget; _DamageTextData({required this.id, required this.widget}); @@ -733,13 +791,9 @@ class _FloatingEffect extends StatefulWidget { const _FloatingEffect({ Key? key, - required this.icon, - required this.color, - required this.size, - required this.onRemove, }) : super(key: key); @@ -750,18 +804,14 @@ class _FloatingEffect extends StatefulWidget { 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, ); @@ -773,7 +823,6 @@ class __FloatingEffectState extends State<_FloatingEffect> _opacityAnimation = Tween(begin: 1.0, end: 0.0).animate( CurvedAnimation( parent: _controller, - curve: const Interval(0.5, 1.0, curve: Curves.easeOut), ), ); @@ -788,7 +837,6 @@ class __FloatingEffectState extends State<_FloatingEffect> @override void dispose() { _controller.dispose(); - super.dispose(); } @@ -796,14 +844,11 @@ class __FloatingEffectState extends State<_FloatingEffect> 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), ), ); @@ -814,7 +859,6 @@ class __FloatingEffectState extends State<_FloatingEffect> class _FloatingEffectData { final String id; - final Widget widget; _FloatingEffectData({required this.id, required this.widget}); diff --git a/lib/screens/character_selection_screen.dart b/lib/screens/character_selection_screen.dart index 02a8695..c3e9ba6 100644 --- a/lib/screens/character_selection_screen.dart +++ b/lib/screens/character_selection_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/battle_provider.dart'; import 'main_wrapper.dart'; +import '../widgets/responsive_container.dart'; class CharacterSelectionScreen extends StatelessWidget { const CharacterSelectionScreen({super.key}); @@ -9,63 +10,85 @@ class CharacterSelectionScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: const Text("Choose Your Hero"), - centerTitle: true, - ), + backgroundColor: Colors.black, // Outer background body: Center( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: InkWell( - onTap: () { - // Initialize Game - context.read().initializeBattle(); - - // Navigate to Game Screen (MainWrapper) - // Using pushReplacement to prevent going back to selection - Navigator.pushAndRemoveUntil( - context, - MaterialPageRoute(builder: (context) => const MainWrapper()), - (route) => false, - ); - }, - child: Card( - color: Colors.blueGrey[800], - elevation: 8, - child: Container( - width: 300, - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.shield, size: 80, color: Colors.blue), - const SizedBox(height: 16), - const Text( - "Warrior", - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Colors.white, + child: ResponsiveContainer( + child: Scaffold( + appBar: AppBar( + title: const Text("Choose Your Hero"), + centerTitle: true, + ), + body: Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: InkWell( + onTap: () { + // Initialize Game + context.read().initializeBattle(); + + // Navigate to Game Screen (MainWrapper) + // Using pushReplacement to prevent going back to selection + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + builder: (context) => const MainWrapper(), + ), + (route) => false, + ); + }, + child: Card( + color: Colors.blueGrey[800], + elevation: 8, + child: Container( + width: 300, + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.shield, + size: 80, + color: Colors.blue, + ), + const SizedBox(height: 16), + const Text( + "Warrior", + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 8), + const Text( + "A balanced fighter with a sword and shield. Great for beginners.", + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey), + ), + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 8), + const Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Text( + "HP: 80", + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text( + "ATK: 5", + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text( + "DEF: 5", + style: TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + ], ), ), - const SizedBox(height: 8), - const Text( - "A balanced fighter with a sword and shield. Great for beginners.", - textAlign: TextAlign.center, - style: TextStyle(color: Colors.grey), - ), - const SizedBox(height: 16), - const Divider(), - const SizedBox(height: 8), - const Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Text("HP: 100", style: TextStyle(fontWeight: FontWeight.bold)), - Text("ATK: 10", style: TextStyle(fontWeight: FontWeight.bold)), - Text("DEF: 5", style: TextStyle(fontWeight: FontWeight.bold)), - ], - ), - ], + ), ), ), ), diff --git a/lib/screens/inventory_screen.dart b/lib/screens/inventory_screen.dart index 01f1fa4..ef89065 100644 --- a/lib/screens/inventory_screen.dart +++ b/lib/screens/inventory_screen.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import '../providers/battle_provider.dart'; import '../game/model/item.dart'; import '../game/enums.dart'; +import '../utils/item_utils.dart'; class InventoryScreen extends StatelessWidget { const InventoryScreen({super.key}); @@ -101,10 +102,10 @@ class InventoryScreen extends StatelessWidget { ), const SizedBox(height: 4), Icon( - _getIconForSlot(slot), + ItemUtils.getIcon(slot), size: 24, color: item != null - ? Colors.white + ? ItemUtils.getColor(slot) : Colors.grey, ), const SizedBox(height: 4), @@ -171,7 +172,11 @@ class InventoryScreen extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.backpack, size: 32), + Icon( + ItemUtils.getIcon(item.slot), + size: 32, + color: ItemUtils.getColor(item.slot), + ), Padding( padding: const EdgeInsets.all(4.0), child: Text( @@ -208,19 +213,6 @@ class InventoryScreen extends StatelessWidget { ); } - IconData _getIconForSlot(EquipmentSlot slot) { - switch (slot) { - case EquipmentSlot.weapon: - return Icons.g_mobiledata; // Using a generic 'game' icon for weapon - case EquipmentSlot.armor: - return Icons.checkroom; - case EquipmentSlot.shield: - return Icons.shield; - case EquipmentSlot.accessory: - return Icons.diamond; - } - } - Widget _buildStatItem(String label, String value, {Color? color}) { return Column( children: [ diff --git a/lib/screens/main_menu_screen.dart b/lib/screens/main_menu_screen.dart index c507e95..95ffc9f 100644 --- a/lib/screens/main_menu_screen.dart +++ b/lib/screens/main_menu_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'character_selection_screen.dart'; +import '../widgets/responsive_container.dart'; class MainMenuScreen extends StatelessWidget { const MainMenuScreen({super.key}); @@ -16,50 +17,56 @@ class MainMenuScreen extends StatelessWidget { colors: [Colors.black, Colors.blueGrey[900]!], ), ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.gavel, size: 100, color: Colors.amber), - const SizedBox(height: 20), - const Text( - "COLOSSEUM'S CHOICE", - style: TextStyle( - fontSize: 32, - fontWeight: FontWeight.bold, - letterSpacing: 2.0, - color: Colors.white, + child: ResponsiveContainer( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.gavel, size: 100, color: Colors.amber), + const SizedBox(height: 20), + const Text( + "COLOSSEUM'S CHOICE", + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + letterSpacing: 2.0, + color: Colors.white, + ), ), - ), - const SizedBox(height: 10), - const Text( - "Rise as a Legend", - style: TextStyle( - fontSize: 16, - color: Colors.grey, - fontStyle: FontStyle.italic, + const SizedBox(height: 10), + const Text( + "Rise as a Legend", + style: TextStyle( + fontSize: 16, + color: Colors.grey, + fontStyle: FontStyle.italic, + ), ), - ), - const SizedBox(height: 60), - ElevatedButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const CharacterSelectionScreen(), + const SizedBox(height: 60), + ElevatedButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const CharacterSelectionScreen(), + ), + ); + }, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 50, + vertical: 15, ), - ); - }, - style: ElevatedButton.styleFrom( - padding: - const EdgeInsets.symmetric(horizontal: 50, vertical: 15), - backgroundColor: Colors.amber[700], - foregroundColor: Colors.black, - textStyle: - const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + backgroundColor: Colors.amber[700], + foregroundColor: Colors.black, + textStyle: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + child: const Text("START GAME"), ), - child: const Text("START GAME"), - ), - ], + ], + ), ), ), ); diff --git a/lib/screens/main_wrapper.dart b/lib/screens/main_wrapper.dart index 683afb3..dad326e 100644 --- a/lib/screens/main_wrapper.dart +++ b/lib/screens/main_wrapper.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'battle_screen.dart'; import 'inventory_screen.dart'; +import '../widgets/responsive_container.dart'; class MainWrapper extends StatefulWidget { const MainWrapper({super.key}); @@ -12,35 +13,36 @@ class MainWrapper extends StatefulWidget { class _MainWrapperState extends State { int _currentIndex = 0; - final List _screens = [ - const BattleScreen(), - const InventoryScreen(), - ]; + final List _screens = [const BattleScreen(), const InventoryScreen()]; @override Widget build(BuildContext context) { return Scaffold( - body: IndexedStack( - index: _currentIndex, - children: _screens, - ), - bottomNavigationBar: BottomNavigationBar( - currentIndex: _currentIndex, - onTap: (index) { - setState(() { - _currentIndex = index; - }); - }, - items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.flash_on), - label: 'Battle', + backgroundColor: Colors.black, // Outer background for web + body: Center( + child: ResponsiveContainer( + child: Scaffold( + body: IndexedStack(index: _currentIndex, children: _screens), + bottomNavigationBar: BottomNavigationBar( + currentIndex: _currentIndex, + onTap: (index) { + setState(() { + _currentIndex = index; + }); + }, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.flash_on), + label: 'Battle', + ), + BottomNavigationBarItem( + icon: Icon(Icons.backpack), + label: 'Inventory', + ), + ], + ), ), - BottomNavigationBarItem( - icon: Icon(Icons.backpack), - label: 'Inventory', - ), - ], + ), ), ); } diff --git a/lib/utils/item_utils.dart b/lib/utils/item_utils.dart new file mode 100644 index 0000000..d9f8686 --- /dev/null +++ b/lib/utils/item_utils.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import '../game/enums.dart'; + +class ItemUtils { + static IconData getIcon(EquipmentSlot slot) { + switch (slot) { + case EquipmentSlot.weapon: + return Icons.change_history; // Triangle + case EquipmentSlot.shield: + return Icons.shield; + case EquipmentSlot.armor: + return Icons.checkroom; + case EquipmentSlot.accessory: + return Icons.diamond; + } + } + + static Color getColor(EquipmentSlot slot) { + switch (slot) { + case EquipmentSlot.weapon: + return Colors.red; + case EquipmentSlot.shield: + return Colors.blue; + case EquipmentSlot.armor: + return Colors.blue; + case EquipmentSlot.accessory: + return Colors.orange; + } + } +} diff --git a/lib/widgets/responsive_container.dart b/lib/widgets/responsive_container.dart new file mode 100644 index 0000000..6b0f56b --- /dev/null +++ b/lib/widgets/responsive_container.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +class ResponsiveContainer extends StatelessWidget { + final Widget child; + final double maxWidth; + final double maxHeight; + + const ResponsiveContainer({ + Key? key, + required this.child, + this.maxWidth = 600.0, + this.maxHeight = 1000.0, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth, maxHeight: maxHeight), + child: child, + ), + ); + } +} diff --git a/prompt/00_project_context_restore.md b/prompt/00_project_context_restore.md index 1219f16..1073f3a 100644 --- a/prompt/00_project_context_restore.md +++ b/prompt/00_project_context_restore.md @@ -7,78 +7,69 @@ - **프로젝트명:** Colosseum's Choice - **플랫폼:** Flutter (Android/iOS/Web/Desktop) - **장르:** 텍스트 기반의 턴제 RPG + GUI (로그라이크 요소 포함) -- **상태:** 프로토타입 단계 (핵심 전투, 아이템, 적 시스템 데이터화 완료) +- **상태:** 프로토타입 단계 (전투 시각화, 데이터 주도 시스템 구현 완료) ## 2. 현재 구현된 핵심 기능 (Feature Status) ### A. 게임 흐름 (Game Flow) 1. **메인 메뉴 (`MainMenuScreen`):** 게임 시작 버튼. -2. **캐릭터 선택 (`CharacterSelectionScreen`):** 현재 'Warrior' 직업만 구현됨. 선택 시 게임 초기화. +2. **캐릭터 선택 (`CharacterSelectionScreen`):** 'Warrior' 직업 구현. 3. **메인 게임 (`MainWrapper`):** 하단 탭 네비게이션 (Battle / Inventory). ### B. 전투 시스템 (`BattleProvider`) - **턴제 전투:** 플레이어 턴 -> 적 턴. - **행동 선택:** 공격(Attack) / 방어(Defend). -- **리스크 시스템 (Risk System):** - - 플레이어와 적 모두 **Safe / Normal / Risky** 중 하나를 선택하여 행동. - - Safe: 100% 성공, 50% 효율. - - Normal: 80% 성공, 100% 효율. - - Risky: 40% 성공, 200% 효율. +- **리스크 시스템 (Risk System):** Safe(100%/50%), Normal(80%/100%), Risky(40%/200%) 선택. - **적 인공지능 (Enemy AI & Intent):** - - 적은 턴 시작 시 행동(공격/방어)과 리스크 레벨을 무작위로 결정. - - **Intent UI:** 플레이어는 적의 다음 행동(아이콘, 설명)을 미리 볼 수 있음. - - _규칙:_ 적의 `baseDefense`가 0이면 방어 행동을 하지 않음. -- **상태이상 (Status Effects):** - - `Stun`, `Bleed`, `Vulnerable`, `DefenseForbidden` 구현됨. + - **Intent UI:** 적의 다음 행동(공격/방어, 리스크)을 미리 표시. + - **선제 방어 (Pre-emptive Defense):** 적이 방어를 선택하면 턴 시작 전에 즉시 방어도가 적용됨. +- **시각 효과 (Visual Effects):** + - **Floating Text:** 데미지 발생 시 캐릭터 위에 데미지 수치가 떠오름 (일반: 빨강, 출혈: 보라, 취약: 주황). + - **Effect Icons:** 공격/방어 및 리스크 레벨에 따른 아이콘 애니메이션 출력. +- **상태이상:** `Stun`, `Bleed`, `Vulnerable`, `DefenseForbidden`. ### C. 데이터 주도 설계 (Data-Driven Design) -- **JSON 데이터 관리:** `assets/data/` 폴더 내 JSON 파일로 게임 데이터 관리. - - `items.json`: 아이템 정의 (이름, 스탯, 효과, **가격**, **이미지 경로**). - - `enemies.json`: 적 정의 (Normal/Elite, 스탯, **이미지 경로**). -- **데이터 로더:** - - `ItemTable`: `items.json` 로드 및 `ItemTemplate` 관리. - - `EnemyTable`: `enemies.json` 로드 및 `EnemyTemplate` 관리. +- **JSON 데이터:** `assets/data/items.json`, `assets/data/enemies.json`. +- **데이터 로더:** `ItemTable`, `EnemyTable`. ### D. 아이템 및 경제 (`Item`, `Inventory`) -- **장비:** 무기, 방어구, 방패, 장신구 슬롯. -- **가격 정책:** - - `items.json`에 정의된 고정 `price` 사용. - - **판매(Sell):** 상점 등에서 판매 시 원가의 **60%** (소수점 버림, `GameMath.floor`) 획득. -- **이미지 필드:** 향후 UI 사용을 위해 `Item` 및 `Enemy` 모델에 `image` 필드 추가됨. +- **장비:** 무기, 방어구, 방패, 장신구. +- **가격:** JSON 고정 가격 사용. 판매 시 60% (`GameMath.floor`) 획득. +- **이미지:** `items.json`, `enemies.json`에 이미지 경로 필드 포함. -### E. 스테이지 시스템 (`StageModel`, `StageType`) +### E. 스테이지 시스템 (`StageModel`) -- **진행:** `currentStage` 객체로 관리. -- **타입:** Battle, Shop (5단위), Rest (8단위), Elite (10단위). -- **적 생성:** `EnemyTable`에서 현재 스테이지 타입(Normal/Elite)에 맞는 적을 무작위로 스폰하며, 스테이지에 따라 스탯 스케일링 적용. +- **타입:** Battle, Shop, Rest, Elite. +- **적 생성:** 스테이지 레벨에 따른 스탯 스케일링 적용. ## 3. 핵심 파일 및 아키텍처 -- **`lib/providers/battle_provider.dart`:** 게임의 **Core Logic**. 상태 관리, 전투 루프, 적 AI(Intent) 생성, 스테이지 전환 담당. -- **`lib/game/data/`:** - - `item_table.dart`: 아이템 JSON 로더. - - `enemy_table.dart`: 적 JSON 로더. +- **`lib/providers/battle_provider.dart`:** + - **Core Logic:** 상태 관리, 전투 루프. + - **Streams:** `damageStream`, `effectStream`을 통해 UI(`BattleScreen`)에 비동기 이벤트 전달. +- **`lib/game/enums.dart`:** 프로젝트 전반의 Enum 통합 관리 (`ActionType`, `RiskLevel`, `StageType` 등). - **`lib/game/model/`:** - - `entity.dart`: `Character` 클래스 (Player/Enemy 공용). `image` 필드 포함. - - `item.dart`: `Item` 클래스. `price`, `image` 필드 포함. -- **`assets/data/`:** `items.json`, `enemies.json`. + - `damage_event.dart`, `effect_event.dart`: 이벤트 모델. + - `entity.dart`: `Character` (Player/Enemy). + - `item.dart`: `Item`. +- **`lib/screens/battle_screen.dart`:** + - `StreamSubscription`을 통해 이펙트 이벤트 수신 및 `Overlay` 애니메이션 렌더링. ## 4. 작업 컨벤션 (Working Conventions) -- **Prompt Driven Development:** `prompt/XX_description.md` 형식을 유지하며 작업. -- **State Management:** `Provider` 사용. -- **Data:** JSON 파일 기반의 데이터 관리. +- **Prompt Driven Development:** `prompt/XX_description.md` 유지. +- **State Management:** `Provider` + `Stream` (이벤트성 데이터). +- **Data:** JSON 기반. ## 5. 다음 단계 작업 (Next Steps) -1. **상점 구매 기능:** `Shop` 스테이지에서 아이템 목록을 보여주고 구매하는 UI 구현. -2. **이미지 리소스 적용:** JSON에 정의된 경로에 실제 이미지 파일(`assets/images/...`)을 추가하고 UI(`BattleScreen`, `InventoryScreen`)에 표시. -3. **UI 개선:** 텍스트 로그 외에 시각적 피드백(데미지 플로팅, 효과 이펙트) 추가. -4. **밸런싱 및 콘텐츠 확장:** 더 많은 아이템과 적 데이터 추가. +1. **상점 구매 기능:** `Shop` 스테이지 구매 UI 구현. +2. **이미지 리소스 적용:** JSON 경로에 맞는 실제 이미지 파일 추가 및 UI 표시. +3. **밸런싱 및 콘텐츠 확장:** 아이템/적 데이터 추가 및 밸런스 조정. --- diff --git a/prompt/26_refactor_damage_types.md b/prompt/26_refactor_damage_types.md new file mode 100644 index 0000000..cfad2ed --- /dev/null +++ b/prompt/26_refactor_damage_types.md @@ -0,0 +1,57 @@ +# 26. 데미지 타입 리팩토링 및 색상 지정 (Refactor Damage Types & Colors) + +## 1. 배경 (Background) + +현재 데미지 텍스트의 색상이 `BattleProvider` 내에서 하드코딩되어 있거나 `Color` 객체를 직접 전달하는 방식으로 구현되어 있습니다. 이를 `DamageType` Enum을 통해 구조화하고, 타입별로 색상을 중앙에서 관리하도록 개선하고자 합니다. + +## 2. 목표 (Objective) + +- `DamageType` Enum을 정의하여 데미지의 종류(일반, 출혈, 취약 등)를 구분합니다. +- `DamageEvent`가 `Color` 대신 `DamageType`을 가지도록 수정합니다. +- UI(`BattleScreen`)에서 `DamageType`에 따라 미리 정의된 색상을 출력하도록 합니다. +- **색상 변경:** + - 기본(Normal) 데미지: **회색 (Grey)** + - 출혈(Bleed) 데미지: **빨간색 (Red)** + - (기타) 취약(Vulnerable): **주황색 (Orange)** (기존 유지/변경 고려) + +## 3. 작업 상세 (Implementation Details) + +### A. Enum 정의 (`lib/game/enums.dart`) + +- `DamageType` Enum 추가: + ```dart + enum DamageType { + normal, + bleed, + vulnerable, + // critical, // 추후 확장 가능 + } + ``` + +### B. 모델 수정 (`lib/game/model/damage_event.dart`) + +- `DamageEvent` 클래스 수정: + - `final Color color` 필드 삭제. + - `final DamageType type` 필드 추가. + - (선택) `Color get color` getter를 추가하여 타입별 색상 반환 로직 구현 (또는 UI에서 처리). + - Normal: `Colors.grey` + - Bleed: `Colors.red` + - Vulnerable: `Colors.orange` + +### C. 로직 수정 (`lib/providers/battle_provider.dart`) + +- `_damageEventController` 및 `_applyDamage` 메서드 수정. +- 데미지 발생 시점에 적절한 `DamageType`을 전달하도록 변경. + - 일반 공격: `DamageType.normal` + - 출혈 데미지: `DamageType.bleed` + - 취약 상태 공격: `DamageType.vulnerable` + +### D. UI 수정 (`lib/screens/battle_screen.dart`) + +- `_addFloatingDamageText` 메서드에서 `event.color` 대신 `event.type`에 따른 색상을 사용하도록 수정. + +## 4. 기대 효과 + +- 데미지 색상 정책을 한곳에서 관리하여 일관성 유지. +- 코드 가독성 향상 및 하드코딩 제거. +- 추후 새로운 데미지 타입(독, 화상 등) 추가 시 확장이 용이함. diff --git a/prompt/27_predetermine_enemy_action.md b/prompt/27_predetermine_enemy_action.md new file mode 100644 index 0000000..1edc65b --- /dev/null +++ b/prompt/27_predetermine_enemy_action.md @@ -0,0 +1,45 @@ +# 27. 적 행동 결과 선결정 (Pre-determine Enemy Action) + +## 1. 배경 (Background) + +현재 적의 행동(Intent)은 턴 시작 시 결정되지만, 그 행동의 **성공 여부(Risk에 따른 확률)**는 적의 턴이 실제로 실행될 때(`_enemyTurn`) 결정되거나, 방어의 경우 `_generateEnemyIntent`에서 즉시 적용되도록 수정되었으나 문제가 보고되었습니다. +사용자는 "내 턴이 시작됐을 때 적의 행동(확률적인 부분 포함)이 모두 결정되기를" 원합니다. 특히 적이 방어를 선택했을 때, 플레이어의 공격 턴에 방어도가 적용되어 있어야 합니다. + +## 2. 문제 분석 (Problem Analysis) + +- **현상:** 플레이어 공격력 8, 적이 방어 행동을 취했음에도 데미지가 차감되지 않음. +- **원인 추정:** + 1. `_generateEnemyIntent`에서 방어도를 올리는 로직이 제대로 동작하지 않았거나, + 2. `enemy.armor`가 턴 시작 시점이나 다른 곳에서 초기화되고 있을 가능성. + 3. 또는 UI상으로는 방어한다고 나오지만 실제 내부 로직에서는 아직 적용되지 않은 상태(기존 로직 잔재). + +## 3. 목표 (Objective) + +- **결과 선결정 (Pre-determination):** `_generateEnemyIntent` 시점에 적의 행동 성공 여부(`isSuccess`)와 최종 수치(`finalValue`)를 미리 계산하여 `EnemyIntent`에 저장. +- **즉시 적용 (Immediate Application):** + - **방어(Defend):** 성공 시, **즉시** `enemy.armor`를 증가시켜 플레이어 턴 동안 유지되게 함. + - **공격(Attack):** 성공 여부와 데미지를 미리 결정해두고, `_enemyTurn`에서는 그 결과만 실행. + +## 4. 작업 상세 (Implementation Details) + +### A. 모델 수정 (`lib/providers/battle_provider.dart` 내 `EnemyIntent`) + +- `EnemyIntent` 클래스에 필드 추가: + - `final bool isSuccess;` // 성공 여부 + - `final int finalValue;` // 최종 적용될 수치 (데미지 또는 방어도) + +### B. 로직 수정 (`BattleProvider`) + +1. **`_generateEnemyIntent` 수정:** + - 행동 타입(Attack/Defend)과 Risk 결정 후, **즉시 확률(Random)을 굴림**. + - `isSuccess`와 `finalValue`를 계산하여 `EnemyIntent` 생성. + - **방어(Defend)의 경우:** `isSuccess`가 `true`라면 `enemy.armor`에 `finalValue`를 **즉시 더함**. + - _주의:_ 턴이 지날 때 방어도가 초기화되는 로직과 충돌하지 않도록 확인. +2. **`_enemyTurn` 수정:** + - 다시 확률을 굴리지 않고, `currentEnemyIntent.isSuccess`를 확인하여 행동 수행. + - 방어의 경우 이미 적용되었으므로, 로그만 출력하거나 추가 이펙트만 재생 (중복 적용 방지). + +### C. 검증 (Verification) + +- 테스트 코드를 통해 적이 방어 의도를 가졌을 때 `enemy.armor`가 즉시 증가하는지 확인. +- 플레이어가 공격했을 때 방어도가 적용되어 데미지가 감소하는지 확인. diff --git a/prompt/28_enemy_armor_decay.md b/prompt/28_enemy_armor_decay.md new file mode 100644 index 0000000..7dea0fa --- /dev/null +++ b/prompt/28_enemy_armor_decay.md @@ -0,0 +1,44 @@ +# 28. 적 방어도 누적 및 감소 (Enemy Armor Accumulation & Decay) + +## 1. 배경 (Background) + +사용자는 "적의 방어도 증가 액션도 플레이어와 동일하게 방어도가 누적되는 것"을 원합니다. +현재 적의 방어도는 `_generateEnemyIntent`에서 `+=` 연산자를 통해 누적되도록 수정되었으나(27번 작업), 플레이어와 달리 **턴 시작 시 방어도가 감소(Decay)하는 로직**이 부재합니다. +"플레이어와 동일하게" 동작하려면, 적의 턴이 돌아왔을 때(즉, 플레이어의 공격을 막아낸 후) 남은 방어도가 일부 감소해야 합니다. + +## 2. 목표 (Objective) + +- **방어도 누적 확인:** `_generateEnemyIntent`에서 `+=`를 사용하여 방어도가 누적됨을 유지. +- **방어도 감소(Decay) 추가:** 적의 턴이 시작될 때(`_enemyTurn` 초입), 적의 현재 방어도를 50% 감소시킵니다. (플레이어 로직과 대칭) + +## 3. 작업 상세 (Implementation Details) + +### A. 로직 수정 (`lib/providers/battle_provider.dart`) + +- `_enemyTurn` 메서드 시작 부분에 방어도 감소 로직 추가: + ```dart + // Enemy Turn Start Logic + // Armor decay + if (enemy.armor > 0) { + enemy.armor = (enemy.armor * 0.5).toInt(); + _addLog("Enemy's armor decayed to ${enemy.armor}."); + } + ``` + +### B. UI 수정 (`lib/screens/battle_screen.dart`) + +- **적 스탯 표시:** `_buildCharacterStatus` 위젯에서 적(Enemy)인 경우에도 Armor, ATK, DEF 정보를 표시하도록 수정합니다. + - 기존에는 `if (!isEnemy)` 조건으로 플레이어에게만 표시되었던 부분을 공통으로 표시되도록 변경합니다. + +### C. 검증 (Verification) + +- **시나리오:** + 1. 적이 방어(예: +10 Armor)를 선택. + 2. 플레이어가 공격하지 않거나 약하게 공격하여 방어도가 남음(예: 10 남음). + 3. 적의 턴이 시작될 때, 방어도가 5(50%)로 감소하는지 로그 및 UI 확인. + 4. 다음 적의 행동이 또 방어라면, 남은 5에 새로운 방어도가 더해지는지 확인. + +## 4. 기대 효과 + +- 플레이어와 적의 방어 메커니즘이 대칭적으로 동작하여 일관성 확보. +- 적이 방어만 계속하여 무한히 단단해지는 것을 방지(밸런스 조절). diff --git a/prompt/29_fix_player_attack_vs_enemy_armor.md b/prompt/29_fix_player_attack_vs_enemy_armor.md new file mode 100644 index 0000000..8bde6a8 --- /dev/null +++ b/prompt/29_fix_player_attack_vs_enemy_armor.md @@ -0,0 +1,57 @@ +# 29. 플레이어 공격 시 적 방어도 적용 수정 (Fix Player Attack vs Enemy Armor) + +## 1. 배경 (Background) + +사용자는 "플레이어의 공격 계산 시 enemy의 armor가 반영되지 않고 있다"고 보고했습니다. +코드 확인 결과, `playerAction` 메서드에서 플레이어가 공격할 때 `_applyDamage`를 직접 호출하여 적의 방어도를 무시하고 체력에 직접 데미지를 주고 있습니다. 반면, 적의 턴(`_enemyTurn`)에서는 플레이어의 방어도를 계산하는 로직이 존재합니다. + +## 2. 목표 (Objective) + +- `playerAction` 메서드 내 공격 로직을 수정하여, **적의 방어도(Armor)**를 먼저 차감하고 남은 데미지만 체력에 적용하도록 합니다. +- 방어도 차감 시 로그를 출력하여 플레이어가 방어 효과를 인지할 수 있도록 합니다. + +## 3. 작업 상세 (Implementation Details) + +### A. 로직 수정 (`lib/providers/battle_provider.dart`) + +- `playerAction` 메서드의 `ActionType.attack` 처리 부분 수정: + + ```dart + // 기존: _applyDamage(enemy, damage, ...); + + // 수정: + int damageToHp = 0; + if (enemy.armor > 0) { + if (enemy.armor >= damage) { + enemy.armor -= damage; + damageToHp = 0; + _addLog("Enemy's armor absorbed all $damage damage."); + } else { + damageToHp = damage - enemy.armor; + _addLog("Enemy's armor absorbed ${enemy.armor} damage."); + enemy.armor = 0; + } + } else { + damageToHp = damage; + } + + if (damageToHp > 0) { + _applyDamage(enemy, damageToHp, targetType: DamageTarget.enemy); + _addLog("Player dealt $damageToHp damage to Enemy."); + } else { + _addLog("Player's attack was fully blocked by armor."); + } + ``` + +### B. 검증 (Verification) + +- **시나리오:** + 1. 적이 방어하여 방어도를 획득(예: 10). + 2. 플레이어가 공격(예: 15 데미지). + 3. 적의 방어도가 0이 되고, 체력은 5만 감소하는지 확인. + 4. 로그에 "Enemy's armor absorbed..." 메시지가 출력되는지 확인. + +## 4. 기대 효과 + +- 적의 방어 행동이 실제로 의미를 가지게 됨. +- 전투의 전략적 깊이 증가 (방어도가 높은 적에게는 관통 공격이나 다른 전략 필요). diff --git a/prompt/30_responsive_ui_improvement.md b/prompt/30_responsive_ui_improvement.md new file mode 100644 index 0000000..8e22864 --- /dev/null +++ b/prompt/30_responsive_ui_improvement.md @@ -0,0 +1,41 @@ +# 30. 통합 반응형 UI 개선 (Integrated Responsive UI Improvement) + +## 1. 배경 (Background) + +현재 UI는 모바일 화면을 기준으로 개발되었으나, 웹(Web) 환경에서 테스트 시 화면이 너무 커지거나 레이아웃이 어색해지는 문제가 있습니다. +사용자는 "웹에서 너무 커지는 것보다 태블릿에서 사용해도 문제가 되지 않을 정도의 해상도"를 원하며, `BottomNavigationBar`를 포함한 전체적인 UI가 웹 환경에서 어색하지 않게 조정되기를 원합니다. + +## 2. 목표 (Objective) + +- **최대 너비 및 높이 제한 (Max Width & Height Constraint):** + - 웹이나 태블릿 등 큰 화면에서 콘텐츠가 지나치게 늘어나는 것을 방지합니다. + - **MaxWidth:** 600px (태블릿/모바일 적정 너비) + - **MaxHeight:** 1000px (세로 비율 유지) +- **전역 중앙 정렬 (Global Center Alignment):** + - 앱의 모든 화면(`MainWrapper`, `CharacterSelectionScreen` 등)을 중앙에 배치하고, 남는 여백은 어두운 배경(검은색)으로 처리하여 몰입감을 높입니다. +- **ResponsiveContainer 도입:** + - 위 제약 조건을 쉽게 적용할 수 있는 재사용 가능한 위젯을 구현합니다. + +## 3. 작업 상세 (Implementation Details) + +### A. `lib/widgets/responsive_container.dart` 구현 + +- `maxWidth`와 `maxHeight`를 제한하고 중앙 정렬하는 래퍼 위젯. +- 기본값: `maxWidth = 600.0`, `maxHeight = 1000.0`. + +### B. 화면별 적용 + +1. **MainWrapper (`lib/screens/main_wrapper.dart`)**: + - 전체 앱(`Scaffold` + `BottomNavigationBar`)을 `ResponsiveContainer`로 감싸서 하단 바도 함께 줄어들도록 처리. + - 외부 배경은 `Colors.black`으로 설정. +2. **CharacterSelectionScreen (`lib/screens/character_selection_screen.dart`)**: + - 동일하게 외부 배경과 `ResponsiveContainer` 적용. +3. **BattleScreen (`lib/screens/battle_screen.dart`)**: + - `ResponsiveContainer` 내부에서 `Stack`과 플로팅 텍스트(`DamageEvent`, `EffectEvent`) 위치가 올바르게 계산되도록 `GlobalKey` 활용 및 좌표 보정 로직 적용. + +## 4. 검증 (Verification) + +- **Web 빌드 테스트:** + - 브라우저 창을 가로/세로로 크게 늘렸을 때, 앱 화면이 600x1000 박스 내에 유지되는지 확인. + - `BottomNavigationBar`가 전체 너비로 늘어나지 않고 앱 너비에 맞춰지는지 확인. + - 플로팅 텍스트가 캐릭터 위치에 정확히 뜨는지 확인. diff --git a/prompt/31_item_type_icons.md b/prompt/31_item_type_icons.md new file mode 100644 index 0000000..2f3b6eb --- /dev/null +++ b/prompt/31_item_type_icons.md @@ -0,0 +1,72 @@ +# 33. 아이템 타입별 아이콘 및 색상 적용 (Item Type Icons & Colors) + +## 1. 배경 (Background) + +사용자는 아이템의 종류(무기, 방패, 갑옷, 장신구)를 직관적으로 구별할 수 있도록, 인벤토리와 아이템 획득 화면에서 특정 아이콘과 색상을 적용하기를 원합니다. + +## 2. 목표 (Objective) + +- **아이콘 및 색상 규칙 적용:** + - **무기 (Weapon):** 빨간색 삼각형 (`Icons.change_history`, `Colors.red`) + - **방패 (Shield):** 파란색 방패 (`Icons.shield`, `Colors.blue`) + - **갑옷 (Armor):** 파란색 옷 (`Icons.checkroom`, `Colors.blue`) + - **장신구 (Accessory):** 보라색 다이아몬드 (`Icons.diamond`, `Colors.purple`) +- **적용 범위:** + - **InventoryScreen:** 장착 슬롯 및 가방(Bag) 아이템. + - **BattleScreen:** 승리 후 보상 선택 팝업. + +## 3. 작업 상세 (Implementation Details) + +### A. `lib/utils/item_utils.dart` 생성 (또는 기존 유틸 활용) + +- 아이템 아이콘과 색상을 반환하는 헬퍼 함수 작성. + +```dart +import 'package:flutter/material.dart'; +import '../game/enums.dart'; +import '../game/model/item.dart'; + +class ItemUtils { + static IconData getIcon(EquipmentSlot slot) { + switch (slot) { + case EquipmentSlot.weapon: + return Icons.change_history; // Triangle + case EquipmentSlot.shield: + return Icons.shield; + case EquipmentSlot.armor: + return Icons.checkroom; + case EquipmentSlot.accessory: + return Icons.diamond; + } + } + + static Color getColor(EquipmentSlot slot) { + switch (slot) { + case EquipmentSlot.weapon: + return Colors.red; + case EquipmentSlot.shield: + case EquipmentSlot.armor: + return Colors.blue; + case EquipmentSlot.accessory: + return Colors.purple; + } + } +} +``` + +### B. `lib/screens/inventory_screen.dart` 수정 + +- `_getIconForSlot` 메서드 제거 또는 `ItemUtils` 사용으로 대체. +- 장착 슬롯(`Equipped Items`)의 아이콘/색상 변경. +- 가방(`Bag`) 그리드 아이템의 아이콘을 `Icons.backpack`에서 해당 아이템의 타입 아이콘으로 변경. + +### C. `lib/screens/battle_screen.dart` 수정 + +- 보상 팝업(`SimpleDialogOption`)에 아이템 아이콘 추가 (텍스트 옆 또는 위). + +## 4. 검증 (Verification) + +- **Inventory Test:** + - 인벤토리 진입 시 장착된 아이템과 가방의 아이템이 지정된 아이콘/색상으로 표시되는지 확인. +- **Battle Reward Test:** + - 전투 승리 후 보상 목록에 아이템 아이콘이 올바르게 표시되는지 확인.