From 457eed4d3e63aa3ca25fe66eb96ec50004ebb9da Mon Sep 17 00:00:00 2001 From: Horoli Date: Tue, 2 Dec 2025 17:28:16 +0900 Subject: [PATCH] update --- assets/data/enemies.json | 54 ++++++ assets/data/items.json | 146 ++++++++++++++++ lib/game/data/enemy_table.dart | 60 +++++++ lib/game/data/item_table.dart | 183 ++++---------------- lib/game/model/item.dart | 13 +- lib/main.dart | 13 +- lib/providers/battle_provider.dart | 199 ++++++++++++++++++---- lib/screens/battle_screen.dart | 52 ++++++ prompt/21_refactor_item_system_to_json.md | 34 ++++ prompt/22_refactor_enemy_system.md | 64 +++++++ pubspec.yaml | 5 +- test/enemy_intent_test.dart | 44 +++++ test/enemy_load_test.dart | 24 +++ test/item_load_test.dart | 40 +++++ 14 files changed, 740 insertions(+), 191 deletions(-) create mode 100644 assets/data/enemies.json create mode 100644 assets/data/items.json create mode 100644 lib/game/data/enemy_table.dart create mode 100644 prompt/21_refactor_item_system_to_json.md create mode 100644 prompt/22_refactor_enemy_system.md create mode 100644 test/enemy_intent_test.dart create mode 100644 test/enemy_load_test.dart create mode 100644 test/item_load_test.dart diff --git a/assets/data/enemies.json b/assets/data/enemies.json new file mode 100644 index 0000000..8048da8 --- /dev/null +++ b/assets/data/enemies.json @@ -0,0 +1,54 @@ +{ + "normal": [ + { + "name": "Goblin", + "baseHp": 20, + "baseAtk": 5, + "baseDefense": 0 + }, + { + "name": "Slime", + "baseHp": 30, + "baseAtk": 3, + "baseDefense": 1 + }, + { + "name": "Wolf", + "baseHp": 25, + "baseAtk": 7, + "baseDefense": 0 + }, + { + "name": "Bandit", + "baseHp": 35, + "baseAtk": 6, + "baseDefense": 1 + }, + { + "name": "Skeleton", + "baseHp": 15, + "baseAtk": 8, + "baseDefense": 0 + } + ], + "elite": [ + { + "name": "Orc Warrior", + "baseHp": 60, + "baseAtk": 12, + "baseDefense": 3 + }, + { + "name": "Giant Spider", + "baseHp": 50, + "baseAtk": 15, + "baseDefense": 2 + }, + { + "name": "Dark Knight", + "baseHp": 80, + "baseAtk": 10, + "baseDefense": 5 + } + ] +} diff --git a/assets/data/items.json b/assets/data/items.json new file mode 100644 index 0000000..a8ffdba --- /dev/null +++ b/assets/data/items.json @@ -0,0 +1,146 @@ +{ + "weapons": [ + { + "name": "Rusty Dagger", + "description": "Old and rusty, but better than nothing.", + "baseAtk": 3, + "slot": "weapon" + }, + { + "name": "Iron Sword", + "description": "A standard soldier's sword.", + "baseAtk": 8, + "slot": "weapon" + }, + { + "name": "Battle Axe", + "description": "Heavy but powerful.", + "baseAtk": 12, + "slot": "weapon" + }, + { + "name": "Stunning Hammer", + "description": "A heavy hammer that can stun foes.", + "baseAtk": 10, + "slot": "weapon", + "effects": [ + { + "type": "stun", + "probability": 20, + "duration": 1 + } + ] + }, + { + "name": "Jagged Dagger", + "description": "A cruel dagger that causes bleeding.", + "baseAtk": 7, + "slot": "weapon", + "effects": [ + { + "type": "bleed", + "probability": 30, + "duration": 3, + "value": 5 + } + ] + }, + { + "name": "Sunderer Axe", + "description": "An axe that exposes enemy weaknesses.", + "baseAtk": 11, + "slot": "weapon", + "effects": [ + { + "type": "vulnerable", + "probability": 100, + "duration": 2 + } + ] + } + ], + "armors": [ + { + "name": "Torn Tunic", + "description": "Offers minimal protection.", + "baseHp": 10, + "slot": "armor" + }, + { + "name": "Leather Vest", + "description": "Light and flexible.", + "baseHp": 30, + "slot": "armor" + }, + { + "name": "Chainmail", + "description": "Reliable protection against cuts.", + "baseHp": 60, + "slot": "armor" + } + ], + "shields": [ + { + "name": "Pot Lid", + "description": "It was used for cooking.", + "baseArmor": 1, + "slot": "shield" + }, + { + "name": "Wooden Shield", + "description": "Sturdy oak wood.", + "baseArmor": 3, + "slot": "shield" + }, + { + "name": "Kite Shield", + "description": "Used by knights.", + "baseArmor": 6, + "slot": "shield" + }, + { + "name": "Cursed Shield", + "description": "A shield that prevents the wielder from defending themselves.", + "baseArmor": 5, + "slot": "shield", + "effects": [ + { + "type": "defenseForbidden", + "probability": 100, + "duration": 999 + } + ] + } + ], + "accessories": [ + { + "name": "Old Ring", + "description": "A tarnished ring.", + "baseAtk": 1, + "baseHp": 5, + "slot": "accessory" + }, + { + "name": "Copper Ring", + "description": "A simple ring", + "baseAtk": 1, + "baseHp": 5, + "slot": "accessory" + }, + { + "name": "Ruby Amulet", + "description": "Glows with a faint red light.", + "baseAtk": 3, + "baseHp": 15, + "slot": "accessory" + }, + { + "name": "Hero's Badge", + "description": "A badge of honor.", + "baseAtk": 5, + "baseHp": 25, + "baseArmor": 1, + "slot": "accessory" + } + ] +} diff --git a/lib/game/data/enemy_table.dart b/lib/game/data/enemy_table.dart new file mode 100644 index 0000000..3ff194c --- /dev/null +++ b/lib/game/data/enemy_table.dart @@ -0,0 +1,60 @@ +import 'dart:convert'; +import 'package:flutter/services.dart'; +import '../model/entity.dart'; + +class EnemyTemplate { + final String name; + final int baseHp; + final int baseAtk; + final int baseDefense; + + const EnemyTemplate({ + required this.name, + required this.baseHp, + required this.baseAtk, + required this.baseDefense, + }); + + factory EnemyTemplate.fromJson(Map json) { + return EnemyTemplate( + name: json['name'], + baseHp: json['baseHp'] ?? 10, + baseAtk: json['baseAtk'] ?? 1, + baseDefense: json['baseDefense'] ?? 0, + ); + } + + Character createCharacter({int stage = 1}) { + // Simple additive scaling + int scaledHp = baseHp + (stage - 1) * 5; + int scaledAtk = baseAtk + (stage - 1); + int scaledDefense = baseDefense + (stage ~/ 5); // +1 defense every 5 stages + + return Character( + name: name, + maxHp: scaledHp, + atk: scaledAtk, + baseDefense: scaledDefense, + armor: 0, + ); + } +} + +class EnemyTable { + static List normalEnemies = []; + static List eliteEnemies = []; + + static Future load() async { + final String jsonString = await rootBundle.loadString( + 'assets/data/enemies.json', + ); + final Map data = jsonDecode(jsonString); + + normalEnemies = (data['normal'] as List) + .map((e) => EnemyTemplate.fromJson(e)) + .toList(); + eliteEnemies = (data['elite'] as List) + .map((e) => EnemyTemplate.fromJson(e)) + .toList(); + } +} diff --git a/lib/game/data/item_table.dart b/lib/game/data/item_table.dart index 09dc081..a8d86d0 100644 --- a/lib/game/data/item_table.dart +++ b/lib/game/data/item_table.dart @@ -1,5 +1,7 @@ +import 'dart:convert'; +import 'package:flutter/services.dart'; import '../model/item.dart'; -import '../model/status_effect.dart'; // Import StatusEffect for ItemEffect +import '../model/status_effect.dart'; class ItemTemplate { final String name; @@ -8,7 +10,7 @@ class ItemTemplate { final int baseHp; final int baseArmor; final EquipmentSlot slot; - final List effects; // New: Effects this item can inflict + final List effects; const ItemTemplate({ required this.name, @@ -17,9 +19,24 @@ class ItemTemplate { this.baseHp = 0, this.baseArmor = 0, required this.slot, - this.effects = const [], // Default to no effects + this.effects = const [], }); + factory ItemTemplate.fromJson(Map json) { + return ItemTemplate( + name: json['name'], + description: json['description'], + baseAtk: json['baseAtk'] ?? 0, + baseHp: json['baseHp'] ?? 0, + baseArmor: json['baseArmor'] ?? 0, + slot: EquipmentSlot.values.firstWhere((e) => e.name == json['slot']), + effects: (json['effects'] as List?) + ?.map((e) => ItemEffect.fromJson(e)) + .toList() ?? + [], + ); + } + // Create an instance of Item based on this template, optionally scaling with stage Item createItem({int stage = 1}) { // Simple scaling logic: add stage-1 to relevant stats @@ -49,156 +66,20 @@ class ItemTemplate { } class ItemTable { - static final List weapons = [ - const ItemTemplate( - name: "Rusty Dagger", - description: "Old and rusty, but better than nothing.", - baseAtk: 3, - slot: EquipmentSlot.weapon, - ), - const ItemTemplate( - name: "Iron Sword", - description: "A standard soldier's sword.", - baseAtk: 8, - slot: EquipmentSlot.weapon, - ), - const ItemTemplate( - name: "Battle Axe", - description: "Heavy but powerful.", - baseAtk: 12, - slot: EquipmentSlot.weapon, - ), - // New: Weapons with status effects - ItemTemplate( - name: "Stunning Hammer", - description: "A heavy hammer that can stun foes.", - baseAtk: 10, - slot: EquipmentSlot.weapon, - effects: [ - ItemEffect( - type: StatusEffectType.stun, - probability: 20, - duration: 1, - ), // 20% chance to stun for 1 turn - ], - ), - ItemTemplate( - name: "Jagged Dagger", - description: "A cruel dagger that causes bleeding.", - baseAtk: 7, - slot: EquipmentSlot.weapon, - effects: [ - ItemEffect( - type: StatusEffectType.bleed, - probability: 30, - duration: 3, - value: 5, - ), // 30% chance to bleed (5 dmg/turn for 3 turns) - ], - ), - ItemTemplate( - name: "Sunderer Axe", - description: "An axe that exposes enemy weaknesses.", - baseAtk: 11, - slot: EquipmentSlot.weapon, - effects: [ - ItemEffect( - type: StatusEffectType.vulnerable, - probability: 100, - duration: 2, - ), // 100% chance to make vulnerable for 2 turns - ], - ), - ]; + static List weapons = []; + static List armors = []; + static List shields = []; + static List accessories = []; - static const List armors = [ - ItemTemplate( - name: "Torn Tunic", - description: "Offers minimal protection.", - baseHp: 10, - slot: EquipmentSlot.armor, - ), - ItemTemplate( - name: "Leather Vest", - description: "Light and flexible.", - baseHp: 30, - slot: EquipmentSlot.armor, - ), - ItemTemplate( - name: "Chainmail", - description: "Reliable protection against cuts.", - baseHp: 60, - slot: EquipmentSlot.armor, - ), - ]; + static Future load() async { + final String jsonString = await rootBundle.loadString('assets/data/items.json'); + final Map data = jsonDecode(jsonString); - static final List shields = [ - const ItemTemplate( - name: "Pot Lid", - description: "It was used for cooking.", - baseArmor: 1, - slot: EquipmentSlot.shield, - ), - const ItemTemplate( - name: "Wooden Shield", - description: "Sturdy oak wood.", - baseArmor: 3, - slot: EquipmentSlot.shield, - ), - const ItemTemplate( - name: "Kite Shield", - description: "Used by knights.", - baseArmor: 6, - slot: EquipmentSlot.shield, - ), - // New: Shield with Defense Forbidden effect (example) - ItemTemplate( - name: "Cursed Shield", - description: - "A shield that prevents the wielder from defending themselves.", - baseArmor: 5, - slot: EquipmentSlot.shield, - effects: [ - ItemEffect( - type: StatusEffectType.defenseForbidden, - probability: 100, - duration: 999, - ), // Always prevent defending (long duration for testing) - ], - ), - ]; - - static const List accessories = [ - ItemTemplate( - name: "Old Ring", - description: "A tarnished ring.", - baseAtk: 1, - baseHp: 5, - slot: EquipmentSlot.accessory, - ), - ItemTemplate( - name: "Copper Ring", - description: "A simple ring", - baseAtk: 1, - baseHp: 5, - slot: EquipmentSlot.accessory, - ), - ItemTemplate( - name: "Ruby Amulet", - description: "Glows with a faint red light.", - baseAtk: 3, - baseHp: 15, - slot: EquipmentSlot.accessory, - ), - ItemTemplate( - name: "Hero's Badge", - description: "A badge of honor.", - baseAtk: 5, - baseHp: 25, - baseArmor: 1, - slot: EquipmentSlot.accessory, - ), - ]; + weapons = (data['weapons'] as List).map((e) => ItemTemplate.fromJson(e)).toList(); + armors = (data['armors'] as List).map((e) => ItemTemplate.fromJson(e)).toList(); + shields = (data['shields'] as List).map((e) => ItemTemplate.fromJson(e)).toList(); + accessories = (data['accessories'] as List).map((e) => ItemTemplate.fromJson(e)).toList(); + } static List get allItems => [ ...weapons, diff --git a/lib/game/model/item.dart b/lib/game/model/item.dart index f00b841..9b9c519 100644 --- a/lib/game/model/item.dart +++ b/lib/game/model/item.dart @@ -16,14 +16,23 @@ class ItemEffect { this.value = 0, }); + factory ItemEffect.fromJson(Map json) { + return ItemEffect( + type: StatusEffectType.values.firstWhere((e) => e.name == json['type']), + probability: json['probability'] ?? 0, + duration: json['duration'] ?? 0, + value: json['value'] ?? 0, + ); + } + String get description { String typeStr = type.name.toUpperCase(); // Customize names if needed if (type == StatusEffectType.defenseForbidden) typeStr = "UNBLOCKABLE"; - + String durationStr = "${duration}t"; String valStr = value > 0 ? " ($value dmg)" : ""; - + return "$typeStr ${probability}% ($durationStr)$valStr"; } } diff --git a/lib/main.dart b/lib/main.dart index a016010..e59ab58 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,14 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'game/data/item_table.dart'; +import 'game/data/enemy_table.dart'; import 'providers/battle_provider.dart'; import 'screens/main_menu_screen.dart'; -void main() { +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await ItemTable.load(); + await EnemyTable.load(); runApp(const MyApp()); } @@ -13,9 +18,7 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MultiProvider( - providers: [ - ChangeNotifierProvider(create: (_) => BattleProvider()), - ], + providers: [ChangeNotifierProvider(create: (_) => BattleProvider())], child: MaterialApp( title: "Colosseum's Choice", theme: ThemeData.dark(), @@ -23,4 +26,4 @@ class MyApp extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/providers/battle_provider.dart b/lib/providers/battle_provider.dart index 4dfd7c5..7bbc71b 100644 --- a/lib/providers/battle_provider.dart +++ b/lib/providers/battle_provider.dart @@ -6,17 +6,35 @@ import '../game/model/item.dart'; import '../game/model/status_effect.dart'; import '../game/model/stage.dart'; // Import StageModel import '../game/data/item_table.dart'; +import '../game/data/enemy_table.dart'; import '../utils/game_math.dart'; enum ActionType { attack, defend } enum RiskLevel { safe, normal, risky } +enum EnemyActionType { attack, defend } + +class EnemyIntent { + final EnemyActionType type; + final int value; + final RiskLevel risk; + final String description; + + EnemyIntent({ + required this.type, + required this.value, + required this.risk, + required this.description, + }); +} + class BattleProvider with ChangeNotifier { late Character player; late Character enemy; // Kept for compatibility, active during Battle/Elite late StageModel currentStage; // The current stage object + EnemyIntent? currentEnemyIntent; List battleLogs = []; bool isPlayerTurn = true; @@ -114,20 +132,46 @@ class BattleProvider with ChangeNotifier { if (type == StageType.battle || type == StageType.elite) { bool isElite = type == StageType.elite; - int hpMultiplier = isElite ? 1 : 1; - int atkMultiplier = isElite ? 4 : 2; + // Select random enemy template + final random = Random(); + EnemyTemplate template; + if (isElite) { + if (EnemyTable.eliteEnemies.isNotEmpty) { + template = EnemyTable + .eliteEnemies[random.nextInt(EnemyTable.eliteEnemies.length)]; + } else { + // Fallback if no elite enemies loaded + template = const EnemyTemplate( + name: "Elite Guardian", + baseHp: 50, + baseAtk: 10, + baseDefense: 2, + ); + } + } else { + if (EnemyTable.normalEnemies.isNotEmpty) { + template = EnemyTable + .normalEnemies[random.nextInt(EnemyTable.normalEnemies.length)]; + } else { + // Fallback + template = const EnemyTemplate( + name: "Enemy", + baseHp: 20, + baseAtk: 5, + baseDefense: 0, + ); + } + } - int enemyHp = 1 + (stage - 1) * hpMultiplier; - int enemyAtk = 8 + (stage - 1) * atkMultiplier; - - String name = isElite ? "Elite Guardian" : "Enemy"; - newEnemy = Character(name: name, maxHp: enemyHp, armor: 0, atk: enemyAtk); + newEnemy = template.createCharacter(stage: stage); // Assign to the main 'enemy' field for UI compatibility enemy = newEnemy; isPlayerTurn = true; showRewardPopup = false; + _generateEnemyIntent(); // Generate first intent + _addLog("Stage $stage ($type) started! A wild ${enemy.name} appeared."); } else if (type == StageType.shop) { // Generate random items for shop @@ -263,35 +307,65 @@ class BattleProvider with ChangeNotifier { return; } - if (canAct) { - int incomingDamage = enemy.totalAtk; - int damageToHp = 0; + if (canAct && currentEnemyIntent != null) { + final intent = currentEnemyIntent!; - // Enemy attack logic - // (Simple logic: Enemy always attacks for now) - // Note: Enemy doesn't have equipment yet, so no effects applied by enemy. + // Check Success Rate based on Risk + final random = Random(); + bool success = false; + switch (intent.risk) { + case RiskLevel.safe: + success = random.nextDouble() < 1.0; + break; + case RiskLevel.normal: + success = random.nextDouble() < 0.8; + break; + case RiskLevel.risky: + success = random.nextDouble() < 0.4; + break; + } - // Handle Player Armor - if (player.armor > 0) { - if (player.armor >= incomingDamage) { - player.armor -= incomingDamage; - damageToHp = 0; - _addLog("Armor absorbed all $incomingDamage damage."); - } else { - damageToHp = incomingDamage - player.armor; - _addLog("Armor absorbed ${player.armor} damage."); - player.armor = 0; + if (success) { + if (intent.type == EnemyActionType.attack) { + int incomingDamage = intent.value; + int damageToHp = 0; + + // Handle Player Armor + if (player.armor > 0) { + if (player.armor >= incomingDamage) { + player.armor -= incomingDamage; + damageToHp = 0; + _addLog("Armor absorbed all $incomingDamage damage."); + } else { + damageToHp = incomingDamage - player.armor; + _addLog("Armor absorbed ${player.armor} damage."); + player.armor = 0; + } + } else { + damageToHp = incomingDamage; + } + + if (damageToHp > 0) { + _applyDamage(player, damageToHp); + _addLog("Enemy dealt $damageToHp damage to Player HP."); + } + } else if (intent.type == EnemyActionType.defend) { + int armorGained = intent.value; + enemy.armor += armorGained; + _addLog("Enemy gained $armorGained armor."); } } else { - damageToHp = incomingDamage; + _addLog("Enemy's ${intent.risk.name} action missed!"); } - - if (damageToHp > 0) { - _applyDamage(player, damageToHp); - _addLog("Enemy dealt $damageToHp damage to Player HP."); - } - } else { + } else if (!canAct) { _addLog("Enemy is stunned and cannot act!"); + } else { + _addLog("Enemy did nothing."); + } + + // Generate next intent + if (!player.isDead) { + _generateEnemyIntent(); } // Player Turn Start Logic @@ -452,4 +526,69 @@ class BattleProvider with ChangeNotifier { stage++; _prepareNextStage(); } + + void _generateEnemyIntent() { + if (enemy.isDead) { + currentEnemyIntent = null; + return; + } + + final random = Random(); + + // Decide Action Type + // If baseDefense is 0, CANNOT defend. + bool canDefend = enemy.baseDefense > 0; + bool isAttack = true; + + if (canDefend) { + // 70% Attack, 30% Defend + isAttack = random.nextDouble() < 0.7; + } else { + isAttack = true; + } + + // Decide Risk Level + RiskLevel risk = RiskLevel.values[random.nextInt(RiskLevel.values.length)]; + double efficiency = 1.0; + switch (risk) { + case RiskLevel.safe: + efficiency = 0.5; + break; + case RiskLevel.normal: + efficiency = 1.0; + break; + case RiskLevel.risky: + efficiency = 2.0; + break; + } + + if (isAttack) { + // Attack Intent + // Variance: +/- 20% + double variance = 0.8 + random.nextDouble() * 0.4; + 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})", + ); + } + notifyListeners(); + } } diff --git a/lib/screens/battle_screen.dart b/lib/screens/battle_screen.dart index f5d4956..b372e7c 100644 --- a/lib/screens/battle_screen.dart +++ b/lib/screens/battle_screen.dart @@ -373,6 +373,58 @@ class _BattleScreenState extends State { }).toList(), ), ), + if (isEnemy) + 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(); + }, + ), if (!isEnemy) ...[ Text("Armor: ${character.armor}"), Text("ATK: ${character.totalAtk}"), diff --git a/prompt/21_refactor_item_system_to_json.md b/prompt/21_refactor_item_system_to_json.md new file mode 100644 index 0000000..2856021 --- /dev/null +++ b/prompt/21_refactor_item_system_to_json.md @@ -0,0 +1,34 @@ +# 21. 아이템 시스템 JSON 리팩토링 (Refactor Item System to JSON) + +## 1. 개요 (Overview) +기존에 `ItemTable` 클래스 내부에 하드코딩되어 있던 아이템 데이터(Weapon, Armor, Shield, Accessory)를 외부 JSON 파일(`assets/data/items.json`)로 분리하여 관리하도록 리팩토링했습니다. 이를 통해 데이터와 로직을 분리하고, 추후 데이터 확장 및 관리를 용이하게 만들었습니다. + +## 2. 변경 사항 (Changes) + +### A. 데이터 파일 생성 +* **파일:** `assets/data/items.json` +* **내용:** 기존 `ItemTable`에 정의되어 있던 모든 아이템 데이터를 JSON 형식으로 이관. +* **구조:** `weapons`, `armors`, `shields`, `accessories` 4개의 배열을 포함하는 객체. + +### B. 설정 변경 +* **파일:** `pubspec.yaml` +* **내용:** `assets` 섹션에 `assets/data/` 경로 추가. + +### C. 코드 변경 +1. **`lib/game/model/item.dart`** + * `ItemEffect` 클래스에 `fromJson` 팩토리 생성자 추가. +2. **`lib/game/data/item_table.dart`** + * `ItemTemplate` 클래스에 `fromJson` 팩토리 생성자 추가. + * 하드코딩된 리스트(`weapons`, `armors` 등)를 빈 리스트로 초기화. + * `load()` 비동기 메서드 추가: `rootBundle`을 통해 JSON 파일을 읽어와 리스트를 채움. +3. **`lib/main.dart`** + * `main()` 함수를 `async`로 변경. + * 앱 실행 전 `await ItemTable.load()`를 호출하여 데이터 초기화 보장. + +## 3. 검증 (Verification) +* `test/item_load_test.dart`를 생성하여 JSON 로딩 및 파싱이 정상적으로 이루어지는지 테스트 완료. +* 앱 실행 시 에러 없이 아이템 데이터가 로드됨을 확인. + +## 4. 향후 계획 (Next Steps) +* 새로운 아이템 추가 시 `assets/data/items.json` 파일만 수정하면 됨. +* 상점 구매 목록 등도 JSON 데이터를 기반으로 구성 가능. diff --git a/prompt/22_refactor_enemy_system.md b/prompt/22_refactor_enemy_system.md new file mode 100644 index 0000000..5fcd345 --- /dev/null +++ b/prompt/22_refactor_enemy_system.md @@ -0,0 +1,64 @@ +# 22. 적 시스템 리팩토링 및 AI 의도(Intent) 구현 + +## 목표 + +적 시스템을 데이터 주도(Data-Driven) 방식으로 리팩토링하고, 적이 자신의 행동(공격, 방어)을 미리 계획하여 플레이어에게 보여주는 "의도(Intent) 시스템"을 구현합니다. + +## 1. 데이터 주도 적 시스템 (완료) + +- **JSON 데이터**: `assets/data/enemies.json` 파일을 생성하여 `normal`(일반) 및 `elite`(정예) 적 목록을 정의했습니다. +- **데이터 로더**: JSON 데이터를 로드하고 파싱하기 위해 `lib/game/data/enemy_table.dart`를 생성했습니다. +- **스폰(Spawn)**: 로드된 데이터에서 무작위로 적을 생성하도록 `BattleProvider`를 업데이트했습니다. + +## 2. 적 AI 및 의도(Intent) 시스템 (구현 예정) + +- **목표**: 적은 플레이어와 유사한 행동(공격, 방어)을 무작위로 선택하여 수행해야 합니다. +- **가시성**: 플레이어는 자신의 턴 동안 적이 다음 턴에 무엇을 할지 볼 수 있어야 합니다. + +### 구현 상세 + +#### A. 적 의도(Intent) 구조 + +적의 행동을 나타내는 `EnemyIntent` 클래스와 열거형(Enum)을 정의합니다. + +```dart +enum EnemyActionType { attack, defend } + +class EnemyIntent { + final EnemyActionType type; + final int value; // 데미지 양 또는 방어구 양 + final String description; // UI 표시용 설명 + + EnemyIntent({required this.type, required this.value, required this.description}); +} +``` + +#### B. BattleProvider 업데이트 + +1. **상태(State)**: `BattleProvider`에 `EnemyIntent? currentEnemyIntent`를 추가합니다. +2. **생성(Generation)**: `_generateEnemyIntent()` 메서드를 생성합니다: + - **방어 불가 조건**: 적의 `baseDefense`가 0이면 방어 행동을 선택하지 않습니다. + - **리스크 레벨(Risk Level)**: 적도 플레이어처럼 Safe/Normal/Risky 중 하나를 무작위로 선택합니다. + - **수치 계산**: 선택된 리스크 레벨의 효율(Efficiency)을 적용하여 데미지/방어량을 계산합니다. +3. **흐름(Flow)**: + - **스폰 시**: 초기 의도를 생성합니다. + - **적 턴 종료 후**: 다음 턴을 위한 새로운 의도를 생성합니다. +4. **실행(Execution)**: + - 저장된 `currentEnemyIntent`의 리스크 레벨에 따른 성공 확률을 체크합니다. + - 성공 시 의도된 행동을 수행하고, 실패 시 빗나감(Miss) 처리합니다. + +#### C. UI 업데이트 (BattleScreen) + +1. **의도 표시**: + - `BattleScreen`에서 적의 체력 바 또는 이름 아래에 현재 의도를 보여주는 위젯을 추가합니다. + - `description`을 표시합니다 (예: "5의 피해로 공격"). + - 아이콘(공격은 칼, 방어는 방패)을 추가하여 시각적으로 표현합니다. +2. **가시성**: + - 플레이어의 턴이고 적이 살아있을 때만 의도를 표시합니다. + +## 검증 + +- JSON에서 적이 정상적으로 스폰되는지 확인합니다. +- 전투 시작 시 적이 의도를 생성하는지 확인합니다. +- 적이 의도대로 행동을 수행하는지 확인합니다. +- 행동 후 적이 새로운 의도를 생성하는지 확인합니다. diff --git a/pubspec.yaml b/pubspec.yaml index 8bc64b2..3fc8dd1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,9 +59,8 @@ flutter: uses-material-design: true # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + assets: + - assets/data/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/test/enemy_intent_test.dart b/test/enemy_intent_test.dart new file mode 100644 index 0000000..f5702bf --- /dev/null +++ b/test/enemy_intent_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:game_test/providers/battle_provider.dart'; +import 'package:game_test/game/data/enemy_table.dart'; +import 'package:game_test/game/data/item_table.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + await ItemTable.load(); + await EnemyTable.load(); + }); + + test('Enemy generates intent on spawn', () { + final provider = BattleProvider(); + provider.initializeBattle(); + + // Should have an enemy and an intent + expect(provider.enemy, isNotNull); + expect(provider.currentEnemyIntent, isNotNull); + print('Initial Intent: ${provider.currentEnemyIntent!.description}'); + }); + + test('Enemy executes intent and generates new one', () async { + final provider = BattleProvider(); + provider.initializeBattle(); + + // Force player turn to end to trigger enemy turn + // We can't easily call private methods, but we can simulate flow or check state + // BattleProvider logic is tightly coupled with async delays in _enemyTurn, + // so unit testing the exact flow is tricky without mocking. + // Instead, we will test the public state changes if possible or just rely on the fact that + // initializeBattle calls _prepareNextStage which calls _generateEnemyIntent. + + // Let's verify the intent structure + final intent = provider.currentEnemyIntent!; + expect(intent.value, greaterThan(0)); + expect(intent.type, anyOf(EnemyActionType.attack, EnemyActionType.defend)); + expect( + intent.risk, + anyOf(RiskLevel.safe, RiskLevel.normal, RiskLevel.risky), + ); + }); +} diff --git a/test/enemy_load_test.dart b/test/enemy_load_test.dart new file mode 100644 index 0000000..2872138 --- /dev/null +++ b/test/enemy_load_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:game_test/game/data/enemy_table.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('Load Enemy Table', () async { + await EnemyTable.load(); + + expect(EnemyTable.normalEnemies.isNotEmpty, true); + expect(EnemyTable.eliteEnemies.isNotEmpty, true); + + final goblin = EnemyTable.normalEnemies.firstWhere( + (e) => e.name == 'Goblin', + ); + expect(goblin.baseHp, 20); + expect(goblin.baseAtk, 5); + + final orc = EnemyTable.eliteEnemies.firstWhere( + (e) => e.name == 'Orc Warrior', + ); + expect(orc.baseHp, 60); + }); +} diff --git a/test/item_load_test.dart b/test/item_load_test.dart new file mode 100644 index 0000000..cd5a8b4 --- /dev/null +++ b/test/item_load_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:game_test/game/data/item_table.dart'; +import 'package:flutter/services.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('ItemTable loads items from JSON', () async { + // We need to ensure the assets are loaded. + // In a unit test, rootBundle might not point to the real assets folder easily without setup. + // However, we can verify that the code *attempts* to load and parses correctly if we mock the bundle, + // OR we can try to run it and see if it finds the file. + + // For this environment, let's try to run it. If it fails to find asset, we might need to mock. + // But since we want to verify the JSON content validity, mocking the *content* with the *actual file content* is a good middle ground if direct loading fails. + + // Let's try direct load first. + try { + await ItemTable.load(); + + expect(ItemTable.weapons.isNotEmpty, true, reason: "Weapons should be loaded"); + expect(ItemTable.armors.isNotEmpty, true, reason: "Armors should be loaded"); + expect(ItemTable.shields.isNotEmpty, true, reason: "Shields should be loaded"); + expect(ItemTable.accessories.isNotEmpty, true, reason: "Accessories should be loaded"); + + print("Loaded ${ItemTable.weapons.length} weapons"); + print("Loaded ${ItemTable.armors.length} armors"); + print("Loaded ${ItemTable.shields.length} shields"); + print("Loaded ${ItemTable.accessories.length} accessories"); + + } catch (e) { + // If asset loading fails (common in raw flutter_test without integration test setup), + // we can't easily fix the environment here, but we can verify the JSON parsing logic + // by mocking the channel if we really wanted to. + // But for now, let's see if it works. + print("Error loading items: $e"); + rethrow; + } + }); +}