diff --git a/PROJECT_OVERVIEW.md b/PROJECT_OVERVIEW.md new file mode 100644 index 0000000..e04aa75 --- /dev/null +++ b/PROJECT_OVERVIEW.md @@ -0,0 +1,256 @@ +# 프로젝트 개요 + +작성일: 2026-04-27 + +## 한 줄 요약 + +`Colosseum's Choice`는 Flutter로 만든 콜로세움 테마의 턴제 로그라이트 전투 게임입니다. 플레이어는 전투, 상점, 휴식 스테이지를 반복하며 장비와 소모품을 얻고, 위험도 선택 기반의 공격/방어 액션으로 더 높은 스테이지를 진행합니다. + +## 기본 정보 + +- 앱 타이틀: `Colosseum's Choice` +- Flutter 패키지명: `game_test` +- 주요 기술: Flutter, Dart, Provider, SharedPreferences +- 지원 플랫폼 폴더: `android/`, `ios/`, `web/`, `macos/`, `linux/`, `windows/` +- 주요 소스 루트: `lib/` +- 주요 데이터 루트: `assets/data/` + +## 실행 흐름 + +1. `lib/main.dart` + - Flutter 바인딩을 초기화합니다. + - `ItemTable.load()`, `EnemyTable.load()`, `PlayerTable.load()`로 JSON 데이터를 먼저 로드합니다. + - `MultiProvider`로 `SettingsProvider`, `ShopProvider`, `BattleProvider`를 등록합니다. + - `MainMenuScreen`을 첫 화면으로 띄웁니다. + +2. 메인 메뉴 + - 저장 데이터가 있으면 `Continue`가 노출됩니다. + - 이어하기는 `SaveManager.loadGame()` 결과를 `BattleProvider.loadFromSave()`로 복원합니다. + - 새 게임은 `CharacterSelectionScreen`으로 이동합니다. + +3. 새 게임 시작 + - `CharacterSelectionScreen`에서 전사를 선택합니다. + - `BattleProvider.initializeBattle()`로 플레이어와 1스테이지를 초기화합니다. + - `StoryScreen`을 거쳐 `MainWrapper`로 진입합니다. + +4. 실제 플레이 화면 + - `MainWrapper`는 하단 탭 구조입니다. + - 첫 탭은 현재 스테이지 타입에 따라 `Battle`, `Shop`, `Rest`로 표시됩니다. + - 나머지 탭은 `Inventory`, `Settings`입니다. + +## 핵심 게임 루프 + +- 스테이지 타입은 `BattleProvider._prepareNextStage()`에서 결정됩니다. +- 우선순위는 `elite -> shop -> rest -> battle`입니다. +- 현재 설정 기준: + - 엘리트: 12스테이지마다 + - 상점: 5스테이지마다 + - 휴식: 8스테이지마다 + - 티어 1: 1-12 스테이지 + - 티어 2: 13-24 스테이지 + - 티어 3: 25 스테이지 이후 + +전투에서 승리하면 골드와 보상 선택지가 지급되고, 보상을 선택하거나 스킵하면 체력을 일부 회복한 뒤 다음 스테이지로 넘어갑니다. 상점/휴식 스테이지는 별도 UI를 거쳐 다음 스테이지로 진행합니다. + +## 전투 시스템 + +전투는 `BattleProvider`와 `CombatCalculator`가 중심입니다. + +- 플레이어 액션: `attack`, `defend` +- 위험도: `safe`, `normal`, `risky` +- 공격은 `totalAtk`, 방어는 `totalDefense`를 기반으로 계산됩니다. +- 위험도는 성공 확률과 효율 배율을 함께 바꿉니다. +- 운(`luck`)은 성공 확률에 더해지고 최대 100%로 제한됩니다. +- 적은 매 턴 `EnemyIntent`를 생성해 다음 행동과 위험도를 미리 보여줍니다. + +현재 전투 수치 설정은 `lib/game/config/battle_config.dart`에 있습니다. + +- Safe: 성공률 100%, 공격 50%, 방어 100% +- Normal: 성공률 80%, 공격 100%, 방어 200% +- Risky: 성공률 40%, 공격 200%, 방어 300% +- 적 행동 비율: 공격 70%, 방어 30% + +피해 계산은 방어도와 상태 이상을 반영합니다. + +- 방어도는 HP 피해보다 먼저 피해를 흡수합니다. +- `vulnerable` 상태면 받는 피해가 `GameConfig.vulnerableDamageMultiplier`만큼 증가합니다. +- 회피(`dodge`)가 성공하면 공격 피해가 들어가지 않습니다. +- 현재 방어도 감소율은 `GameConfig.armorDecayRate`로 관리됩니다. + +## 상태 이상 + +상태 이상 타입은 `lib/game/enums.dart`의 `StatusEffectType`에 정의되어 있습니다. + +- `stun`: 해당 턴 행동 불가 +- `vulnerable`: 받는 피해 증가 +- `bleed`: 턴 시작 시 고정 피해 +- `defenseForbidden`: 방어 액션 사용 불가 +- `disarmed`: 공격력 감소 +- `attackUp`: 공격력 증가 버프 + +아이템 효과는 `ItemEffect`로 표현되며, 공격 성공 후 `CombatCalculator.getAppliedEffects()`와 `BattleProvider._tryApplyStatusEffects()`를 통해 대상에게 적용됩니다. + +## 보상, 아이템, 장비 + +아이템 데이터는 `assets/data/items.json`에서 로드되고 `ItemTable`이 관리합니다. + +- 슬롯: weapon, armor, shield, accessory, consumable +- 희귀도: normal, magic, rare, legendary, unique +- 티어: tier1, tier2, tier3 +- 장비 스탯: 공격력, HP, 방어도, 회피, 운 +- 소모품: 즉시 회복, 방어도 증가, 버프 적용 등에 사용됩니다. + +아이템 생성은 `LootGenerator`가 담당합니다. + +- Normal 등급은 가중치 기반 접두어로 기본 스탯 변형을 받을 수 있습니다. +- Magic 등급은 접두어를 통해 하나 이상의 스탯 보너스를 받을 수 있습니다. +- Rare 등급은 별도 이름 생성과 강한 스탯 변형을 받을 수 있습니다. +- Legendary/Unique는 기본 템플릿 성격을 유지합니다. + +인벤토리는 `Character.inventory`에 보관되고, 최대 크기는 `GameConfig.maxInventorySize`입니다. 장비 장착/해제 시 최대 HP 변화로 인한 체력 악용을 막기 위해 현재 HP 비율을 유지합니다. + +## 경제와 상점 + +상점 로직은 `ShopProvider`와 `widgets/stage/shop_ui.dart`가 담당합니다. + +- 상점 스테이지에서 장비 4개와 소모품 2개를 생성합니다. +- 아이템 구매는 골드와 인벤토리 여유 공간을 검사합니다. +- 재입고 비용은 `GameConfig.shopRerollCost`입니다. +- 판매 가격은 `item.price * GameConfig.sellPriceMultiplier`를 내림 처리합니다. + +전투 승리 골드는 다음 공식으로 지급됩니다. + +```text +baseGoldReward + stage * goldRewardPerStage + random(0..goldRewardVariance - 1) +``` + +## 저장 시스템 + +저장은 `lib/game/save_manager.dart`가 담당하며 `shared_preferences`를 사용합니다. + +- 저장 키: `GameConfig.saveKey` +- 저장 시점: 새 스테이지 준비 시점 +- 저장 내용: 스테이지, 턴 수, 플레이어 JSON, 저장 시각 +- 패배 시 저장 데이터가 삭제됩니다. + +현재 저장 구조는 아이템의 `id`를 중심으로 복원합니다. 따라서 런타임에 생성된 접두어, 이름, 세부 스탯 변형을 완전히 보존하려면 `Item.toJson()`/`Item.fromJson()` 형태의 상세 저장 구조가 필요합니다. + +## 주요 폴더 구조 + +```text +lib/ + main.dart 앱 진입점, 데이터 선로딩, Provider 등록 + game/ + model/ Character, Item, Stage, StatusEffect 등 도메인 모델 + data/ JSON 로더, 아이템/적/플레이어 테이블, 이름/접두어 데이터 + logic/ 전투 계산, 전리품 생성, 전투 로그 관리 + config/ 게임 밸런스, 전투 수치, 테마, 문구 상수 + enums.dart 게임 전반에서 쓰는 enum 정의 + save_manager.dart SharedPreferences 저장/불러오기 + providers/ BattleProvider, ShopProvider, SettingsProvider + screens/ 메인 메뉴, 캐릭터 선택, 스토리, 전투, 인벤토리, 설정 + widgets/ + battle/ 전투 UI, 애니메이션, 피드백 텍스트, 로그, 컨트롤 + inventory/ 캐릭터 스탯, 장비, 인벤토리 그리드 + stage/ 상점, 휴식 스테이지 UI + common/ 공통 버튼, 아이콘, 아이템 카드 + utils/ 수학, 아이템, 토스트 유틸 +``` + +```text +assets/ + data/ + items.json 아이템 템플릿 + enemies.json 적 템플릿 + players.json 플레이어 템플릿 + icon/ 레거시 아이콘 데이터 + images/ + character/ 플레이어 캐릭터 이미지와 공격 프레임 + enemies/ 적 이미지 + background/ 배경 이미지 + icons/ 장비, 포션, 테두리 등 UI 아이콘 +``` + +## 화면 구성 + +- `MainMenuScreen`: 시작 화면, 저장 데이터 확인, 이어하기/새 게임 +- `CharacterSelectionScreen`: 플레이어 템플릿 선택 +- `StoryScreen`: 전투 시작 전 스토리 화면 +- `MainWrapper`: 하단 탭과 현재 스테이지 화면 전환 +- `BattleScreen`: 전투 UI, 이펙트 스트림 구독, 애니메이션 처리 +- `InventoryScreen`: 캐릭터 능력치, 장착 장비, 인벤토리 +- `SettingsScreen`: 애니메이션, 흔들림 옵션, 재시작, 메인 메뉴 복귀 + +## 이벤트와 UI 동기화 + +전투 로직과 UI 애니메이션은 이벤트 스트림으로 느슨하게 연결되어 있습니다. + +- `DamageEvent`: 실제 HP 피해 텍스트 표시 +- `EffectEvent`: 공격/방어/실패/회피 등 시각 효과와 충돌 타이밍 전달 +- `BattleScreen`은 두 스트림을 구독하고, 애니메이션이 끝나는 시점에 `BattleProvider.handleImpact()`를 호출해 실제 피해/방어도 변화를 적용합니다. + +이 구조 덕분에 계산 로직과 화면 효과가 분리되어 있지만, 턴 전환은 애니메이션 완료 콜백에 의존하는 부분이 있으므로 전투 UI 수정 시 `handleImpact()` 호출 흐름을 함께 확인해야 합니다. + +## 설정과 밸런스 조정 위치 + +- 스테이지, 경제, 저장, 회복, 피해 배율: `lib/game/config/game_config.dart` +- 전투 성공률, 효율, 이펙트 크기/색상: `lib/game/config/battle_config.dart` +- 아이템 희귀도 가중치, 접두어 확률: `lib/game/config/item_config.dart` +- 앱 문구: `lib/game/config/app_strings.dart` +- 색상, 폰트 크기, UI 상수: `lib/game/config/theme_config.dart` + +## 테스트 + +테스트는 `test/` 아래에 있습니다. + +- 아이템 로딩과 랜덤 선택 +- 아이템 희귀도/티어 +- 적 데이터 로딩 +- 캐릭터 장비와 HP 비율 처리 +- 전투 Provider의 방어도 초기화 +- 적 의도 생성/실행 +- disarm 상태 효과 + +주요 명령: + +```bash +flutter pub get +flutter test +flutter run +``` + +웹으로 실행하려면 보통 다음 명령을 사용합니다. + +```bash +flutter run -d chrome +``` + +## 확장 작업 가이드 + +새 아이템을 추가할 때: + +1. `assets/data/items.json`에 템플릿을 추가합니다. +2. 이미지가 필요하면 `assets/images/icons/...` 아래에 추가합니다. +3. 새 폴더를 추가했다면 `pubspec.yaml`의 `flutter.assets`에 등록합니다. +4. 로딩 테스트나 랜덤 선택 테스트를 확인합니다. + +새 적을 추가할 때: + +1. `assets/data/enemies.json`의 `normal` 또는 `elite`에 템플릿을 추가합니다. +2. 이미지가 필요하면 `assets/images/enemies/`에 추가합니다. +3. 티어와 장비 ID가 실제 아이템 데이터와 맞는지 확인합니다. + +전투 밸런스를 조정할 때: + +1. 위험도별 성공률/효율은 `BattleConfig`를 수정합니다. +2. 스테이지 보상, 상점 주기, 회복량은 `GameConfig`를 수정합니다. +3. 장비 드롭 희귀도는 `ItemConfig.defaultRarityWeights`를 수정합니다. + +## 현재 눈여겨볼 점 + +- `pubspec.yaml`의 패키지 설명은 아직 Flutter 기본 문구입니다. +- 앱 타이틀은 `Colosseum's Choice`지만 패키지명은 `game_test`입니다. +- `StoryScreen`은 아직 플레이스홀더 이미지와 텍스트를 사용합니다. +- `initializeBattle()`에서 테스트용 아이템과 포션을 기본 지급하고 있습니다. +- 저장은 아이템 ID 중심이라 생성된 접두어/세부 스탯 보존이 제한적입니다. +- 자산 폴더를 새로 추가할 때는 `pubspec.yaml` 등록 여부를 반드시 확인해야 합니다. diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index ac3b479..7bd3188 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Tue Mar 31 23:58:22 KST 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip diff --git a/assets/data/items.json b/assets/data/items.json index f1d788e..254b311 100644 --- a/assets/data/items.json +++ b/assets/data/items.json @@ -9,7 +9,8 @@ "price": 30, "image": "assets/images/items/gladius.png", "rarity": "normal", - "tier": "tier1" + "tier": "tier1", + "weaponType": "oneHanded" }, { "id": "flail", @@ -21,7 +22,8 @@ "price": 45, "image": "assets/images/items/flail.png", "rarity": "magic", - "tier": "tier1" + "tier": "tier1", + "weaponType": "oneHanded" }, { "id": "trident", @@ -33,7 +35,8 @@ "price": 70, "image": "assets/images/items/trident.png", "rarity": "normal", - "tier": "tier2" + "tier": "tier2", + "weaponType": "twoHanded" }, { "id": "scimitar", @@ -45,7 +48,8 @@ "price": 90, "image": "assets/images/items/scimitar.png", "rarity": "magic", - "tier": "tier2" + "tier": "tier2", + "weaponType": "oneHanded" }, { "id": "war_axe", @@ -56,7 +60,8 @@ "price": 120, "image": "assets/images/items/war_axe.png", "rarity": "magic", - "tier": "tier2" + "tier": "tier2", + "weaponType": "twoHanded" }, { "id": "war_hammer", @@ -74,7 +79,8 @@ } ], "rarity": "rare", - "tier": "tier2" + "tier": "tier2", + "weaponType": "twoHanded" }, { "id": "barbed_net", @@ -89,11 +95,12 @@ "type": "bleed", "probability": 100, "duration": 3, - "value": 30 + "value": 5 } ], "rarity": "rare", - "tier": "tier1" + "tier": "tier1", + "weaponType": "oneHanded" }, { "id": "steel_greatsword", @@ -104,7 +111,8 @@ "price": 180, "image": "assets/images/items/steel_greatsword.png", "rarity": "magic", - "tier": "tier3" + "tier": "tier3", + "weaponType": "twoHanded" }, { "id": "hooked_spear", @@ -122,7 +130,8 @@ } ], "rarity": "rare", - "tier": "tier3" + "tier": "tier3", + "weaponType": "twoHanded" }, { "id": "executioners_axe", @@ -140,7 +149,8 @@ } ], "rarity": "legendary", - "tier": "tier3" + "tier": "tier3", + "weaponType": "twoHanded" } ], "armors": [ @@ -373,4 +383,4 @@ "image": "assets/images/items/potion.png" } ] -} +} \ No newline at end of file diff --git a/assets/images/character/warrior.png b/assets/images/character/warrior.png deleted file mode 100644 index e21cb9e..0000000 Binary files a/assets/images/character/warrior.png and /dev/null differ diff --git a/assets/images/character/warrior_attack_1.png b/assets/images/character/warrior_attack_1.png deleted file mode 100644 index f4f4eec..0000000 Binary files a/assets/images/character/warrior_attack_1.png and /dev/null differ diff --git a/assets/images/character/warrior_attack_2.png b/assets/images/character/warrior_attack_2.png deleted file mode 100644 index bff9db5..0000000 Binary files a/assets/images/character/warrior_attack_2.png and /dev/null differ diff --git a/assets/images/effects/heal.png b/assets/images/effects/heal.png new file mode 100644 index 0000000..9a240c5 Binary files /dev/null and b/assets/images/effects/heal.png differ diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..620e46e --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/lib/game/config/animation_config.dart b/lib/game/config/animation_config.dart index 55dd322..e1e942b 100644 --- a/lib/game/config/animation_config.dart +++ b/lib/game/config/animation_config.dart @@ -8,7 +8,7 @@ class AnimationConfig { static const Duration fadeDuration = Duration(milliseconds: 200); // Attack Animations - static const Duration attackSafe = Duration(milliseconds: 200); + static const Duration attackSafe = Duration(milliseconds: 500); static const Duration attackNormal = Duration(milliseconds: 400); static const Duration attackRiskyTotal = Duration(milliseconds: 1100); static const Duration attackRiskyScale = Duration(milliseconds: 600); @@ -17,7 +17,7 @@ class AnimationConfig { // Curves static const Curve floatingTextCurve = Curves.easeOut; static const Curve floatingEffectScaleCurve = Curves.elasticOut; - static const Curve attackSafeCurve = Curves.elasticIn; + static const Curve attackSafeCurve = Curves.easeOutQuad; static const Curve attackNormalCurve = Curves.easeOutQuad; static const Curve attackRiskyDashCurve = Curves.easeInExpo; diff --git a/lib/game/data/enemy_table.dart b/lib/game/data/enemy_table.dart index 21696a2..001d8ed 100644 --- a/lib/game/data/enemy_table.dart +++ b/lib/game/data/enemy_table.dart @@ -7,6 +7,8 @@ import '../config/game_config.dart'; import 'item_table.dart'; class EnemyTemplate { + static const String fallbackImage = 'assets/images/enemies/Orc.png'; + final String name; final int baseHp; final int baseAtk; @@ -27,19 +29,30 @@ class EnemyTemplate { this.tier = 1, }); - factory EnemyTemplate.fromJson(Map json) { + factory EnemyTemplate.fromJson( + Map json, { + Set? availableAssets, + }) { + final imagePath = json['image'] as String?; + return EnemyTemplate( name: json['name'], baseHp: json['baseHp'] ?? 10, baseAtk: json['baseAtk'] ?? 1, baseDefense: json['baseDefense'] ?? 0, baseDodge: json['baseDodge'] ?? 1, // Parse from JSON or default to 1 - image: json['image'], + image: _resolveImagePath(imagePath, availableAssets), equipmentIds: (json['equipment'] as List?)?.cast() ?? [], tier: json['tier'] ?? 1, ); } + static String? _resolveImagePath(String? imagePath, Set? assets) { + if (imagePath == null || imagePath.isEmpty) return null; + if (assets == null || assets.contains(imagePath)) return imagePath; + return fallbackImage; + } + Character createCharacter({int stage = 1}) { // Stage-based scaling for enemy stats is removed to simplify balancing. // Enemy stats are now fixed as defined in the EnemyTemplate. @@ -79,15 +92,25 @@ class EnemyTable { 'assets/data/enemies.json', ); final Map data = jsonDecode(jsonString); + final availableAssets = await _loadAvailableAssets(); normalEnemies = (data['normal'] as List) - .map((e) => EnemyTemplate.fromJson(e)) + .map((e) => EnemyTemplate.fromJson(e, availableAssets: availableAssets)) .toList(); eliteEnemies = (data['elite'] as List) - .map((e) => EnemyTemplate.fromJson(e)) + .map((e) => EnemyTemplate.fromJson(e, availableAssets: availableAssets)) .toList(); } + static Future?> _loadAvailableAssets() async { + try { + final manifest = await AssetManifest.loadFromAssetBundle(rootBundle); + return manifest.listAssets().toSet(); + } catch (_) { + return null; + } + } + /// Returns a random enemy suitable for the current stage. static EnemyTemplate getRandomEnemy({ required int stage, diff --git a/lib/game/data/item_table.dart b/lib/game/data/item_table.dart index ef2dabe..04490ef 100644 --- a/lib/game/data/item_table.dart +++ b/lib/game/data/item_table.dart @@ -23,6 +23,7 @@ class ItemTemplate { final int luck; final ItemRarity rarity; final ItemTier tier; + final WeaponType? weaponType; // New: oneHanded or twoHanded const ItemTemplate({ required this.id, @@ -39,6 +40,7 @@ class ItemTemplate { this.luck = 0, this.rarity = ItemRarity.magic, this.tier = ItemTier.tier1, + this.weaponType, }); factory ItemTemplate.fromJson(Map json) { @@ -57,7 +59,7 @@ class ItemTemplate { hpBonus: json['hpBonus'] ?? json['baseHp'] ?? 0, armorBonus: json['armorBonus'] ?? json['baseArmor'] ?? 0, dodge: json['dodge'] ?? 0, - slot: EquipmentSlot.values.firstWhere((e) => e.name == json['slot']), + slot: equipmentSlotFromName(json['slot']), effects: effectsList, price: json['price'] ?? 10, image: json['image'], @@ -68,6 +70,9 @@ class ItemTemplate { tier: json['tier'] != null ? ItemTier.values.firstWhere((e) => e.name == json['tier']) : ItemTier.tier1, + weaponType: json['weaponType'] != null + ? WeaponType.values.firstWhere((e) => e.name == json['weaponType']) + : null, ); } @@ -86,6 +91,7 @@ class ItemTable { static List consumables = []; static final Map _items = {}; + static List get subweapons => shields; static void initialize() { // This function is now a placeholder. All loading is handled in load(). @@ -95,16 +101,22 @@ class ItemTable { // Clear previous data _items.clear(); - final String jsonString = - await rootBundle.loadString('assets/data/items.json'); + final String jsonString = await rootBundle.loadString( + 'assets/data/items.json', + ); final Map data = jsonDecode(jsonString); // Helper function to load and register items - void _loadAndRegister(String key, List list) { + void loadAndRegister( + String key, + List list, { + bool clear = true, + }) { if (data[key] != null) { - list.clear(); - var loadedItems = - (data[key] as List).map((e) => ItemTemplate.fromJson(e)).toList(); + if (clear) list.clear(); + var loadedItems = (data[key] as List) + .map((e) => ItemTemplate.fromJson(e)) + .toList(); list.addAll(loadedItems); for (var item in loadedItems) { _items[item.id] = item; @@ -112,11 +124,12 @@ class ItemTable { } } - _loadAndRegister('weapons', weapons); - _loadAndRegister('armors', armors); - _loadAndRegister('shields', shields); - _loadAndRegister('accessories', accessories); - _loadAndRegister('consumables', consumables); + loadAndRegister('weapons', weapons); + loadAndRegister('armors', armors); + loadAndRegister('shields', shields); + loadAndRegister('subweapons', shields, clear: false); + loadAndRegister('accessories', accessories); + loadAndRegister('consumables', consumables); } static List get allItems => _items.values.toList(); @@ -124,6 +137,7 @@ class ItemTable { static ItemTemplate? get(String id) { return _items[id]; } + static final Random _random = Random(); /// Returns all items matching the given tier. diff --git a/lib/game/enums.dart b/lib/game/enums.dart index d0395a9..ab73f0f 100644 --- a/lib/game/enums.dart +++ b/lib/game/enums.dart @@ -34,6 +34,42 @@ enum StageType { enum EquipmentSlot { weapon, armor, shield, accessory, consumable } +enum WeaponType { oneHanded, twoHanded } + +EquipmentSlot equipmentSlotFromName(String name) { + switch (name) { + case 'mainWeapon': + case 'weapon': + return EquipmentSlot.weapon; + case 'subweapon': + case 'shield': + return EquipmentSlot.shield; + case 'armor': + return EquipmentSlot.armor; + case 'accessory': + return EquipmentSlot.accessory; + case 'consumable': + return EquipmentSlot.consumable; + default: + return EquipmentSlot.weapon; + } +} + +String equipmentSlotStorageName(EquipmentSlot slot) { + switch (slot) { + case EquipmentSlot.weapon: + return 'mainWeapon'; + case EquipmentSlot.shield: + return 'subweapon'; + case EquipmentSlot.armor: + return 'armor'; + case EquipmentSlot.accessory: + return 'accessory'; + case EquipmentSlot.consumable: + return 'consumable'; + } +} + enum DamageType { normal, bleed, vulnerable } enum StatType { maxHp, atk, defense, luck, dodge } diff --git a/lib/game/logic/combat_calculator.dart b/lib/game/logic/combat_calculator.dart index bea3fdd..31cb425 100644 --- a/lib/game/logic/combat_calculator.dart +++ b/lib/game/logic/combat_calculator.dart @@ -4,7 +4,6 @@ import '../model/status_effect.dart'; import '../enums.dart'; import '../config/game_config.dart'; import '../config/battle_config.dart'; // Import BattleConfig -import '../model/damage_event.dart'; class CombatResult { final bool success; diff --git a/lib/game/logic/loot_generator.dart b/lib/game/logic/loot_generator.dart index 4a9e3a1..379bcff 100644 --- a/lib/game/logic/loot_generator.dart +++ b/lib/game/logic/loot_generator.dart @@ -28,6 +28,7 @@ class LootGenerator { luck: template.luck, rarity: template.rarity, tier: template.tier, + weaponType: template.weaponType, ); } @@ -167,6 +168,7 @@ class LootGenerator { luck: finalLuck, rarity: template.rarity, tier: template.tier, + weaponType: template.weaponType, ); } } diff --git a/lib/game/model/damage_event.dart b/lib/game/model/damage_event.dart index 8843c72..dc6b02d 100644 --- a/lib/game/model/damage_event.dart +++ b/lib/game/model/damage_event.dart @@ -7,11 +7,13 @@ class DamageEvent { final int damage; final DamageTarget target; final DamageType type; + final RiskLevel? risk; DamageEvent({ required this.damage, required this.target, this.type = DamageType.normal, + this.risk, }); Color get color { diff --git a/lib/game/model/entity.dart b/lib/game/model/entity.dart index e03260a..9a32485 100644 --- a/lib/game/model/entity.dart +++ b/lib/game/model/entity.dart @@ -51,7 +51,9 @@ class Character { 'baseDodge': baseDodge, 'gold': gold, 'image': image, - 'equipment': equipment.map((key, value) => MapEntry(key.name, value.id)), + 'equipment': equipment.map( + (key, value) => MapEntry(equipmentSlotStorageName(key), value.id), + ), 'inventory': inventory.map((e) => e.id).toList(), 'statusEffects': statusEffects.map((e) => e.toJson()).toList(), 'permanentModifiers': permanentModifiers.map((e) => e.toJson()).toList(), @@ -76,11 +78,7 @@ class Character { equipMap.forEach((slotName, itemId) { final template = ItemTable.get(itemId); if (template != null) { - // Find slot enum - final slot = EquipmentSlot.values.firstWhere( - (e) => e.name == slotName, - orElse: () => EquipmentSlot.weapon, // Fallback - ); + final slot = equipmentSlotFromName(slotName); char.equipment[slot] = template.createItem(); } }); @@ -112,36 +110,61 @@ class Character { } /// Adds a status effect. If it already exists, it refreshes duration or stacks based on logic. - /// For now, we'll implement a simple refresh/overwrite logic. void addStatusEffect(StatusEffect newEffect) { - // Check if effect exists var existing = statusEffects .where((e) => e.type == newEffect.type) .firstOrNull; if (existing != null) { - // Refresh duration if the new one is longer, or just reset it? - // Let's max the duration for now. - if (newEffect.duration > existing.duration) { + if (existing.type == StatusEffectType.bleed) { + // Stack bleed damage + existing.value += newEffect.value; + existing.stacks += 1; + + // Cap at 30 (10 stacks of 3) + if (existing.value > 30) { + existing.value = 30; + } + if (existing.stacks > 10) { + existing.stacks = 10; + } + + // Always refresh duration for bleed stacking existing.duration = newEffect.duration; + } else { + // For other effects, just refresh duration if longer + if (newEffect.duration > existing.duration) { + existing.duration = newEffect.duration; + } } - // Logic for 'value' (stacking bleed?) can be added here. } else { - statusEffects.add(newEffect); + // Create a copy of the new effect to avoid reference issues if it comes from an item template + statusEffects.add(StatusEffect( + type: newEffect.type, + duration: newEffect.duration, + value: newEffect.value, + stacks: newEffect.stacks, + )); } } - /// Decrements duration of all effects and removes expired ones. - /// Returns a list of expired effects if needed for UI logs. - void updateStatusEffects() { - // Remove effects with 0 or less duration first (safety cleanup) - statusEffects.removeWhere((e) => e.duration <= 0); - + /// Decrements duration of start-of-turn effects (Bleed, Stun). + void updateStartOfTurnStatusEffects() { for (var effect in statusEffects) { - effect.duration--; + if (effect.type == StatusEffectType.bleed || effect.type == StatusEffectType.stun) { + effect.duration--; + } } + statusEffects.removeWhere((e) => e.duration <= 0); + } - // Remove effects that just expired (duration went to 0 or -1) + /// Decrements duration of end-of-turn effects (all others). + void updateEndOfTurnStatusEffects() { + for (var effect in statusEffects) { + if (effect.type != StatusEffectType.bleed && effect.type != StatusEffectType.stun) { + effect.duration--; + } + } statusEffects.removeWhere((e) => e.duration <= 0); } @@ -210,23 +233,65 @@ class Character { // Equips an item (swapping if necessary) // Returns true if successful bool equip(Item newItem) { + return equipToSlot(newItem, newItem.defaultEquipSlot); + } + + bool equipToSlot(Item newItem, EquipmentSlot targetSlot) { if (!inventory.contains(newItem)) return false; + if (!newItem.canEquipTo(targetSlot)) return false; + + // Check inventory capacity before unequipping multiple items + int itemsToUnequip = 0; + if (equipment.containsKey(targetSlot)) itemsToUnequip++; + + if (targetSlot == EquipmentSlot.weapon && newItem.weaponType == WeaponType.twoHanded) { + if (equipment.containsKey(EquipmentSlot.shield)) itemsToUnequip++; + } else if (targetSlot == EquipmentSlot.shield) { + if (equipment.containsKey(EquipmentSlot.weapon) && equipment[EquipmentSlot.weapon]!.weaponType == WeaponType.twoHanded) { + itemsToUnequip++; + } + } + + // newItem leaves inventory (-1), itemsToUnequip go to inventory (+itemsToUnequip) + if (inventory.length - 1 + itemsToUnequip > maxInventorySize) { + return false; // Not enough space + } // 1. Calculate current HP ratio before any changes double hpRatio = totalMaxHp > 0 ? hp / totalMaxHp : 0.0; // Avoid division by zero - // 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); + // 2. Handle 2H / 1H rules + if (targetSlot == EquipmentSlot.weapon && newItem.weaponType == WeaponType.twoHanded) { + // If equipping 2H weapon, unequip shield slot if occupied + if (equipment.containsKey(EquipmentSlot.shield)) { + Item shieldItem = equipment[EquipmentSlot.shield]!; + equipment.remove(EquipmentSlot.shield); + inventory.add(shieldItem); + } + } else if (targetSlot == EquipmentSlot.shield) { + // If equipping to shield slot, check if main weapon is 2H + if (equipment.containsKey(EquipmentSlot.weapon)) { + Item mainWeapon = equipment[EquipmentSlot.weapon]!; + if (mainWeapon.weaponType == WeaponType.twoHanded) { + // Unequip 2H weapon + equipment.remove(EquipmentSlot.weapon); + inventory.add(mainWeapon); + } + } + } + + // 3. Handle Swap: If slot is occupied, unequip the old item first + if (equipment.containsKey(targetSlot)) { + Item oldItem = equipment[targetSlot]!; + equipment.remove(targetSlot); inventory.add(oldItem); } - // 3. Move new item: Inventory -> Equipment + // 4. Move new item: Inventory -> Equipment inventory.remove(newItem); - equipment[newItem.slot] = newItem; + equipment[targetSlot] = newItem; // 4. Update current HP based on the new totalMaxHp and previous ratio hp = (totalMaxHp * hpRatio).toInt(); @@ -244,13 +309,17 @@ class Character { bool unequip(Item item) { if (!equipment.containsValue(item)) return false; + final slot = equipment.entries + .firstWhere((entry) => entry.value == item) + .key; + // 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); + equipment.remove(slot); inventory.add(item); // 2. Update current HP based on the new totalMaxHp and previous ratio diff --git a/lib/game/model/heal_event.dart b/lib/game/model/heal_event.dart new file mode 100644 index 0000000..a869e2f --- /dev/null +++ b/lib/game/model/heal_event.dart @@ -0,0 +1,11 @@ +enum HealTarget { player, enemy } + +class HealEvent { + final int amount; + final HealTarget target; + + HealEvent({ + required this.amount, + required this.target, + }); +} diff --git a/lib/game/model/item.dart b/lib/game/model/item.dart index 9aeb800..407e008 100644 --- a/lib/game/model/item.dart +++ b/lib/game/model/item.dart @@ -32,7 +32,7 @@ class ItemEffect { String durationStr = "${duration}t"; String valStr = value > 0 ? " ($value dmg)" : ""; - return "$typeStr ${probability}% ($durationStr)$valStr"; + return "$typeStr $probability% ($durationStr)$valStr"; } } @@ -51,6 +51,7 @@ class Item { final int luck; // Success rate bonus (e.g. 5 = 5%) final ItemRarity rarity; final ItemTier tier; + final WeaponType? weaponType; // New: oneHanded or twoHanded const Item({ required this.id, @@ -67,6 +68,7 @@ class Item { this.luck = 0, this.rarity = ItemRarity.magic, this.tier = ItemTier.tier1, + this.weaponType, }); String get typeName { @@ -76,11 +78,35 @@ class Item { case EquipmentSlot.armor: return "Armor"; case EquipmentSlot.shield: - return "Shield"; + return "Subweapon"; case EquipmentSlot.accessory: return "Accessory"; case EquipmentSlot.consumable: return "Potion"; } } + + EquipmentSlot get defaultEquipSlot => slot; + + List get compatibleEquipSlots { + switch (slot) { + case EquipmentSlot.weapon: + if (weaponType == WeaponType.oneHanded) { + return const [EquipmentSlot.weapon, EquipmentSlot.shield]; + } else if (weaponType == WeaponType.twoHanded) { + return const [EquipmentSlot.weapon]; + } + return const [EquipmentSlot.weapon]; // Default fallback + case EquipmentSlot.armor: + case EquipmentSlot.shield: + case EquipmentSlot.accessory: + return [slot]; + case EquipmentSlot.consumable: + return const []; + } + } + + bool canEquipTo(EquipmentSlot targetSlot) { + return compatibleEquipSlots.contains(targetSlot); + } } diff --git a/lib/game/model/status_effect.dart b/lib/game/model/status_effect.dart index 9f1b913..8978276 100644 --- a/lib/game/model/status_effect.dart +++ b/lib/game/model/status_effect.dart @@ -3,15 +3,17 @@ import '../enums.dart'; class StatusEffect { final StatusEffectType type; int duration; // Turns remaining - final int value; // Intensity (e.g., bleed damage amount) + int value; // Intensity (e.g., bleed damage amount, now mutable for stacking) + int stacks; // Number of stacks - StatusEffect({required this.type, required this.duration, this.value = 0}); + StatusEffect({required this.type, required this.duration, this.value = 0, this.stacks = 1}); Map toJson() { return { 'type': type.name, 'duration': duration, 'value': value, + 'stacks': stacks, }; } @@ -20,6 +22,7 @@ class StatusEffect { type: StatusEffectType.values.firstWhere((e) => e.name == json['type']), duration: json['duration'], value: json['value'], + stacks: json['stacks'] ?? 1, ); } } diff --git a/lib/game/models.dart b/lib/game/models.dart index 2a57607..96b12d3 100644 --- a/lib/game/models.dart +++ b/lib/game/models.dart @@ -1,5 +1,6 @@ export 'model/damage_event.dart'; export 'model/effect_event.dart'; +export 'model/heal_event.dart'; export 'model/entity.dart'; export 'model/item.dart'; export 'model/stage.dart'; diff --git a/lib/game/save_manager.dart b/lib/game/save_manager.dart index 2b368ae..7b587b6 100644 --- a/lib/game/save_manager.dart +++ b/lib/game/save_manager.dart @@ -1,7 +1,6 @@ import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; import '../providers.dart'; -import 'model/entity.dart'; import 'config/game_config.dart'; class SaveManager { diff --git a/lib/providers/battle_provider.dart b/lib/providers/battle_provider.dart index 114cdc9..33b353e 100644 --- a/lib/providers/battle_provider.dart +++ b/lib/providers/battle_provider.dart @@ -43,6 +43,9 @@ class EnemyIntent { } class BattleProvider with ChangeNotifier { + static final Duration _turnEffectVisualDelay = + AnimationConfig.floatingTextDuration + const Duration(milliseconds: 100); + late Character player; late Character enemy; // Kept for compatibility, active during Battle/Elite @@ -52,8 +55,6 @@ class BattleProvider with ChangeNotifier { final BattleLogManager _logManager = BattleLogManager(); bool isPlayerTurn = true; int _turnTransactionId = 0; // To prevent async race conditions - bool skipAnimations = false; // Sync with SettingsProvider - int stage = 1; int turnCount = 1; List rewardOptions = []; @@ -62,6 +63,19 @@ class BattleProvider with ChangeNotifier { List get logs => _logManager.logs; int get lastGoldReward => _lastGoldReward; + StageType get nextStageType => getStageTypeFor(stage + 1); + + StageType getStageTypeFor(int stageNumber) { + if (stageNumber % GameConfig.eliteStageInterval == 0) { + return StageType.elite; + } else if (stageNumber % GameConfig.shopStageInterval == 0) { + return StageType.shop; + } else if (stageNumber % GameConfig.restStageInterval == 0) { + return StageType.rest; + } + + return StageType.battle; + } void refreshUI() { notifyListeners(); @@ -75,6 +89,10 @@ class BattleProvider with ChangeNotifier { final _effectEventController = StreamController.broadcast(); Stream get effectStream => _effectEventController.stream; + // Heal Event Stream + final _healEventController = StreamController.broadcast(); + Stream get healStream => _healEventController.stream; + // Dependency injection final ShopProvider shopProvider; final Random _random; // Injected Random instance @@ -88,6 +106,7 @@ class BattleProvider with ChangeNotifier { void dispose() { _damageEventController.close(); // StreamController 닫기 _effectEventController.close(); + _healEventController.close(); super.dispose(); } @@ -136,8 +155,8 @@ class BattleProvider with ChangeNotifier { player.gold = GameConfig.startingGold; // Add new status effect items for testing - player.addToInventory(ItemTable.weapons[3].createItem()); // Stunning Hammer - player.addToInventory(ItemTable.weapons[4].createItem()); // Jagged Dagger + player.addToInventory(ItemTable.weapons[6].createItem()); // Stunning Hammer + player.addToInventory(ItemTable.weapons[9].createItem()); // Jagged Dagger player.addToInventory(ItemTable.weapons[5].createItem()); // Sunderer Axe player.addToInventory(ItemTable.shields[3].createItem()); // Cursed Shield @@ -164,18 +183,7 @@ class BattleProvider with ChangeNotifier { // Reset Player Armor at start of new stage player.armor = 0; - StageType type; - - // Stage Type Logic - if (stage % GameConfig.eliteStageInterval == 0) { - type = StageType.elite; // Every 10th stage is a Boss/Elite - } else if (stage % GameConfig.shopStageInterval == 0) { - type = StageType.shop; // Every 5th stage is a Shop (except 10, 20...) - } else if (stage % GameConfig.restStageInterval == 0) { - type = StageType.rest; // Every 8th stage is a Rest - } else { - type = StageType.battle; - } + StageType type = getStageTypeFor(stage); // Prepare Data based on Type Character? newEnemy; @@ -239,8 +247,7 @@ class BattleProvider with ChangeNotifier { // 0. Ensure Pre-emptive Enemy Defense is applied (if not already via animation) applyPendingEnemyDefense(); - // Update Enemy Status Effects at the start of Player's turn (user request) - enemy.updateStatusEffects(); // 1. Check for Defense Forbidden status + // 1. Check for Defense Forbidden status (Player) if (type == ActionType.defend && player.hasStatus(StatusEffectType.defenseForbidden)) { _addLog("Cannot defend! You are under Defense Forbidden status."); @@ -262,7 +269,7 @@ class BattleProvider with ChangeNotifier { // If a visual effect occurred (bleed, stun), wait a bit before action if (turnEffect.effectTriggered) { - await Future.delayed(const Duration(milliseconds: 800)); + await Future.delayed(_turnEffectVisualDelay); } if (!turnEffect.canAct) { @@ -390,7 +397,7 @@ class BattleProvider with ChangeNotifier { void _endPlayerTurn() { // Update durations at end of turn - player.updateStatusEffects(); + player.updateEndOfTurnStatusEffects(); // Check if enemy is dead from bleed if (enemy.isDead) { @@ -519,6 +526,9 @@ class BattleProvider with ChangeNotifier { effectTriggered = true; } + // 3. Update durations for start-of-turn effects immediately + character.updateStartOfTurnStatusEffects(); + return TurnEffectResult( canAct: !isStunned, effectTriggered: effectTriggered, @@ -550,7 +560,7 @@ class BattleProvider with ChangeNotifier { // If a visual effect occurred (bleed, stun), wait a bit before action if (turnEffect.effectTriggered) { - await Future.delayed(const Duration(milliseconds: 800)); + await Future.delayed(_turnEffectVisualDelay); } if (turnEffect.canAct && currentEnemyIntent != null) { @@ -665,7 +675,7 @@ class BattleProvider with ChangeNotifier { if (player.isDead) return; // Game Over check // Update enemy status at the end of their turn - enemy.updateStatusEffects(); + enemy.updateEndOfTurnStatusEffects(); // Generate NEXT intent _generateEnemyIntent(); @@ -765,16 +775,20 @@ class BattleProvider with ChangeNotifier { notifyListeners(); } - bool selectReward(Item item) { + bool selectReward(Item item, {bool completeStage = true}) { if (item.id == "reward_skip") { _addLog("Skipped reward."); - _completeStage(); + if (completeStage) { + _completeStage(); + } return true; } else { bool added = player.addToInventory(item); if (added) { _addLog("Added ${item.name} to inventory."); - _completeStage(); + if (completeStage) { + _completeStage(); + } return true; } else { _addLog("Inventory is full! Could not take ${item.name}."); @@ -783,6 +797,10 @@ class BattleProvider with ChangeNotifier { } } + void completeStage() { + _completeStage(); + } + void _completeStage() { // Heal player after selecting reward int healAmount = GameMath.floor( @@ -802,9 +820,20 @@ class BattleProvider with ChangeNotifier { notifyListeners(); } - void equipItem(Item item) { - if (player.equip(item)) { - _addLog("Equipped ${item.name}."); + void equipItem(Item item, {EquipmentSlot? targetSlot}) { + final success = targetSlot == null + ? player.equip(item) + : player.equipToSlot(item, targetSlot); + + if (success) { + final slotName = targetSlot == null + ? item.typeName + : targetSlot == EquipmentSlot.weapon + ? "Main Weapon" + : targetSlot == EquipmentSlot.shield + ? "Subweapon" + : targetSlot.name; + _addLog("Equipped ${item.name} as $slotName."); } else { _addLog( "Failed to equip ${item.name}.", @@ -857,6 +886,7 @@ class BattleProvider with ChangeNotifier { int healedAmount = player.hp - currentHp; if (healedAmount > 0) { _addLog("Used ${item.name}. Recovered $healedAmount HP."); + _healEventController.sink.add(HealEvent(amount: healedAmount, target: HealTarget.player)); effectApplied = true; } else { _addLog("Used ${item.name}. HP is already full."); @@ -1095,6 +1125,7 @@ class BattleProvider with ChangeNotifier { type: target.hasStatus(StatusEffectType.vulnerable) ? DamageType.vulnerable : DamageType.normal, + risk: event.risk, ), ); _addLog("${attacker.name} dealt $damageToHp damage to ${target.name}."); diff --git a/lib/providers/shop_provider.dart b/lib/providers/shop_provider.dart index af82d68..a4032c0 100644 --- a/lib/providers/shop_provider.dart +++ b/lib/providers/shop_provider.dart @@ -17,9 +17,9 @@ class ShopProvider with ChangeNotifier { void generateShopItems(int stage) { ItemTier currentTier = ItemTier.tier1; - if (stage > GameConfig.tier2StageMax) + if (stage > GameConfig.tier2StageMax) { currentTier = ItemTier.tier3; - else if (stage > GameConfig.tier1StageMax) + } else if (stage > GameConfig.tier1StageMax) currentTier = ItemTier.tier2; availableItems = []; diff --git a/lib/screens/battle_screen.dart b/lib/screens/battle_screen.dart index ce5f09c..046e1d6 100644 --- a/lib/screens/battle_screen.dart +++ b/lib/screens/battle_screen.dart @@ -10,6 +10,7 @@ import '../widgets.dart'; import '../utils.dart'; import 'main_menu_screen.dart'; import '../game/config.dart'; +import '../widgets/battle/effect_sprite_widget.dart'; enum AnimationPhase { none, start, middle, end } @@ -26,6 +27,7 @@ class _BattleScreenState extends State { final List _floatingFeedbackTexts = []; StreamSubscription? _damageSubscription; StreamSubscription? _effectSubscription; + StreamSubscription? _healSubscription; final GlobalKey _playerKey = GlobalKey(); final GlobalKey _enemyKey = GlobalKey(); final GlobalKey _stackKey = GlobalKey(); @@ -36,9 +38,13 @@ class _BattleScreenState extends State { GlobalKey(); // Added Enemy Anim Key final GlobalKey _explosionKey = GlobalKey(); + final GlobalKey _effectSpriteKey = + GlobalKey(); bool _showLogs = false; bool _isPlayerAttacking = false; // Player Attack Animation State bool _isEnemyAttacking = false; // Enemy Attack Animation State + bool _showEquipmentSwapPanel = false; + bool _isCompletingReward = false; DateTime? _lastFeedbackTime; // Cooldown to prevent duplicate feedback texts // New State for Interactive Defense Animation @@ -47,8 +53,9 @@ class _BattleScreenState extends State { AnimationPhase _playerAnimPhase = AnimationPhase.none; String? _getOverrideImage(bool isPlayer) { - if (!isPlayer) + if (!isPlayer) { return null; // Enemy animation image logic can be added later + } if (_playerAnimPhase == AnimationPhase.start) { return "assets/images/character/warrior_attack_1.png"; @@ -64,7 +71,6 @@ class _BattleScreenState extends State { @override void initState() { super.initState(); - print("[UI Debug] BattleScreen initialized: ${hashCode}"); // Debug Log final battleProvider = context.read(); _damageSubscription = battleProvider.damageStream.listen( _addFloatingDamageText, @@ -72,12 +78,14 @@ class _BattleScreenState extends State { _effectSubscription = battleProvider.effectStream.listen( _addFloatingEffect, ); + _healSubscription = battleProvider.healStream.listen(_onHealEvent); } @override void dispose() { _damageSubscription?.cancel(); _effectSubscription?.cancel(); + _healSubscription?.cancel(); super.dispose(); } @@ -111,56 +119,19 @@ class _BattleScreenState extends State { final String id = UniqueKey().toString(); - // Scale based on risk if available in event? - // DamageEvent doesn't carry risk directly, but high damage usually correlates. - // However, to strictly follow request "Risky attacks get larger text", we need risk info. - // Currently DamageEvent (model/damage_event.dart) does NOT have risk field. - // We can infer or add it. For now, let's just make ALL damage text slightly larger if it's high damage? - // OR better: check if we can pass risk. - // Wait, the user asked to scale based on risk. - // Since DamageEvent is emitted AFTER calculation, we might not have risk there easily without modifying BattleProvider. - // BUT! EffectEvent HAS risk. And EffectEvent handles ICONS. - // DamageEvent handles NUMBERS. - - // Let's modify DamageEvent to include risk or isCritical flag? - // Actually, simply checking if damage > 20 or similar is a heuristic. - // But the user specifically said "Risky attacks". - - // Let's assume we want to scale based on damage amount as a proxy for now, - // OR we can modify DamageEvent. Modifying DamageEvent is cleaner. - - // START_REPLACE logic: I will modify the scale widget wrapper. - // Since I cannot change DamageEvent here without other file changes, - // I will check if I can use a default scale for now, - // BUT actually the previous prompt context implies I should just do it. - - // Let's look at `FloatingDamageText`. It takes a `scale` parameter? No. - // It's a widget. I can wrap it in Transform.scale. - - // Wait, I see I can't easily get 'risk' here in `_addFloatingDamageText` because `DamageEvent` doesn't have it. - // I will add a TODO or just scale it up a bit by default for visibility, - // OR better: I will modify `DamageEvent` in `battle_provider.dart` to include `isRisky` or `risk` enum. - - // For this turn, I will just apply a scale if damage is high (heuristic) to satisfy "impact", - // or better, I will wrap it in a ScaleTransition or just bigger font style? - // `FloatingDamageText` is a custom widget. - - // Let's look at `FloatingDamageText` implementation (it's imported). - // Assuming I can pass a style or it has fixed style. - - // Let's just wrap `FloatingDamageText` in a `Transform.scale` with a value. - // I'll define a variable scale. - - double scale = BattleConfig.damageScaleNormal; - if (event.damage > BattleConfig.highDamageThreshold) { - scale = BattleConfig.damageScaleHigh; - } + final double scale = + event.risk == RiskLevel.risky || + (event.risk == null && + event.damage > BattleConfig.highDamageThreshold) + ? BattleConfig.damageScaleHigh + : BattleConfig.damageScaleNormal; setState(() { _floatingDamageTexts.add( DamageTextData( id: id, widget: Positioned( + key: ValueKey('pos_$id'), left: position.dx, top: position.dy, child: Transform.scale( @@ -184,11 +155,69 @@ class _BattleScreenState extends State { }); } + void _onHealEvent(HealEvent event) { + if (!mounted) return; + + // Find position: Default to center of screen + Offset position = Offset( + MediaQuery.of(context).size.width / 2, + MediaQuery.of(context).size.height / 2, + ); + + // Try to get player's position if visible (in Battle UI) + if (event.target == HealTarget.player && + _playerKey.currentContext != null) { + RenderBox? renderBox = + _playerKey.currentContext!.findRenderObject() as RenderBox?; + if (renderBox != null) { + position = renderBox.localToGlobal( + Offset(renderBox.size.width / 2, renderBox.size.height / 2), + ); + } + } + + // Play visual effect (heal.png has 4 frames) + _effectSpriteKey.currentState?.playEffect( + position: position, + assetPath: 'assets/images/effects/heal.png', + frameCount: 4, + tileWidth: 100.0, // Assuming each frame is 100x100 + tileHeight: 100.0, + scale: 2.0, + ); + + // Play floating text + final String id = UniqueKey().toString(); + setState(() { + _floatingDamageTexts.add( + DamageTextData( + id: id, + widget: Positioned( + key: ValueKey('pos_$id'), + left: position.dx + BattleConfig.damageTextOffsetX, + top: position.dy + BattleConfig.damageTextOffsetY, + child: FloatingDamageText( + key: ValueKey(id), + damage: "+${event.amount}", + color: ThemeConfig.statHpPlayerColor, // Green color for heal + onRemove: () { + if (mounted) { + setState(() { + _floatingDamageTexts.removeWhere((e) => e.id == id); + }); + } + }, + ), + ), + ), + ); + }); + } + final Set _processedEffectIds = {}; void _addFloatingEffect(EffectEvent event) { if (_processedEffectIds.contains(event.id)) { - // print("[UI Debug] Duplicate Event Ignored: ${event.id}"); return; } @@ -200,17 +229,16 @@ class _BattleScreenState extends State { if (!mounted) return; - // Feedback Text Cooldown + bool shouldShowFeedback = true; if (event.feedbackType != null) { - // print( - // "[UI Debug] Feedback Event: ${event.id}, Type: ${event.feedbackType}", - // ); + final now = DateTime.now(); if (_lastFeedbackTime != null && - DateTime.now().difference(_lastFeedbackTime!).inMilliseconds < + now.difference(_lastFeedbackTime!).inMilliseconds < BattleConfig.feedbackCooldownMs) { - return; // Skip if too soon + shouldShowFeedback = false; + } else { + _lastFeedbackTime = now; } - _lastFeedbackTime = DateTime.now(); } GlobalKey targetKey = event.target == EffectTarget.player @@ -255,6 +283,8 @@ class _BattleScreenState extends State { // Handle Feedback Text (MISS / FAILED) if (event.feedbackType != null) { + if (!shouldShowFeedback) return; + String feedbackText; Color feedbackColor; switch (event.feedbackType) { @@ -290,6 +320,7 @@ class _BattleScreenState extends State { id: id, eventId: event.id, widget: Positioned( + key: ValueKey('pos_$id'), left: position.dx, top: position.dy, child: FloatingFeedbackText( @@ -323,6 +354,7 @@ class _BattleScreenState extends State { FloatingEffectData( id: id, widget: Positioned( + key: ValueKey('pos_$id'), left: position.dx, top: position.dy, child: FloatingEffect( @@ -345,7 +377,11 @@ class _BattleScreenState extends State { } // 1. Player Attack Animation Trigger (Success or Miss) - if (event.type == ActionType.attack && event.target == EffectTarget.enemy) { + if (event.isVisualOnly) { + showEffect(); + context.read().handleImpact(event); + } else if (event.type == ActionType.attack && + event.target == EffectTarget.enemy) { final RenderBox? playerBox = _playerKey.currentContext?.findRenderObject() as RenderBox?; final RenderBox? enemyBox = @@ -527,10 +563,15 @@ class _BattleScreenState extends State { } void _showRiskLevelSelection(BuildContext context, ActionType actionType) { + if (_showEquipmentSwapPanel) { + setState(() => _showEquipmentSwapPanel = false); + } + // 1. Check if we need to trigger enemy animation first bool triggered = _triggerEnemyDefenseIfNeeded(context); - if (triggered) + if (triggered) { return; // If triggered, we wait for animation (and input block) + } final battleProvider = context.read(); final player = battleProvider.player; @@ -606,6 +647,10 @@ class _BattleScreenState extends State { } void _showInventoryDialog(BuildContext context) { + if (_showEquipmentSwapPanel) { + setState(() => _showEquipmentSwapPanel = false); + } + final battleProvider = context.read(); final List consumables = battleProvider.player.inventory .where((item) => item.slot == EquipmentSlot.consumable) @@ -669,309 +714,473 @@ class _BattleScreenState extends State { ); } - @override - Widget build(BuildContext context) { - // Sync animation setting to provider logic - final settings = context.watch(); - context.read().skipAnimations = - !settings.enableEnemyAnimations; + void _toggleEquipmentSwapPanel() { + setState(() { + _showEquipmentSwapPanel = !_showEquipmentSwapPanel; + }); + } - return ResponsiveContainer( - child: Consumer( - builder: (context, battleProvider, child) { - if (battleProvider.currentStage.type == StageType.shop) { - return ShopUI(battleProvider: battleProvider); - } else if (battleProvider.currentStage.type == StageType.rest) { - return RestUI(battleProvider: battleProvider); - } - - return ShakeWidget( - key: _shakeKey, - child: Stack( - key: _stackKey, - children: [ - // 1. Background Image - Container( - decoration: const BoxDecoration( - image: DecorationImage( - image: AssetImage('assets/images/background/tier_1.jpg'), - fit: BoxFit.cover, - ), + Widget _buildEquipmentSwapPanel() { + return Material( + color: Colors.transparent, + child: Container( + height: 236, + decoration: BoxDecoration( + color: ThemeConfig.battleBg.withValues(alpha: 0.92), + border: Border.all(color: ThemeConfig.textColorGrey), + borderRadius: BorderRadius.circular(8), + boxShadow: const [ + BoxShadow( + color: Colors.black54, + blurRadius: 10, + offset: Offset(0, 4), + ), + ], + ), + child: Column( + children: [ + SizedBox( + height: 40, + child: Row( + children: [ + const SizedBox(width: 10), + const Icon( + Icons.swap_horiz, + color: ThemeConfig.mainIconColor, + size: 20, ), - ), - // 1.1 Opacity Layer - Container(color: Colors.black.withValues(alpha: 0.7)), - - // 2. Battle Content (Top Bar + Characters) - Column( - children: [ - // Top Bar - const BattleHeader(), - - // Battle Area (Characters) - Expanded to fill available space - Expanded( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Stack( - children: [ - // Player (Bottom Left) - Rendered First - Positioned( - bottom: 80, // Space for FABs - left: 16, // Add some padding from left - child: CharacterStatusCard( - character: battleProvider.player, - isPlayer: true, - isTurn: battleProvider.isPlayerTurn, - key: _playerKey, - animationKey: _playerAnimKey, - hideStats: _isPlayerAttacking, - overrideImage: _getOverrideImage(true), - ), - ), - // Enemy (Top Right) - Rendered Last (On Top) - Positioned( - top: 16, // Add some padding from top - right: 16, // Add some padding from right - child: CharacterStatusCard( - character: battleProvider.enemy, - isPlayer: false, - isTurn: !battleProvider.isPlayerTurn, - key: _enemyKey, - animationKey: _enemyAnimKey, // Direct Pass - hideStats: _isEnemyAttacking, - ), - ), - ], // Close children list - ), // Close Stack - ), // Close Padding - ), // Close Expanded - ], // Close Column - ), // Close Column - // 3. Logs Overlay - if (_showLogs && battleProvider.logs.isNotEmpty) - Positioned( - top: 60, - left: 16, - right: 16, - height: BattleConfig.logsOverlayHeight, - child: BattleLogOverlay(logs: battleProvider.logs), - ), - - // 4. Battle Controls (Bottom Right) - Positioned( - bottom: 20, - right: 20, - child: BattleControls( - isAttackEnabled: - battleProvider.isPlayerTurn && - !battleProvider.player.isDead && - !battleProvider.enemy.isDead && - !battleProvider.showRewardPopup && - !_isPlayerAttacking && - !_isEnemyAttacking, // Enabled even if disarmed (damage reduced) - isDefendEnabled: - battleProvider.isPlayerTurn && - !battleProvider.player.isDead && - !battleProvider.enemy.isDead && - !battleProvider.showRewardPopup && - !_isPlayerAttacking && - !_isEnemyAttacking && - !battleProvider.player.hasStatus( - StatusEffectType.defenseForbidden, - ), // Disable if defense is forbidden - onAttackPressed: () => - _showRiskLevelSelection(context, ActionType.attack), - onDefendPressed: () => - _showRiskLevelSelection(context, ActionType.defend), - onItemPressed: () => _showInventoryDialog(context), - ), - ), - - // 5. Log Toggle Button (Bottom Left) - Positioned( - bottom: 20, - left: 20, - child: FloatingActionButton( - heroTag: "logToggle", - mini: true, - backgroundColor: ThemeConfig.toggleBtnBg, - onPressed: () { - setState(() { - _showLogs = !_showLogs; - }); - }, - child: Icon( - _showLogs ? Icons.visibility_off : Icons.visibility, - color: ThemeConfig.textColorWhite, - ), - ), - ), - - // Reward Popup - if (battleProvider.showRewardPopup) - Container( - color: ThemeConfig.cardBgColor, - child: Center( - child: SimpleDialog( - title: Row( - children: [ - const Text( - "${AppStrings.victory} ${AppStrings.chooseReward}", - ), - const Spacer(), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.monetization_on, - color: ThemeConfig.statGoldColor, - size: ThemeConfig.itemIconSizeSmall, - ), - const SizedBox(width: 4), - Text( - "${battleProvider.lastGoldReward} G", - style: TextStyle( - color: ThemeConfig.statGoldColor, - fontSize: ThemeConfig.fontSizeBody, - fontWeight: ThemeConfig.fontWeightBold, - ), - ), - ], - ), - ], - ), - children: battleProvider.rewardOptions.map((item) { - bool isSkip = item.id == "reward_skip"; - return SimpleDialogOption( - onPressed: () { - bool success = battleProvider.selectReward(item); - if (!success) { - ToastUtils.showTopToast( - context, - "${AppStrings.inventoryFull} Cannot take item.", - ); - } - }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (!isSkip) - Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: ThemeConfig.rewardItemBg, - borderRadius: BorderRadius.circular( - 4, - ), - border: Border.all( - color: - item.rarity != ItemRarity.magic - ? ItemUtils.getRarityColor( - item.rarity, - ) - : ThemeConfig.rarityCommon, - ), - ), - child: Image.asset( - ItemUtils.getIconPath(item.slot), - width: ThemeConfig.itemIconSizeMedium, - height: - ThemeConfig.itemIconSizeMedium, - fit: BoxFit.contain, - filterQuality: FilterQuality.high, - ), - ), - if (!isSkip) const SizedBox(width: 12), - Text( - item.name, - style: TextStyle( - fontWeight: ThemeConfig.fontWeightBold, - fontSize: ThemeConfig.fontSizeLarge, - color: isSkip - ? ThemeConfig.textColorGrey - : ItemUtils.getRarityColor( - item.rarity, - ), - ), - ), - ], - ), - if (!isSkip) _buildItemStatText(item), - Text( - item.description, - style: const TextStyle( - fontSize: ThemeConfig.fontSizeMedium, - color: ThemeConfig.textColorGrey, - ), - ), - ], - ), - ); - }).toList(), + const SizedBox(width: 6), + const Expanded( + child: Text( + "Equipment", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: ThemeConfig.textColorWhite, + fontSize: ThemeConfig.fontSizeBody, + fontWeight: ThemeConfig.fontWeightBold, ), ), ), + IconButton( + visualDensity: VisualDensity.compact, + onPressed: () { + setState(() => _showEquipmentSwapPanel = false); + }, + icon: const Icon( + Icons.close, + color: ThemeConfig.textColorWhite, + size: 18, + ), + ), + ], + ), + ), + const Divider(height: 1, color: ThemeConfig.textColorGrey), + const Expanded( + child: InventoryGridWidget( + mode: InventoryGridMode.equipmentSwap, + equipmentOnly: true, + showHeader: false, + gridPadding: EdgeInsets.all(8.0), + childAspectRatio: 1.05, + ), + ), + ], + ), + ), + ); + } - // Floating Effects - ..._floatingDamageTexts.map((e) => e.widget), - ..._floatingEffects.map((e) => e.widget), - ..._floatingFeedbackTexts.map((e) => e.widget), + bool _canUseEquipmentSwap(BattleProvider battleProvider) { + return battleProvider.isPlayerTurn && + !battleProvider.player.isDead && + !battleProvider.enemy.isDead && + !battleProvider.showRewardPopup && + !_isPlayerAttacking && + !_isEnemyAttacking; + } - // Explosion Layer - ExplosionWidget(key: _explosionKey), + Widget _buildEquipmentSwapButton(BattleProvider battleProvider) { + final canUse = _canUseEquipmentSwap(battleProvider); - // Game Over Overlay - if (battleProvider.player.isDead) - Container( - color: ThemeConfig.battleBg, - child: Center( - child: Column( + return FloatingActionButton( + heroTag: "equipmentSwap", + mini: true, + backgroundColor: canUse + ? ThemeConfig.toggleBtnBg + : ThemeConfig.btnDisabled, + onPressed: canUse ? _toggleEquipmentSwapPanel : null, + child: Icon( + _showEquipmentSwapPanel && canUse ? Icons.close : Icons.swap_horiz, + color: ThemeConfig.textColorWhite, + ), + ); + } + + bool get _hasPendingBattleAnimations { + return _isPlayerAttacking || + _isEnemyAttacking || + _floatingDamageTexts.isNotEmpty || + _floatingEffects.isNotEmpty || + _floatingFeedbackTexts.isNotEmpty || + (_explosionKey.currentState?.isAnimating ?? false); + } + + Future _waitForBattleAnimationsToSettle() async { + final deadline = DateTime.now().add( + AnimationConfig.attackRiskyTotal + + AnimationConfig.floatingTextDuration + + const Duration(milliseconds: 400), + ); + + while (mounted && + _hasPendingBattleAnimations && + DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 50)); + } + } + + Future _selectRewardAfterAnimationsIfNeeded(Item item) async { + if (_isCompletingReward) return; + + final battleProvider = context.read(); + final shouldWaitForShop = + battleProvider.nextStageType == StageType.shop && + _hasPendingBattleAnimations; + + setState(() => _isCompletingReward = true); + final success = battleProvider.selectReward(item, completeStage: false); + + if (!success) { + if (mounted) { + setState(() => _isCompletingReward = false); + ToastUtils.showTopToast( + context, + "${AppStrings.inventoryFull} Cannot take item.", + ); + } + return; + } + + if (shouldWaitForShop) { + await _waitForBattleAnimationsToSettle(); + } + + if (!mounted) return; + context.read().completeStage(); + setState(() => _isCompletingReward = false); + } + + @override + Widget build(BuildContext context) { + return ResponsiveContainer( + child: Stack( + children: [ + Consumer( + builder: (context, battleProvider, child) { + if (battleProvider.currentStage.type == StageType.shop) { + return ShopUI(battleProvider: battleProvider); + } else if (battleProvider.currentStage.type == StageType.rest) { + return RestUI(battleProvider: battleProvider); + } + return _buildBattleUI(battleProvider); + }, + ), + EffectSpriteWidget(key: _effectSpriteKey), + ], + ), + ); + } + + Widget _buildBattleUI(BattleProvider battleProvider) { + return ShakeWidget( + key: _shakeKey, + child: Stack( + key: _stackKey, + children: [ + // 1. Background Image + Container( + decoration: const BoxDecoration( + image: DecorationImage( + image: AssetImage('assets/images/background/tier_1.jpg'), + fit: BoxFit.cover, + ), + ), + ), + // 1.1 Opacity Layer + Container(color: Colors.black.withValues(alpha: 0.7)), + + // 2. Battle Content (Top Bar + Characters) + Column( + children: [ + // Top Bar + const BattleHeader(), + + // Battle Area (Characters) - Expanded to fill available space + Expanded( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Stack( + children: [ + // Player (Bottom Left) - Rendered First + Positioned( + bottom: 80, // Space for FABs + left: 16, // Add some padding from left + child: CharacterStatusCard( + character: battleProvider.player, + isPlayer: true, + isTurn: battleProvider.isPlayerTurn, + key: _playerKey, + animationKey: _playerAnimKey, + hideStats: _isPlayerAttacking, + overrideImage: _getOverrideImage(true), + ), + ), + // Enemy (Top Right) - Rendered Last (On Top) + Positioned( + top: 16, // Add some padding from top + right: 16, // Add some padding from right + child: CharacterStatusCard( + character: battleProvider.enemy, + isPlayer: false, + isTurn: !battleProvider.isPlayerTurn, + key: _enemyKey, + animationKey: _enemyAnimKey, // Direct Pass + hideStats: _isEnemyAttacking, + ), + ), + ], // Close children list + ), // Close Stack + ), // Close Padding + ), // Close Expanded + ], // Close Column + ), // Close Column + // 3. Logs Overlay + if (_showLogs && battleProvider.logs.isNotEmpty) + Positioned( + top: 60, + left: 16, + right: 16, + height: BattleConfig.logsOverlayHeight, + child: BattleLogOverlay(logs: battleProvider.logs), + ), + + // 4. Battle Controls (Bottom Right) + Positioned( + bottom: 20, + right: 20, + child: BattleControls( + isAttackEnabled: + battleProvider.isPlayerTurn && + !battleProvider.player.isDead && + !battleProvider.enemy.isDead && + !battleProvider.showRewardPopup && + !_isPlayerAttacking && + !_isEnemyAttacking, // Enabled even if disarmed (damage reduced) + isDefendEnabled: + battleProvider.isPlayerTurn && + !battleProvider.player.isDead && + !battleProvider.enemy.isDead && + !battleProvider.showRewardPopup && + !_isPlayerAttacking && + !_isEnemyAttacking && + !battleProvider.player.hasStatus( + StatusEffectType.defenseForbidden, + ), // Disable if defense is forbidden + onAttackPressed: () => + _showRiskLevelSelection(context, ActionType.attack), + onDefendPressed: () => + _showRiskLevelSelection(context, ActionType.defend), + onItemPressed: () => _showInventoryDialog(context), + ), + ), + + if (_showEquipmentSwapPanel && _canUseEquipmentSwap(battleProvider)) + Positioned( + bottom: 20, + right: 96, + width: 260, + child: _buildEquipmentSwapPanel(), + ), + + // 5. Log Toggle Button (Bottom Left) + Positioned( + bottom: 20, + left: 20, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildEquipmentSwapButton(battleProvider), + const SizedBox(height: 12), + FloatingActionButton( + heroTag: "logToggle", + mini: true, + backgroundColor: ThemeConfig.toggleBtnBg, + onPressed: () { + setState(() { + _showLogs = !_showLogs; + }); + }, + child: Icon( + _showLogs ? Icons.visibility_off : Icons.visibility, + color: ThemeConfig.textColorWhite, + ), + ), + ], + ), + ), + + // Reward Popup + if (battleProvider.showRewardPopup) + Container( + color: ThemeConfig.cardBgColor, + child: Center( + child: SimpleDialog( + title: Row( + children: [ + const Text( + "${AppStrings.victory} ${AppStrings.chooseReward}", + ), + const Spacer(), + Row( mainAxisSize: MainAxisSize.min, children: [ - const Text( - AppStrings.defeat, - style: TextStyle( - color: ThemeConfig.statHpColor, - fontSize: ThemeConfig.fontSizeHuge, - fontWeight: ThemeConfig.fontWeightBold, - letterSpacing: ThemeConfig.letterSpacingHeader, - ), + Icon( + Icons.monetization_on, + color: ThemeConfig.statGoldColor, + size: ThemeConfig.itemIconSizeSmall, ), - const SizedBox(height: 32), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: ThemeConfig.menuButtonBg, - padding: const EdgeInsets.symmetric( - horizontal: ThemeConfig.paddingBtnHorizontal, - vertical: ThemeConfig.paddingBtnVertical, - ), - ), - onPressed: () { - Navigator.of(context).pushAndRemoveUntil( - MaterialPageRoute( - builder: (context) => const MainMenuScreen(), - ), - (route) => false, - ); - }, - child: const Text( - AppStrings.returnToMenu, - style: TextStyle( - color: ThemeConfig.textColorWhite, - fontSize: ThemeConfig.fontSizeHeader, - ), + const SizedBox(width: 4), + Text( + "${battleProvider.lastGoldReward} G", + style: TextStyle( + color: ThemeConfig.statGoldColor, + fontSize: ThemeConfig.fontSizeBody, + fontWeight: ThemeConfig.fontWeightBold, ), ), ], ), - ), + ], ), - ], + children: battleProvider.rewardOptions.map((item) { + bool isSkip = item.id == "reward_skip"; + return SimpleDialogOption( + onPressed: _isCompletingReward + ? null + : () => _selectRewardAfterAnimationsIfNeeded(item), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (!isSkip) + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: ThemeConfig.rewardItemBg, + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: item.rarity != ItemRarity.magic + ? ItemUtils.getRarityColor( + item.rarity, + ) + : ThemeConfig.rarityCommon, + ), + ), + child: Image.asset( + ItemUtils.getIconPath(item.slot), + width: ThemeConfig.itemIconSizeMedium, + height: ThemeConfig.itemIconSizeMedium, + fit: BoxFit.contain, + filterQuality: FilterQuality.high, + ), + ), + if (!isSkip) const SizedBox(width: 12), + Text( + item.name, + style: TextStyle( + fontWeight: ThemeConfig.fontWeightBold, + fontSize: ThemeConfig.fontSizeLarge, + color: isSkip + ? ThemeConfig.textColorGrey + : ItemUtils.getRarityColor(item.rarity), + ), + ), + ], + ), + if (!isSkip) _buildItemStatText(item), + Text( + item.description, + style: const TextStyle( + fontSize: ThemeConfig.fontSizeMedium, + color: ThemeConfig.textColorGrey, + ), + ), + ], + ), + ); + }).toList(), + ), + ), ), - ); - }, + + // Floating Effects + ..._floatingDamageTexts.map((e) => e.widget), + ..._floatingEffects.map((e) => e.widget), + ..._floatingFeedbackTexts.map((e) => e.widget), + + // Explosion Layer + ExplosionWidget(key: _explosionKey), + + // Game Over Overlay + if (battleProvider.player.isDead) + Container( + color: ThemeConfig.battleBg, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + AppStrings.defeat, + style: TextStyle( + color: ThemeConfig.statHpColor, + fontSize: ThemeConfig.fontSizeHuge, + fontWeight: ThemeConfig.fontWeightBold, + letterSpacing: ThemeConfig.letterSpacingHeader, + ), + ), + const SizedBox(height: 32), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: ThemeConfig.menuButtonBg, + padding: const EdgeInsets.symmetric( + horizontal: ThemeConfig.paddingBtnHorizontal, + vertical: ThemeConfig.paddingBtnVertical, + ), + ), + onPressed: () { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (context) => const MainMenuScreen(), + ), + (route) => false, + ); + }, + child: const Text( + AppStrings.returnToMenu, + style: TextStyle( + color: ThemeConfig.textColorWhite, + fontSize: ThemeConfig.fontSizeHeader, + ), + ), + ), + ], + ), + ), + ), + ], ), ); } diff --git a/lib/screens/main_menu_screen.dart b/lib/screens/main_menu_screen.dart index fb4444f..bd17d91 100644 --- a/lib/screens/main_menu_screen.dart +++ b/lib/screens/main_menu_screen.dart @@ -6,7 +6,6 @@ import '../widgets.dart'; import '../game/save_manager.dart'; import '../providers.dart'; import '../game/config.dart'; -import '../widgets/test/sprite_animation_widget.dart'; class MainMenuScreen extends StatefulWidget { const MainMenuScreen({super.key}); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 52ac770..db2ba99 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -39,7 +39,7 @@ class SettingsScreen extends StatelessWidget { onChanged: (value) { settings.toggleEnemyAnimations(value); }, - activeColor: ThemeConfig.btnActionActive, + activeThumbColor: ThemeConfig.btnActionActive, ), const SizedBox(height: 20), const Text( diff --git a/lib/utils/item_utils.dart b/lib/utils/item_utils.dart index d45b155..d3541fe 100644 --- a/lib/utils/item_utils.dart +++ b/lib/utils/item_utils.dart @@ -33,6 +33,36 @@ class ItemUtils { } } + static String getSlotLabel(EquipmentSlot slot) { + switch (slot) { + case EquipmentSlot.weapon: + return 'MAIN'; + case EquipmentSlot.shield: + return 'SUB'; + case EquipmentSlot.armor: + return 'ARMOR'; + case EquipmentSlot.accessory: + return 'ACCESSORY'; + case EquipmentSlot.consumable: + return 'ITEM'; + } + } + + static String getSlotName(EquipmentSlot slot) { + switch (slot) { + case EquipmentSlot.weapon: + return 'Main Weapon'; + case EquipmentSlot.shield: + return 'Subweapon'; + case EquipmentSlot.armor: + return 'Armor'; + case EquipmentSlot.accessory: + return 'Accessory'; + case EquipmentSlot.consumable: + return 'Item'; + } + } + static String getBorderPath(ItemRarity rarity) { switch (rarity) { case ItemRarity.normal: diff --git a/lib/widgets/battle/battle_animation_widget.dart b/lib/widgets/battle/battle_animation_widget.dart index b3641bc..6fcd7ff 100644 --- a/lib/widgets/battle/battle_animation_widget.dart +++ b/lib/widgets/battle/battle_animation_widget.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../providers/settings_provider.dart'; import '../../game/enums.dart'; +import '../../game/config.dart'; class BattleAnimationWidget extends StatefulWidget { final Widget child; @@ -24,11 +25,11 @@ class BattleAnimationWidgetState extends State super.initState(); _scaleController = AnimationController( vsync: this, - duration: const Duration(milliseconds: 800), + duration: AnimationConfig.attackRiskyScale, ); _translateController = AnimationController( vsync: this, - duration: const Duration(milliseconds: 1000), + duration: AnimationConfig.attackRiskyDash, ); _scaleAnimation = Tween( @@ -58,80 +59,69 @@ class BattleAnimationWidgetState extends State VoidCallback? onAnimationMiddle, VoidCallback? onAnimationEnd, }) async { - // onAnimationStart?.call(); // Start Phase + _resetControllers(); + onAnimationStart?.call(); if (risk == RiskLevel.safe || risk == RiskLevel.normal) { - // Safe & Normal: Dash/Wobble without scale final isSafe = risk == RiskLevel.safe; - final duration = isSafe ? 500 : 400; + final duration = AnimationConfig.getAttackDuration(risk); final offsetFactor = isSafe ? 0.2 : 0.5; + final curve = isSafe + ? AnimationConfig.attackSafeCurve + : AnimationConfig.attackNormalCurve; - _translateController.duration = Duration(milliseconds: duration); - _translateAnimation = - Tween( - begin: Offset.zero, - end: targetOffset * offsetFactor, - ).animate( - CurvedAnimation( - parent: _translateController, - curve: Curves.easeOutQuad, - ), - ); + _translateController.duration = duration; + _translateAnimation = Tween( + begin: Offset.zero, + end: targetOffset * offsetFactor, + ).animate(CurvedAnimation(parent: _translateController, curve: curve)); await _translateController.forward(); if (!mounted) return; - // onAnimationMiddle?.call(); // Middle Phase + onAnimationEnd?.call(); onImpact(); await _translateController.reverse(); } else { - onAnimationStart?.call(); // Start Phase - // Risky: Scale + Heavy Dash final attackScale = context.read().attackAnimScale; _scaleAnimation = Tween(begin: 1.0, end: attackScale).animate( CurvedAnimation(parent: _scaleController, curve: Curves.easeOut), ); - _scaleController.duration = const Duration(milliseconds: 600); - _translateController.duration = const Duration(milliseconds: 500); + _scaleController.duration = AnimationConfig.attackRiskyScale; + _translateController.duration = AnimationConfig.attackRiskyDash; - // 1. Scale Up (Preparation) await _scaleController.forward(); if (!mounted) return; - onAnimationMiddle?.call(); // Middle Phase + onAnimationMiddle?.call(); - // 2. Dash to Target (Impact) - // Adjust offset to prevent complete overlap (stop slightly short) since both share the same layer stack final adjustedOffset = targetOffset * 0.5; _translateAnimation = Tween(begin: Offset.zero, end: adjustedOffset).animate( CurvedAnimation( parent: _translateController, - curve: Curves.easeInExpo, // Heavy impact curve + curve: AnimationConfig.attackRiskyDashCurve, ), ); await _translateController.forward(); if (!mounted) return; - // onAnimationEnd?.call(); // End Phase (Moved before Impact) - - // 3. Impact Callback (Shake) + onAnimationEnd?.call(); onImpact(); - // 4. Return (Reset) - _scaleController.reverse(); - await _translateController.reverse(); + await Future.wait([ + _scaleController.reverse(), + _translateController.reverse(), + ]); } - - // onAnimationEnd removed from here } Future animateDefense(VoidCallback onImpact) async { - // Defense: Wobble/Shake horizontally + _resetControllers(); _translateController.duration = const Duration(milliseconds: 800); // Sequence: Left -> Right -> Center @@ -165,6 +155,17 @@ class BattleAnimationWidgetState extends State _translateController.reset(); } + void _resetControllers() { + if (_scaleController.isAnimating) { + _scaleController.stop(); + } + if (_translateController.isAnimating) { + _translateController.stop(); + } + _scaleController.reset(); + _translateController.reset(); + } + @override Widget build(BuildContext context) { return AnimatedBuilder( diff --git a/lib/widgets/battle/character_status_card.dart b/lib/widgets/battle/character_status_card.dart index 20a6f1b..e61d9b1 100644 --- a/lib/widgets/battle/character_status_card.dart +++ b/lib/widgets/battle/character_status_card.dart @@ -84,7 +84,9 @@ class CharacterStatusCard extends StatelessWidget { borderRadius: BorderRadius.circular(4), ), child: Text( - "${effect.type.name.toUpperCase()} (${effect.duration})", + effect.stacks > 1 + ? "${effect.type.name.toUpperCase()} x${effect.stacks} (${effect.duration})" + : "${effect.type.name.toUpperCase()} (${effect.duration})", style: const TextStyle( color: ThemeConfig.effectText, fontSize: ThemeConfig.statusEffectFontSize, diff --git a/lib/widgets/battle/effect_sprite_widget.dart b/lib/widgets/battle/effect_sprite_widget.dart new file mode 100644 index 0000000..e18e3b8 --- /dev/null +++ b/lib/widgets/battle/effect_sprite_widget.dart @@ -0,0 +1,173 @@ +import 'dart:async'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class SpriteEffect { + final Offset position; + final String assetPath; + final int frameCount; + final double tileWidth; + final double tileHeight; + final double scale; + + ui.Image? image; + int currentFrame = 0; + bool isFinished = false; + + SpriteEffect({ + required this.position, + required this.assetPath, + required this.frameCount, + this.tileWidth = 100.0, + this.tileHeight = 100.0, + this.scale = 2.0, + }); +} + +class EffectSpriteWidget extends StatefulWidget { + const EffectSpriteWidget({super.key}); + + @override + EffectSpriteWidgetState createState() => EffectSpriteWidgetState(); +} + +class EffectSpriteWidgetState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + final List _effects = []; + final Map _imageCache = {}; + + @override + void initState() { + super.initState(); + // Approximately 10 FPS (100ms per frame) + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 100), + ); + _controller.addStatusListener((status) { + if (status == AnimationStatus.completed) { + _updateFrames(); + if (_effects.isNotEmpty) { + _controller.forward(from: 0); + } + } + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future playEffect({ + required Offset position, + required String assetPath, + required int frameCount, + double tileWidth = 100.0, + double tileHeight = 100.0, + double scale = 2.0, + }) async { + final effect = SpriteEffect( + position: position, + assetPath: assetPath, + frameCount: frameCount, + tileWidth: tileWidth, + tileHeight: tileHeight, + scale: scale, + ); + + // Preload image if not cached + if (!_imageCache.containsKey(assetPath)) { + try { + final ByteData data = await rootBundle.load(assetPath); + final List bytes = data.buffer.asUint8List(); + final Completer completer = Completer(); + ui.decodeImageFromList(Uint8List.fromList(bytes), (ui.Image img) { + completer.complete(img); + }); + _imageCache[assetPath] = await completer.future; + } catch (e) { + debugPrint('Failed to load effect image $assetPath: $e'); + return; + } + } + + effect.image = _imageCache[assetPath]; + + setState(() { + _effects.add(effect); + if (!_controller.isAnimating) { + _controller.forward(from: 0); + } + }); + } + + void _updateFrames() { + if (_effects.isEmpty) return; + + setState(() { + for (var i = _effects.length - 1; i >= 0; i--) { + final effect = _effects[i]; + effect.currentFrame++; + if (effect.currentFrame >= effect.frameCount) { + effect.isFinished = true; + _effects.removeAt(i); + } + } + }); + } + + @override + Widget build(BuildContext context) { + if (_effects.isEmpty) return const SizedBox.shrink(); + + return IgnorePointer( + child: CustomPaint( + size: Size.infinite, + painter: MultiSpriteEffectPainter(effects: _effects), + ), + ); + } +} + +class MultiSpriteEffectPainter extends CustomPainter { + final List effects; + + MultiSpriteEffectPainter({required this.effects}); + + @override + void paint(Canvas canvas, Size size) { + for (final effect in effects) { + if (effect.image == null) continue; + + final double srcX = effect.currentFrame * effect.tileWidth; + final double srcY = 0.0; + final Rect src = Rect.fromLTWH(srcX, srcY, effect.tileWidth, effect.tileHeight); + + final double drawWidth = effect.tileWidth * effect.scale; + final double drawHeight = effect.tileHeight * effect.scale; + // Center the effect on the position + final Rect dst = Rect.fromLTWH( + effect.position.dx - drawWidth / 2, + effect.position.dy - drawHeight / 2, + drawWidth, + drawHeight, + ); + + canvas.drawImageRect( + effect.image!, + src, + dst, + Paint()..filterQuality = FilterQuality.none, + ); + } + } + + @override + bool shouldRepaint(covariant MultiSpriteEffectPainter oldDelegate) { + return true; // Repaint constantly while animating + } +} diff --git a/lib/widgets/battle/explosion_widget.dart b/lib/widgets/battle/explosion_widget.dart index 7c74519..402af73 100644 --- a/lib/widgets/battle/explosion_widget.dart +++ b/lib/widgets/battle/explosion_widget.dart @@ -32,6 +32,8 @@ class ExplosionWidgetState extends State final List _particles = []; final Random _random = Random(); + bool get isAnimating => _controller.isAnimating || _particles.isNotEmpty; + @override void initState() { super.initState(); @@ -127,7 +129,7 @@ class ExplosionPainter extends CustomPainter { void paint(Canvas canvas, Size size) { for (final p in particles) { final paint = Paint() - ..color = p.color.withOpacity(p.life.clamp(0.0, 1.0)) + ..color = p.color.withValues(alpha: p.life.clamp(0.0, 1.0)) ..style = PaintingStyle.fill; canvas.drawCircle(p.position, p.size, paint); diff --git a/lib/widgets/battle/floating_battle_texts.dart b/lib/widgets/battle/floating_battle_texts.dart index afd9062..4266843 100644 --- a/lib/widgets/battle/floating_battle_texts.dart +++ b/lib/widgets/battle/floating_battle_texts.dart @@ -8,11 +8,11 @@ class FloatingDamageText extends StatefulWidget { final VoidCallback onRemove; const FloatingDamageText({ - Key? key, + super.key, required this.damage, required this.color, required this.onRemove, - }) : super(key: key); + }); @override FloatingDamageTextState createState() => FloatingDamageTextState(); @@ -111,12 +111,12 @@ class FloatingEffect extends StatefulWidget { final VoidCallback onRemove; const FloatingEffect({ - Key? key, + super.key, required this.icon, required this.color, required this.size, required this.onRemove, - }) : super(key: key); + }); @override FloatingEffectState createState() => FloatingEffectState(); @@ -193,11 +193,11 @@ class FloatingFeedbackText extends StatefulWidget { final VoidCallback onRemove; const FloatingFeedbackText({ - Key? key, + super.key, required this.feedback, required this.color, required this.onRemove, - }) : super(key: key); + }); @override FloatingFeedbackTextState createState() => FloatingFeedbackTextState(); diff --git a/lib/widgets/common/item_card_widget.dart b/lib/widgets/common/item_card_widget.dart index b4dc476..c8dd7f7 100644 --- a/lib/widgets/common/item_card_widget.dart +++ b/lib/widgets/common/item_card_widget.dart @@ -9,6 +9,7 @@ class ItemCardWidget extends StatelessWidget { final VoidCallback? onTap; final bool showPrice; final bool canBuy; + final bool compact; const ItemCardWidget({ super.key, @@ -16,6 +17,7 @@ class ItemCardWidget extends StatelessWidget { this.onTap, this.showPrice = false, this.canBuy = true, + this.compact = false, }); @override @@ -38,12 +40,12 @@ class ItemCardWidget extends StatelessWidget { children: [ // Background Watermark/Silhouette Icon (Top-Left) Positioned( - left: 8, - top: 8, + left: compact ? 4 : 8, + top: compact ? 4 : 8, child: Image.asset( ItemUtils.getIconPath(item.slot), - width: 32, - height: 32, + width: compact ? 24 : 32, + height: compact ? 24 : 32, fit: BoxFit.contain, color: Colors.black12, // Shadow silhouette ), @@ -51,12 +53,12 @@ class ItemCardWidget extends StatelessWidget { // Main Content (Centered) Center( child: Padding( - padding: const EdgeInsets.all(4.0), + padding: EdgeInsets.all(compact ? 2.0 : 4.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ - const SizedBox(height: 12), + if (!compact) const SizedBox(height: 12), Text( item.name, maxLines: 1, @@ -65,15 +67,23 @@ class ItemCardWidget extends StatelessWidget { style: TextStyle( fontWeight: FontWeight.bold, color: ItemUtils.getRarityColor(item.rarity), - fontSize: 12, + fontSize: compact ? 10 : 12, ), ), - const SizedBox(height: 4), + if (item.weaponType != null) + Text( + item.weaponType == WeaponType.oneHanded ? "1-Handed" : "2-Handed", + style: const TextStyle(fontSize: 9, color: ThemeConfig.textColorGrey), + ), + SizedBox(height: compact ? 1 : 4), // Show Item Stats - FittedBox( - fit: BoxFit.scaleDown, - child: _buildItemStatText(item), - ), + if (compact) + _buildCompactItemStatText(item) + else + FittedBox( + fit: BoxFit.scaleDown, + child: _buildItemStatText(item), + ), if (showPrice) ...[ const SizedBox(height: 4), Text( @@ -98,12 +108,52 @@ class ItemCardWidget extends StatelessWidget { ); } + Widget _buildCompactItemStatText(Item item) { + final stats = []; + + if (item.atkBonus != 0) { + stats.add("${_sign(item.atkBonus)}${item.atkBonus}A"); + } + if (item.hpBonus != 0) { + stats.add("${_sign(item.hpBonus)}${item.hpBonus}H"); + } + if (item.armorBonus != 0) { + stats.add("${_sign(item.armorBonus)}${item.armorBonus}D"); + } + if (item.luck != 0) { + stats.add("${_sign(item.luck)}${item.luck}L"); + } + + final effect = item.effects.isNotEmpty + ? item.effects.first.type.name.toUpperCase() + : null; + final text = [ + if (stats.isNotEmpty) stats.join(" "), + if (effect != null) effect, + ].join(" "); + + if (text.isEmpty) return const SizedBox.shrink(); + + return Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: ThemeConfig.fontSizeTiny, + color: ThemeConfig.statAtkColor, + ), + ); + } + + String _sign(int value) => value > 0 ? "+" : ""; + Widget _buildItemStatText(Item item) { List stats = []; // Helper to format stat string String formatStat(int value, String label) { - String sign = value > 0 ? "+" : ""; // Negative values already have '-' + String sign = _sign(value); // Negative values already have '-' return "$sign$value $label"; } diff --git a/lib/widgets/inventory/equipped_items_widget.dart b/lib/widgets/inventory/equipped_items_widget.dart index 2e801dc..5c90d48 100644 --- a/lib/widgets/inventory/equipped_items_widget.dart +++ b/lib/widgets/inventory/equipped_items_widget.dart @@ -33,6 +33,14 @@ class EquippedItemsWidget extends StatelessWidget { .where((slot) => slot != EquipmentSlot.consumable) .map((slot) { final item = player.equipment[slot]; + bool isShieldLocked = false; + if (slot == EquipmentSlot.shield) { + final mainWeapon = player.equipment[EquipmentSlot.weapon]; + if (mainWeapon != null && mainWeapon.weaponType == WeaponType.twoHanded) { + isShieldLocked = true; + } + } + return Expanded( child: InkWell( onTap: item != null @@ -45,7 +53,7 @@ class EquippedItemsWidget extends StatelessWidget { child: Card( color: item != null ? ThemeConfig.equipmentCardBg - : ThemeConfig.emptySlotBg, + : (isShieldLocked ? Colors.black26 : ThemeConfig.emptySlotBg), shape: item != null && item.rarity != ItemRarity.magic ? RoundedRectangleBorder( @@ -65,7 +73,7 @@ class EquippedItemsWidget extends StatelessWidget { right: 4, top: 4, child: Text( - slot.name.toUpperCase(), + ItemUtils.getSlotLabel(slot), style: const TextStyle( fontSize: ThemeConfig.fontSizeTiny, fontWeight: ThemeConfig.fontWeightBold, @@ -78,7 +86,7 @@ class EquippedItemsWidget extends StatelessWidget { left: 4, top: 4, child: Opacity( - opacity: item != null ? 0.5 : 0.2, + opacity: item != null ? 0.5 : (isShieldLocked ? 0.1 : 0.2), child: Image.asset( ItemUtils.getIconPath(slot), width: 40, @@ -100,7 +108,7 @@ class EquippedItemsWidget extends StatelessWidget { FittedBox( fit: BoxFit.scaleDown, child: Text( - item?.name ?? AppStrings.emptySlot, + item?.name ?? (isShieldLocked ? "Locked (2H)" : AppStrings.emptySlot), textAlign: TextAlign.center, style: TextStyle( fontSize: @@ -111,7 +119,7 @@ class EquippedItemsWidget extends StatelessWidget { ? ItemUtils.getRarityColor( item.rarity, ) - : ThemeConfig.textColorGrey, + : (isShieldLocked ? Colors.red.withOpacity(0.5) : ThemeConfig.textColorGrey), ), maxLines: 2, overflow: TextOverflow.ellipsis, @@ -181,6 +189,16 @@ class EquippedItemsWidget extends StatelessWidget { _buildStatChangeRow("Current HP", currentHp, newHp), _buildStatChangeRow(AppStrings.atk, currentAtk, newAtk), _buildStatChangeRow(AppStrings.def, currentDef, newDef), + if (itemToUnequip.effects.isNotEmpty) ...[ + const Divider(color: ThemeConfig.textColorGrey, height: 16), + ...itemToUnequip.effects.map((e) => Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text("- ", style: TextStyle(color: ThemeConfig.textColorGrey, fontSize: 12)), + Expanded(child: Text(e.description, style: const TextStyle(color: ThemeConfig.textColorGrey, fontSize: 12))), + ], + )), + ], ], ), actions: [ diff --git a/lib/widgets/inventory/inventory_grid_widget.dart b/lib/widgets/inventory/inventory_grid_widget.dart index afcef61..3332a55 100644 --- a/lib/widgets/inventory/inventory_grid_widget.dart +++ b/lib/widgets/inventory/inventory_grid_widget.dart @@ -4,75 +4,123 @@ import '../../providers.dart'; import '../../game/models.dart'; import '../../game/enums.dart'; import '../../game/config.dart'; +import '../../utils.dart'; import '../common/item_card_widget.dart'; +enum InventoryGridMode { normal, shop, equipmentSwap } + class InventoryGridWidget extends StatelessWidget { - const InventoryGridWidget({super.key}); + final InventoryGridMode mode; + final bool equipmentOnly; + final bool showHeader; + final int crossAxisCount; + final EdgeInsetsGeometry gridPadding; + final double childAspectRatio; + + const InventoryGridWidget({ + super.key, + this.mode = InventoryGridMode.normal, + this.equipmentOnly = false, + this.showHeader = true, + this.crossAxisCount = 4, + this.gridPadding = const EdgeInsets.all(16.0), + this.childAspectRatio = 1.0, + }); @override Widget build(BuildContext context) { return Consumer( builder: (context, battleProvider, child) { final player = battleProvider.player; + final items = equipmentOnly + ? player.inventory + .where((item) => item.slot != EquipmentSlot.consumable) + .toList() + : player.inventory; + final itemCount = mode == InventoryGridMode.equipmentSwap + ? items.length + : player.maxInventorySize; + return Column( children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 8.0, - ), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - "${AppStrings.bag} (${player.inventory.length}/${player.maxInventorySize})", - style: const TextStyle( - fontSize: ThemeConfig.fontSizeHeader, - fontWeight: ThemeConfig.fontWeightBold, + if (showHeader) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + "${equipmentOnly ? AppStrings.equipment : AppStrings.bag} (${items.length}/${player.maxInventorySize})", + style: const TextStyle( + fontSize: ThemeConfig.fontSizeHeader, + fontWeight: ThemeConfig.fontWeightBold, + ), ), ), ), - ), 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: () { - _showItemActionDialog(context, battleProvider, item); + child: itemCount == 0 + ? const Center( + child: Text( + "No equipment", + style: TextStyle(color: ThemeConfig.textColorGrey), + ), + ) + : GridView.builder( + padding: gridPadding, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + crossAxisSpacing: 8.0, + mainAxisSpacing: 8.0, + childAspectRatio: childAspectRatio, + ), + itemCount: itemCount, + itemBuilder: (context, index) { + if (index < items.length) { + final item = items[index]; + return InkWell( + onTap: () { + if (mode == InventoryGridMode.equipmentSwap) { + _showEquipSlotDialog( + context, + battleProvider, + item, + ); + } else { + _showItemActionDialog( + context, + battleProvider, + item, + ); + } + }, + child: ItemCardWidget( + item: item, + showPrice: false, + canBuy: false, + compact: mode == InventoryGridMode.equipmentSwap, + ), + ); + } else { + return Container( + decoration: BoxDecoration( + border: Border.all( + color: ThemeConfig.textColorGrey, + ), + color: ThemeConfig.emptySlotBg, + ), + child: const Center( + child: Icon( + Icons.add_box, + color: ThemeConfig.textColorGrey, + ), + ), + ); + } }, - child: ItemCardWidget( - item: item, - // Inventory items usually don't show price unless in sell mode, - // but logic here implies standard view. - // If needed, we can toggle showPrice based on context. - showPrice: false, - canBuy: false, - ), - ); - } else { - return Container( - decoration: BoxDecoration( - border: Border.all(color: ThemeConfig.textColorGrey), - color: ThemeConfig.emptySlotBg, - ), - child: const Center( - child: Icon( - Icons.add_box, - color: ThemeConfig.textColorGrey, - ), - ), - ); - } - }, - ), + ), ), ], ); @@ -85,7 +133,11 @@ class InventoryGridWidget extends StatelessWidget { BattleProvider provider, Item item, ) { - bool isShop = provider.currentStage.type == StageType.shop; + final isShop = + mode == InventoryGridMode.shop || + (mode == InventoryGridMode.normal && + provider.currentStage.type == StageType.shop); + final isEquipmentSwap = mode == InventoryGridMode.equipmentSwap; int sellPrice = (item.price * GameConfig.sellPriceMultiplier).floor(); showDialog( @@ -114,7 +166,7 @@ class InventoryGridWidget extends StatelessWidget { SimpleDialogOption( onPressed: () { Navigator.pop(ctx); - _showEquipConfirmationDialog(context, provider, item); + _showEquipSlotDialog(context, provider, item); }, child: const Padding( padding: EdgeInsets.symmetric(vertical: 8.0), @@ -147,27 +199,84 @@ class InventoryGridWidget extends StatelessWidget { ), ), ), - SimpleDialogOption( - onPressed: () { - Navigator.pop(ctx); - _showDiscardConfirmationDialog(context, provider, item); - }, - child: const Padding( - padding: EdgeInsets.symmetric(vertical: 8.0), - child: Row( - children: [ - Icon(Icons.delete, color: ThemeConfig.btnActionActive), - SizedBox(width: 10), - Text(AppStrings.discard), - ], + if (!isEquipmentSwap) + SimpleDialogOption( + onPressed: () { + Navigator.pop(ctx); + _showDiscardConfirmationDialog(context, provider, item); + }, + child: const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + Icon(Icons.delete, color: ThemeConfig.btnActionActive), + SizedBox(width: 10), + Text(AppStrings.discard), + ], + ), ), ), - ), ], ), ); } + void _showEquipSlotDialog( + BuildContext context, + BattleProvider provider, + Item newItem, + ) { + final compatibleSlots = newItem.compatibleEquipSlots; + if (compatibleSlots.isEmpty) return; + + if (compatibleSlots.length == 1) { + _showEquipConfirmationDialog( + context, + provider, + newItem, + compatibleSlots.first, + ); + return; + } + + showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: Text("Equip ${newItem.name}"), + children: compatibleSlots + .map( + (slot) => SimpleDialogOption( + onPressed: () { + Navigator.pop(ctx); + _showEquipConfirmationDialog( + context, + provider, + newItem, + slot, + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + Image.asset( + ItemUtils.getIconPath(slot), + width: ThemeConfig.itemIconSizeMedium, + height: ThemeConfig.itemIconSizeMedium, + color: ThemeConfig.textColorWhite, + ), + const SizedBox(width: 10), + Text(ItemUtils.getSlotName(slot)), + ], + ), + ), + ), + ) + .toList(), + ), + ); + } + void _showSellConfirmationDialog( BuildContext context, BattleProvider provider, @@ -237,9 +346,10 @@ class InventoryGridWidget extends StatelessWidget { BuildContext context, BattleProvider provider, Item newItem, + EquipmentSlot targetSlot, ) { final player = provider.player; - final oldItem = player.equipment[newItem.slot]; + final oldItem = player.equipment[targetSlot]; final currentMaxHp = player.totalMaxHp; final currentAtk = player.totalAtk; @@ -263,7 +373,7 @@ class InventoryGridWidget extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Text( - "${AppStrings.equip} ${newItem.name}?", + "${AppStrings.equip} ${newItem.name} as ${ItemUtils.getSlotName(targetSlot)}?", style: const TextStyle(fontWeight: ThemeConfig.fontWeightBold), ), if (oldItem != null) @@ -289,6 +399,25 @@ class InventoryGridWidget extends StatelessWidget { player.totalDodge, player.totalDodge - (oldItem?.dodge ?? 0) + newItem.dodge, ), + if (newItem.effects.isNotEmpty || (oldItem != null && oldItem.effects.isNotEmpty)) ...[ + const Divider(color: ThemeConfig.textColorGrey, height: 16), + if (oldItem != null && oldItem.effects.isNotEmpty) + ...oldItem.effects.map((e) => Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text("- ", style: TextStyle(color: ThemeConfig.textColorGrey, fontSize: 12)), + Expanded(child: Text(e.description, style: const TextStyle(color: ThemeConfig.textColorGrey, fontSize: 12))), + ], + )), + if (newItem.effects.isNotEmpty) + ...newItem.effects.map((e) => Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text("+ ", style: TextStyle(color: ThemeConfig.rarityLegendary, fontSize: 12, fontWeight: FontWeight.bold)), + Expanded(child: Text(e.description, style: const TextStyle(color: ThemeConfig.rarityLegendary, fontSize: 12, fontWeight: FontWeight.bold))), + ], + )), + ], ], ), actions: [ @@ -298,7 +427,7 @@ class InventoryGridWidget extends StatelessWidget { ), ElevatedButton( onPressed: () { - provider.equipItem(newItem); + provider.equipItem(newItem, targetSlot: targetSlot); Navigator.pop(ctx); }, child: const Text(AppStrings.confirm), diff --git a/lib/widgets/responsive_container.dart b/lib/widgets/responsive_container.dart index 6b0f56b..d08d96e 100644 --- a/lib/widgets/responsive_container.dart +++ b/lib/widgets/responsive_container.dart @@ -6,11 +6,11 @@ class ResponsiveContainer extends StatelessWidget { final double maxHeight; const ResponsiveContainer({ - Key? key, + super.key, required this.child, this.maxWidth = 600.0, this.maxHeight = 1000.0, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/widgets/stage/shop_ui.dart b/lib/widgets/stage/shop_ui.dart index 036fb87..fe36ec5 100644 --- a/lib/widgets/stage/shop_ui.dart +++ b/lib/widgets/stage/shop_ui.dart @@ -134,7 +134,10 @@ class ShopUI extends StatelessWidget { const Divider(color: ThemeConfig.textColorGrey), // Player Inventory (Bottom Half) - const Expanded(flex: 5, child: InventoryGridWidget()), + const Expanded( + flex: 5, + child: InventoryGridWidget(mode: InventoryGridMode.shop), + ), const SizedBox(height: 8), diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig index c2efd0b..4b81f9b 100644 --- a/macos/Flutter/Flutter-Debug.xcconfig +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig index c2efd0b..5caa9d1 100644 --- a/macos/Flutter/Flutter-Release.xcconfig +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/pubspec.yaml b/pubspec.yaml index d06d8f1..7e6b2a2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,3 +36,4 @@ flutter: - assets/images/icons/accessories/ - assets/images/icons/potions/ - assets/images/icons/armors/ + - assets/images/effects/ diff --git a/test/battle_provider_test.dart b/test/battle_provider_test.dart index 3f39329..74bb3a9 100644 --- a/test/battle_provider_test.dart +++ b/test/battle_provider_test.dart @@ -2,7 +2,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; // Import SharedPreferences import 'package:game_test/providers/battle_provider.dart'; import 'package:game_test/providers/shop_provider.dart'; -import 'package:game_test/game/models.dart'; import 'package:game_test/game/enums.dart'; import 'package:game_test/game/data/item_table.dart'; diff --git a/test/character_test.dart b/test/character_test.dart index 0333414..ad01071 100644 --- a/test/character_test.dart +++ b/test/character_test.dart @@ -139,5 +139,36 @@ void main() { ); }, ); + + test('Weapon can be equipped as subweapon for dual wielding', () { + final mainSword = Item( + id: "main_sword", + name: "Main Sword", + description: "ATK +5", + atkBonus: 5, + hpBonus: 0, + slot: EquipmentSlot.weapon, + price: 100, + ); + final offhandDagger = Item( + id: "offhand_dagger", + name: "Offhand Dagger", + description: "ATK +3", + atkBonus: 3, + hpBonus: 0, + slot: EquipmentSlot.weapon, + price: 80, + ); + + player.addToInventory(mainSword); + player.addToInventory(offhandDagger); + + expect(player.equipToSlot(mainSword, EquipmentSlot.weapon), true); + expect(player.equipToSlot(offhandDagger, EquipmentSlot.shield), true); + + expect(player.equipment[EquipmentSlot.weapon], mainSword); + expect(player.equipment[EquipmentSlot.shield], offhandDagger); + expect(player.totalAtk, 18); + }); }); } diff --git a/test/item_load_test.dart b/test/item_load_test.dart index cd5a8b4..323cac4 100644 --- a/test/item_load_test.dart +++ b/test/item_load_test.dart @@ -1,6 +1,5 @@ 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();