From ae1ebdc6bf19d601deeece2332e4d4b99c0a176c Mon Sep 17 00:00:00 2001 From: Horoli Date: Mon, 1 Dec 2025 18:38:47 +0900 Subject: [PATCH] update --- README.md | 32 -- lib/game/data/item_table.dart | 134 +++++++ lib/game/game_instance.dart | 42 --- lib/game/game_manager.dart | 92 ----- lib/game/model/entity.dart | 196 +++++----- lib/game/model/item.dart | 71 ++-- lib/main.dart | 120 +----- lib/providers/battle_provider.dart | 222 +++++++++++ lib/screens/battle_screen.dart | 313 ++++++++++++++++ lib/screens/inventory_screen.dart | 408 +++++++++++++++++++++ lib/screens/main_wrapper.dart | 47 +++ lib/utils/game_math.dart | 5 + prompt/01_game_prototype_request.md | 91 +++++ prompt/02_item_system_request.md | 73 ++++ prompt/03_inventory_ui_request.md | 65 ++++ prompt/04_inventory_slots_request.md | 66 ++++ prompt/05_equipment_slot_refactor.md | 65 ++++ prompt/06_fix_hp_logic.md | 38 ++ prompt/07_stage_heal_and_math_utils.md | 51 +++ prompt/08_fix_equip_hp_exploit.md | 21 ++ prompt/09_ui_ux_improvements.md | 36 ++ prompt/10_add_shield_and_armor_mechanic.md | 34 ++ prompt/11_item_table_and_rewards.md | 44 +++ prompt/12_item_option_display.md | 31 ++ pubspec.yaml | 1 + test/character_test.dart | 93 +++++ test/game_test.dart | 171 --------- 27 files changed, 1965 insertions(+), 597 deletions(-) delete mode 100644 README.md create mode 100644 lib/game/data/item_table.dart delete mode 100644 lib/game/game_instance.dart delete mode 100644 lib/game/game_manager.dart create mode 100644 lib/providers/battle_provider.dart create mode 100644 lib/screens/battle_screen.dart create mode 100644 lib/screens/inventory_screen.dart create mode 100644 lib/screens/main_wrapper.dart create mode 100644 lib/utils/game_math.dart create mode 100644 prompt/01_game_prototype_request.md create mode 100644 prompt/02_item_system_request.md create mode 100644 prompt/03_inventory_ui_request.md create mode 100644 prompt/04_inventory_slots_request.md create mode 100644 prompt/05_equipment_slot_refactor.md create mode 100644 prompt/06_fix_hp_logic.md create mode 100644 prompt/07_stage_heal_and_math_utils.md create mode 100644 prompt/08_fix_equip_hp_exploit.md create mode 100644 prompt/09_ui_ux_improvements.md create mode 100644 prompt/10_add_shield_and_armor_mechanic.md create mode 100644 prompt/11_item_table_and_rewards.md create mode 100644 prompt/12_item_option_display.md create mode 100644 test/character_test.dart delete mode 100644 test/game_test.dart diff --git a/README.md b/README.md deleted file mode 100644 index e01c2ba..0000000 --- a/README.md +++ /dev/null @@ -1,32 +0,0 @@ -당신은 시니어 Flutter 게임 개발자입니다. 현재 우리는 '텍스트/인터페이스 기반의 로그라이크 RPG'를 개발 중입니다. - -지금까지 설계된 게임의 기획 및 아키텍처 내용을 바탕으로, 프로젝트의 **핵심 코어(Core) 로직**을 구현해주세요. UI 코드는 제외하고, 순수 Dart로 작성된 로직 부분만 작성해야 합니다. - -### 1. 게임 기획 요약 - -- **컨셉:** 검투사가 되어 적과 싸우는 턴제 RPG. -- **전투 시스템:** - 행동(공격/방어 등) 선택 후, 강도(Risk)를 선택. - - 강도 예시: 약(90% 성공), 중(60% 성공), 강(30% 성공). -- **파밍 시스템:** 디아블로 식 접두사/접미사 옵션 파밍. 아이템 옵션(Modifier)이 캐릭터 스탯에 합연산/곱연산으로 적용됨. - -### 2. 기술적 아키텍처 (필수 준수 사항) - -- **UI와 로직의 완벽한 분리:** Flutter UI 없이 콘솔에서도 게임이 돌아가야 함. -- **GameInstance (Core):** 앱 실행 시 가장 먼저 생성되는 싱글톤 진입점. `initialize()`에서 게임 데이터를 로드함. -- **GameManager:** 게임의 상태(State)와 흐름을 관리하는 지휘자. `ChangeNotifier`를 상속받아 UI에 알림을 보냄. -- **Entity 시스템:** - - `BaseEntity` (ID, Name) -> `LivingEntity` (HP, Stats) -> `Player`, `Enemy` 상속 구조. -- **Stat 시스템:** - - 단순 `int` 변수가 아닌 `Stat` 객체 사용. - - `Stat` 객체는 `List`를 가지고 있으며, `BaseValue`와 `Modifiers`를 계산해 최종 `Value`를 도출함. - -### 3. 요청 사항 - -위 아키텍처를 기반으로 다음 파일들의 Dart 코드를 작성해주세요. - -1. **`lib/game/game_instance.dart`**: 싱글톤 코어, 초기화 로직 포함. -2. **`lib/game/game_manager.dart`**: 데이터 보유 및 상태 관리 뼈대. -3. **`lib/game/model/stat.dart`**: `Modifier` 타입(Flat, Percent)과 `Stat` 계산 로직 구현. -4. **`lib/game/model/entity.dart`**: `LivingEntity` 추상 클래스와 `Player` 클래스 기본 구조 (Stat 시스템 적용). - -각 파일은 당장 실행 가능하도록 필요한 import 구문과 주석을 포함해주세요. diff --git a/lib/game/data/item_table.dart b/lib/game/data/item_table.dart new file mode 100644 index 0000000..e8b0376 --- /dev/null +++ b/lib/game/data/item_table.dart @@ -0,0 +1,134 @@ +import '../model/item.dart'; + +class ItemTemplate { + final String name; + final String description; + final int baseAtk; + final int baseHp; + final int baseArmor; + final EquipmentSlot slot; + + const ItemTemplate({ + required this.name, + required this.description, + this.baseAtk = 0, + this.baseHp = 0, + this.baseArmor = 0, + required this.slot, + }); + + // 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 + // You can make this more complex (multiplier, tiering, etc.) + int scaledAtk = baseAtk > 0 ? baseAtk + (stage - 1) : 0; + int scaledHp = baseHp > 0 ? baseHp + (stage - 1) * 5 : 0; + int scaledArmor = baseArmor > 0 ? baseArmor + (stage - 1) : 0; + + return Item( + name: "$name${stage > 1 ? ' +${stage - 1}' : ''}", // Append +1, +2 etc. + description: description, + atkBonus: scaledAtk, + hpBonus: scaledHp, + armorBonus: scaledArmor, + slot: slot, + ); + } +} + +class ItemTable { + static const List weapons = [ + ItemTemplate( + name: "Rusty Dagger", + description: "Old and rusty, but better than nothing.", + baseAtk: 3, + slot: EquipmentSlot.weapon, + ), + ItemTemplate( + name: "Iron Sword", + description: "A standard soldier's sword.", + baseAtk: 8, + slot: EquipmentSlot.weapon, + ), + ItemTemplate( + name: "Battle Axe", + description: "Heavy but powerful.", + baseAtk: 12, + slot: EquipmentSlot.weapon, + ), + ]; + + 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 const List shields = [ + ItemTemplate( + name: "Pot Lid", + description: "It was used for cooking.", + baseArmor: 1, + slot: EquipmentSlot.shield, + ), + ItemTemplate( + name: "Wooden Shield", + description: "Sturdy oak wood.", + baseArmor: 3, + slot: EquipmentSlot.shield, + ), + ItemTemplate( + name: "Kite Shield", + description: "Used by knights.", + baseArmor: 6, + slot: EquipmentSlot.shield, + ), + ]; + + static const List accessories = [ + ItemTemplate( + name: "Old Ring", + description: "A tarnished 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, + ), + ]; + + static List get allItems => [ + ...weapons, + ...armors, + ...shields, + ...accessories, + ]; +} diff --git a/lib/game/game_instance.dart b/lib/game/game_instance.dart deleted file mode 100644 index 2da6df5..0000000 --- a/lib/game/game_instance.dart +++ /dev/null @@ -1,42 +0,0 @@ -// lib/game/game_instance.dart - -/// 앱 실행 시 가장 먼저 생성되는 싱글톤 진입점. -/// 게임의 핵심 데이터를 로드하고 관리한다. -class GameInstance { - // 싱글톤 인스턴스 - static final GameInstance _instance = GameInstance._internal(); - - // 팩토리 생성자를 통해 싱글톤 인스턴스를 반환한다. - factory GameInstance() { - return _instance; - } - - // 내부 생성자. 외부에서 직접 인스턴스 생성을 막는다. - GameInstance._internal(); - - bool _isInitialized = false; - - /// 게임이 초기화되었는지 여부를 반환한다. - bool get isInitialized => _isInitialized; - - /// 게임 초기화 로직. - /// 필요한 게임 데이터를 로드하고 시스템을 설정한다. - Future initialize() async { - if (_isInitialized) { - print('GameInstance already initialized.'); - return; - } - - print('Initializing GameInstance...'); - - // TODO: 여기에 실제 게임 데이터 로드 및 초기화 로직 구현 - // 예: 몬스터 데이터, 아이템 데이터, 플레이어 초기 데이터 등 로드 - - await Future.delayed(const Duration(seconds: 1)); // 초기화 지연 시뮬레이션 - - _isInitialized = true; - print('GameInstance initialized successfully.'); - } - - // TODO: 게임 전역에서 공유될 데이터 및 유틸리티 메서드 추가 -} diff --git a/lib/game/game_manager.dart b/lib/game/game_manager.dart deleted file mode 100644 index 9f431e7..0000000 --- a/lib/game/game_manager.dart +++ /dev/null @@ -1,92 +0,0 @@ -// lib/game/game_manager.dart - -import 'package:flutter/foundation.dart'; // ChangeNotifier를 사용하기 위해 필요 -import 'package:game_test/game/model/entity.dart'; -import 'package:game_test/game/game_instance.dart'; - -/// 게임의 상태(State)와 흐름을 관리하는 지휘자. -/// ChangeNotifier를 상속받아 UI에 게임 상태 변경을 알릴 수 있다. -class GameManager extends ChangeNotifier { - Player? _player; - List _currentEnemies = []; - - GameManager() { - _init(); - } - - void _init() async { - // GameInstance가 초기화되었는지 확인 - if (!GameInstance().isInitialized) { - await GameInstance().initialize(); - } - // TODO: 게임 시작 시 필요한 초기화 로직 구현 - // 예: 새로운 플레이어 생성, 첫 스테이지 몬스터 로드 등 - _player = Player(id: 'player_001', name: '용감한 검투사', baseHp: 100); - _currentEnemies = [ - Enemy(id: 'goblin_001', name: '고블린', baseHp: 50), - Enemy(id: 'goblin_002', name: '고블린', baseHp: 55), - ]; - - print('GameManager initialized. Player: ${_player?.name}, Enemies: ${_currentEnemies.length}'); - notifyListeners(); // UI에 초기 상태 변경 알림 - } - - /// 현재 플레이어를 반환한다. - Player? get player => _player; - - /// 현재 전투 중인 적 리스트를 반환한다. - List get currentEnemies => _currentEnemies; - - /// 플레이어의 턴 로직. - /// 선택된 행동(공격, 방어 등)과 강도(Risk)에 따라 게임 상태를 변경한다. - void playerTurn({required String action, required double risk}) { - // TODO: 행동 및 강도에 따른 로직 구현 - print('Player performs $action with risk $risk'); - - // 예시: 간단한 공격 로직 - if (action == 'attack' && _currentEnemies.isNotEmpty) { - final targetEnemy = _currentEnemies.first; // 첫 번째 적 공격 - double damage = _player!.attack.value * risk; // 플레이어의 공격력과 강도에 따라 피해량 계산 - targetEnemy.takeDamage(damage); - print('${_player?.name} attacked ${targetEnemy.name} for ${damage.toInt()} damage.'); - print('${targetEnemy.name} HP: ${targetEnemy.hp.value.toInt()}'); - - if (!targetEnemy.isAlive) { - _currentEnemies.remove(targetEnemy); - print('${targetEnemy.name} defeated!'); - } - } - - notifyListeners(); // 게임 상태 변경 알림 - // TODO: 적 턴 시작 로직 호출 - if (_currentEnemies.isNotEmpty) { - _enemyTurn(); - } else { - print('All enemies defeated! Moving to next stage.'); - // TODO: 다음 스테이지 로직 구현 - } - } - - /// 적의 턴 로직. - void _enemyTurn() { - print('Enemy turn...'); - for (var enemy in _currentEnemies) { - if (enemy.isAlive && _player != null) { - // TODO: 적 AI 로직 구현 - double damageToPlayer = enemy.attack.value; // 적의 공격력 사용 - _player!.takeDamage(damageToPlayer); - print('${enemy.name} attacked ${_player!.name} for ${damageToPlayer.toInt()} damage.'); - print('${_player!.name} HP: ${_player!.hp.value.toInt()}'); - - if (!_player!.isAlive) { - print('Game Over!'); - // TODO: 게임 오버 로직 구현 - break; - } - } - } - notifyListeners(); // 게임 상태 변경 알림 - } - - // TODO: 추가적인 게임 흐름 관리 메서드 (예: 아이템 사용, 스킬 사용, 스테이지 전환 등) -} diff --git a/lib/game/model/entity.dart b/lib/game/model/entity.dart index 33ed5ea..ae220ee 100644 --- a/lib/game/model/entity.dart +++ b/lib/game/model/entity.dart @@ -1,126 +1,112 @@ -// lib/game/model/entity.dart +import 'item.dart'; -import 'package:game_test/game/model/stat.dart'; -import 'package:game_test/game/model/item.dart'; // Add this import - -/// 모든 게임 엔티티의 기본 클래스. -/// 고유 ID와 이름을 가진다. -abstract class BaseEntity { - final String id; +class Character { String name; + int hp; + int baseMaxHp; + int armor; // Current temporary shield/armor points in battle + int baseAtk; + int baseDefense; // Base defense stat + Map equipment = {}; + List inventory = []; + final int maxInventorySize = 16; - BaseEntity({required this.id, required this.name}); + Character({ + required this.name, + int? hp, + required int maxHp, + required this.armor, + required int atk, + this.baseDefense = 0, + }) : baseMaxHp = maxHp, + baseAtk = atk, + hp = hp ?? maxHp; - @override - String toString() => '$name (ID: $id)'; -} - -/// 생명력을 가진 엔티티 (플레이어, 적 등)의 추상 클래스. -/// BaseEntity를 상속받고, 체력(HP)과 스탯 맵을 포함한다. -abstract class LivingEntity extends BaseEntity { - Stat hp; // Health Points - final Map stats = {}; // 다양한 스탯들을 관리하는 맵 - - LivingEntity({ - required super.id, - required super.name, - required double baseHp, - }) : hp = Stat(baseValue: baseHp); - - /// 특정 스탯을 추가한다. - void addStat(String statName, Stat stat) { - stats[statName] = stat; + int get totalMaxHp { + int bonus = equipment.values.fold(0, (sum, item) => sum + item.hpBonus); + return baseMaxHp + bonus; } - /// 특정 스탯을 가져온다. - Stat? getStat(String statName) { - return stats[statName]; + int get totalAtk { + int bonus = equipment.values.fold(0, (sum, item) => sum + item.atkBonus); + return baseAtk + bonus; } - /// 엔티티가 살아있는지 여부를 반환한다. - bool get isAlive => hp.value > 0; + int get totalDefense { + int bonus = equipment.values.fold(0, (sum, item) => sum + item.armorBonus); + return baseDefense + bonus; + } - /// 엔티티에게 피해를 입힌다. - void takeDamage(double amount) { - hp.baseValue -= amount; // HP는 baseValue를 직접 감소시키는 것으로 처리. - if (hp.baseValue < 0) { - hp.baseValue = 0; + bool get isDead => hp <= 0; + + // Adds an item to inventory, returns true if successful, false if inventory is full + bool addToInventory(Item item) { + if (inventory.length < maxInventorySize) { + inventory.add(item); + return true; } + return false; } - /// 엔티티를 치유한다. - void heal(double amount) { - hp.baseValue += amount; - // TODO: 최대 HP 제한 로직 추가 필요 - } + // Equips an item (swapping if necessary) + // Returns true if successful + bool equip(Item newItem) { + if (!inventory.contains(newItem)) return false; - @override - String toString() { - return '${super.toString()}, HP: ${hp.value.toInt()}/${hp.baseValue.toInt()}'; - } -} + // 1. Calculate current HP ratio before any changes + double hpRatio = totalMaxHp > 0 ? hp / totalMaxHp : 0.0; // Avoid division by zero -/// 플레이어 엔티티 클래스. -/// LivingEntity를 상속받으며 플레이어 특유의 로직을 추가할 수 있다. -class Player extends LivingEntity { - // 장비 슬롯 맵 - final Map _equippedWeapons = {}; - - /// 플레이어의 공격 스탯. - Stat attack; - - Player({ - required super.id, - required super.name, - required super.baseHp, - }) : attack = Stat(baseValue: 0.0) { // 공격 스탯 초기화 - // 시작 시 맨손 무장 - equipWeapon(Weapon.unArmed); - } - - /// 현재 장비된 무기를 반환한다. - Weapon? get equippedWeapon => _equippedWeapons[EquipmentSlot.mainHand]; - - /// 무기를 장비한다. - /// 기존에 해당 슬롯에 장비된 무기가 있다면 해제하고 새로운 무기를 장비한다. - void equipWeapon(Weapon newWeapon) { - // 기존 무기가 있다면 해제 - final currentWeapon = _equippedWeapons[newWeapon.slot]; - if (currentWeapon != null) { - attack.removeModifier(currentWeapon.attackModifier); + // 2. Handle Swap: If slot is occupied, unequip the old item first + if (equipment.containsKey(newItem.slot)) { + Item oldItem = equipment[newItem.slot]!; + equipment.remove(newItem.slot); + inventory.add(oldItem); } - // 새로운 무기 장비 및 수정자 적용 - _equippedWeapons[newWeapon.slot] = newWeapon; - attack.addModifier(newWeapon.attackModifier); + // 3. Move new item: Inventory -> Equipment + inventory.remove(newItem); + equipment[newItem.slot] = newItem; + + // 4. Update current HP based on the new totalMaxHp and previous ratio + hp = (totalMaxHp * hpRatio).toInt(); + if (hp < 0) hp = 0; // Ensure HP does not go below zero + if (hp > totalMaxHp) { + hp = totalMaxHp; // Final Safety Clamp, though hpRatio <= 1.0 should prevent this + } + + return true; } - /// 장비된 무기를 해제한다. - /// 맨손 상태로 돌아간다. - void unequipWeapon(EquipmentSlot slot) { - final currentWeapon = _equippedWeapons[slot]; - if (currentWeapon != null && currentWeapon != Weapon.unArmed) { - attack.removeModifier(currentWeapon.attackModifier); - _equippedWeapons.remove(slot); - // 맨손 상태로 복귀 - equipWeapon(Weapon.unArmed); + // Unequips an item + // Returns true if successful (inventory has space) + bool unequip(Item item) { + if (!equipment.containsValue(item)) return false; + + // 1. Calculate current HP ratio before any changes + double hpRatio = totalMaxHp > 0 ? hp / totalMaxHp : 0.0; // Avoid division by zero + + if (inventory.length < maxInventorySize) { + equipment.remove(item.slot); + inventory.add(item); + + // 2. Update current HP based on the new totalMaxHp and previous ratio + hp = (totalMaxHp * hpRatio).toInt(); + if (hp < 0) hp = 0; // Ensure HP does not go below zero + if (hp > totalMaxHp) { + hp = totalMaxHp; // Final Safety Clamp, though hpRatio <= 1.0 should prevent this + } + return true; + } + + return false; + } + + void heal(int amount) { + if (isDead) return; // Cannot heal if dead + + hp += amount; + if (hp > totalMaxHp) { + hp = totalMaxHp; } } - - // TODO: 플레이어 고유의 인벤토리, 장비, 스킬 등의 시스템 추가 -} - -/// 적 엔티티 클래스. -/// LivingEntity를 상속받으며 적 특유의 로직을 추가할 수 있다. -class Enemy extends LivingEntity { - Stat attack; // 적도 공격 스탯을 가질 수 있도록 추가 - - Enemy({ - required super.id, - required super.name, - required super.baseHp, - double baseAttack = 5.0, // 기본 공격력 설정 - }) : attack = Stat(baseValue: baseAttack); - - // TODO: 적 고유의 AI, 드롭 아이템 등의 시스템 추가 } diff --git a/lib/game/model/item.dart b/lib/game/model/item.dart index 9c95781..c820b06 100644 --- a/lib/game/model/item.dart +++ b/lib/game/model/item.dart @@ -1,55 +1,32 @@ -// lib/game/model/item.dart +enum EquipmentSlot { weapon, armor, shield, accessory } -import 'package:game_test/game/model/stat.dart'; - -/// 장비할 수 있는 슬롯의 종류. -enum EquipmentSlot { - mainHand, - offHand, - head, - chest, - legs, - feet, - accessory, -} - -/// 모든 게임 아이템의 기본 클래스. -/// ID, 이름, 설명을 가진다. -abstract class Item { - final String id; +class Item { final String name; final String description; - - Item({ - required this.id, - required this.name, - this.description = '', - }); - - @override - String toString() => name; -} - -/// 무기 아이템 클래스. -/// 공격 스탯에 영향을 주는 수정자를 포함할 수 있다. -class Weapon extends Item { - final Modifier attackModifier; + final int atkBonus; + final int hpBonus; + final int armorBonus; // New stat for defense final EquipmentSlot slot; - Weapon({ - required super.id, - required super.name, - super.description, - required this.attackModifier, - this.slot = EquipmentSlot.mainHand, + Item({ + required this.name, + required this.description, + required this.atkBonus, + required this.hpBonus, + this.armorBonus = 0, // Default to 0 for backward compatibility + required this.slot, }); - /// 플레이어가 무장하지 않았을 때의 기본 무기. - /// 베이스 공격력 1을 제공한다. - static Weapon get unArmed => Weapon( - id: 'unarmed_weapon', - name: '맨주먹', - description: '아무것도 장비하지 않은 상태의 공격.', - attackModifier: Modifier(type: ModifierType.flat, value: 1), - ); + String get typeName { + switch (slot) { + case EquipmentSlot.weapon: + return "Weapon"; + case EquipmentSlot.armor: + return "Armor"; + case EquipmentSlot.shield: + return "Shield"; + case EquipmentSlot.accessory: + return "Accessory"; + } + } } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 7b7f5b6..415e57b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'providers/battle_provider.dart'; +import 'screens/main_wrapper.dart'; void main() { runApp(const MyApp()); @@ -7,116 +10,17 @@ void main() { class MyApp extends StatelessWidget { const MyApp({super.key}); - // This widget is the root of your application. @override Widget build(BuildContext context) { - return MaterialApp( - title: 'Flutter Demo', - theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => BattleProvider()), + ], + child: MaterialApp( + title: "Colosseum's Choice", + theme: ThemeData.dark(), + home: const MainWrapper(), ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), ); } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } - - @override - Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('You have pushed the button this many times:'), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], - ), - ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), // This trailing comma makes auto-formatting nicer for build methods. - ); - } -} +} \ No newline at end of file diff --git a/lib/providers/battle_provider.dart b/lib/providers/battle_provider.dart new file mode 100644 index 0000000..a228238 --- /dev/null +++ b/lib/providers/battle_provider.dart @@ -0,0 +1,222 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import '../game/model/entity.dart'; +import '../game/model/item.dart'; +import '../game/data/item_table.dart'; // Import ItemTable +import '../utils/game_math.dart'; // Import GameMath + +enum ActionType { attack, defend } +enum RiskLevel { safe, normal, risky } + +class BattleProvider with ChangeNotifier { + late Character player; + late Character enemy; + List battleLogs = []; + bool isPlayerTurn = true; + + int stage = 1; + List rewardOptions = []; + bool showRewardPopup = false; + + BattleProvider() { + initializeBattle(); + } + + void initializeBattle() { + stage = 1; + player = Character(name: "Player", maxHp: 100, armor: 0, atk: 10, baseDefense: 5); // Added baseDefense 5 + + // Provide starter equipment + final starterSword = Item(name: "Wooden Sword", description: "A basic sword", atkBonus: 5, hpBonus: 0, slot: EquipmentSlot.weapon); + final starterArmor = Item(name: "Leather Armor", description: "Basic protection", atkBonus: 0, hpBonus: 20, slot: EquipmentSlot.armor); + final starterShield = Item(name: "Wooden Shield", description: "A small shield", atkBonus: 0, hpBonus: 0, armorBonus: 3, slot: EquipmentSlot.shield); + final starterRing = Item(name: "Copper Ring", description: "A simple ring", atkBonus: 1, hpBonus: 5, slot: EquipmentSlot.accessory); + + player.addToInventory(starterSword); + player.equip(starterSword); + + player.addToInventory(starterArmor); + player.equip(starterArmor); + + player.addToInventory(starterShield); + player.equip(starterShield); + + player.addToInventory(starterRing); + player.equip(starterRing); + + _spawnEnemy(); + battleLogs.clear(); + _addLog("Battle started! Stage $stage"); + isPlayerTurn = true; + showRewardPopup = false; + notifyListeners(); + } + + void _spawnEnemy() { + int enemyHp = 5 + (stage - 1) * 20; + int enemyAtk = 8 + (stage - 1) * 2; + enemy = Character(name: "Enemy", maxHp: enemyHp, armor: 0, atk: enemyAtk); + } + + void playerAction(ActionType type, RiskLevel risk) { + if (!isPlayerTurn || player.isDead || enemy.isDead || showRewardPopup) return; + + isPlayerTurn = false; + notifyListeners(); + + _addLog("Player chose to ${type.name} with ${risk.name} risk."); + + final random = Random(); + bool success = false; + double efficiency = 1.0; + + switch (risk) { + case RiskLevel.safe: + success = random.nextDouble() < 1.0; // 100% + efficiency = 0.5; // 50% + break; + case RiskLevel.normal: + success = random.nextDouble() < 0.8; // 80% + efficiency = 1.0; // 100% + break; + case RiskLevel.risky: + success = random.nextDouble() < 0.4; // 40% + efficiency = 2.0; // 200% + break; + } + + if (success) { + if (type == ActionType.attack) { + int damage = (player.totalAtk * efficiency).toInt(); + _applyDamage(enemy, damage); + _addLog("Player dealt $damage damage to Enemy."); + } else { + int armorGained = (player.totalDefense * efficiency).toInt(); // Changed to totalDefense + player.armor += armorGained; + _addLog("Player gained $armorGained armor."); + } + } else { + _addLog("Player's action missed!"); + } + + if (enemy.isDead) { + _onVictory(); + return; + } + + Future.delayed(const Duration(seconds: 1), () => _enemyTurn()); + } + + Future _enemyTurn() async { + if (!isPlayerTurn && (player.isDead || enemy.isDead)) return; // Check if it's the enemy's turn and battle is over + + _addLog("Enemy's turn..."); + + // Enemy attacks player + await Future.delayed(const Duration(seconds: 1)); // Simulating thinking time + + int incomingDamage = enemy.totalAtk; + int damageToHp = 0; + + 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."); + } + + // Player's turn starts, armor decays + if (player.armor > 0) { + player.armor = (player.armor * 0.5).toInt(); + _addLog("Player's armor decayed to ${player.armor}."); + } + + if (player.isDead) { + _addLog("Player defeated! Enemy wins!"); + } + + isPlayerTurn = true; + notifyListeners(); + } + + void _applyDamage(Character target, int damage) { + target.hp -= damage; + if (target.hp < 0) target.hp = 0; + } + + void _addLog(String message) { + battleLogs.add(message); + notifyListeners(); + } + + void _onVictory() { + _addLog("Enemy defeated! Choose a reward."); + + final random = Random(); + List allTemplates = List.from(ItemTable.allItems); + allTemplates.shuffle(random); // Shuffle to randomize selection + + // Take first 3 items (ensure distinct templates if possible, though list is small now) + int count = min(3, allTemplates.length); + rewardOptions = allTemplates.sublist(0, count).map((template) { + return template.createItem(stage: stage); + }).toList(); + + showRewardPopup = true; + notifyListeners(); + } + + void selectReward(Item item) { + bool added = player.addToInventory(item); + if (added) { + _addLog("Added ${item.name} to inventory."); + } else { + _addLog("Inventory is full! ${item.name} discarded."); + } + + // Heal player after selecting reward + int healAmount = GameMath.floor(player.totalMaxHp * 0.5); + player.heal(healAmount); + _addLog("Stage Cleared! Recovered $healAmount HP."); + + stage++; + showRewardPopup = false; + + _spawnEnemy(); + _addLog("Stage $stage started! A wild ${enemy.name} appeared."); + + isPlayerTurn = true; + notifyListeners(); + } + + void equipItem(Item item) { + if (player.equip(item)) { + _addLog("Equipped ${item.name}."); + } else { + _addLog("Failed to equip ${item.name}."); // Should not happen if logic is correct + } + notifyListeners(); + } + + void unequipItem(Item item) { + if (player.unequip(item)) { + _addLog("Unequipped ${item.name}."); + } else { + _addLog("Failed to unequip ${item.name} (Inventory might be full)."); + } + notifyListeners(); + } +} diff --git a/lib/screens/battle_screen.dart b/lib/screens/battle_screen.dart new file mode 100644 index 0000000..5901028 --- /dev/null +++ b/lib/screens/battle_screen.dart @@ -0,0 +1,313 @@ +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'; + +class BattleScreen extends StatefulWidget { + const BattleScreen({super.key}); + + @override + State createState() => _BattleScreenState(); +} + +class _BattleScreenState extends State { + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + // Scroll to the bottom of the log when new messages are added + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + }); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _showRiskLevelSelection(BuildContext context, ActionType actionType) { + final player = context.read().player; + final baseValue = actionType == ActionType.attack + ? player.totalAtk + : player.totalDefense; + + showDialog( + context: context, + builder: (BuildContext context) { + return SimpleDialog( + title: Text("Select Risk Level for ${actionType.name}"), + children: RiskLevel.values.map((risk) { + String infoText = ""; + Color infoColor = Colors.black; + double efficiency = 0.0; + int expectedValue = 0; + + switch (risk) { + case RiskLevel.safe: + efficiency = 0.5; + infoColor = Colors.green; + break; + case RiskLevel.normal: + efficiency = 1.0; + infoColor = Colors.blue; + break; + case RiskLevel.risky: + efficiency = 2.0; + infoColor = Colors.red; + break; + } + + expectedValue = (baseValue * efficiency).toInt(); + String valueUnit = actionType == ActionType.attack + ? "Dmg" + : "Armor"; + String successRate = ""; + + switch (risk) { + case RiskLevel.safe: + successRate = "100%"; + break; + case RiskLevel.normal: + successRate = "80%"; + break; + case RiskLevel.risky: + successRate = "40%"; + break; + } + + infoText = + "Success: $successRate, Eff: ${(efficiency * 100).toInt()}% ($expectedValue $valueUnit)"; + + return SimpleDialogOption( + 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, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + }); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + risk.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + Text( + infoText, + style: TextStyle(fontSize: 12, color: infoColor), + ), + ], + ), + ); + }).toList(), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Consumer( + builder: (context, provider, child) => + Text("Colosseum's Choice - Stage ${provider.stage}"), + ), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => context.read().initializeBattle(), + ), + ], + ), + body: Consumer( + builder: (context, battleProvider, child) { + return Stack( + children: [ + Column( + children: [ + // Top (Status Area) + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildCharacterStatus( + battleProvider.enemy, + isEnemy: true, + ), + _buildCharacterStatus( + battleProvider.player, + isEnemy: false, + ), + ], + ), + ), + // Middle (Log 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, + ), + ); + }, + ), + ), + ), + // Bottom (Control Area) + 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, + ), + ], + ), + ), + ], + ), + if (battleProvider.showRewardPopup) + Container( + color: Colors.black54, + child: Center( + child: SimpleDialog( + title: const Text("Victory! Choose a Reward"), + children: battleProvider.rewardOptions.map((item) { + return SimpleDialogOption( + onPressed: () { + battleProvider.selectReward(item); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + _buildItemStatText(item), // Display stats here + Text( + item.description, + style: const TextStyle( + fontSize: 12, + color: Colors.grey, + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ), + ], + ); + }, + ), + ); + } + + Widget _buildItemStatText(Item item) { + List stats = []; + if (item.atkBonus > 0) stats.add("+${item.atkBonus} ATK"); + if (item.hpBonus > 0) stats.add("+${item.hpBonus} HP"); + if (item.armorBonus > 0) stats.add("+${item.armorBonus} DEF"); + + if (stats.isEmpty) return const SizedBox.shrink(); // Hide if no stats + + return Padding( + padding: const EdgeInsets.only(top: 4.0, bottom: 4.0), + child: Text( + stats.join(", "), + style: const TextStyle(fontSize: 12, color: Colors.blueAccent), + ), + ); + } + + Widget _buildCharacterStatus(Character character, {bool isEnemy = false}) { + return Column( + children: [ + 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: isEnemy ? Colors.red : Colors.green, + backgroundColor: Colors.grey, + ), + ), + if (!isEnemy) ...[ + Text("Armor: ${character.armor}"), + Text("ATK: ${character.totalAtk}"), + Text("DEF: ${character.totalDefense}"), + ], + ], + ); + } + + Widget _buildActionButton( + BuildContext context, + String text, + ActionType actionType, + bool isEnabled, + ) { + return ElevatedButton( + 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), + ); + } +} diff --git a/lib/screens/inventory_screen.dart b/lib/screens/inventory_screen.dart new file mode 100644 index 0000000..321bfd1 --- /dev/null +++ b/lib/screens/inventory_screen.dart @@ -0,0 +1,408 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/battle_provider.dart'; +import '../game/model/item.dart'; +import '../game/model/entity.dart'; + +class InventoryScreen extends StatelessWidget { + const InventoryScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("Inventory & Stats")), + body: Consumer( + builder: (context, battleProvider, child) { + final player = battleProvider.player; + + return Column( + children: [ + // Player Stats Header + Card( + margin: const EdgeInsets.all(16.0), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + Text( + player.name, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text("Stage: ${battleProvider.stage}"), + const Divider(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildStatItem( + "HP", + "${player.hp}/${player.totalMaxHp}", + ), + _buildStatItem("ATK", "${player.totalAtk}"), + _buildStatItem("DEF", "${player.totalDefense}"), + _buildStatItem("Shield", "${player.armor}"), // Temporary armor points + ], + ), + ], + ), + ), + ), + + // Equipped Items Section (Slot based) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Equipped Items", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: EquipmentSlot.values.map((slot) { + final item = player.equipment[slot]; + return Expanded( + child: InkWell( + onTap: item != null + ? () => _showUnequipConfirmationDialog(context, battleProvider, item) + : null, + child: Card( + color: item != null + ? Colors.blueGrey[600] + : Colors.grey[800], + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + Text( + slot.name.toUpperCase(), + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.grey, + ), + ), + const SizedBox(height: 4), + Icon( + _getIconForSlot(slot), + size: 24, + color: item != null + ? Colors.white + : Colors.grey, + ), + const SizedBox(height: 4), + Text( + item?.name ?? "Empty", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + color: item != null + ? Colors.white + : Colors.grey, + ), + overflow: TextOverflow.ellipsis, + ), + if (item != null) _buildItemStatText(item), + ], + ), + ), + ), + ), + ); + }).toList(), + ), + ], + ), + ), + + // Inventory (Bag) Section + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + "Bag (${player.inventory.length}/${player.maxInventorySize})", + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + Expanded( + child: GridView.builder( + padding: const EdgeInsets.all(16.0), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + crossAxisSpacing: 8.0, + mainAxisSpacing: 8.0, + ), + itemCount: player.maxInventorySize, + itemBuilder: (context, index) { + if (index < player.inventory.length) { + final item = player.inventory[index]; + return InkWell( + onTap: () { + // Show confirmation dialog before equipping + _showEquipConfirmationDialog( + context, + battleProvider, + item, + ); + }, + child: Card( + color: Colors.blueGrey[700], + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.backpack, size: 32), + Padding( + padding: const EdgeInsets.all(4.0), + child: Text( + item.name, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 10), + overflow: TextOverflow.ellipsis, + ), + ), + _buildItemStatText(item), + ], + ), + ), + ); + } else { + // Empty slot + return Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + color: Colors.grey[800], + ), + child: const Center( + child: Icon(Icons.add_box, color: Colors.grey), + ), + ); + } + }, + ), + ), + ], + ); + }, + ), + ); + } + + 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) { + return Column( + children: [ + Text(label, style: const TextStyle(color: Colors.grey, fontSize: 12)), + Text( + value, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + ], + ); + } + + void _showEquipConfirmationDialog( + BuildContext context, + BattleProvider provider, + Item newItem, + ) { + final player = provider.player; + final oldItem = player.equipment[newItem.slot]; + + // Calculate predicted stats + final currentMaxHp = player.totalMaxHp; + final currentAtk = player.totalAtk; + final currentDef = player.totalDefense; + final currentHp = player.hp; + + // Predict new stats + int newMaxHp = currentMaxHp - (oldItem?.hpBonus ?? 0) + newItem.hpBonus; + int newAtk = currentAtk - (oldItem?.atkBonus ?? 0) + newItem.atkBonus; + int newDef = currentDef - (oldItem?.armorBonus ?? 0) + newItem.armorBonus; + + // Predict HP (Percentage Logic) + double ratio = currentMaxHp > 0 ? currentHp / currentMaxHp : 0.0; + int newHp = (newMaxHp * ratio).toInt(); + if (newHp < 0) newHp = 0; + if (newHp > newMaxHp) newHp = newMaxHp; + + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text("Change Equipment"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Equip ${newItem.name}?", + style: const TextStyle(fontWeight: FontWeight.bold), + ), + if (oldItem != null) + Text( + "Replaces ${oldItem.name}", + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + const SizedBox(height: 16), + _buildStatChangeRow("Max HP", currentMaxHp, newMaxHp), + _buildStatChangeRow("Current HP", currentHp, newHp), + _buildStatChangeRow("ATK", currentAtk, newAtk), + _buildStatChangeRow("DEF", currentDef, newDef), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text("Cancel"), + ), + ElevatedButton( + onPressed: () { + provider.equipItem(newItem); + Navigator.pop(ctx); + }, + child: const Text("Confirm"), + ), + ], + ), + ); + } + + void _showUnequipConfirmationDialog( + BuildContext context, + BattleProvider provider, + Item itemToUnequip, + ) { + final player = provider.player; + + // Calculate predicted stats + final currentMaxHp = player.totalMaxHp; + final currentAtk = player.totalAtk; + final currentDef = player.totalDefense; + final currentHp = player.hp; + + // Predict new stats (Subtract item bonuses) + int newMaxHp = currentMaxHp - itemToUnequip.hpBonus; + int newAtk = currentAtk - itemToUnequip.atkBonus; + int newDef = currentDef - itemToUnequip.armorBonus; + + // Predict HP (Percentage Logic) + double ratio = currentMaxHp > 0 ? currentHp / currentMaxHp : 0.0; + int newHp = (newMaxHp * ratio).toInt(); + if (newHp < 0) newHp = 0; + if (newHp > newMaxHp) newHp = newMaxHp; + + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text("Unequip Item"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Unequip ${itemToUnequip.name}?", + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + _buildStatChangeRow("Max HP", currentMaxHp, newMaxHp), + _buildStatChangeRow("Current HP", currentHp, newHp), + _buildStatChangeRow("ATK", currentAtk, newAtk), + _buildStatChangeRow("DEF", currentDef, newDef), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text("Cancel"), + ), + ElevatedButton( + onPressed: () { + provider.unequipItem(itemToUnequip); + Navigator.pop(ctx); + }, + child: const Text("Confirm"), + ), + ], + ), + ); + } + + Widget _buildStatChangeRow(String label, int oldVal, int newVal) { + int diff = newVal - oldVal; + Color color = diff > 0 + ? Colors.green + : (diff < 0 ? Colors.red : Colors.grey); + String diffText = diff > 0 ? "(+$diff)" : (diff < 0 ? "($diff)" : ""); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + Row( + children: [ + Text("$oldVal", style: const TextStyle(color: Colors.grey)), + const Icon(Icons.arrow_right, size: 16, color: Colors.grey), + Text( + "$newVal", + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(width: 4), + Text( + diffText, + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildItemStatText(Item item) { + List stats = []; + if (item.atkBonus > 0) stats.add("+${item.atkBonus} ATK"); + if (item.hpBonus > 0) stats.add("+${item.hpBonus} HP"); + if (item.armorBonus > 0) stats.add("+${item.armorBonus} DEF"); + + if (stats.isEmpty) return const SizedBox.shrink(); // Hide if no stats + + return Padding( + padding: const EdgeInsets.only(top: 2.0, bottom: 2.0), + child: Text( + stats.join(", "), + style: const TextStyle(fontSize: 10, color: Colors.blueAccent), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/main_wrapper.dart b/lib/screens/main_wrapper.dart new file mode 100644 index 0000000..683afb3 --- /dev/null +++ b/lib/screens/main_wrapper.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'battle_screen.dart'; +import 'inventory_screen.dart'; + +class MainWrapper extends StatefulWidget { + const MainWrapper({super.key}); + + @override + State createState() => _MainWrapperState(); +} + +class _MainWrapperState extends State { + int _currentIndex = 0; + + 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', + ), + BottomNavigationBarItem( + icon: Icon(Icons.backpack), + label: 'Inventory', + ), + ], + ), + ); + } +} diff --git a/lib/utils/game_math.dart b/lib/utils/game_math.dart new file mode 100644 index 0000000..b67a25a --- /dev/null +++ b/lib/utils/game_math.dart @@ -0,0 +1,5 @@ +class GameMath { + static int floor(double value) { + return value.floor(); + } +} diff --git a/prompt/01_game_prototype_request.md b/prompt/01_game_prototype_request.md new file mode 100644 index 0000000..e83f5c9 --- /dev/null +++ b/prompt/01_game_prototype_request.md @@ -0,0 +1,91 @@ +# Role + +You are a Senior Flutter Developer. +Your task is to build a functional prototype for a "Text/UI-based Turn-based Roguelike Game" called "Colosseum's Choice". + +# Technology Stack + +- **Framework:** Flutter (Pure Flutter, NO Game Engine like Flame) +- **State Management:** Provider +- **Architecture:** MVVM (Model - Provider - Screen) +- **Theme:** Dark Mode + +# Core Game Mechanics + +1. **Risk vs Return:** The player chooses an action (Attack/Defend) and then selects a Risk Level (Safe/Normal/Risky). Higher risk means lower success chance but higher effect. +2. **Armor System:** Armor reduces incoming damage. Player's Armor decays by 50% at the start of their turn. +3. **Turn-Based:** Player acts -> Result processing -> Delay (1 sec) -> Enemy acts -> Result processing. + +# Required Files & Implementation Details + +Please generate the complete Dart code for the following 4 files. +**IMPORTANT:** The code must be complete, error-free, and ready to run after adding the `provider` package. + +--- + +## 1. `lib/models/character.dart` + +**Description:** Data model for Player and Enemy. + +- **Fields:** `String name`, `int hp`, `int maxHp`, `int armor`, `int atk`. +- **Constructor:** Initialize properties. `hp` defaults to `maxHp` if not provided. +- **Methods:** + - `bool get isDead => hp <= 0;` + +## 2. `lib/providers/battle_provider.dart` + +**Description:** Central logic controller using `ChangeNotifier`. + +- **Properties:** + - `Character player`, `Character enemy` + - `List battleLogs` (Stores combat history) + - `bool isPlayerTurn` (To disable buttons during enemy turn) +- **Methods:** + - `void initializeBattle()`: Reset stats, clear logs. Player(HP:100, ATK:10), Enemy(HP:100, ATK:8). + - `void playerAction(ActionType type, RiskLevel risk)`: + 1. **Risk Logic:** + - **Safe:** 100% Success, 50% Efficiency. + - **Normal:** 80% Success, 100% Efficiency. + - **Risky:** 40% Success, 200% Efficiency. + 2. **Calculate Result:** Roll dice. If success, apply Damage (Attack) or Gain Armor (Defend). If fail, log "Miss". + 3. **Turn End:** Call `_enemyTurn()` after a short delay. + - `Future _enemyTurn()`: + - Wait 1 second (simulating thinking). + - Enemy attacks player. (Damage = Enemy ATK - Player Armor). + - Start Player's new turn: **Reduce Player Armor by 50%**. + - `void _addLog(String message)`: Add to list and notify listeners. + +**Enums:** + +- `enum ActionType { attack, defend }` +- `enum RiskLevel { safe, normal, risky }` + +## 3. `lib/screens/battle_screen.dart` + +**Description:** The main UI. + +- **Layout (Column):** + - **Top (Status Area):** Row displaying [Enemy Name/HP] and [Player HP/Armor]. Use `LinearProgressIndicator` for HP bars. + - **Middle (Log Area):** `Expanded` -> `ListView.builder`. + - **Crucial:** Use `ScrollController` to auto-scroll to the bottom whenever a new log is added. + - Style: Black background, green/white text font `Monospace`. + - **Bottom (Control Area):** + - Two large buttons: [ATTACK], [DEFEND]. + - On press, show a `SimpleDialog` or `BottomSheet` to select Risk Level (Safe/Normal/Risky). + - Disable buttons if `!isPlayerTurn` or `game over`. + +## 4. `lib/main.dart` + +**Description:** Entry point. + +- `main()`: `runApp`. +- `MyApp`: Uses `MultiProvider` to provide `BattleProvider`. +- `MaterialApp`: + - `theme`: `ThemeData.dark()`. + - `home`: `BattleScreen`. + +--- + +# Output Format + +Please provide the code for each file in separate code blocks. diff --git a/prompt/02_item_system_request.md b/prompt/02_item_system_request.md new file mode 100644 index 0000000..2574686 --- /dev/null +++ b/prompt/02_item_system_request.md @@ -0,0 +1,73 @@ +# Role + +You are a Senior Flutter Developer. +You are continuing the development of "Colosseum's Choice". +The basic battle prototype is already working. +Now, you need to implement the **Item & Progression System**. + +# Goal + +Modify the existing code to implement the following features: + +1. **Item Model:** Create items that boost stats (ATK, MaxHP). +2. **Inventory System:** Player can equip items, and stats are calculated dynamically (Base + Item Bonus). +3. **Battle Loop:** + - **Victory:** When Enemy HP <= 0, show a dialog to choose 1 of 3 random items. + - **Progression:** After picking an item, the next battle starts immediately with a slightly stronger enemy. + - **HP Rule:** Player HP is NOT fully restored between battles (Roguelike element). + +# Required Changes & Implementation Details + +Please generate the updated code for the following files. +**IMPORTANT:** Preserve the existing "Risk vs Return" and "Armor Decay" logic. + +--- + +## 1. `lib/models/item.dart` (New File) + +**Description:** + +- **Fields:** `String name`, `String description`, `int atkBonus`, `int hpBonus`. +- **Constructor:** Standard constructor. + +## 2. `lib/models/character.dart` (Modify) + +**Description:** Update to support equipment. + +- **New Fields:** `List equipment`. +- **Stat Logic:** + - `int get totalAtk`: Returns `baseAtk` + sum of all equipped items' `atkBonus`. + - `int get totalMaxHp`: Returns `baseMaxHp` + sum of all equipped items' `hpBonus`. + - **Important:** Use `totalAtk` and `totalMaxHp` for battle logic instead of raw fields. +- **Methods:** + - `void equip(Item item)`: Add to equipment. If `hpBonus` > 0, increase current `hp` by that amount as well (optional heal). + +## 3. `lib/providers/battle_provider.dart` (Modify) + +**Description:** Handle Victory and Stage Progression. + +- **New Properties:** + - `int stage`: Tracks current stage number (starts at 1). + - `List rewardOptions`: Stores the 3 random items generated upon victory. + - `bool showRewardPopup`: Flag to trigger UI dialog. +- **Methods:** + - `initializeBattle()`: Reset Player (Stage 1). + - `_onVictory()` (Internal): Called when Enemy dies. Generate 3 random items (e.g., "Rusty Sword (+2 ATK)", "Leather Vest (+10 HP)"). Set `showRewardPopup = true`. + - `selectReward(Item item)`: Equip item to player -> Increase Stage -> Spawn stronger Enemy (Scale Enemy stats by Stage) -> Reset `showRewardPopup`. + - **Update `playerAction`**: Ensure it uses `player.totalAtk` for damage calculation. + +## 4. `lib/screens/battle_screen.dart` (Modify) + +**Description:** Add UI for stats and rewards. + +- **Top Area:** Display `Stage: X`. Update HP bars to show `current / totalMaxHp`. +- **Victory Handling:** + - Use `Consumer` to listen to `battleProvider`. + - If `provider.showRewardPopup` is true, show a `SimpleDialog` (or similar) listing the `rewardOptions`. + - Clicking an option calls `provider.selectReward(item)`. + +--- + +# Output Format + +Please provide the complete code for the modified/new files. diff --git a/prompt/03_inventory_ui_request.md b/prompt/03_inventory_ui_request.md new file mode 100644 index 0000000..554db4c --- /dev/null +++ b/prompt/03_inventory_ui_request.md @@ -0,0 +1,65 @@ +# Role + +You are a Senior Flutter Developer working on "Colosseum's Choice". +The core battle and item system are working perfectly. +Your goal is to implement the **Inventory UI** and **Navigation System**. + +# Requirements + +1. **Navigation (`BottomNavigationBar`):** + + - Create a main wrapper screen to switch between "Battle" and "Inventory". + - **Critical:** Use `IndexedStack` to preserve the state of the `BattleScreen` (keep the fight running) while viewing the Inventory. + +2. **Inventory Screen:** + + - Display the Player's detailed Stats (Total ATK, Total HP, Armor, etc.). + - List all collected/equipped items (`player.equipment`). + - Show each item's name and bonus stats (e.g., "Rusty Sword (+2 ATK)"). + +3. **Refactoring:** + - Update `main.dart` to point to the new Main Wrapper Screen. + +# Required Files + +Please generate the code for the following files. + +--- + +## 1. `lib/screens/inventory_screen.dart` (New File) + +**Description:** + +- **Header:** Show Player Name, Stage, Total HP, Total ATK, Current Armor. +- **Body:** A `ListView` of `player.equipment`. +- **Item Tile:** `Card` or `ListTile` showing: + - Leading: Icon (e.g., `Icons.shield` or `Icons.security`). + - Title: Item Name. + - Subtitle: Description & Stat Bonuses. +- **State:** Use `Consumer` to display live data. + +## 2. `lib/screens/main_wrapper.dart` (New File) + +**Description:** + +- **Widget:** `StatefulWidget`. +- **State:** Holds `_currentIndex` (0 = Battle, 1 = Inventory). +- **Build:** + - Return a `Scaffold`. + - `body`: `IndexedStack` with children `[BattleScreen(), InventoryScreen()]`. + - `bottomNavigationBar`: `BottomNavigationBar` with 2 items: + - Battle (`Icons.sports_kabaddi` or `Icons.flash_on`). + - Inventory (`Icons.backpack` or `Icons.inventory`). + - **Theme:** Ensure the bottom bar matches the Dark Theme. + +## 3. `lib/main.dart` (Update) + +**Description:** + +- Change `home` from `BattleScreen` to `MainWrapper`. + +--- + +# Output Format + +Please provide the complete code for the 3 files above. diff --git a/prompt/04_inventory_slots_request.md b/prompt/04_inventory_slots_request.md new file mode 100644 index 0000000..d5e2230 --- /dev/null +++ b/prompt/04_inventory_slots_request.md @@ -0,0 +1,66 @@ +# Role + +You are a Senior Flutter Developer working on "Colosseum's Choice". +You need to upgrade the Inventory System to a **Grid-based Slot System**. + +# Goal + +Separate "Equipped Items" from "Inventory Items" and create a fixed 16-slot inventory interface. + +# Key Requirements + +1. **Character Model Update:** + - Maintain `equipment` list (Items currently providing stats). + - Add `inventory` list (Items in the bag, providing NO stats). + - Limit `inventory` size to **16 slots**. + - Add methods: `equipItem(item)`, `unequipItem(item)`. +2. **Battle Logic Update:** + - **Victory Reward:** When an item is selected, add it to `inventory` (not `equipment`). + - If inventory is full (16 items), show a "Inventory Full" message (Snack bar or Log) and discard the item (Simple logic for now). +3. **UI Update (Inventory Screen):** + - **Section 1: Equipment:** Show currently equipped items (List or Row). Tap to Unequip. + - **Section 2: Inventory (Bag):** Use `GridView` with **fixed 16 slots** (4x4 grid). + - If a slot has an item: Show Icon & Name. Tap to Equip. + - If a slot is empty: Show an empty box container. + +# Required Files + +Please generate the updated code for the following files. + +--- + +## 1. `lib/models/character.dart` (Update) + +**Changes:** + +- Add `List inventory = [];`. +- Add `int maxInventorySize = 16;`. +- Method `addToInventory(Item item)`: Adds to inventory if length < 16. Returns success boolean. +- Method `equip(Item item)`: Moves item from `inventory` to `equipment`. +- Method `unequip(Item item)`: Moves item from `equipment` to `inventory` (check space first). + +## 2. `lib/providers/battle_provider.dart` (Update) + +**Changes:** + +- `selectReward(Item item)`: Now calls `player.addToInventory(item)`. + - If false (full), add a log "Inventory is full! Item discarded.". +- Add `equipItem(Item item)`: Calls player logic and notifies listeners. +- Add `unequipItem(Item item)`: Calls player logic and notifies listeners. + +## 3. `lib/screens/inventory_screen.dart` (Update) + +**Layout:** + +- **Top (Stats):** Keep existing stat display. +- **Middle (Equipped):** "Currently Equipped" Label -> `ListView` (horizontal or vertical, compact). OnTap -> `provider.unequipItem`. +- **Bottom (Inventory):** "Bag (X/16)" Label -> `GridView.builder` with `itemCount: 16`. + - Loop 0 to 15. + - If index < `player.inventory.length`, render the Item Tile (Tap to `equipItem`). + - Else, render an Empty Slot (Grey container with border). + +--- + +# Output Format + +Please provide the complete code for the 3 modified files. diff --git a/prompt/05_equipment_slot_refactor.md b/prompt/05_equipment_slot_refactor.md new file mode 100644 index 0000000..c041032 --- /dev/null +++ b/prompt/05_equipment_slot_refactor.md @@ -0,0 +1,65 @@ +# Role + +You are a Senior Flutter Developer working on "Colosseum's Choice". +You need to refactor the **Equipment System** to enforce **Slot-based restrictions**. + +# Current Problem + +Currently, `equipment` is a `List`, allowing the player to equip multiple weapons or armors simultaneously. + +# Solution + +Refactor the code to use an `Enum` based Map system: `Map`. + +- **Slots:** `weapon`, `armor`, `accessory`. +- **Rule:** Only one item per slot. Equipping a new item into an occupied slot should **SWAP** them (Old item goes to Inventory, New item goes to Equipment). + +# Required Changes + +Please generate the updated code for the following files. + +--- + +## 1. `lib/models/item.dart` (Update) + +- **Enum:** Create `enum EquipmentSlot { weapon, armor, accessory }`. +- **Class:** Add `final EquipmentSlot slot;` to the `Item` class. +- **Constructor:** Update to require `slot`. +- **Helper:** Add a getter `String get typeName` (returns "Weapon", "Armor", etc. based on enum). + +## 2. `lib/models/character.dart` (Update) + +- **Field Change:** Change `List equipment` to `Map equipment = {};`. +- **Stat Logic:** Update `totalAtk` / `totalMaxHp` to iterate over `equipment.values`. +- **Method `equip(Item newItem)`:** + 1. Check `newItem.slot`. + 2. If `equipment[newItem.slot]` exists: + - Move the _existing_ item to `inventory`. + 3. Remove `newItem` from `inventory`. + 4. Set `equipment[newItem.slot] = newItem`. +- **Method `unequip(Item item)`:** + 1. Check if inventory has space. + 2. Remove from `equipment`. + 3. Add to `inventory`. + +## 3. `lib/providers/battle_provider.dart` (Update) + +- **Item Generation (`_onVictory`):** + - When generating random items, assign appropriate slots. + - Example: "Sword" -> `EquipmentSlot.weapon`, "Plate" -> `EquipmentSlot.armor`. +- **Equip Logic:** `equipItem` now just calls `player.equip(item)` (Swap logic is inside Character). + +## 4. `lib/screens/inventory_screen.dart` (Update) + +- **Equipped Area (UI Change):** + - Instead of a ListView, create a **Row with 3 fixed Cards** (Weapon / Armor / Accessory). + - **Loop:** Iterate through `EquipmentSlot.values`. + - **Content:** + - If `player.equipment[slot]` exists: Show Item Icon & Name. Tap to Unequip. + - If null: Show "Empty [Slot Name]" placeholder. + +--- + +# Output Format + +Please provide the complete code for the 4 modified files. diff --git a/prompt/06_fix_hp_logic.md b/prompt/06_fix_hp_logic.md new file mode 100644 index 0000000..5075dc3 --- /dev/null +++ b/prompt/06_fix_hp_logic.md @@ -0,0 +1,38 @@ +# Role + +You are a Senior Flutter Developer working on "Colosseum's Choice". +You need to fix a critical bug in the **HP Calculation Logic** within the `Character` model. + +# Problem + +1. **Sudden Death:** Unequipping an item subtracts the HP bonus from Current HP. If Current HP is low, the player dies instantly. +2. **Accidental Revive:** Equipping an item adds the HP bonus to Current HP. If the player is dead (0 HP), this revives them. + +# Solution + +1. **Unequip Logic:** Do NOT subtract the bonus. Instead, check if `Current HP > New Total Max HP`. If so, set `Current HP = New Total Max HP`. (Clamp logic). +2. **Equip Logic:** Only add the HP bonus to Current HP if the player is **Alive** (`hp > 0`). + +# Required Changes + +Please generate the updated code for the following file. + +--- + +## 1. `lib/models/character.dart` (Fix) + +**Methods to Update:** + +- `equip(Item item)`: + - Handle swapping (unequip old item first). + - Add new item to `equipment`. + - **Fix:** Only execute `hp += item.hpBonus` if `hp > 0` (Player is alive). +- `unequip(Item item)`: + - Remove item from `equipment`. + - **Fix:** Do NOT do `hp -= hpBonus`. Instead, calculate `totalMaxHp` (which uses the updated equipment list) and ensure `hp` does not exceed it (`if (hp > totalMaxHp) hp = totalMaxHp;`). + +--- + +# Output Format + +Please provide the complete code for `lib/models/character.dart`. diff --git a/prompt/07_stage_heal_and_math_utils.md b/prompt/07_stage_heal_and_math_utils.md new file mode 100644 index 0000000..1efda1d --- /dev/null +++ b/prompt/07_stage_heal_and_math_utils.md @@ -0,0 +1,51 @@ +# Role + +You are a Senior Flutter Developer working on "Colosseum's Choice". +You need to implement a **Stage Recovery System** and a **Global Math Utility**. + +# Goals + +1. **Global Math Utility:** Create a central place to handle math logic (specifically "flooring" values) to be used across the game for consistency. +2. **Stage Recovery:** When a player clears a stage (selects a reward), heal the player for **50% of their Total Max HP** (rounded down). +3. **Character Logic:** Ensure the `Character` class has a proper `heal` method that respects `maxHp`. + +# Required Changes + +Please generate the code for the following files. + +--- + +## 1. `lib/utils/game_math.dart` (New File) + +**Description:** A static utility class for game calculations. +**Methods:** + +- `static int floor(double value)`: Returns the integer part of the value (rounds down). Use this for all percentage-based calculations in the game. + +## 2. `lib/models/character.dart` (Update) + +**Description:** Add healing capability. +**Methods:** + +- `void heal(int amount)`: + - Add `amount` to `hp`. + - Clamp `hp` so it does not exceed `totalMaxHp`. + - **Important:** Only allow healing if `hp > 0` (Dead characters cannot be healed). + +## 3. `lib/providers/battle_provider.dart` (Update) + +**Description:** Implement the healing logic upon stage completion. +**Methods:** + +- `selectReward(Item item)`: + - (Existing logic: Add item to inventory, increase stage...). + - **New Logic:** + 1. Calculate heal amount: `GameMath.floor(player.totalMaxHp * 0.5)`. + 2. Call `player.heal(healAmount)`. + 3. Add a log message: "Stage Cleared! Recovered $healAmount HP.". + +--- + +# Output Format + +Please provide the complete code for `lib/utils/game_math.dart` and the updated `character.dart`, `battle_provider.dart`. diff --git a/prompt/08_fix_equip_hp_exploit.md b/prompt/08_fix_equip_hp_exploit.md new file mode 100644 index 0000000..6b1bbfe --- /dev/null +++ b/prompt/08_fix_equip_hp_exploit.md @@ -0,0 +1,21 @@ +# 장비 착용/해제 시 HP 처리 로직 수정 + +## 현재 상황 및 문제점 +현재 시스템에서는 방어구나 장신구 등 최대 체력(Max HP)을 올려주는 장비를 착용하거나 해제할 때, 체력 처리 방식에 따라 예상치 못한 동작(예: 체력 회복 꼼수 등)이 발생할 수 있습니다. + +## 요청 사항 +장비를 착용하거나 해제할 때, **최대 체력(Max HP)의 변동에 관계없이 현재 체력(Current HP)의 퍼센트(%) 비율을 유지**하도록 로직을 수정해주세요. + +### 구체적인 요구조건 +1. **장비 변경 전 현재 HP 비율 계산:** 장비 착용/해제 전에 `Current HP / Max HP` 비율을 계산합니다. +2. **장비 변경 후 HP 적용:** 장비 변경(Max HP 변화)이 발생한 후, 이전에 계산한 HP 비율을 새로운 `Max HP`에 적용하여 `Current HP`를 설정합니다. + * 예시: 현재 50/100 (50%) -> 장비 착용으로 Max HP가 150이 되면, Current HP는 75 (150의 50%)로 조정됩니다. + * 예시: 현재 150/150 (100%) -> 장비 해제로 Max HP가 100이 되면, Current HP는 100 (100의 100%)으로 조정됩니다. +3. **최소값 및 최대값 보정:** `Current HP`는 항상 0보다 크거나 같아야 하며, 새로운 `Max HP`를 초과할 수 없습니다. + +## 목표 +- 장비 변경 시 `Current HP`가 `Max HP`의 비율에 맞춰 일관성 있게 변화하도록 합니다. + +## 참고 코드 +- `lib/game/model/entity.dart` (Character 클래스 내 장비 착용/해제 로직) +- `lib/providers/battle_provider.dart` (장비 장착/해제 액션 처리) \ No newline at end of file diff --git a/prompt/09_ui_ux_improvements.md b/prompt/09_ui_ux_improvements.md new file mode 100644 index 0000000..4aa838c --- /dev/null +++ b/prompt/09_ui_ux_improvements.md @@ -0,0 +1,36 @@ +# 전투 UI 개선 및 장비 변경 UX 강화 + +## 목표 +전투 화면에서의 사용자 선택에 대한 정보를 명확히 제공하고, 인벤토리에서 장비 변경 시 사용자의 실수를 방지하며 변경 사항을 미리 확인할 수 있도록 UX를 개선합니다. + +## 요청 사항 + +### 1. 전투 행동 UI 개선 (공격/방어 확률 및 예상 수치 명시) +현재 전투 화면에서 공격(Attack) 및 방어(Defend) 버튼을 누를 때 나타나는 리스크 수준(Safe, Normal, Risky)에 대한 성공 확률과 효율 정보뿐만 아니라, **실제 적용될 예상 수치**를 함께 표시해주세요. + +- **변경 전:** 단순히 버튼만 존재하거나 텍스트로만 표시됨. +- **변경 후:** 각 행동 선택지 옆이나 하단에 구체적인 확률과 **예상 데미지/방어량**을 명시합니다. + - **Safe:** 성공률 100%, 효율 50% (예: 데미지 5) + - **Normal:** 성공률 80%, 효율 100% (예: 데미지 10) + - **Risky:** 성공률 40%, 효율 200% (예: 데미지 20) +- **계산식:** `Player Total ATK * Efficiency` +- 사용자가 선택하기 전에 자신이 입힐 데미지나 얻을 방어도가 얼마인지 직관적으로 알 수 있어야 합니다. + +### 2. 장비 변경/해제 Preview 및 확인 절차 추가 +인벤토리에서 장비를 **장착(교체)**하거나 **해제(Unequip)**할 때, 변경되는 스탯 정보를 미리 보여주고 사용자에게 최종 확인을 받는 팝업을 구현해주세요. + +- **동작 흐름:** + 1. 인벤토리의 아이템을 선택(장착 시도)하거나 장착된 슬롯을 선택(해제 시도). + 2. **"장비 변경 확인" 팝업**이 표시됨. + 3. 팝업 내용: + - **변경 전 스탯:** 현재 공격력(ATK), 체력(HP/MaxHP), 방어력(Armor) 등 + - **변경 후 스탯:** 장비 교체/해제 시 예상되는 공격력, 체력, 방어력 + - **스탯 변화량:** 상승(초록색), 하락(빨간색) 등으로 시각적 차별화 권장 + - **Current HP 예측:** 장비 변경 전후 HP 퍼센트 유지 로직 적용 + 4. **"변경하시겠습니까?"** (또는 "해제하시겠습니까?") 문구와 함께 [확인] / [취소] 버튼 제공. + 5. [확인] 클릭 시 장비 교체 또는 해제 로직 실행. + +## 관련 파일 +- `lib/screens/battle_screen.dart`: 전투 UI, 예상 데미지/방어량 계산 및 표시 +- `lib/screens/inventory_screen.dart`: 인벤토리 UI, 장착 및 해제 시 스탯 프리뷰 팝업 +- `lib/providers/battle_provider.dart`: 전투 로직 및 확률 데이터 참조 \ No newline at end of file diff --git a/prompt/10_add_shield_and_armor_mechanic.md b/prompt/10_add_shield_and_armor_mechanic.md new file mode 100644 index 0000000..1d9f4f8 --- /dev/null +++ b/prompt/10_add_shield_and_armor_mechanic.md @@ -0,0 +1,34 @@ +# 방패 아이템 추가 및 방어 메커니즘 개편 + +## 목표 +게임에 '방패(Shield)' 장비 슬롯을 추가하고, 전투 중 '방어(Defend)' 행동의 효율 계산 방식을 공격력(ATK) 기반에서 방어력(Armor) 기반으로 변경합니다. + +## 요청 사항 + +### 1. 장비 슬롯 및 아이템 속성 확장 +- **EquipmentSlot 추가:** `shield` 슬롯을 추가합니다. (기존: weapon, armor, accessory) +- **Item 속성 추가:** 아이템에 물리적 방어력을 나타내는 `armorBonus` 속성을 추가합니다. + - 방패(Shield)와 갑옷(Armor) 아이템은 주로 `armorBonus`를 제공해야 합니다. + - 기존 갑옷(Armor) 아이템이 MaxHP를 올려주던 컨셉을 유지할지, 방어력으로 변경할지 결정이 필요하나, 요청에 따라 **방패는 Armor 포인트**를 올려주는 역할을 합니다. + +### 2. 캐릭터 스탯 로직 변경 +- **Total Armor 계산:** 캐릭터의 총 방어력(`totalArmor`)은 `기본 방어력 + 장착 아이템의 armorBonus 합계`로 계산됩니다. +- **기본 스탯:** 캐릭터 생성 시 적절한 기본 방어력(Base Armor)을 부여하거나 0으로 시작합니다. + +### 3. 전투 시스템 (방어 행동) 변경 +- **Defend 메커니즘 수정:** + - 기존: `Armor Gained = Total ATK * Efficiency` + - **변경:** `Armor Gained = Total Armor * Efficiency` + - 즉, 방어력이 높을수록 방어 행동(Defend) 시 더 단단한 일시적 보호막(Temporary Armor)을 얻게 됩니다. + - *주의:* `totalArmor`가 0이면 방어 행동의 효과가 0이 되므로, 최소한의 기본 방어력을 보장하거나 로직을 조정해야 합니다. + +### 4. UI 및 아이템 생성 로직 업데이트 +- **인벤토리 화면:** 방패 슬롯을 UI에 표시하고, 아이콘을 지정합니다. +- **보상 시스템:** 전투 승리 보상 목록에 '방패'가 등장하도록 추가합니다. +- **스탯 프리뷰:** 장비 교체 팝업 등에서 `Armor` 스탯의 변화도 보여주어야 합니다. + +## 관련 파일 +- `lib/game/model/item.dart`: `EquipmentSlot`, `Item` 필드 수정 +- `lib/game/model/entity.dart`: `totalArmor` getter 추가 및 관련 로직 +- `lib/providers/battle_provider.dart`: `defend` 로직 수정, 방패 드랍 로직 추가 +- `lib/screens/inventory_screen.dart`: UI 업데이트 diff --git a/prompt/11_item_table_and_rewards.md b/prompt/11_item_table_and_rewards.md new file mode 100644 index 0000000..1d64efc --- /dev/null +++ b/prompt/11_item_table_and_rewards.md @@ -0,0 +1,44 @@ +# 아이템 테이블 구축 및 보상 시스템 개편 + +## 목표 +하드코딩된 랜덤 아이템 생성 로직을 제거하고, 사전에 정의된 **아이템 드랍 테이블(Item Drop Table)**을 기반으로 보상을 생성하도록 시스템을 개편합니다. 또한, 게임 시작 시 기본 장비 지급 로직을 공식화합니다. + +## 요청 사항 + +### 1. 아이템 데이터 테이블 생성 +부위별로 다양한 아이템의 이름과 스탯 옵션을 정의하는 데이터 구조(List 또는 Map)를 만들어주세요. +각 아이템은 고정된 이름과 기본 스탯 범위를 가지거나, 티어별로 구분될 수 있습니다. + +**예시 데이터 구조 (개념):** +* **Weapons:** + * "Rusty Sword" (ATK +3) + * "Iron Sword" (ATK +8) + * "Steel Claymore" (ATK +15) +* **Armors:** + * "Tattered Shirt" (HP +10) + * "Leather Vest" (HP +30) + * "Chainmail" (HP +60) +* **Shields:** + * "Wooden Lid" (DEF +2) + * "Round Shield" (DEF +5) + * "Tower Shield" (DEF +10) +* **Accessories:** + * "Old Ring" (ATK +1, HP +5) + * "Ruby Ring" (ATK +5, HP +10) + +### 2. 스테이지 보상 로직 변경 +- **기존:** `Random`으로 이름과 수치를 즉석에서 생성. +- **변경:** + 1. 정의된 **아이템 테이블**에서 3개의 아이템을 무작위로 선택합니다. (중복 방지 권장) + 2. 스테이지가 높아질수록 더 좋은 아이템이 나올 확률을 높이거나, 테이블 자체가 스테이지별로 나뉘어 있다면 해당 스테이지 그룹에서 선택합니다. (단순하게는 전체 풀에서 랜덤 선택하되, 스탯에 `stage` 변수를 약간 반영하여 강화된 상태로 드랍되게 할 수도 있습니다.) + +### 3. 초기 장비 지급 (이미 적용됨, 확인 차원) +- 게임 시작(`initializeBattle`) 시, 플레이어에게 다음 기본 장비 세트를 지급하고 자동 장착시킵니다. + - **Weapon:** Wooden Sword (ATK+5) + - **Armor:** Leather Armor (HP+20) + - **Shield:** Wooden Shield (DEF+3) + - **Accessory:** Copper Ring (ATK+1, HP+5) + +## 관련 파일 +- `lib/game/data/item_table.dart` (새로 생성 필요: 아이템 데이터 관리) +- `lib/providers/battle_provider.dart` (보상 생성 로직 수정) diff --git a/prompt/12_item_option_display.md b/prompt/12_item_option_display.md new file mode 100644 index 0000000..b73e6ef --- /dev/null +++ b/prompt/12_item_option_display.md @@ -0,0 +1,31 @@ +# 아이템 선택 및 인벤토리 UI에 상세 옵션 표시 + +## 목표 +업그레이드된 아이템 시스템에 맞춰, 아이템의 이름뿐만 아니라 해당 아이템이 제공하는 실제 스탯 보너스(공격력, 최대 체력, 방어력 등)를 사용자 인터페이스에 명확하게 표시하여 사용자가 아이템의 가치를 쉽게 파악할 수 있도록 합니다. + +## 요청 사항 + +### 1. 아이템 선택창 (보상 팝업) 상세 옵션 표시 +스테이지 클리어 후 보상 아이템을 선택하는 팝업(`SimpleDialog` 내 `SimpleDialogOption`)에 각 아이템의 이름과 설명 외에, 해당 아이템이 부여하는 **ATK 보너스, MaxHP 보너스, DEF 보너스**를 명확하게 표시해주세요. +- **표시 형식 예시:** + - "Iron Sword (+8 ATK)" + - "Leather Vest (+30 MaxHP)" + - "Wooden Shield (+3 DEF)" + - "Ruby Amulet (+3 ATK, +15 MaxHP)" +- 아이템의 description에 이 정보가 이미 포함되어 있더라도, 스탯 정보는 별도로 강조하여 시각적으로 쉽게 구분되도록 해주세요. + +### 2. 인벤토리 UI (장착된 아이템 및 가방) 상세 옵션 표시 +인벤토리 화면에서 장착된 아이템과 가방(인벤토리)에 있는 아이템 모두에 대해 상세 옵션을 표시해주세요. +- **장착된 아이템:** 각 슬롯에 장착된 아이템의 이름 아래에 해당 아이템이 부여하는 **ATK 보너스, MaxHP 보너스, DEF 보너스**를 표시합니다. +- **가방 아이템:** `GridView`로 표시되는 각 아이템 카드에 이름 아래에 **ATK 보너스, MaxHP 보너스, DEF 보너스**를 표시합니다. +- **표시 형식 예시:** (아이템 선택창과 유사하게) + - "Iron Sword" + - "+8 ATK" + - "Leather Vest" + - "+30 MaxHP" +- 스탯이 0인 경우(예: ATK 보너스만 있는 아이템의 HP 보너스)는 표시하지 않거나, "N/A" 등으로 표시할 수 있습니다. (표시하지 않는 것을 권장) + +## 관련 파일 +- `lib/screens/battle_screen.dart` (아이템 선택창/보상 팝업) +- `lib/screens/inventory_screen.dart` (인벤토리 및 장착 아이템 UI) +- `lib/game/model/item.dart` (Item 객체의 속성 참조) diff --git a/pubspec.yaml b/pubspec.yaml index 0a945e6..8bc64b2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,6 +34,7 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + provider: ^6.0.5 dev_dependencies: flutter_test: diff --git a/test/character_test.dart b/test/character_test.dart new file mode 100644 index 0000000..738a566 --- /dev/null +++ b/test/character_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:game_test/game/model/entity.dart'; +import 'package:game_test/game/model/item.dart'; + +void main() { + group('Character Equipment & HP Logic', () { + late Character player; + late Item armorHp50; + late Item armorHp100; + late Item armorHp20; + + setUp(() { + player = Character(name: "TestPlayer", maxHp: 100, armor: 0, atk: 10); + armorHp50 = Item( + name: "Armor +50", + description: "HP +50", + atkBonus: 0, + hpBonus: 50, + slot: EquipmentSlot.armor, + ); + armorHp100 = Item( + name: "Armor +100", + description: "HP +100", + atkBonus: 0, + hpBonus: 100, + slot: EquipmentSlot.armor, + ); + armorHp20 = Item( + name: "Armor +20", + description: "HP +20", + atkBonus: 0, + hpBonus: 20, + slot: EquipmentSlot.armor, + ); + + // Add items to inventory initially + player.addToInventory(armorHp50); + player.addToInventory(armorHp100); + player.addToInventory(armorHp20); + }); + + test('Equipping item increases MaxHP and scales Current HP proportionally', () { + expect(player.hp, 100); + expect(player.totalMaxHp, 100); + + player.equip(armorHp50); // From 100/100 (100% HP) to 150 MaxHP + + expect(player.totalMaxHp, 150, reason: "Max HP should increase by 50"); + expect(player.hp, 150, reason: "Current HP should scale to 100% of new MaxHP"); + }); + + test('Unequipping item decreases MaxHP and scales Current HP proportionally', () { + player.equip(armorHp50); // HP becomes 150/150 + player.unequip(armorHp50); // MaxHP becomes 100 + + expect(player.totalMaxHp, 100); + expect(player.hp, 100, reason: "Current HP should scale to 100% of new MaxHP"); + }); + + test('Unequipping item clamps Current HP if it exceeds new MaxHP (Already 100% HP)', () { + player.equip(armorHp50); // HP becomes 150/150 + // No need to heal(50) as it's already 150/150. + expect(player.hp, 150); + + player.unequip(armorHp50); // MaxHP 100 + + expect(player.totalMaxHp, 100); + expect(player.hp, 100, reason: "Current HP should scale to 100% of new MaxHP"); + }); + + test('Swapping items handles HP correctly (Upgrade and maintain percentage)', () { + player.equip(armorHp50); // HP 100/100 -> equip -> 150/150 (100%) + player.hp = 75; // Set HP to 75/150 (50%) + expect(player.hp, 75); + expect(player.totalMaxHp, 150); + + player.equip(armorHp100); // Swap armorHp50 (HP+50) with armorHp100 (HP+100). New MaxHP is 200. + + expect(player.totalMaxHp, 200); + expect(player.hp, 100, reason: "HP should scale to 50% of new MaxHP (200 * 0.5 = 100)"); + expect(player.inventory.contains(armorHp50), true, reason: "Old item returned to inventory"); + }); + + test('Swapping items handles HP correctly (Downgrade causing clamp due to percentage)', () { + player.equip(armorHp50); // HP 100/100 -> equip -> 150/150 (100%) + + player.equip(armorHp20); // Swap armorHp50 (HP+50) with armorHp20 (HP+20). New MaxHP is 120. + + expect(player.totalMaxHp, 120); + expect(player.hp, 120, reason: "HP should scale to 100% of new MaxHP (120 * 1.0 = 120)"); + }); + }); +} diff --git a/test/game_test.dart b/test/game_test.dart deleted file mode 100644 index 0444e18..0000000 --- a/test/game_test.dart +++ /dev/null @@ -1,171 +0,0 @@ -// test/game_test.dart -import 'package:flutter_test/flutter_test.dart'; -import 'package:game_test/game/game_instance.dart'; -import 'package:game_test/game/game_manager.dart'; -import 'package:game_test/game/model/entity.dart'; -import 'package:game_test/game/model/stat.dart'; -import 'package:game_test/game/model/item.dart'; - -void main() { - group('Game Core Logic Tests', () { - test('Stat calculation with Modifiers', () { - final strength = Stat(baseValue: 100); - expect(strength.value, 100.0); - - // Add flat modifier - strength.addModifier(Modifier(type: ModifierType.flat, value: 10)); - expect(strength.value, closeTo(110.0, 0.00001)); - - // Add percent modifier - strength.addModifier(Modifier(type: ModifierType.percent, value: 0.10)); // +10% - // 110 * (1 + 0.10) = 121 - expect(strength.value, closeTo(121.0, 0.00001)); - - // Add another flat modifier - strength.addModifier(Modifier(type: ModifierType.flat, value: 20)); - // (110 + 20) * (1 + 0.10) = 130 * 1.1 = 143 - expect(strength.value, closeTo(143.0, 0.00001)); - - // Add another percent modifier - strength.addModifier(Modifier(type: ModifierType.percent, value: 0.05)); // +5% - // (110 + 20) * (1 + 0.10 + 0.05) = 130 * 1.15 = 149.5 - expect(strength.value, closeTo(149.5, 0.00001)); - - // Remove a modifier - final flatModifier10 = Modifier(type: ModifierType.flat, value: 10); - strength.removeModifier(flatModifier10); - // (100 + 20) * (1 + 0.10 + 0.05) = 120 * 1.15 = 138 - expect(strength.value, closeTo(138.0, 0.00001)); - - final percentModifier10 = Modifier(type: ModifierType.percent, value: 0.10); - strength.removeModifier(percentModifier10); - // (100 + 20) * (1 + 0.05) = 120 * 1.05 = 126 - expect(strength.value, closeTo(126.0, 0.00001)); - }); - - test('LivingEntity HP and damage', () { - final player = Player(id: 'p1', name: 'Test Player', baseHp: 100); - expect(player.hp.value, 100.0); - expect(player.isAlive, isTrue); - - player.takeDamage(20); - expect(player.hp.value, 80.0); - expect(player.isAlive, isTrue); - - player.takeDamage(90); // Should go to 0 - expect(player.hp.value, 0.0); - expect(player.isAlive, isFalse); - - player.heal(50); - expect(player.hp.value, 50.0); - expect(player.isAlive, isTrue); - }); - - group('Player Equipment Tests', () { - test('Player starts unarmed with base attack 1', () { - final player = Player(id: 'p1', name: 'Test Player', baseHp: 100); - expect(player.equippedWeapon?.id, 'unarmed_weapon'); - expect(player.attack.value, closeTo(1.0, 0.00001)); - }); - - test('Player equips a new weapon and attack changes', () { - final player = Player(id: 'p1', name: 'Test Player', baseHp: 100); - final sword = Weapon( - id: 'iron_sword', - name: '무쇠 검', - attackModifier: Modifier(type: ModifierType.flat, value: 10), - ); - - player.equipWeapon(sword); - expect(player.equippedWeapon?.id, 'iron_sword'); - expect(player.attack.value, closeTo(10.0, 0.00001)); // Base attack from sword - }); - - test('Player unequips weapon and returns to unarmed', () { - final player = Player(id: 'p1', name: 'Test Player', baseHp: 100); - final sword = Weapon( - id: 'iron_sword', - name: '무쇠 검', - attackModifier: Modifier(type: ModifierType.flat, value: 10), - ); - - player.equipWeapon(sword); - expect(player.attack.value, closeTo(10.0, 0.00001)); - - player.unequipWeapon(EquipmentSlot.mainHand); - expect(player.equippedWeapon?.id, 'unarmed_weapon'); - expect(player.attack.value, closeTo(1.0, 0.00001)); - }); - - test('Equipping a new weapon replaces the old one', () { - final player = Player(id: 'p1', name: 'Test Player', baseHp: 100); - final sword = Weapon( - id: 'iron_sword', - name: '무쇠 검', - attackModifier: Modifier(type: ModifierType.flat, value: 10), - ); - final axe = Weapon( - id: 'steel_axe', - name: '강철 도끼', - attackModifier: Modifier(type: ModifierType.flat, value: 15), - ); - - player.equipWeapon(sword); - expect(player.attack.value, closeTo(10.0, 0.00001)); - - player.equipWeapon(axe); - expect(player.equippedWeapon?.id, 'steel_axe'); - expect(player.attack.value, closeTo(15.0, 0.00001)); - }); - }); - - test('GameInstance singleton and initialization', () async { - final instance1 = GameInstance(); - final instance2 = GameInstance(); - - expect(instance1, same(instance2)); // Verify singleton - expect(instance1.isInitialized, isFalse); - - await instance1.initialize(); - expect(instance1.isInitialized, isTrue); - - // Calling initialize again should not re-initialize - await instance2.initialize(); - expect(instance2.isInitialized, isTrue); - }); - - test('GameManager initializes and player/enemies exist', () async { - final gameManager = GameManager(); - // Allow some time for async initialization in GameManager constructor - await Future.delayed(const Duration(milliseconds: 100)); - - expect(gameManager.player, isNotNull); - expect(gameManager.player?.name, '용감한 검투사'); - expect(gameManager.currentEnemies.length, 2); - }); - - test('GameManager player turn and enemy turn logic', () async { - final gameManager = GameManager(); - await Future.delayed(const Duration(milliseconds: 100)); // Ensure initialization - - final initialPlayerHp = gameManager.player!.hp.value; - final initialEnemyCount = gameManager.currentEnemies.length; - - // Player attacks with medium risk (e.g., 0.6 for 60% success/damage) - gameManager.playerTurn(action: 'attack', risk: 0.6); - await Future.delayed(const Duration(milliseconds: 100)); // Allow enemy turn to complete - - // Expect player to have taken damage from enemy's turn - expect(gameManager.player!.hp.value, lessThan(initialPlayerHp)); - - // Expect at least one enemy to have taken damage or been defeated - // The current simple logic removes enemy if defeated, so check count. - if (gameManager.currentEnemies.length < initialEnemyCount) { - print('Enemy was defeated during playerTurn test.'); - } else { - final remainingEnemy = gameManager.currentEnemies.first; - expect(remainingEnemy.hp.value, closeTo(49.4, 0.00001)); - } - }); - }); -}