diff --git a/assets/data/enemies.json b/assets/data/enemies.json index 994f557..5fa3236 100644 --- a/assets/data/enemies.json +++ b/assets/data/enemies.json @@ -5,35 +5,40 @@ "baseHp": 20, "baseAtk": 5, "baseDefense": 5, - "image": "assets/images/enemies/goblin.png" + "image": "assets/images/enemies/goblin.png", + "equipment": ["rusty_dagger"] }, { "name": "Slime", "baseHp": 30, "baseAtk": 3, "baseDefense": 5, - "image": "assets/images/enemies/slime.png" + "image": "assets/images/enemies/slime.png", + "equipment": ["rusty_dagger"] }, { "name": "Wolf", "baseHp": 25, "baseAtk": 7, "baseDefense": 5, - "image": "assets/images/enemies/wolf.png" + "image": "assets/images/enemies/wolf.png", + "equipment": ["rusty_dagger"] }, { "name": "Bandit", "baseHp": 35, "baseAtk": 6, "baseDefense": 5, - "image": "assets/images/enemies/bandit.png" + "image": "assets/images/enemies/bandit.png", + "equipment": ["rusty_dagger"] }, { "name": "Skeleton", "baseHp": 15, "baseAtk": 8, "baseDefense": 5, - "image": "assets/images/enemies/skeleton.png" + "image": "assets/images/enemies/skeleton.png", + "equipment": ["rusty_dagger"] } ], "elite": [ @@ -42,21 +47,24 @@ "baseHp": 60, "baseAtk": 12, "baseDefense": 3, - "image": "assets/images/enemies/orc_warrior.png" + "image": "assets/images/enemies/orc_warrior.png", + "equipment": ["battle_axe", "leather_vest"] }, { "name": "Giant Spider", "baseHp": 50, "baseAtk": 15, "baseDefense": 2, - "image": "assets/images/enemies/giant_spider.png" + "image": "assets/images/enemies/giant_spider.png", + "equipment": ["jagged_dagger"] }, { "name": "Dark Knight", "baseHp": 80, "baseAtk": 10, "baseDefense": 5, - "image": "assets/images/enemies/dark_knight.png" + "image": "assets/images/enemies/dark_knight.png", + "equipment": ["stunning_hammer", "kite_shield"] } ] } diff --git a/assets/data/items.json b/assets/data/items.json index 5e917d1..0951a5e 100644 --- a/assets/data/items.json +++ b/assets/data/items.json @@ -1,6 +1,7 @@ { "weapons": [ { + "id": "rusty_dagger", "name": "Rusty Dagger", "description": "Old and rusty, but better than nothing.", "baseAtk": 3, @@ -9,6 +10,7 @@ "image": "assets/images/items/rusty_dagger.png" }, { + "id": "iron_sword", "name": "Iron Sword", "description": "A standard soldier's sword.", "baseAtk": 8, @@ -17,6 +19,7 @@ "image": "assets/images/items/iron_sword.png" }, { + "id": "battle_axe", "name": "Battle Axe", "description": "Heavy but powerful.", "baseAtk": 12, @@ -25,6 +28,7 @@ "image": "assets/images/items/battle_axe.png" }, { + "id": "stunning_hammer", "name": "Stunning Hammer", "description": "A heavy hammer that can stun foes.", "baseAtk": 10, @@ -40,6 +44,7 @@ ] }, { + "id": "jagged_dagger", "name": "Jagged Dagger", "description": "A cruel dagger that causes bleeding.", "baseAtk": 7, @@ -56,6 +61,7 @@ ] }, { + "id": "sunderer_axe", "name": "Sunderer Axe", "description": "An axe that exposes enemy weaknesses.", "baseAtk": 11, @@ -73,6 +79,7 @@ ], "armors": [ { + "id": "torn_tunic", "name": "Torn Tunic", "description": "Offers minimal protection.", "baseHp": 10, @@ -81,6 +88,7 @@ "image": "assets/images/items/torn_tunic.png" }, { + "id": "leather_vest", "name": "Leather Vest", "description": "Light and flexible.", "baseHp": 30, @@ -89,6 +97,7 @@ "image": "assets/images/items/leather_vest.png" }, { + "id": "chainmail", "name": "Chainmail", "description": "Reliable protection against cuts.", "baseHp": 60, @@ -99,6 +108,7 @@ ], "shields": [ { + "id": "pot_lid", "name": "Pot Lid", "description": "It was used for cooking.", "baseArmor": 1, @@ -107,6 +117,7 @@ "image": "assets/images/items/pot_lid.png" }, { + "id": "wooden_shield", "name": "Wooden Shield", "description": "Sturdy oak wood.", "baseArmor": 3, @@ -115,6 +126,7 @@ "image": "assets/images/items/wooden_shield.png" }, { + "id": "kite_shield", "name": "Kite Shield", "description": "Used by knights.", "baseArmor": 6, @@ -123,6 +135,7 @@ "image": "assets/images/items/kite_shield.png" }, { + "id": "cursed_shield", "name": "Cursed Shield", "description": "A shield that prevents the wielder from defending themselves.", "baseArmor": 5, @@ -140,6 +153,7 @@ ], "accessories": [ { + "id": "old_ring", "name": "Old Ring", "description": "A tarnished ring.", "baseAtk": 1, @@ -149,6 +163,7 @@ "image": "assets/images/items/old_ring.png" }, { + "id": "copper_ring", "name": "Copper Ring", "description": "A simple ring", "baseAtk": 1, @@ -158,6 +173,7 @@ "image": "assets/images/items/copper_ring.png" }, { + "id": "ruby_amulet", "name": "Ruby Amulet", "description": "Glows with a faint red light.", "baseAtk": 3, @@ -167,6 +183,7 @@ "image": "assets/images/items/ruby_amulet.png" }, { + "id": "heros_badge", "name": "Hero's Badge", "description": "A badge of honor.", "baseAtk": 5, diff --git a/assets/data/players.json b/assets/data/players.json new file mode 100644 index 0000000..17f8e4d --- /dev/null +++ b/assets/data/players.json @@ -0,0 +1,11 @@ +[ + { + "id": "warrior", + "name": "Warrior", + "description": "A balanced fighter with a sword and shield. Great for beginners.", + "baseHp": 50, + "baseAtk": 5, + "baseDefense": 5, + "image": "assets/images/players/warrior.png" + } +] diff --git a/img_ref/02_err_battle_screen.jpg b/img_ref/02_err_battle_screen.jpg new file mode 100644 index 0000000..eb3c791 Binary files /dev/null and b/img_ref/02_err_battle_screen.jpg differ diff --git a/img_ref/03_err_inventory_screen.jpg b/img_ref/03_err_inventory_screen.jpg new file mode 100644 index 0000000..eee5ac4 Binary files /dev/null and b/img_ref/03_err_inventory_screen.jpg differ diff --git a/lib/game/data/enemy_table.dart b/lib/game/data/enemy_table.dart index 5448b77..2189338 100644 --- a/lib/game/data/enemy_table.dart +++ b/lib/game/data/enemy_table.dart @@ -2,12 +2,15 @@ import 'dart:convert'; import 'package:flutter/services.dart'; import '../model/entity.dart'; +import 'item_table.dart'; + class EnemyTemplate { final String name; final int baseHp; final int baseAtk; final int baseDefense; final String? image; + final List equipmentIds; const EnemyTemplate({ required this.name, @@ -15,6 +18,7 @@ class EnemyTemplate { required this.baseAtk, required this.baseDefense, this.image, + this.equipmentIds = const [], }); factory EnemyTemplate.fromJson(Map json) { @@ -24,6 +28,7 @@ class EnemyTemplate { baseAtk: json['baseAtk'] ?? 1, baseDefense: json['baseDefense'] ?? 0, image: json['image'], + equipmentIds: (json['equipment'] as List?)?.cast() ?? [], ); } @@ -33,7 +38,7 @@ class EnemyTemplate { int scaledAtk = baseAtk + (stage - 1); int scaledDefense = baseDefense + (stage ~/ 5); // +1 defense every 5 stages - return Character( + final character = Character( name: name, maxHp: scaledHp, atk: scaledAtk, @@ -41,6 +46,20 @@ class EnemyTemplate { armor: 0, image: image, ); + + // Equip items + for (final itemId in equipmentIds) { + final itemTemplate = ItemTable.get(itemId); + if (itemTemplate != null) { + // Create item scaled to stage (optional, currently stage 1) + // Enemies might get stronger items at higher stages + final item = itemTemplate.createItem(stage: stage); + character.addToInventory(item); + character.equip(item); + } + } + + return character; } } diff --git a/lib/game/data/item_table.dart b/lib/game/data/item_table.dart index 49e8f28..6fd5456 100644 --- a/lib/game/data/item_table.dart +++ b/lib/game/data/item_table.dart @@ -4,6 +4,7 @@ import '../model/item.dart'; import '../enums.dart'; class ItemTemplate { + final String id; final String name; final String description; final int baseAtk; @@ -15,6 +16,7 @@ class ItemTemplate { final String? image; const ItemTemplate({ + required this.id, required this.name, required this.description, this.baseAtk = 0, @@ -28,6 +30,9 @@ class ItemTemplate { factory ItemTemplate.fromJson(Map json) { return ItemTemplate( + id: + json['id'] ?? + json['name'], // Fallback to name if id is missing (for backward compatibility during dev) name: json['name'], description: json['description'], baseAtk: json['baseAtk'] ?? 0, @@ -60,6 +65,7 @@ class ItemTemplate { } return Item( + id: id, name: "$name${stage > 1 ? ' +${stage - 1}' : ''}", // Append +1, +2 etc. description: description, atkBonus: scaledAtk, @@ -105,4 +111,12 @@ class ItemTable { ...shields, ...accessories, ]; + + static ItemTemplate? get(String id) { + try { + return allItems.firstWhere((item) => item.id == id); + } catch (e) { + return null; + } + } } diff --git a/lib/game/data/player_table.dart b/lib/game/data/player_table.dart new file mode 100644 index 0000000..50bd24d --- /dev/null +++ b/lib/game/data/player_table.dart @@ -0,0 +1,66 @@ +import 'dart:convert'; +import 'package:flutter/services.dart'; +import '../model/entity.dart'; + +class PlayerTemplate { + final String id; + final String name; + final String description; + final int baseHp; + final int baseAtk; + final int baseDefense; + final String? image; + + const PlayerTemplate({ + required this.id, + required this.name, + required this.description, + required this.baseHp, + required this.baseAtk, + required this.baseDefense, + this.image, + }); + + factory PlayerTemplate.fromJson(Map json) { + return PlayerTemplate( + id: json['id'], + name: json['name'], + description: json['description'], + baseHp: json['baseHp'], + baseAtk: json['baseAtk'], + baseDefense: json['baseDefense'], + image: json['image'], + ); + } + + Character createCharacter() { + return Character( + name: name, + maxHp: baseHp, + atk: baseAtk, + baseDefense: baseDefense, + armor: 0, + ); + } +} + +class PlayerTable { + static List players = []; + + static Future load() async { + final String jsonString = await rootBundle.loadString( + 'assets/data/players.json', + ); + final List data = jsonDecode(jsonString); + + players = data.map((e) => PlayerTemplate.fromJson(e)).toList(); + } + + static PlayerTemplate? get(String id) { + try { + return players.firstWhere((player) => player.id == id); + } catch (e) { + return null; + } + } +} diff --git a/lib/game/model/item.dart b/lib/game/model/item.dart index 4719589..bb139c2 100644 --- a/lib/game/model/item.dart +++ b/lib/game/model/item.dart @@ -36,6 +36,7 @@ class ItemEffect { } class Item { + final String id; // Unique identifier final String name; final String description; final int atkBonus; @@ -47,6 +48,7 @@ class Item { final String? image; // New: Image path Item({ + required this.id, required this.name, required this.description, required this.atkBonus, diff --git a/lib/main.dart b/lib/main.dart index e59ab58..022134d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'game/data/item_table.dart'; import 'game/data/enemy_table.dart'; +import 'game/data/player_table.dart'; import 'providers/battle_provider.dart'; import 'screens/main_menu_screen.dart'; @@ -9,6 +10,7 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); await ItemTable.load(); await EnemyTable.load(); + await PlayerTable.load(); runApp(const MyApp()); } diff --git a/lib/providers/battle_provider.dart b/lib/providers/battle_provider.dart index fc0a57c..dedf429 100644 --- a/lib/providers/battle_provider.dart +++ b/lib/providers/battle_provider.dart @@ -7,7 +7,9 @@ import '../game/model/item.dart'; import '../game/model/status_effect.dart'; import '../game/model/stage.dart'; import '../game/data/item_table.dart'; + import '../game/data/enemy_table.dart'; +import '../game/data/player_table.dart'; import '../utils/game_math.dart'; import '../game/enums.dart'; import '../game/model/damage_event.dart'; // DamageEvent import @@ -70,16 +72,24 @@ class BattleProvider with ChangeNotifier { void initializeBattle() { stage = 1; turnCount = 1; - player = Character( - name: "Player", - maxHp: 80, - armor: 0, - atk: 5, - baseDefense: 5, - ); + // Load player from PlayerTable + final playerTemplate = PlayerTable.get("warrior"); + if (playerTemplate != null) { + player = playerTemplate.createCharacter(); + } else { + // Fallback if data is missing + player = Character( + name: "Player", + maxHp: 50, + armor: 0, + atk: 5, + baseDefense: 5, + ); + } // Provide starter equipment final starterSword = Item( + id: "starter_sword", name: "Wooden Sword", description: "A basic sword", atkBonus: 5, @@ -87,6 +97,7 @@ class BattleProvider with ChangeNotifier { slot: EquipmentSlot.weapon, ); final starterArmor = Item( + id: "starter_armor", name: "Leather Armor", description: "Basic protection", atkBonus: 0, @@ -94,6 +105,7 @@ class BattleProvider with ChangeNotifier { slot: EquipmentSlot.armor, ); final starterShield = Item( + id: "starter_shield", name: "Wooden Shield", description: "A small shield", atkBonus: 0, @@ -102,6 +114,7 @@ class BattleProvider with ChangeNotifier { slot: EquipmentSlot.shield, ); final starterRing = Item( + id: "starter_ring", name: "Copper Ring", description: "A simple ring", atkBonus: 1, @@ -436,6 +449,9 @@ class BattleProvider with ChangeNotifier { _applyDamage(player, damageToHp, targetType: DamageTarget.player); _addLog("Enemy dealt $damageToHp damage to Player HP."); } + + // Try applying status effects from enemy equipment + _tryApplyStatusEffects(enemy, player); } else { _addLog("Enemy's ${intent.risk.name} attack missed!"); _effectEventController.sink.add( diff --git a/lib/screens/battle_screen.dart b/lib/screens/battle_screen.dart index d26ff68..5f1f6d8 100644 --- a/lib/screens/battle_screen.dart +++ b/lib/screens/battle_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.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'; @@ -9,6 +9,10 @@ import '../game/model/effect_event.dart'; import 'dart:async'; import '../widgets/responsive_container.dart'; import '../utils/item_utils.dart'; +import '../widgets/battle/character_status_card.dart'; +import '../widgets/battle/battle_log_overlay.dart'; +import '../widgets/battle/floating_battle_texts.dart'; +import '../widgets/battle/stage_ui.dart'; class BattleScreen extends StatefulWidget { const BattleScreen({super.key}); @@ -18,25 +22,19 @@ class BattleScreen extends StatefulWidget { } class _BattleScreenState extends State { - final ScrollController _scrollController = ScrollController(); - final List<_DamageTextData> _floatingDamageTexts = []; - final List<_FloatingEffectData> _floatingEffects = []; - final List<_FeedbackTextData> _floatingFeedbackTexts = []; + final List _floatingDamageTexts = []; + final List _floatingEffects = []; + final List _floatingFeedbackTexts = []; StreamSubscription? _damageSubscription; StreamSubscription? _effectSubscription; final GlobalKey _playerKey = GlobalKey(); final GlobalKey _enemyKey = GlobalKey(); final GlobalKey _stackKey = GlobalKey(); + bool _showLogs = true; @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollController.hasClients) { - _scrollController.jumpTo(_scrollController.position.maxScrollExtent); - } - }); - final battleProvider = context.read(); _damageSubscription = battleProvider.damageStream.listen( _addFloatingDamageText, @@ -48,7 +46,6 @@ class _BattleScreenState extends State { @override void dispose() { - _scrollController.dispose(); _damageSubscription?.cancel(); _effectSubscription?.cancel(); super.dispose(); @@ -82,12 +79,12 @@ class _BattleScreenState extends State { setState(() { _floatingDamageTexts.add( - _DamageTextData( + DamageTextData( id: id, widget: Positioned( left: position.dx, top: position.dy, - child: _FloatingDamageText( + child: FloatingDamageText( key: ValueKey(id), damage: event.damage.toString(), color: event.color, @@ -153,12 +150,12 @@ class _BattleScreenState extends State { final String id = UniqueKey().toString(); setState(() { _floatingFeedbackTexts.add( - _FeedbackTextData( + FeedbackTextData( id: id, widget: Positioned( left: position.dx, top: position.dy, - child: _FloatingFeedbackText( + child: FloatingFeedbackText( key: ValueKey(id), feedback: feedbackText, color: feedbackColor, @@ -213,12 +210,12 @@ class _BattleScreenState extends State { setState(() { _floatingEffects.add( - _FloatingEffectData( + FloatingEffectData( id: id, widget: Positioned( left: position.dx, top: position.dy, - child: _FloatingEffect( + child: FloatingEffect( key: ValueKey(id), icon: icon, color: color, @@ -295,13 +292,6 @@ class _BattleScreenState extends State { onPressed: () { context.read().playerAction(actionType, risk); Navigator.pop(context); - WidgetsBinding.instance.addPostFrameCallback((_) { - _scrollController.animateTo( - _scrollController.position.maxScrollExtent, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - ); - }); }, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -329,15 +319,18 @@ class _BattleScreenState extends State { child: Consumer( builder: (context, battleProvider, child) { if (battleProvider.currentStage.type == StageType.shop) { - return _buildShopUI(context, battleProvider); + return ShopUI(battleProvider: battleProvider); } else if (battleProvider.currentStage.type == StageType.rest) { - return _buildRestUI(context, battleProvider); + return RestUI(battleProvider: battleProvider); } return Stack( key: _stackKey, children: [ + // 1. Background (Black) Container(color: Colors.black87), + + // 2. Battle Content (Top Bar + Characters) Column( children: [ // Top Bar @@ -346,125 +339,135 @@ class _BattleScreenState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - "Stage ${battleProvider.stage}", - style: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + "Stage ${battleProvider.stage}", + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), ), ), - Text( - "Turn ${battleProvider.turnCount}", - style: const TextStyle( - color: Colors.white, - fontSize: 18, + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + "Turn ${battleProvider.turnCount}", + style: const TextStyle( + color: Colors.white, + fontSize: 18, + ), + ), ), ), ], ), ), - // Battle Area + // Battle Area (Characters) - Expanded to fill available space Expanded( child: Padding( - // padding: const EdgeInsets.symmetric(horizontal: 40.0), - padding: const EdgeInsets.all(70.0), - child: Column( + padding: const EdgeInsets.all(16.0), + child: Stack( children: [ - // 적 영역 (우측 상단) - Expanded( - child: Align( - alignment: Alignment.topRight, - child: _buildCharacterStatus( - battleProvider.enemy, - isPlayer: false, - isTurn: !battleProvider.isPlayerTurn, - key: _enemyKey, - ), + // Enemy (Top Right) + Positioned( + top: 0, + right: 0, + child: CharacterStatusCard( + character: battleProvider.enemy, + isPlayer: false, + isTurn: !battleProvider.isPlayerTurn, + key: _enemyKey, ), ), - // 플레이어 영역 (좌측 하단) - Expanded( - child: Align( - alignment: Alignment.bottomLeft, - child: _buildCharacterStatus( - battleProvider.player, - isPlayer: true, - isTurn: battleProvider.isPlayerTurn, - key: _playerKey, - ), + // Player (Bottom Left) + Positioned( + bottom: 80, // Space for FABs + left: 0, + child: CharacterStatusCard( + character: battleProvider.player, + isPlayer: true, + isTurn: battleProvider.isPlayerTurn, + key: _playerKey, ), ), ], ), ), ), - - // Action Buttons - Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - 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, - ), - ], - ), - ), - ), - ], - ), - ), ], ), + + // 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, @@ -519,9 +522,11 @@ class _BattleScreenState extends State { ), ), ), + + // Floating Effects ..._floatingDamageTexts.map((e) => e.widget), ..._floatingEffects.map((e) => e.widget), - ..._floatingFeedbackTexts.map((e) => e.widget), // 새로운 피드백 텍스트 추가 + ..._floatingFeedbackTexts.map((e) => e.widget), ], ); }, @@ -529,49 +534,6 @@ 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"); @@ -605,435 +567,21 @@ class _BattleScreenState extends State { ); } - Widget _buildCharacterStatus( - Character character, { - 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( - 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( - width: 100, // 임시 크기 - height: 100, // 임시 크기 - decoration: BoxDecoration( - color: isPlayer - ? Colors.lightBlue - : Colors.deepOrange, // 플레이어/적 구분 색상 - borderRadius: BorderRadius.circular(8), - ), - child: Center( - child: isPlayer - ? const Icon( - Icons.person, - size: 60, - color: Colors.white, - ) // 플레이어 아이콘 - : const Icon( - Icons.psychology, - size: 60, - color: Colors.white, - ), // 적 아이콘 (몬스터 대신) - ), - ), - const SizedBox(height: 8), // 아이콘과 정보 사이 간격 - - if (!isPlayer) - Consumer( - builder: (context, provider, child) { - if (provider.currentEnemyIntent != null && !character.isDead) { - final intent = provider.currentEnemyIntent!; - return Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Container( - padding: const EdgeInsets.all(8.0), - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.redAccent), - ), - child: Column( - children: [ - Text( - "INTENT", - style: TextStyle( - color: Colors.redAccent, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - intent.type == EnemyActionType.attack - ? Icons.flash_on - : Icons.shield, - color: Colors.yellow, - size: 16, - ), - const SizedBox(width: 4), - Text( - intent.description, - style: const TextStyle( - color: Colors.white, - fontSize: 12, - ), - ), - ], - ), - ], - ), - ), - ); - } - return const SizedBox.shrink(); - }, - ), - ], - ); - } - - Widget _buildActionButton( + Widget _buildFloatingActionButton( BuildContext context, - String text, + String label, + IconData icon, + Color color, ActionType actionType, bool isEnabled, ) { - return ElevatedButton( + return FloatingActionButton( + heroTag: label, onPressed: isEnabled ? () => _showRiskLevelSelection(context, actionType) : null, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15), - backgroundColor: Colors.blueGrey, - foregroundColor: Colors.white, - textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - child: Text(text), + backgroundColor: isEnabled ? color : Colors.grey, + child: Icon(icon), ); } } - -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(); - } - }); - } - - @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}); -} - -// 새로운 _FloatingFeedbackText 위젯 -class _FloatingFeedbackText extends StatefulWidget { - final String feedback; - final Color color; - final VoidCallback onRemove; - - const _FloatingFeedbackText({ - Key? key, - required this.feedback, - required this.color, - required this.onRemove, - }) : super(key: key); - - @override - __FloatingFeedbackTextState createState() => __FloatingFeedbackTextState(); -} - -class __FloatingFeedbackTextState extends State<_FloatingFeedbackText> - 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(); - } - }); - } - - @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.feedback, - 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 _FeedbackTextData { - final String id; - final Widget widget; - - _FeedbackTextData({required this.id, required this.widget}); -} diff --git a/lib/screens/character_selection_screen.dart b/lib/screens/character_selection_screen.dart index c3e9ba6..fabb849 100644 --- a/lib/screens/character_selection_screen.dart +++ b/lib/screens/character_selection_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/battle_provider.dart'; +import '../game/data/player_table.dart'; import 'main_wrapper.dart'; import '../widgets/responsive_container.dart'; @@ -9,6 +10,15 @@ class CharacterSelectionScreen extends StatelessWidget { @override Widget build(BuildContext context) { + // Fetch Warrior data + final warrior = PlayerTable.get("warrior"); + + if (warrior == null) { + return const Scaffold( + body: Center(child: Text("Error: Player data not found")), + ); + } + return Scaffold( backgroundColor: Colors.black, // Outer background body: Center( @@ -51,37 +61,43 @@ class CharacterSelectionScreen extends StatelessWidget { color: Colors.blue, ), const SizedBox(height: 16), - const Text( - "Warrior", - style: TextStyle( + Text( + warrior.name, + style: const 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.", + Text( + warrior.description, textAlign: TextAlign.center, - style: TextStyle(color: Colors.grey), + style: const TextStyle(color: Colors.grey), ), const SizedBox(height: 16), const Divider(), const SizedBox(height: 8), - const Row( + Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ Text( - "HP: 80", - style: TextStyle(fontWeight: FontWeight.bold), + "HP: ${warrior.baseHp}", + style: const TextStyle( + fontWeight: FontWeight.bold, + ), ), Text( - "ATK: 5", - style: TextStyle(fontWeight: FontWeight.bold), + "ATK: ${warrior.baseAtk}", + style: const TextStyle( + fontWeight: FontWeight.bold, + ), ), Text( - "DEF: 5", - style: TextStyle(fontWeight: FontWeight.bold), + "DEF: ${warrior.baseDefense}", + style: const TextStyle( + fontWeight: FontWeight.bold, + ), ), ], ), diff --git a/lib/screens/inventory_screen.dart b/lib/screens/inventory_screen.dart index ef89065..0294167 100644 --- a/lib/screens/inventory_screen.dart +++ b/lib/screens/inventory_screen.dart @@ -88,41 +88,73 @@ class InventoryScreen extends StatelessWidget { color: item != null ? Colors.blueGrey[600] : Colors.grey[800], - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - children: [ - Text( + child: Stack( + children: [ + // Slot Name (Top Right) + Positioned( + right: 4, + top: 4, + child: Text( slot.name.toUpperCase(), style: const TextStyle( - fontSize: 10, + fontSize: 8, fontWeight: FontWeight.bold, - color: Colors.grey, + color: Colors.white30, ), ), - const SizedBox(height: 4), - Icon( - ItemUtils.getIcon(slot), - size: 24, - color: item != null - ? ItemUtils.getColor(slot) - : Colors.grey, - ), - const SizedBox(height: 4), - Text( - item?.name ?? "Empty", - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 12, + ), + // Faded Icon (Top Left) + Positioned( + left: 4, + top: 4, + child: Opacity( + opacity: item != null ? 0.2 : 0.1, + child: Icon( + ItemUtils.getIcon(slot), + size: 40, color: item != null - ? Colors.white + ? ItemUtils.getColor(slot) : Colors.grey, ), - overflow: TextOverflow.ellipsis, ), - if (item != null) _buildItemStatText(item), - ], - ), + ), + // Content + Center( + child: Padding( + padding: const EdgeInsets.all(4.0), + child: Column( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + const SizedBox( + height: 12, + ), // Spacing for top elements + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + item?.name ?? "Empty", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: item != null + ? Colors.white + : Colors.grey, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + if (item != null) + FittedBox( + fit: BoxFit.scaleDown, + child: _buildItemStatText(item), + ), + ], + ), + ), + ), + ], ), ), ), @@ -169,24 +201,49 @@ class InventoryScreen extends StatelessWidget { }, child: Card( color: Colors.blueGrey[700], - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + child: Stack( children: [ - Icon( - ItemUtils.getIcon(item.slot), - size: 32, - color: ItemUtils.getColor(item.slot), - ), - Padding( - padding: const EdgeInsets.all(4.0), - child: Text( - item.name, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 10), - overflow: TextOverflow.ellipsis, + // Faded Icon in Top-Left + Positioned( + left: 4, + top: 4, + child: Opacity( + opacity: 0.2, + child: Icon( + ItemUtils.getIcon(item.slot), + size: 40, + color: ItemUtils.getColor(item.slot), + ), + ), + ), + // Centered Content + Center( + child: Padding( + padding: const EdgeInsets.all(4.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + item.name, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + FittedBox( + fit: BoxFit.scaleDown, + child: _buildItemStatText(item), + ), + ], + ), ), ), - _buildItemStatText(item), ], ), ), diff --git a/lib/widgets/battle/battle_log_overlay.dart b/lib/widgets/battle/battle_log_overlay.dart new file mode 100644 index 0000000..87458f3 --- /dev/null +++ b/lib/widgets/battle/battle_log_overlay.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +class BattleLogOverlay extends StatelessWidget { + final List logs; + + const BattleLogOverlay({super.key, required this.logs}); + + @override + Widget build(BuildContext context) { + if (logs.isEmpty) return const SizedBox.shrink(); + + return Container( + padding: const EdgeInsets.all(8.0), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + ), + child: ListView.builder( + reverse: true, + itemCount: logs.length, + itemBuilder: (context, index) { + final logIndex = logs.length - 1 - index; + return Text( + logs[logIndex], + style: const TextStyle(color: Colors.white70, fontSize: 12), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/battle/character_status_card.dart b/lib/widgets/battle/character_status_card.dart new file mode 100644 index 0000000..ba5bc5d --- /dev/null +++ b/lib/widgets/battle/character_status_card.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../game/model/entity.dart'; +import '../../game/enums.dart'; +import '../../providers/battle_provider.dart'; + +class CharacterStatusCard extends StatelessWidget { + final Character character; + final bool isPlayer; + final bool isTurn; + + const CharacterStatusCard({ + super.key, + required this.character, + this.isPlayer = false, + this.isTurn = false, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + "Armor: ${character.armor}", + style: const TextStyle(color: Colors.white), + ), + ), + 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( + width: 100, // 임시 크기 + height: 100, // 임시 크기 + decoration: BoxDecoration( + color: isPlayer + ? Colors.lightBlue + : Colors.deepOrange, // 플레이어/적 구분 색상 + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: isPlayer + ? const Icon( + Icons.person, + size: 60, + color: Colors.white, + ) // 플레이어 아이콘 + : const Icon( + Icons.psychology, + size: 60, + color: Colors.white, + ), // 적 아이콘 (몬스터 대신) + ), + ), + const SizedBox(height: 8), // 아이콘과 정보 사이 간격 + + if (!isPlayer) + Consumer( + builder: (context, provider, child) { + if (provider.currentEnemyIntent != null && !character.isDead) { + final intent = provider.currentEnemyIntent!; + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Container( + padding: const EdgeInsets.all(8.0), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.redAccent), + ), + child: Column( + children: [ + Text( + "INTENT", + style: TextStyle( + color: Colors.redAccent, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + intent.type == EnemyActionType.attack + ? Icons.flash_on + : Icons.shield, + color: Colors.yellow, + size: 16, + ), + const SizedBox(width: 4), + Text( + intent.description, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ], + ), + ], + ), + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ], + ); + } +} diff --git a/lib/widgets/battle/floating_battle_texts.dart b/lib/widgets/battle/floating_battle_texts.dart new file mode 100644 index 0000000..22fc329 --- /dev/null +++ b/lib/widgets/battle/floating_battle_texts.dart @@ -0,0 +1,274 @@ +import 'package:flutter/material.dart'; + +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 + 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(); + } + }); + } + + @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 + 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}); +} + +class FloatingFeedbackText extends StatefulWidget { + final String feedback; + final Color color; + final VoidCallback onRemove; + + const FloatingFeedbackText({ + Key? key, + required this.feedback, + required this.color, + required this.onRemove, + }) : super(key: key); + + @override + FloatingFeedbackTextState createState() => FloatingFeedbackTextState(); +} + +class FloatingFeedbackTextState extends State + 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(); + } + }); + } + + @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.feedback, + 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 FeedbackTextData { + final String id; + final Widget widget; + + FeedbackTextData({required this.id, required this.widget}); +} diff --git a/lib/widgets/battle/stage_ui.dart b/lib/widgets/battle/stage_ui.dart new file mode 100644 index 0000000..a2c823f --- /dev/null +++ b/lib/widgets/battle/stage_ui.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import '../../providers/battle_provider.dart'; + +class ShopUI extends StatelessWidget { + final BattleProvider battleProvider; + + const ShopUI({super.key, required this.battleProvider}); + + @override + Widget build(BuildContext context) { + 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"), + ), + ], + ), + ); + } +} + +class RestUI extends StatelessWidget { + final BattleProvider battleProvider; + + const RestUI({super.key, required this.battleProvider}); + + @override + Widget build(BuildContext context) { + 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)"), + ), + ], + ), + ); + } +} diff --git a/prompt/00_project_context_restore.md b/prompt/00_project_context_restore.md index 5bd5d46..a2d0041 100644 --- a/prompt/00_project_context_restore.md +++ b/prompt/00_project_context_restore.md @@ -20,6 +20,7 @@ - `ResponsiveContainer` 위젯을 통해 최대 너비(600px) 및 높이(1000px) 제한. - 웹/태블릿 환경에서도 모바일 앱처럼 중앙 정렬된 화면 제공. - **Battle UI Layout:** 플레이어(좌측 하단) vs 적(우측 상단) 대각선 구도 배치 및 캐릭터 임시 아이콘 적용. + - **Widget Refactoring:** `BattleScreen`의 주요 UI 컴포넌트(`CharacterStatusCard`, `BattleLogOverlay` 등)를 `lib/widgets/battle/`로 분리하여 모듈화. ### B. 전투 시스템 (`BattleProvider`) @@ -31,6 +32,9 @@ - **선제 방어 (Pre-emptive Defense):** 적이 방어를 선택하면 턴 시작 전에 즉시 방어도가 적용됨. - **Defense Restriction:** `DefenseForbidden` 상태 시 방어 행동 선택 불가. - **Variance Removed:** 적의 공격/방어 수치 계산 시 랜덤 분산(Variance) 제거 (고정 수치). +- **적 장비 시스템 (Enemy Equipment):** + - 적에게 아이템 장착 가능 (`enemies.json`의 `equipment` 필드). + - 장착된 아이템의 스탯 및 특수 효과(상태이상 등)가 전투 시 적용됨. - **시각 효과 (Visual Effects):** - **Floating Text:** 데미지 발생 시 캐릭터 위에 데미지 수치가 떠오름 (일반: 빨강, 출혈: 보라, 취약: 주황). - **Action Feedback:** 공격 빗나감(MISS - 적 위치/회색) 및 방어 실패(FAILED - 내 위치/빨강) 텍스트 오버레이. @@ -39,8 +43,8 @@ ### C. 데이터 주도 설계 (Data-Driven Design) -- **JSON 데이터:** `assets/data/items.json`, `assets/data/enemies.json`. -- **데이터 로더:** `ItemTable`, `EnemyTable`. +- **JSON 데이터:** `assets/data/items.json` (ID 포함), `assets/data/enemies.json` (장비 포함). +- **데이터 로더:** `ItemTable` (ID 조회 지원), `EnemyTable` (장비 장착 지원). ### D. 아이템 및 경제 (`Item`, `Inventory`) @@ -65,13 +69,15 @@ - **Streams:** `damageStream`, `effectStream`을 통해 UI(`BattleScreen`)에 비동기 이벤트 전달. - **`lib/game/enums.dart`:** 프로젝트 전반의 Enum 통합 관리 (`ActionType`, `RiskLevel`, `StageType` 등). - **`lib/utils/item_utils.dart`:** 아이템 타입별 아이콘 및 색상 로직 중앙화. +- **`lib/widgets/battle/`:** `BattleScreen`에서 분리된 재사용 가능한 위젯들 (`CharacterStatusCard`, `BattleLogOverlay`, `FloatingBattleTexts`, `StageUI`). - **`lib/widgets/responsive_container.dart`:** 반응형 레이아웃 컨테이너. - **`lib/game/model/`:** - `damage_event.dart`, `effect_event.dart`: 이벤트 모델. - `entity.dart`: `Character` (Player/Enemy). - - `item.dart`: `Item`. + - `item.dart`: `Item` (ID 필드 포함). - **`lib/screens/battle_screen.dart`:** - `StreamSubscription`을 통해 이펙트 이벤트 수신 및 `Overlay` 애니메이션 렌더링. + - `Stack` 및 `Positioned` 기반의 정교한 레이아웃. ## 4. 작업 컨벤션 (Working Conventions) diff --git a/prompt/34_battle_screen_refactor.md b/prompt/34_battle_screen_refactor.md new file mode 100644 index 0000000..df5cb3f --- /dev/null +++ b/prompt/34_battle_screen_refactor.md @@ -0,0 +1,28 @@ +# 배틀 화면 위젯 리팩토링 + +## 목표 + +UI 컴포넌트를 별도의 위젯으로 추출하여 `BattleScreen`의 코드 유지보수성, 가독성 및 구조를 개선합니다. + +## 변경 사항 + +1. **디렉토리 구조:** + + - `lib/widgets/battle/` 디렉토리를 생성했습니다. + +2. **추출된 위젯:** + + - `CharacterStatusCard` (`lib/widgets/battle/character_status_card.dart`): 캐릭터 스탯(HP, 방어도, 공격력, 방어력), 체력 바, 상태 효과 및 의도를 표시합니다. + - `BattleLogOverlay` (`lib/widgets/battle/battle_log_overlay.dart`): 스크롤 가능한 배틀 로그 목록을 표시합니다. + - `FloatingBattleTexts` (`lib/widgets/battle/floating_battle_texts.dart`): 애니메이션 시각 효과를 위한 `FloatingDamageText`, `FloatingEffect`, `FloatingFeedbackText`를 포함합니다. + - `StageUI` (`lib/widgets/battle/stage_ui.dart`): 비전투 스테이지를 위한 `ShopUI`와 `RestUI`를 포함합니다. + +3. **BattleScreen 업데이트:** + - 추출된 위젯을 임포트하고 사용하도록 `lib/screens/battle_screen.dart`를 리팩토링했습니다. + - 인라인 위젯 빌드 메서드(`_buildCharacterStatus`, `_buildShopUI`, `_buildRestUI` 등)와 내부 클래스(`_FloatingDamageText` 등)를 제거했습니다. + +## 이점 + +- **복잡도 감소:** `BattleScreen`은 이제 레이아웃과 상태 관리에 집중합니다. +- **재사용성:** 위젯은 필요에 따라 앱의 다른 부분에서 재사용할 수 있습니다. +- **유지보수성:** 특정 UI 요소를 찾고 수정하기가 더 쉬워졌습니다. diff --git a/prompt/35_enemy_equipment_system.md b/prompt/35_enemy_equipment_system.md new file mode 100644 index 0000000..f757b70 --- /dev/null +++ b/prompt/35_enemy_equipment_system.md @@ -0,0 +1,27 @@ +# 적 장비 시스템 구현 (Enemy Equipment System) + +## 목표 + +적에게 아이템을 장착시켜 전투의 다양성을 높이고, 아이템 데이터 구조를 개선합니다. + +## 주요 변경 사항 + +### 1. 데이터 구조 개선 + +- **`assets/data/items.json`**: 모든 아이템에 고유 `id` 필드를 추가했습니다. (예: `"id": "rusty_dagger"`) +- **`assets/data/enemies.json`**: 적 정보에 `equipment` 필드(아이템 ID 리스트)를 추가했습니다. (예: `Goblin`은 `rusty_dagger` 장착) + +### 2. 게임 로직 업데이트 + +- **`lib/game/model/item.dart`**: `Item` 클래스에 `id` 필드를 추가했습니다. +- **`lib/game/data/item_table.dart`**: ID로 아이템을 조회하는 `get(String id)` 메서드를 구현했습니다. +- **`lib/game/data/enemy_table.dart`**: 적 생성(`createCharacter`) 시 `equipment` 필드에 명시된 아이템을 자동으로 인벤토리에 추가하고 장착하도록 수정했습니다. +- **`lib/providers/battle_provider.dart`**: 초기 플레이어 지급 아이템 생성 시 `id`를 포함하도록 수정했습니다. + +### 3. 버그 수정 + +- **`lib/screens/battle_screen.dart`**: `ScrollController`가 연결되지 않아 발생하던 에러를 수정했습니다. (불필요한 컨트롤러 제거) + +## 결과 + +이제 `enemies.json` 설정만으로 적에게 다양한 장비를 입혀 스탯과 특수 효과(출혈, 스턴 등)를 부여할 수 있습니다. diff --git a/prompt/36_centralize_player_data.md b/prompt/36_centralize_player_data.md new file mode 100644 index 0000000..f45e6d6 --- /dev/null +++ b/prompt/36_centralize_player_data.md @@ -0,0 +1,35 @@ +# 플레이어 데이터 중앙화 (Centralize Player Data) + +## 목표 + +캐릭터 선택 화면과 전투 시스템(`BattleProvider`)에서 사용하는 플레이어 스탯이 하드코딩되어 불일치하는 문제를 해결하기 위해, 플레이어 데이터를 JSON 파일로 중앙화하여 관리합니다. + +## 주요 변경 사항 + +### 1. 데이터 구조 추가 + +- **`assets/data/players.json`**: 플레이어 템플릿 데이터를 정의했습니다. + ```json + [ + { + "id": "warrior", + "name": "Warrior", + "description": "A balanced fighter...", + "baseHp": 50, + "baseAtk": 5, + "baseDefense": 5, + ... + } + ] + ``` +- **`lib/game/data/player_table.dart`**: `players.json`을 로드하고 파싱하는 `PlayerTable` 클래스를 구현했습니다. + +### 2. 게임 로직 업데이트 + +- **`lib/main.dart`**: 앱 시작 시 `PlayerTable.load()`를 호출하여 데이터를 메모리에 적재합니다. +- **`lib/screens/character_selection_screen.dart`**: 하드코딩된 텍스트 대신 `PlayerTable.get("warrior")`를 사용하여 UI를 렌더링합니다. +- **`lib/providers/battle_provider.dart`**: 전투 초기화(`initializeBattle`) 시 `PlayerTable`에서 캐릭터 정보를 가져와 생성합니다. + +## 결과 + +이제 `players.json` 파일만 수정하면 게임 내 모든 곳(선택 화면, 전투 시작 스탯 등)에 일관되게 반영됩니다. diff --git a/test/character_test.dart b/test/character_test.dart index 65e128a..0333414 100644 --- a/test/character_test.dart +++ b/test/character_test.dart @@ -13,6 +13,7 @@ void main() { setUp(() { player = Character(name: "TestPlayer", maxHp: 100, armor: 0, atk: 10); armorHp50 = Item( + id: "armor_hp_50", name: "Armor +50", description: "HP +50", atkBonus: 0, @@ -21,6 +22,7 @@ void main() { price: 100, ); armorHp100 = Item( + id: "armor_hp_100", name: "Armor +100", description: "HP +100", atkBonus: 0, @@ -29,6 +31,7 @@ void main() { price: 200, ); armorHp20 = Item( + id: "armor_hp_20", name: "Armor +20", description: "HP +20", atkBonus: 0,