Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83ea191ca2 | ||
|
|
0e0748540e | ||
|
|
0420e23939 | ||
|
|
adb6c2d20b | ||
|
|
cb48d0db48 | ||
|
|
3b9fa47f0a |
@@ -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` 등록 여부를 반드시 확인해야 합니다.
|
||||
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 420 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 345 KiB |
|
After Width: | Height: | Size: 933 B |
|
After Width: | Height: | Size: 465 B |
|
After Width: | Height: | Size: 556 B |
@@ -1 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -97,4 +97,26 @@ class BattleConfig {
|
||||
return sizeSafe;
|
||||
}
|
||||
}
|
||||
|
||||
static String getFeedbackText(BattleFeedbackType type) {
|
||||
switch (type) {
|
||||
case BattleFeedbackType.miss:
|
||||
return "MISS";
|
||||
case BattleFeedbackType.failed:
|
||||
return "FAILED";
|
||||
case BattleFeedbackType.dodge:
|
||||
return "DODGE";
|
||||
}
|
||||
}
|
||||
|
||||
static Color getFeedbackColor(BattleFeedbackType type) {
|
||||
switch (type) {
|
||||
case BattleFeedbackType.miss:
|
||||
return Colors.redAccent; // Or use ThemeConfig.missText if preferred
|
||||
case BattleFeedbackType.failed:
|
||||
return Colors.orangeAccent; // Or use ThemeConfig.failedText
|
||||
case BattleFeedbackType.dodge:
|
||||
return Colors.greenAccent; // Or use ThemeConfig.statLuckColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ class GameConfig {
|
||||
static const int restStageInterval = 8;
|
||||
static const int tier1StageMax = 12;
|
||||
static const int tier2StageMax = 24;
|
||||
static const int maxStage = 36;
|
||||
|
||||
// Battle
|
||||
static const double stageHealRatio = 0.1;
|
||||
|
||||
@@ -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<String, dynamic> json) {
|
||||
factory EnemyTemplate.fromJson(
|
||||
Map<String, dynamic> json, {
|
||||
Set<String>? 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<dynamic>?)?.cast<String>() ?? [],
|
||||
tier: json['tier'] ?? 1,
|
||||
);
|
||||
}
|
||||
|
||||
static String? _resolveImagePath(String? imagePath, Set<String>? 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<String, dynamic> 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<Set<String>?> _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,
|
||||
|
||||
@@ -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<String, dynamic> 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<ItemTemplate> consumables = [];
|
||||
|
||||
static final Map<String, ItemTemplate> _items = {};
|
||||
static List<ItemTemplate> 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<String, dynamic> data = jsonDecode(jsonString);
|
||||
|
||||
// Helper function to load and register items
|
||||
void _loadAndRegister(String key, List<ItemTemplate> list) {
|
||||
void loadAndRegister(
|
||||
String key,
|
||||
List<ItemTemplate> 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<ItemTemplate> 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.
|
||||
|
||||
@@ -10,7 +10,8 @@ enum StatusEffectType {
|
||||
bleed, // Takes damage at start/end of turn
|
||||
defenseForbidden, // Cannot use Defend action
|
||||
disarmed, // Attack strength reduced (e.g., 10%)
|
||||
attackUp, // New: Increases Attack Power
|
||||
attackUp,
|
||||
heal, // New: Increases Attack Power
|
||||
}
|
||||
|
||||
/// 공격 실패 시 이펙트 피드백 타입 정의
|
||||
@@ -34,6 +35,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 }
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
export 'logic/battle_log_manager.dart';
|
||||
export 'logic/combat_calculator.dart';
|
||||
export 'logic/loot_generator.dart';
|
||||
export 'logic/enemy_ai_service.dart';
|
||||
export 'logic/stage_manager.dart';
|
||||
export 'logic/effect_event_factory.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;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'dart:math';
|
||||
import '../enums.dart';
|
||||
import '../models.dart';
|
||||
|
||||
class EffectEventFactory {
|
||||
/// Generates a unique ID for events.
|
||||
static String _generateId(Random random) {
|
||||
return DateTime.now().millisecondsSinceEpoch.toString() +
|
||||
random.nextInt(1000).toString();
|
||||
}
|
||||
|
||||
/// Creates a successful attack event.
|
||||
static EffectEvent createAttackEvent({
|
||||
required Character attacker,
|
||||
required Character target,
|
||||
required EffectTarget effectTarget,
|
||||
required RiskLevel risk,
|
||||
required int damage,
|
||||
required Random random,
|
||||
}) {
|
||||
return EffectEvent(
|
||||
id: _generateId(random),
|
||||
type: ActionType.attack,
|
||||
risk: risk,
|
||||
target: effectTarget,
|
||||
feedbackType: null,
|
||||
attacker: attacker,
|
||||
targetEntity: target,
|
||||
damageValue: damage,
|
||||
isSuccess: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates a dodge event.
|
||||
static EffectEvent createDodgeEvent({
|
||||
required Character attacker,
|
||||
required Character target,
|
||||
required EffectTarget effectTarget,
|
||||
required RiskLevel risk,
|
||||
required Random random,
|
||||
}) {
|
||||
return EffectEvent(
|
||||
id: _generateId(random),
|
||||
type: ActionType.attack,
|
||||
risk: risk,
|
||||
target: effectTarget,
|
||||
feedbackType: BattleFeedbackType.dodge,
|
||||
attacker: attacker,
|
||||
targetEntity: target,
|
||||
damageValue: 0,
|
||||
isSuccess: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates a miss or failure event.
|
||||
static EffectEvent createFailureEvent({
|
||||
required Character attacker,
|
||||
required Character target,
|
||||
required EffectTarget effectTarget,
|
||||
required ActionType type,
|
||||
required RiskLevel risk,
|
||||
required Random random,
|
||||
}) {
|
||||
BattleFeedbackType feedbackType = (type == ActionType.attack)
|
||||
? BattleFeedbackType.miss
|
||||
: BattleFeedbackType.failed;
|
||||
|
||||
return EffectEvent(
|
||||
id: _generateId(random),
|
||||
type: type,
|
||||
risk: risk,
|
||||
target: effectTarget,
|
||||
feedbackType: feedbackType,
|
||||
attacker: attacker,
|
||||
targetEntity: target,
|
||||
isSuccess: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates a successful defense event.
|
||||
static EffectEvent createDefenseEvent({
|
||||
required Character attacker,
|
||||
required Character target,
|
||||
required EffectTarget effectTarget,
|
||||
required RiskLevel risk,
|
||||
required int armorGained,
|
||||
required Random random,
|
||||
}) {
|
||||
return EffectEvent(
|
||||
id: _generateId(random),
|
||||
type: ActionType.defend,
|
||||
risk: risk,
|
||||
target: effectTarget,
|
||||
feedbackType: null,
|
||||
attacker: attacker,
|
||||
targetEntity: target,
|
||||
armorGained: armorGained,
|
||||
isSuccess: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'dart:math';
|
||||
import '../enums.dart';
|
||||
import '../models.dart';
|
||||
import '../config.dart';
|
||||
import 'combat_calculator.dart';
|
||||
|
||||
class EnemyAIService {
|
||||
/// Decides the next action for the enemy based on status effects and probability.
|
||||
static EnemyIntent generateIntent(Character enemy, Random random) {
|
||||
if (enemy.isDead) {
|
||||
return EnemyIntent(
|
||||
type: EnemyActionType.attack,
|
||||
value: 0,
|
||||
risk: RiskLevel.safe,
|
||||
description: "Dead",
|
||||
isSuccess: false,
|
||||
finalValue: 0,
|
||||
);
|
||||
}
|
||||
|
||||
// Decide Action Type
|
||||
bool canDefend =
|
||||
enemy.baseDefense > 0 &&
|
||||
!enemy.hasStatus(StatusEffectType.defenseForbidden);
|
||||
|
||||
bool isAttack = true; // Default to attack
|
||||
|
||||
if (canDefend) {
|
||||
// Both options available or forced? Currently using probability from config.
|
||||
isAttack = random.nextDouble() < BattleConfig.enemyAttackChance;
|
||||
} else {
|
||||
// Must attack if defense is forbidden or base defense is 0
|
||||
isAttack = true;
|
||||
}
|
||||
|
||||
// Decide Risk Level
|
||||
RiskLevel risk = RiskLevel.values[random.nextInt(RiskLevel.values.length)];
|
||||
|
||||
CombatResult result;
|
||||
if (isAttack) {
|
||||
result = CombatCalculator.calculateActionOutcome(
|
||||
actionType: ActionType.attack,
|
||||
risk: risk,
|
||||
luck: enemy.totalLuck,
|
||||
baseValue: enemy.totalAtk,
|
||||
);
|
||||
|
||||
return EnemyIntent(
|
||||
type: EnemyActionType.attack,
|
||||
value: result.value,
|
||||
risk: risk,
|
||||
description: "${result.value} (${risk.name})",
|
||||
isSuccess: result.success,
|
||||
finalValue: result.value,
|
||||
);
|
||||
} else {
|
||||
result = CombatCalculator.calculateActionOutcome(
|
||||
actionType: ActionType.defend,
|
||||
risk: risk,
|
||||
luck: enemy.totalLuck,
|
||||
baseValue: enemy.totalDefense,
|
||||
);
|
||||
|
||||
return EnemyIntent(
|
||||
type: EnemyActionType.defend,
|
||||
value: result.value,
|
||||
risk: risk,
|
||||
description: "${result.value} (${risk.name})",
|
||||
isSuccess: result.success,
|
||||
finalValue: result.value,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'dart:math';
|
||||
import '../enums.dart';
|
||||
import '../models.dart';
|
||||
import '../data.dart';
|
||||
import '../config.dart';
|
||||
|
||||
class StageManager {
|
||||
/// Determines the stage type based on the stage number and configured intervals.
|
||||
static 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;
|
||||
}
|
||||
|
||||
/// Generates a Character instance for the enemy in the current stage.
|
||||
static Character generateEnemy(int stage, StageType type) {
|
||||
bool isElite = type == StageType.elite;
|
||||
|
||||
// For non-battle stages, return dummy characters (Merchants/Campfires)
|
||||
if (type == StageType.shop) {
|
||||
return Character(name: "Merchant", maxHp: 9999, armor: 0, atk: 0);
|
||||
} else if (type == StageType.rest) {
|
||||
return Character(name: "Campfire", maxHp: 9999, armor: 0, atk: 0);
|
||||
}
|
||||
|
||||
// Normal or Elite Battle
|
||||
EnemyTemplate template = EnemyTable.getRandomEnemy(
|
||||
stage: stage,
|
||||
isElite: isElite,
|
||||
);
|
||||
return template.createCharacter(stage: stage);
|
||||
}
|
||||
|
||||
/// Generates 3 item reward options based on the current stage and tier.
|
||||
static List<Item> generateRewardOptions(int stage, StageType currentStageType) {
|
||||
ItemTier currentTier = _getTierForStage(stage);
|
||||
List<Item> rewardOptions = [];
|
||||
|
||||
bool isElite = currentStageType == StageType.elite;
|
||||
bool isTier1 = currentTier == ItemTier.tier1;
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
ItemRarity? minRarity;
|
||||
ItemRarity? maxRarity;
|
||||
|
||||
if (isElite && i == 0) {
|
||||
if (isTier1) {
|
||||
minRarity = ItemRarity.rare;
|
||||
maxRarity = ItemRarity.rare;
|
||||
} else {
|
||||
minRarity = ItemRarity.legendary;
|
||||
}
|
||||
} else {
|
||||
if (isTier1) {
|
||||
maxRarity = ItemRarity.magic;
|
||||
}
|
||||
}
|
||||
|
||||
ItemTemplate? item = ItemTable.getRandomItem(
|
||||
tier: currentTier,
|
||||
minRarity: minRarity,
|
||||
maxRarity: maxRarity,
|
||||
);
|
||||
|
||||
if (item != null) {
|
||||
rewardOptions.add(item.createItem(stage: stage));
|
||||
}
|
||||
}
|
||||
|
||||
rewardOptions.add(_createSkipRewardItem());
|
||||
return rewardOptions;
|
||||
}
|
||||
|
||||
/// Calculates the gold reward for defeating an enemy.
|
||||
static int calculateGoldReward(int stage, Random random) {
|
||||
return GameConfig.baseGoldReward +
|
||||
(stage * GameConfig.goldRewardPerStage) +
|
||||
random.nextInt(GameConfig.goldRewardVariance);
|
||||
}
|
||||
|
||||
static ItemTier _getTierForStage(int stage) {
|
||||
if (stage > GameConfig.tier2StageMax) {
|
||||
return ItemTier.tier3;
|
||||
} else if (stage > GameConfig.tier1StageMax) {
|
||||
return ItemTier.tier2;
|
||||
}
|
||||
return ItemTier.tier1;
|
||||
}
|
||||
|
||||
static Item _createSkipRewardItem() {
|
||||
return Item(
|
||||
id: "reward_skip",
|
||||
name: "Skip Reward",
|
||||
description: "Take nothing and move on.",
|
||||
atkBonus: 0,
|
||||
hpBonus: 0,
|
||||
slot: EquipmentSlot.accessory,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import '../enums.dart';
|
||||
|
||||
/// Represents the result of applying status effects at the start of a turn.
|
||||
class TurnEffectResult {
|
||||
final bool canAct;
|
||||
final bool effectTriggered;
|
||||
|
||||
TurnEffectResult({required this.canAct, required this.effectTriggered});
|
||||
}
|
||||
|
||||
/// Represents the planned action of an enemy for the current turn.
|
||||
class EnemyIntent {
|
||||
final EnemyActionType type;
|
||||
final int value;
|
||||
final RiskLevel risk;
|
||||
final String description;
|
||||
final bool isSuccess;
|
||||
final int finalValue;
|
||||
bool isApplied; // Mutable flag to prevent double execution
|
||||
|
||||
EnemyIntent({
|
||||
required this.type,
|
||||
required this.value,
|
||||
required this.risk,
|
||||
required this.description,
|
||||
required this.isSuccess,
|
||||
required this.finalValue,
|
||||
this.isApplied = false,
|
||||
});
|
||||
}
|
||||
@@ -5,13 +5,17 @@ enum DamageTarget { player, enemy }
|
||||
|
||||
class DamageEvent {
|
||||
final int damage;
|
||||
final int armorDamage; // New field
|
||||
final DamageTarget target;
|
||||
final DamageType type;
|
||||
final RiskLevel? risk;
|
||||
|
||||
DamageEvent({
|
||||
required this.damage,
|
||||
this.armorDamage = 0,
|
||||
required this.target,
|
||||
this.type = DamageType.normal,
|
||||
this.risk,
|
||||
});
|
||||
|
||||
Color get color {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
enum HealTarget { player, enemy }
|
||||
|
||||
class HealEvent {
|
||||
final int amount;
|
||||
final HealTarget target;
|
||||
|
||||
HealEvent({
|
||||
required this.amount,
|
||||
required this.target,
|
||||
});
|
||||
}
|
||||
@@ -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<EquipmentSlot> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, dynamic> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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';
|
||||
export 'model/stat.dart';
|
||||
export 'model/stat_modifier.dart';
|
||||
export 'model/status_effect.dart';
|
||||
export 'model/battle_models.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 {
|
||||
|
||||
@@ -7,9 +7,14 @@ class SettingsProvider with ChangeNotifier {
|
||||
|
||||
bool _enableEnemyAnimations = true; // Default: Enabled
|
||||
double _attackAnimScale = 5.0; // Default: 5.0
|
||||
double _shakeOffset = 30.0; // Default: 30.0
|
||||
double _shakeCount =
|
||||
3.0; // Default: 3.0 (using double for slider convenience, cast to int where needed)
|
||||
|
||||
bool get enableEnemyAnimations => _enableEnemyAnimations;
|
||||
double get attackAnimScale => _attackAnimScale;
|
||||
double get shakeOffset => _shakeOffset;
|
||||
double get shakeCount => _shakeCount;
|
||||
|
||||
SettingsProvider() {
|
||||
_loadSettings();
|
||||
@@ -19,6 +24,8 @@ class SettingsProvider with ChangeNotifier {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_enableEnemyAnimations = prefs.getBool(_keyEnemyAnim) ?? true;
|
||||
_attackAnimScale = prefs.getDouble(_keyAttackAnimScale) ?? 5.0;
|
||||
_shakeOffset = prefs.getDouble('settings_shake_offset') ?? 30.0;
|
||||
_shakeCount = prefs.getDouble('settings_shake_count') ?? 3.0;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -35,4 +42,18 @@ class SettingsProvider with ChangeNotifier {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble(_keyAttackAnimScale, value);
|
||||
}
|
||||
|
||||
Future<void> setShakeOffset(double value) async {
|
||||
_shakeOffset = value;
|
||||
notifyListeners();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble('settings_shake_offset', value);
|
||||
}
|
||||
|
||||
Future<void> setShakeCount(double value) async {
|
||||
_shakeCount = value;
|
||||
notifyListeners();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble('settings_shake_count', value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../game/enums.dart';
|
||||
import '../game/models.dart';
|
||||
import '../game/config.dart';
|
||||
import '../providers/battle_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../widgets/battle/effect_sprite_widget.dart';
|
||||
import '../widgets/battle/floating_battle_texts.dart';
|
||||
import '../widgets/battle/battle_animation_widget.dart';
|
||||
import '../widgets/battle/shake_widget.dart';
|
||||
import '../widgets/battle/explosion_widget.dart';
|
||||
|
||||
enum AnimationPhase { none, start, middle, end, hurt, block }
|
||||
|
||||
mixin BattleVisualHandler<T extends StatefulWidget> on State<T> {
|
||||
// State variables for animations
|
||||
final List<DamageTextData> floatingDamageTexts = [];
|
||||
final List<FloatingEffectData> floatingEffects = [];
|
||||
final List<FeedbackTextData> floatingFeedbackTexts = [];
|
||||
|
||||
final GlobalKey playerKey = GlobalKey();
|
||||
final GlobalKey enemyKey = GlobalKey();
|
||||
final GlobalKey stackKey = GlobalKey();
|
||||
final GlobalKey<ShakeWidgetState> shakeKey = GlobalKey<ShakeWidgetState>();
|
||||
final GlobalKey<BattleAnimationWidgetState> playerAnimKey = GlobalKey();
|
||||
final GlobalKey<BattleAnimationWidgetState> enemyAnimKey = GlobalKey();
|
||||
final GlobalKey<ExplosionWidgetState> explosionKey = GlobalKey();
|
||||
final GlobalKey<EffectSpriteWidgetState> effectSpriteKey = GlobalKey();
|
||||
final GlobalKey rootStackKey = GlobalKey();
|
||||
|
||||
AnimationPhase playerAnimPhase = AnimationPhase.none;
|
||||
RiskLevel? activeRiskLevel;
|
||||
bool isAttackSuccess = true;
|
||||
bool isPlayerAttacking = false;
|
||||
bool isEnemyAttacking = false;
|
||||
|
||||
StreamSubscription<DamageEvent>? damageSubscription;
|
||||
StreamSubscription<EffectEvent>? effectSubscription;
|
||||
StreamSubscription<HealEvent>? healSubscription;
|
||||
|
||||
DateTime? lastFeedbackTime;
|
||||
|
||||
void setupVisualListeners(BattleProvider battleProvider) {
|
||||
damageSubscription = battleProvider.damageStream.listen(_onDamageEvent);
|
||||
effectSubscription = battleProvider.effectStream.listen(onEffectEvent);
|
||||
healSubscription = battleProvider.healStream.listen(onHealEvent);
|
||||
}
|
||||
|
||||
void disposeVisualListeners() {
|
||||
damageSubscription?.cancel();
|
||||
effectSubscription?.cancel();
|
||||
healSubscription?.cancel();
|
||||
}
|
||||
|
||||
// --- Animation Core Logic ---
|
||||
|
||||
void _onDamageEvent(DamageEvent event) {
|
||||
if (!mounted) return;
|
||||
|
||||
if (event.target == DamageTarget.player) {
|
||||
if (event.armorDamage > 0) {
|
||||
_triggerPlayerBlock();
|
||||
} else if (event.damage > 0) {
|
||||
_triggerPlayerHurt();
|
||||
}
|
||||
}
|
||||
|
||||
addFloatingDamageText(event);
|
||||
}
|
||||
|
||||
void _triggerPlayerHurt() {
|
||||
if (playerAnimPhase != AnimationPhase.none &&
|
||||
playerAnimPhase != AnimationPhase.hurt) {
|
||||
return;
|
||||
}
|
||||
setState(() => playerAnimPhase = AnimationPhase.hurt);
|
||||
Future.delayed(const Duration(milliseconds: 800), () {
|
||||
if (mounted && playerAnimPhase == AnimationPhase.hurt) {
|
||||
setState(() => playerAnimPhase = AnimationPhase.none);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _triggerPlayerBlock() {
|
||||
if (playerAnimPhase != AnimationPhase.none &&
|
||||
playerAnimPhase != AnimationPhase.block) {
|
||||
return;
|
||||
}
|
||||
setState(() => playerAnimPhase = AnimationPhase.block);
|
||||
Future.delayed(const Duration(milliseconds: 800), () {
|
||||
if (mounted && playerAnimPhase == AnimationPhase.block) {
|
||||
setState(() => playerAnimPhase = AnimationPhase.none);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void onHealEvent(HealEvent event) {
|
||||
if (!mounted) return;
|
||||
|
||||
RenderBox? rootBox =
|
||||
rootStackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (rootBox == null) return;
|
||||
Offset rootOffset = rootBox.localToGlobal(Offset.zero);
|
||||
|
||||
Offset position = Offset(rootBox.size.width / 2, rootBox.size.height / 2);
|
||||
|
||||
if (event.target == HealTarget.player && playerAnimKey.currentContext != null) {
|
||||
RenderBox? imageBox =
|
||||
playerAnimKey.currentContext!.findRenderObject() as RenderBox?;
|
||||
if (imageBox != null) {
|
||||
Offset globalCenter = imageBox.localToGlobal(
|
||||
Offset(imageBox.size.width / 2, imageBox.size.height / 2),
|
||||
);
|
||||
position = globalCenter - rootOffset;
|
||||
}
|
||||
}
|
||||
|
||||
effectSpriteKey.currentState?.playEffect(
|
||||
position: position,
|
||||
assetPath: 'assets/images/effects/heal.png',
|
||||
frameCount: 4,
|
||||
tileWidth: 100.0,
|
||||
tileHeight: 100.0,
|
||||
scale: 6.0,
|
||||
);
|
||||
|
||||
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,
|
||||
onRemove: () {
|
||||
if (mounted) {
|
||||
setState(() => floatingDamageTexts.removeWhere((e) => e.id == id));
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void onEffectEvent(EffectEvent event) {
|
||||
if (!mounted) return;
|
||||
|
||||
void showEffect() {
|
||||
if (event.feedbackType != null) {
|
||||
addFloatingFeedbackText(event);
|
||||
return;
|
||||
}
|
||||
|
||||
IconData icon = BattleConfig.getIcon(event.type);
|
||||
Color color = BattleConfig.getColor(event.type, event.risk);
|
||||
double size = BattleConfig.getSize(event.risk);
|
||||
addFloatingEffect(event, icon, color, size);
|
||||
}
|
||||
|
||||
if (event.isVisualOnly) {
|
||||
showEffect();
|
||||
context.read<BattleProvider>().handleImpact(event);
|
||||
} else if (event.type == ActionType.attack && event.target == EffectTarget.enemy) {
|
||||
final RenderBox? pBox = playerKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final RenderBox? eBox = enemyKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
|
||||
if (pBox != null && eBox != null) {
|
||||
final offset = eBox.localToGlobal(Offset.zero) - pBox.localToGlobal(Offset.zero);
|
||||
|
||||
setState(() {
|
||||
isPlayerAttacking = true;
|
||||
activeRiskLevel = event.risk;
|
||||
isAttackSuccess = event.isSuccess == true && event.feedbackType == null;
|
||||
});
|
||||
|
||||
final animRisk = event.feedbackType != null ? RiskLevel.safe : event.risk;
|
||||
|
||||
playerAnimKey.currentState?.animateAttack(
|
||||
offset,
|
||||
() {
|
||||
showEffect();
|
||||
context.read<BattleProvider>().handleImpact(event);
|
||||
if (event.risk == RiskLevel.risky && event.feedbackType == null) {
|
||||
shakeKey.currentState?.shake();
|
||||
RenderBox? sBox = stackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (sBox != null) {
|
||||
Offset localEnemyPos = sBox.globalToLocal(eBox.localToGlobal(Offset.zero)) +
|
||||
Offset(eBox.size.width / 2, eBox.size.height / 2);
|
||||
explosionKey.currentState?.explode(localEnemyPos);
|
||||
}
|
||||
}
|
||||
},
|
||||
animRisk,
|
||||
onAnimationStart: () => setState(() => playerAnimPhase = AnimationPhase.start),
|
||||
onAnimationMiddle: () => setState(() => playerAnimPhase = AnimationPhase.middle),
|
||||
onAnimationEnd: () => setState(() => playerAnimPhase = AnimationPhase.end),
|
||||
).then((_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isPlayerAttacking = false;
|
||||
playerAnimPhase = AnimationPhase.none;
|
||||
activeRiskLevel = null;
|
||||
isAttackSuccess = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (event.type == ActionType.attack && event.target == EffectTarget.player) {
|
||||
bool enableAnim = context.read<SettingsProvider>().enableEnemyAnimations;
|
||||
if (!enableAnim) {
|
||||
showEffect();
|
||||
context.read<BattleProvider>().handleImpact(event);
|
||||
return;
|
||||
}
|
||||
|
||||
final RenderBox? pBox = playerKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final RenderBox? eBox = enemyKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
|
||||
if (pBox != null && eBox != null) {
|
||||
final offset = pBox.localToGlobal(Offset.zero) - eBox.localToGlobal(Offset.zero);
|
||||
setState(() => isEnemyAttacking = true);
|
||||
|
||||
final animRisk = event.feedbackType != null ? RiskLevel.safe : event.risk;
|
||||
|
||||
enemyAnimKey.currentState?.animateAttack(offset, () {
|
||||
showEffect();
|
||||
context.read<BattleProvider>().handleImpact(event);
|
||||
if (event.risk == RiskLevel.risky && event.feedbackType == null) {
|
||||
shakeKey.currentState?.shake();
|
||||
RenderBox? sBox = stackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (sBox != null) {
|
||||
Offset localPlayerPos = sBox.globalToLocal(pBox.localToGlobal(Offset.zero)) +
|
||||
Offset(pBox.size.width / 2, pBox.size.height / 2);
|
||||
explosionKey.currentState?.explode(localPlayerPos);
|
||||
}
|
||||
}
|
||||
}, animRisk).then((_) {
|
||||
if (mounted) setState(() => isEnemyAttacking = false);
|
||||
});
|
||||
}
|
||||
} else if (event.type == ActionType.defend) {
|
||||
if (event.target == EffectTarget.player) {
|
||||
setState(() => isPlayerAttacking = true);
|
||||
playerAnimKey.currentState?.animateDefense(() {
|
||||
showEffect();
|
||||
context.read<BattleProvider>().handleImpact(event);
|
||||
}).then((_) {
|
||||
if (mounted) setState(() => isPlayerAttacking = false);
|
||||
});
|
||||
} else {
|
||||
showEffect();
|
||||
context.read<BattleProvider>().handleImpact(event);
|
||||
}
|
||||
} else {
|
||||
showEffect();
|
||||
context.read<BattleProvider>().handleImpact(event);
|
||||
}
|
||||
}
|
||||
|
||||
void addFloatingDamageText(DamageEvent event) {
|
||||
if (!mounted) return;
|
||||
final String id = UniqueKey().toString();
|
||||
final targetKey = event.target == DamageTarget.player ? playerKey : enemyKey;
|
||||
final RenderBox? box = targetKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (box == null) return;
|
||||
|
||||
Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height / 3));
|
||||
RenderBox? rootBox = rootStackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (rootBox != null) position = rootBox.globalToLocal(position);
|
||||
|
||||
setState(() {
|
||||
floatingDamageTexts.add(
|
||||
DamageTextData(
|
||||
id: id,
|
||||
widget: Positioned(
|
||||
key: ValueKey('pos_$id'),
|
||||
left: position.dx,
|
||||
top: position.dy,
|
||||
child: FloatingDamageText(
|
||||
key: ValueKey(id),
|
||||
damage: event.damage.toString(),
|
||||
color: event.color,
|
||||
onRemove: () {
|
||||
if (mounted) {
|
||||
setState(() => floatingDamageTexts.removeWhere((e) => e.id == id));
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void addFloatingEffect(EffectEvent event, IconData icon, Color color, double size) {
|
||||
if (!mounted) return;
|
||||
final String id = UniqueKey().toString();
|
||||
final targetKey = event.target == EffectTarget.player ? playerKey : enemyKey;
|
||||
final RenderBox? box = targetKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (box == null) return;
|
||||
|
||||
Offset position = box.localToGlobal(Offset.zero);
|
||||
RenderBox? rootBox = rootStackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (rootBox != null) {
|
||||
position = rootBox.globalToLocal(position);
|
||||
}
|
||||
|
||||
double offsetX = 0;
|
||||
double offsetY = 0;
|
||||
if (event.target == EffectTarget.enemy) {
|
||||
offsetX = box.size.width * BattleConfig.effectEnemyOffsetX;
|
||||
offsetY = box.size.height * BattleConfig.effectEnemyOffsetY;
|
||||
} else {
|
||||
offsetX = box.size.width * BattleConfig.effectPlayerOffsetX;
|
||||
offsetY = box.size.height * BattleConfig.effectPlayerOffsetY;
|
||||
}
|
||||
position = position + Offset(offsetX, offsetY);
|
||||
|
||||
setState(() {
|
||||
floatingEffects.add(
|
||||
FloatingEffectData(
|
||||
id: id,
|
||||
widget: Positioned(
|
||||
key: ValueKey('pos_$id'),
|
||||
left: position.dx,
|
||||
top: position.dy,
|
||||
child: FloatingEffect(
|
||||
key: ValueKey(id),
|
||||
icon: icon,
|
||||
color: color,
|
||||
size: size,
|
||||
onRemove: () {
|
||||
if (mounted) {
|
||||
setState(() => floatingEffects.removeWhere((e) => e.id == id));
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void addFloatingFeedbackText(EffectEvent event) {
|
||||
if (!mounted || event.feedbackType == null) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
if (lastFeedbackTime != null && now.difference(lastFeedbackTime!) < const Duration(milliseconds: 300)) {
|
||||
return;
|
||||
}
|
||||
lastFeedbackTime = now;
|
||||
|
||||
final String id = UniqueKey().toString();
|
||||
final targetKey = event.target == EffectTarget.player ? playerKey : enemyKey;
|
||||
final RenderBox? box = targetKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (box == null) return;
|
||||
|
||||
Offset position = box.localToGlobal(Offset.zero);
|
||||
RenderBox? rootBox = rootStackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (rootBox != null) {
|
||||
position = rootBox.globalToLocal(position);
|
||||
}
|
||||
|
||||
double offsetX = 0;
|
||||
double offsetY = 0;
|
||||
if (event.target == EffectTarget.enemy) {
|
||||
offsetX = box.size.width * BattleConfig.effectEnemyOffsetX;
|
||||
offsetY = box.size.height * BattleConfig.effectEnemyOffsetY;
|
||||
} else {
|
||||
offsetX = box.size.width * BattleConfig.effectPlayerOffsetX;
|
||||
offsetY = box.size.height * BattleConfig.effectPlayerOffsetY;
|
||||
}
|
||||
position = position + Offset(offsetX, offsetY);
|
||||
|
||||
final feedbackText = BattleConfig.getFeedbackText(event.feedbackType!);
|
||||
final feedbackColor = BattleConfig.getFeedbackColor(event.feedbackType!);
|
||||
|
||||
setState(() {
|
||||
floatingFeedbackTexts.add(
|
||||
FeedbackTextData(
|
||||
id: id,
|
||||
eventId: event.id,
|
||||
widget: Positioned(
|
||||
key: ValueKey('pos_$id'),
|
||||
left: position.dx,
|
||||
top: position.dy,
|
||||
child: FloatingFeedbackText(
|
||||
key: ValueKey(id),
|
||||
feedback: feedbackText,
|
||||
color: feedbackColor,
|
||||
onRemove: () {
|
||||
if (mounted) {
|
||||
setState(() => floatingFeedbackTexts.removeWhere((e) => e.id == id));
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
bool get hasPendingBattleAnimations {
|
||||
return isPlayerAttacking ||
|
||||
isEnemyAttacking ||
|
||||
floatingDamageTexts.isNotEmpty ||
|
||||
floatingEffects.isNotEmpty ||
|
||||
floatingFeedbackTexts.isNotEmpty ||
|
||||
(explosionKey.currentState?.isAnimating ?? false);
|
||||
}
|
||||
|
||||
Future<void> waitForBattleAnimationsToSettle() async {
|
||||
final deadline = DateTime.now().add(
|
||||
AnimationConfig.attackRiskyTotal +
|
||||
AnimationConfig.floatingTextDuration +
|
||||
const Duration(milliseconds: 400),
|
||||
);
|
||||
|
||||
while (mounted && hasPendingBattleAnimations && DateTime.now().isBefore(deadline)) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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});
|
||||
|
||||
@@ -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(
|
||||
@@ -84,6 +84,93 @@ class SettingsScreen extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
Consumer<SettingsProvider>(
|
||||
builder: (context, settings, child) {
|
||||
return SizedBox(
|
||||
width: 300,
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Shake Offset',
|
||||
style: TextStyle(color: ThemeConfig.textColorWhite),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'0',
|
||||
style: TextStyle(color: ThemeConfig.textColorGrey),
|
||||
),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: settings.shakeOffset,
|
||||
min: 0.0,
|
||||
max: 100.0,
|
||||
divisions: 100,
|
||||
label: settings.shakeOffset.toStringAsFixed(1),
|
||||
activeColor: ThemeConfig.btnActionActive,
|
||||
inactiveColor: ThemeConfig.textColorGrey,
|
||||
onChanged: (value) {
|
||||
settings.setShakeOffset(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'100',
|
||||
style: TextStyle(color: ThemeConfig.textColorGrey),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
'Current: ${settings.shakeOffset.toStringAsFixed(1)}',
|
||||
style: const TextStyle(
|
||||
color: ThemeConfig.textColorWhite,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Shake Count',
|
||||
style: TextStyle(color: ThemeConfig.textColorWhite),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'1',
|
||||
style: TextStyle(color: ThemeConfig.textColorGrey),
|
||||
),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: settings.shakeCount,
|
||||
min: 1.0,
|
||||
max: 10.0,
|
||||
divisions: 9,
|
||||
label: settings.shakeCount.toStringAsFixed(0),
|
||||
activeColor: ThemeConfig.btnActionActive,
|
||||
inactiveColor: ThemeConfig.textColorGrey,
|
||||
onChanged: (value) {
|
||||
settings.setShakeCount(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'10',
|
||||
style: TextStyle(color: ThemeConfig.textColorGrey),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
'Current: ${settings.shakeCount.toStringAsFixed(0)}',
|
||||
style: const TextStyle(
|
||||
color: ThemeConfig.textColorWhite,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// const SizedBox(height: 20),
|
||||
// const Text(
|
||||
|
||||
@@ -21,15 +21,60 @@ class ItemUtils {
|
||||
static String getIconPath(EquipmentSlot slot) {
|
||||
switch (slot) {
|
||||
case EquipmentSlot.weapon:
|
||||
return 'assets/data/icon/icon_weapon.png';
|
||||
return 'assets/images/icons/weapons/sword_02.png';
|
||||
case EquipmentSlot.shield:
|
||||
return 'assets/data/icon/icon_shield.png';
|
||||
return 'assets/images/icons/subweapons/shield_01.png';
|
||||
case EquipmentSlot.armor:
|
||||
return 'assets/data/icon/icon_armor.png';
|
||||
return 'assets/images/icons/armors/armor_02.png';
|
||||
case EquipmentSlot.accessory:
|
||||
return 'assets/data/icon/icon_accessory.png';
|
||||
return 'assets/images/icons/accessories/ring_02.png';
|
||||
case EquipmentSlot.consumable:
|
||||
return 'assets/data/icon/icon_accessory.png'; // Todo: Add potion icon
|
||||
return 'assets/images/icons/potions/potion_blue.png';
|
||||
}
|
||||
}
|
||||
|
||||
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:
|
||||
return 'assets/images/icons/borders/border_000.png';
|
||||
case ItemRarity.magic:
|
||||
return 'assets/images/icons/borders/border_001.png';
|
||||
case ItemRarity.rare:
|
||||
return 'assets/images/icons/borders/border_004.png';
|
||||
case ItemRarity.legendary:
|
||||
return 'assets/images/icons/borders/border_005.png';
|
||||
case ItemRarity.unique:
|
||||
return 'assets/images/icons/borders/border_014.png';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export 'battle/battle_bottom_section.dart';
|
||||
export 'battle/battle_arena.dart';
|
||||
export 'battle/battle_animation_widget.dart';
|
||||
export 'battle/battle_controls.dart';
|
||||
export 'battle/battle_header.dart';
|
||||
@@ -7,3 +9,5 @@ export 'battle/explosion_widget.dart';
|
||||
export 'battle/floating_battle_texts.dart';
|
||||
export 'battle/risk_selection_dialog.dart';
|
||||
export 'battle/shake_widget.dart';
|
||||
export 'battle/battle_overlays.dart';
|
||||
export 'battle/effect_sprite_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<BattleAnimationWidget>
|
||||
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<double>(
|
||||
@@ -58,80 +59,69 @@ class BattleAnimationWidgetState extends State<BattleAnimationWidget>
|
||||
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<Offset>(
|
||||
begin: Offset.zero,
|
||||
end: targetOffset * offsetFactor,
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: _translateController,
|
||||
curve: Curves.easeOutQuad,
|
||||
),
|
||||
);
|
||||
_translateController.duration = duration;
|
||||
_translateAnimation = Tween<Offset>(
|
||||
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<SettingsProvider>().attackAnimScale;
|
||||
_scaleAnimation = Tween<double>(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<Offset>(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<void> 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<BattleAnimationWidget>
|
||||
_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(
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../providers/battle_provider.dart';
|
||||
import 'character_status_card.dart';
|
||||
import 'shake_widget.dart';
|
||||
import 'battle_animation_widget.dart';
|
||||
|
||||
class BattleArena extends StatelessWidget {
|
||||
final BattleProvider battleProvider;
|
||||
final GlobalKey playerKey;
|
||||
final GlobalKey enemyKey;
|
||||
final GlobalKey<BattleAnimationWidgetState> playerAnimKey;
|
||||
final GlobalKey<BattleAnimationWidgetState> enemyAnimKey;
|
||||
final GlobalKey<ShakeWidgetState> shakeKey;
|
||||
final GlobalKey stackKey;
|
||||
final bool isPlayerAttacking;
|
||||
final bool isEnemyAttacking;
|
||||
final String? playerOverrideImage;
|
||||
final String? enemyOverrideImage;
|
||||
|
||||
const BattleArena({
|
||||
super.key,
|
||||
required this.battleProvider,
|
||||
required this.playerKey,
|
||||
required this.enemyKey,
|
||||
required this.playerAnimKey,
|
||||
required this.enemyAnimKey,
|
||||
required this.shakeKey,
|
||||
required this.stackKey,
|
||||
required this.isPlayerAttacking,
|
||||
required this.isEnemyAttacking,
|
||||
this.playerOverrideImage,
|
||||
this.enemyOverrideImage,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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. Character Area
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Player (Bottom Left)
|
||||
Positioned(
|
||||
bottom: 80,
|
||||
left: 16,
|
||||
child: CharacterStatusCard(
|
||||
character: battleProvider.player,
|
||||
isPlayer: true,
|
||||
isTurn: battleProvider.isPlayerTurn,
|
||||
key: playerKey,
|
||||
animationKey: playerAnimKey,
|
||||
hideStats: isPlayerAttacking,
|
||||
overrideImage: playerOverrideImage,
|
||||
),
|
||||
),
|
||||
// Enemy (Top Right)
|
||||
Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: CharacterStatusCard(
|
||||
character: battleProvider.enemy,
|
||||
isPlayer: false,
|
||||
isTurn: !battleProvider.isPlayerTurn,
|
||||
key: enemyKey,
|
||||
animationKey: enemyAnimKey,
|
||||
hideStats: isEnemyAttacking,
|
||||
overrideImage: enemyOverrideImage,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../game/enums.dart';
|
||||
import '../../game/config.dart';
|
||||
import '../../providers/battle_provider.dart';
|
||||
import 'battle_controls.dart';
|
||||
import 'battle_log_overlay.dart';
|
||||
|
||||
class BattleBottomSection extends StatelessWidget {
|
||||
final BattleProvider battleProvider;
|
||||
final bool showLogs;
|
||||
final bool isPlayerAttacking;
|
||||
final bool isEnemyAttacking;
|
||||
final VoidCallback onToggleLogs;
|
||||
final VoidCallback onAttackPressed;
|
||||
final VoidCallback onDefendPressed;
|
||||
final VoidCallback onItemPressed;
|
||||
|
||||
// Custom buttons/panels passed from parent to keep their logic there for now
|
||||
final Widget equipmentSwapButton;
|
||||
final Widget? equipmentSwapPanel;
|
||||
|
||||
const BattleBottomSection({
|
||||
super.key,
|
||||
required this.battleProvider,
|
||||
required this.showLogs,
|
||||
required this.isPlayerAttacking,
|
||||
required this.isEnemyAttacking,
|
||||
required this.onToggleLogs,
|
||||
required this.onAttackPressed,
|
||||
required this.onDefendPressed,
|
||||
required this.onItemPressed,
|
||||
required this.equipmentSwapButton,
|
||||
this.equipmentSwapPanel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
// 1. Logs Overlay
|
||||
if (showLogs && battleProvider.logs.isNotEmpty)
|
||||
Positioned(
|
||||
top: 60,
|
||||
left: 16,
|
||||
right: 16,
|
||||
height: BattleConfig.logsOverlayHeight,
|
||||
child: BattleLogOverlay(logs: battleProvider.logs),
|
||||
),
|
||||
|
||||
// 2. Battle Controls (Bottom Right)
|
||||
Positioned(
|
||||
bottom: 20,
|
||||
right: 20,
|
||||
child: BattleControls(
|
||||
isAttackEnabled: battleProvider.isPlayerTurn &&
|
||||
!battleProvider.player.isDead &&
|
||||
!battleProvider.enemy.isDead &&
|
||||
!battleProvider.showRewardPopup &&
|
||||
!isPlayerAttacking &&
|
||||
!isEnemyAttacking,
|
||||
isDefendEnabled: battleProvider.isPlayerTurn &&
|
||||
!battleProvider.player.isDead &&
|
||||
!battleProvider.enemy.isDead &&
|
||||
!battleProvider.showRewardPopup &&
|
||||
!isPlayerAttacking &&
|
||||
!isEnemyAttacking &&
|
||||
!battleProvider.player.hasStatus(
|
||||
StatusEffectType.defenseForbidden,
|
||||
),
|
||||
onAttackPressed: onAttackPressed,
|
||||
onDefendPressed: onDefendPressed,
|
||||
onItemPressed: onItemPressed,
|
||||
),
|
||||
),
|
||||
|
||||
// 3. Equipment Swap Panel
|
||||
if (equipmentSwapPanel != null)
|
||||
Positioned(
|
||||
bottom: 20,
|
||||
right: 96,
|
||||
width: 260,
|
||||
child: equipmentSwapPanel!,
|
||||
),
|
||||
|
||||
// 4. Log Toggle & Swap Button (Bottom Left)
|
||||
Positioned(
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
equipmentSwapButton,
|
||||
const SizedBox(height: 12),
|
||||
FloatingActionButton(
|
||||
heroTag: "logToggle",
|
||||
mini: true,
|
||||
backgroundColor: ThemeConfig.toggleBtnBg,
|
||||
onPressed: onToggleLogs,
|
||||
child: Icon(
|
||||
showLogs ? Icons.visibility_off : Icons.visibility,
|
||||
color: ThemeConfig.textColorWhite,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../game/config.dart';
|
||||
import '../common/custom_button_widget.dart';
|
||||
|
||||
class BattleControls extends StatelessWidget {
|
||||
final bool isAttackEnabled;
|
||||
@@ -18,57 +18,57 @@ class BattleControls extends StatelessWidget {
|
||||
required this.onItemPressed, // New
|
||||
});
|
||||
|
||||
Widget _buildFloatingActionButton({
|
||||
required String label,
|
||||
required Color color,
|
||||
required String iconPath, // Changed from ActionType to String
|
||||
required bool isEnabled,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
return FloatingActionButton(
|
||||
heroTag: label,
|
||||
onPressed: isEnabled ? onPressed : null,
|
||||
backgroundColor: isEnabled ? color : ThemeConfig.btnDisabled,
|
||||
child: Image.asset(
|
||||
iconPath,
|
||||
width: 32,
|
||||
height: 32,
|
||||
color: ThemeConfig.textColorWhite, // Tint icon white
|
||||
fit: BoxFit.contain,
|
||||
filterQuality: FilterQuality.high,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildFloatingActionButton(
|
||||
label: "ATK",
|
||||
color: ThemeConfig.btnActionActive,
|
||||
iconPath: 'assets/data/icon/icon_weapon.png',
|
||||
isEnabled: isAttackEnabled,
|
||||
onPressed: onAttackPressed,
|
||||
CustomButtonWidget(
|
||||
width: 64,
|
||||
height: 64,
|
||||
padding: const EdgeInsets.all(12),
|
||||
onTap: isAttackEnabled ? onAttackPressed : null,
|
||||
child: Opacity(
|
||||
opacity: isAttackEnabled ? 1.0 : 0.5,
|
||||
child: Image.asset(
|
||||
'assets/images/icons/weapons/sword_02.png',
|
||||
color: Colors.white,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildFloatingActionButton(
|
||||
label: "DEF",
|
||||
color: ThemeConfig.btnDefendActive,
|
||||
iconPath: 'assets/data/icon/icon_shield.png',
|
||||
isEnabled: isDefendEnabled,
|
||||
onPressed: onDefendPressed,
|
||||
CustomButtonWidget(
|
||||
width: 64,
|
||||
height: 64,
|
||||
padding: const EdgeInsets.all(12),
|
||||
onTap: isDefendEnabled ? onDefendPressed : null,
|
||||
child: Opacity(
|
||||
opacity: isDefendEnabled ? 1.0 : 0.5,
|
||||
child: Image.asset(
|
||||
'assets/images/icons/subweapons/shield_02.png',
|
||||
color: Colors.white,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildFloatingActionButton(
|
||||
label: "ITEM",
|
||||
color: Colors.indigoAccent, // Distinct color for Item
|
||||
iconPath:
|
||||
'assets/data/icon/icon_accessory.png', // Placeholder for Bag
|
||||
isEnabled:
|
||||
isAttackEnabled, // Enabled when it's player turn (same as attack)
|
||||
onPressed: onItemPressed,
|
||||
CustomButtonWidget(
|
||||
width: 64,
|
||||
height: 64,
|
||||
padding: const EdgeInsets.all(12),
|
||||
onTap:
|
||||
onItemPressed, // Item usually always enabled or managed by parent? Sticking to logic
|
||||
child: Opacity(
|
||||
opacity: isAttackEnabled
|
||||
? 1.0
|
||||
: 0.5, // Using AttackEnabled as proxy for "Player Turn"? Text implies "Enabled when it's player turn"
|
||||
child: Image.asset(
|
||||
'assets/images/icons/potions/potion_blue.png',
|
||||
color: Colors.white,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../game/enums.dart';
|
||||
import '../../game/models.dart';
|
||||
import '../../game/config.dart';
|
||||
import '../../providers/battle_provider.dart';
|
||||
import '../../utils/item_utils.dart';
|
||||
import '../inventory/item_stat_widget.dart';
|
||||
import '../test/sprite_animation_widget.dart';
|
||||
import '../../screens/main_menu_screen.dart';
|
||||
|
||||
class BattleRewardOverlay extends StatefulWidget {
|
||||
final BattleProvider battleProvider;
|
||||
|
||||
const BattleRewardOverlay({super.key, required this.battleProvider});
|
||||
|
||||
@override
|
||||
State<BattleRewardOverlay> createState() => _BattleRewardOverlayState();
|
||||
}
|
||||
|
||||
class _BattleRewardOverlayState extends State<BattleRewardOverlay> {
|
||||
bool _isCompletingReward = false;
|
||||
|
||||
Future<void> _selectReward(Item item) async {
|
||||
if (_isCompletingReward) return;
|
||||
setState(() => _isCompletingReward = true);
|
||||
|
||||
widget.battleProvider.selectReward(item);
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _isCompletingReward = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final battleProvider = widget.battleProvider;
|
||||
return 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: _isCompletingReward ? null : () => _selectReward(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) ItemStatWidget(item: item),
|
||||
Text(
|
||||
item.description,
|
||||
style: const TextStyle(
|
||||
fontSize: ThemeConfig.fontSizeMedium,
|
||||
color: ThemeConfig.textColorGrey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BattleDefeatOverlay extends StatelessWidget {
|
||||
const BattleDefeatOverlay({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: ThemeConfig.battleBg,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SpriteAnimationWidget(
|
||||
assetPath: 'assets/images/character/Knight-Death.png',
|
||||
frameCount: 4,
|
||||
scale: 4.0,
|
||||
loop: false,
|
||||
customDuration: Duration(milliseconds: 1500),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -127,7 +129,28 @@ class CharacterStatusCard extends StatelessWidget {
|
||||
// assetPath: 'assets/images/character/Soldier.png',
|
||||
assetPath: overrideImage ?? character.image!,
|
||||
scale: 5.0, // Zoomed in (300x300 in 200x200 box)
|
||||
frameCount: 6,
|
||||
frameCount: (overrideImage != null &&
|
||||
(overrideImage!.contains("Knight-Hurt") ||
|
||||
overrideImage!.contains("Knight-Death") ||
|
||||
overrideImage!.contains("Knight-Block")))
|
||||
? 4
|
||||
: (overrideImage != null &&
|
||||
overrideImage!.contains("Knight-Attack01"))
|
||||
? 7
|
||||
: (overrideImage != null &&
|
||||
overrideImage!
|
||||
.contains("Knight-Attack02"))
|
||||
? 10
|
||||
: (overrideImage != null &&
|
||||
overrideImage!
|
||||
.contains("Knight-Attack03"))
|
||||
? 11
|
||||
: 6,
|
||||
loop: !(overrideImage != null &&
|
||||
(overrideImage!.contains("Knight-Hurt") ||
|
||||
overrideImage!.contains("Knight-Death") ||
|
||||
overrideImage!.contains("Knight-Block") ||
|
||||
overrideImage!.contains("Knight-Attack"))),
|
||||
flip: !isPlayer,
|
||||
fallbackAssetPath: !isPlayer
|
||||
? 'assets/images/enemies/Orc.png'
|
||||
|
||||
@@ -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<EffectSpriteWidget>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
final List<SpriteEffect> _effects = [];
|
||||
final Map<String, ui.Image> _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<void> 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<int> bytes = data.buffer.asUint8List();
|
||||
final Completer<ui.Image> 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<SpriteEffect> 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
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ class ExplosionWidgetState extends State<ExplosionWidget>
|
||||
final List<Particle> _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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
|
||||
class ShakeWidget extends StatefulWidget {
|
||||
final Widget child;
|
||||
@@ -10,7 +12,7 @@ class ShakeWidget extends StatefulWidget {
|
||||
const ShakeWidget({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.shakeOffset = 10.0,
|
||||
this.shakeOffset = 30.0,
|
||||
this.shakeCount = 3,
|
||||
this.duration = const Duration(milliseconds: 400),
|
||||
});
|
||||
@@ -46,14 +48,24 @@ class ShakeWidgetState extends State<ShakeWidget>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Read settings from provider (listen: false because we only read in build,
|
||||
// actually we want to listen to updates if settings change while looking at it?
|
||||
// But usually ShakeWidget is in BattleScreen.
|
||||
// However, the previous implementation used `widget.shakeCount` and `widget.shakeOffset`.
|
||||
// We should prefer provider values if available, or allow overrides?
|
||||
// The prompt says "shakeWidget에서 shakeCount, shakeOffset을 settings에서 설정할 수 있게 globalOption으로 설정해야해."
|
||||
// So we use Provider values.
|
||||
|
||||
final settings = context.watch<SettingsProvider>();
|
||||
final shakeCount = settings.shakeCount;
|
||||
final shakeOffset = settings.shakeOffset;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
final double sineValue = sin(
|
||||
widget.shakeCount * 2 * pi * _controller.value,
|
||||
);
|
||||
final double sineValue = sin(shakeCount * 2 * pi * _controller.value);
|
||||
return Transform.translate(
|
||||
offset: Offset(sineValue * widget.shakeOffset, 0),
|
||||
offset: Offset(sineValue * shakeOffset, 0),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CustomButtonWidget extends StatelessWidget {
|
||||
final Widget child;
|
||||
final VoidCallback? onTap;
|
||||
final String imagePath;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final Color? backgroundColor;
|
||||
|
||||
const CustomButtonWidget({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.onTap,
|
||||
this.imagePath = 'assets/images/icons/borders/border_000.png',
|
||||
this.width,
|
||||
this.height,
|
||||
this.padding = EdgeInsets.zero,
|
||||
this.backgroundColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Background Color
|
||||
if (backgroundColor != null)
|
||||
Positioned.fill(child: Container(color: backgroundColor)),
|
||||
// Background Image (Border)
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
imagePath,
|
||||
fit: BoxFit.fill,
|
||||
filterQuality: FilterQuality.high,
|
||||
),
|
||||
),
|
||||
// Content
|
||||
Padding(
|
||||
padding: padding,
|
||||
child: Center(child: child),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -24,7 +26,7 @@ class ItemCardWidget extends StatelessWidget {
|
||||
onTap: onTap,
|
||||
child: Card(
|
||||
color: ThemeConfig.shopItemCardBg, // Configurable if needed
|
||||
shape: item.rarity != ItemRarity.magic
|
||||
shape: item.rarity != ItemRarity.normal
|
||||
? RoundedRectangleBorder(
|
||||
side: BorderSide(
|
||||
color: ItemUtils.getRarityColor(item.rarity),
|
||||
@@ -33,55 +35,125 @@ class ItemCardWidget extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
)
|
||||
: null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Image.asset(
|
||||
ItemUtils.getIconPath(item.slot),
|
||||
fit: BoxFit.contain,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Background Watermark/Silhouette Icon (Top-Left)
|
||||
Positioned(
|
||||
left: compact ? 4 : 8,
|
||||
top: compact ? 4 : 8,
|
||||
child: Image.asset(
|
||||
ItemUtils.getIconPath(item.slot),
|
||||
width: compact ? 24 : 32,
|
||||
height: compact ? 24 : 32,
|
||||
fit: BoxFit.contain,
|
||||
color: Colors.black12, // Shadow silhouette
|
||||
),
|
||||
),
|
||||
// Main Content (Centered)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(compact ? 2.0 : 4.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (!compact) const SizedBox(height: 12),
|
||||
Text(
|
||||
item.name,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: ItemUtils.getRarityColor(item.rarity),
|
||||
fontSize: compact ? 10 : 12,
|
||||
),
|
||||
),
|
||||
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
|
||||
if (compact)
|
||||
_buildCompactItemStatText(item)
|
||||
else
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: _buildItemStatText(item),
|
||||
),
|
||||
if (showPrice) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
"${item.price} G",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: canBuy
|
||||
? ThemeConfig.statGoldColor
|
||||
: ThemeConfig.textColorGrey,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
item.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: ItemUtils.getRarityColor(item.rarity),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
// Show Item Stats (Fixed to show negative values)
|
||||
FittedBox(fit: BoxFit.scaleDown, child: _buildItemStatText(item)),
|
||||
if (showPrice) ...[
|
||||
const Spacer(),
|
||||
Text(
|
||||
"${item.price} G",
|
||||
style: TextStyle(
|
||||
color: canBuy
|
||||
? ThemeConfig.statGoldColor
|
||||
: ThemeConfig.textColorGrey,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompactItemStatText(Item item) {
|
||||
final stats = <String>[];
|
||||
|
||||
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<String> 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";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export 'inventory/character_stats_widget.dart';
|
||||
export 'inventory/inventory_grid_widget.dart';
|
||||
export 'inventory/equipped_items_widget.dart';
|
||||
export 'inventory/item_stat_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: [
|
||||
|
||||
@@ -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<BattleProvider>(
|
||||
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),
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../game/models.dart';
|
||||
import '../../game/config.dart';
|
||||
|
||||
class ItemStatWidget extends StatelessWidget {
|
||||
final Item item;
|
||||
final double fontSize;
|
||||
final Color? color;
|
||||
|
||||
const ItemStatWidget({
|
||||
super.key,
|
||||
required this.item,
|
||||
this.fontSize = ThemeConfig.fontSizeMedium,
|
||||
this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
List<String> stats = [];
|
||||
if (item.atkBonus > 0) stats.add("+${item.atkBonus} ${AppStrings.atk}");
|
||||
if (item.hpBonus > 0) stats.add("+${item.hpBonus} ${AppStrings.hp}");
|
||||
if (item.armorBonus > 0) {
|
||||
stats.add("+${item.armorBonus} ${AppStrings.def}");
|
||||
}
|
||||
if (item.luck > 0) stats.add("+${item.luck} ${AppStrings.luck}");
|
||||
if (item.dodge > 0) stats.add("+${item.dodge}% Dodge");
|
||||
|
||||
List<String> effectTexts = item.effects.map((e) => e.description).toList();
|
||||
|
||||
if (stats.isEmpty && effectTexts.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (stats.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0, bottom: 4.0),
|
||||
child: Text(
|
||||
stats.join(", "),
|
||||
style: TextStyle(
|
||||
fontSize: fontSize,
|
||||
color: color ?? ThemeConfig.statAtkColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (effectTexts.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: Text(
|
||||
effectTexts.join(", "),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: ThemeConfig.rarityLegendary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ class SpriteAnimationWidget extends StatefulWidget {
|
||||
final int frameCount;
|
||||
final double scale;
|
||||
final bool flip;
|
||||
final bool loop;
|
||||
final Duration? customDuration;
|
||||
final String? fallbackAssetPath;
|
||||
|
||||
const SpriteAnimationWidget({
|
||||
@@ -17,10 +19,11 @@ class SpriteAnimationWidget extends StatefulWidget {
|
||||
required this.assetPath,
|
||||
this.tileWidth = 100.0,
|
||||
this.tileHeight = 100.0,
|
||||
this.frameCount =
|
||||
6, // Default guess, will adjust logic to use actual image width if possible
|
||||
this.frameCount = 6,
|
||||
this.scale = 1.0,
|
||||
this.flip = false,
|
||||
this.loop = true,
|
||||
this.customDuration,
|
||||
this.fallbackAssetPath,
|
||||
});
|
||||
|
||||
@@ -40,11 +43,22 @@ class _SpriteAnimationWidgetState extends State<SpriteAnimationWidget>
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 600), // 100ms per frame approx
|
||||
duration: widget.customDuration ?? const Duration(milliseconds: 600),
|
||||
);
|
||||
_loadImage();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant SpriteAnimationWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.assetPath != widget.assetPath) {
|
||||
// Don't set _isLoading = true to avoid flickering.
|
||||
// Keep showing the old image until the new one is loaded.
|
||||
_controller.reset();
|
||||
_loadImage();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadImage() async {
|
||||
try {
|
||||
await _loadAsset(widget.assetPath);
|
||||
@@ -82,11 +96,18 @@ class _SpriteAnimationWidgetState extends State<SpriteAnimationWidget>
|
||||
? maxFrames
|
||||
: widget.frameCount;
|
||||
|
||||
// Adjust duration based on frame count
|
||||
_controller.duration = Duration(
|
||||
milliseconds: _calculatedFrameCount * 100,
|
||||
);
|
||||
_controller.repeat();
|
||||
// Adjust duration based on frame count if not custom
|
||||
if (widget.customDuration == null) {
|
||||
_controller.duration = Duration(
|
||||
milliseconds: _calculatedFrameCount * 100,
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.loop) {
|
||||
_controller.repeat();
|
||||
} else {
|
||||
_controller.forward();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -99,17 +120,26 @@ class _SpriteAnimationWidgetState extends State<SpriteAnimationWidget>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading || _image == null) {
|
||||
if (_image == null) {
|
||||
return SizedBox(
|
||||
width: widget.tileWidth * widget.scale,
|
||||
height: widget.tileHeight * widget.scale,
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
int frame;
|
||||
if (!widget.loop) {
|
||||
frame = (_controller.value * _calculatedFrameCount)
|
||||
.floor()
|
||||
.clamp(0, _calculatedFrameCount - 1);
|
||||
} else {
|
||||
frame = (_controller.value * _calculatedFrameCount).floor() %
|
||||
_calculatedFrameCount;
|
||||
}
|
||||
|
||||
return Transform.scale(
|
||||
scaleX: widget.flip ? -1.0 : 1.0,
|
||||
alignment: Alignment.center,
|
||||
@@ -120,9 +150,7 @@ class _SpriteAnimationWidgetState extends State<SpriteAnimationWidget>
|
||||
),
|
||||
painter: SpriteSheetPainter(
|
||||
image: _image!,
|
||||
currentFrame:
|
||||
(_controller.value * _calculatedFrameCount).floor() %
|
||||
_calculatedFrameCount,
|
||||
currentFrame: frame,
|
||||
tileWidth: widget.tileWidth,
|
||||
tileHeight: widget.tileHeight,
|
||||
scale: widget.scale,
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "ephemeral/Flutter-Generated.xcconfig"
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "ephemeral/Flutter-Generated.xcconfig"
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,26 @@
|
||||
# Custom Button Widget & Asset Implementation
|
||||
|
||||
> Restoration of Prompt 69
|
||||
|
||||
## Goal
|
||||
|
||||
Implement a unified `CustomButtonWidget` that utilizes game assets (borders/panels) for a consistent UI look, and apply it to both `ItemCardWidget` and `BattleControls`.
|
||||
|
||||
## Tasks
|
||||
|
||||
1. **Create `CustomButtonWidget`** (`lib/widgets/common/custom_button_widget.dart`)
|
||||
- Should support an image background (`assets/images/icons/borders/...`).
|
||||
- Should support `onTap`.
|
||||
- Should support arbitrary `child` content.
|
||||
- Should support `size` or adaptive sizing.
|
||||
- Optional: Support "Icon Button" mode for convenience or just composability.
|
||||
|
||||
2. **Refactor `BattleControls`** (`lib/widgets/battle/battle_controls.dart`)
|
||||
- Replace `CustomIconButton` with `CustomButtonWidget` (or wrap content in it).
|
||||
- Use appropriate border assets (e.g., `border_000.png` or specialized ones).
|
||||
|
||||
3. **Refactor `ItemCardWidget`** (`lib/widgets/common/item_card_widget.dart`)
|
||||
- (Cancelled) User decided to keep the current version of ItemCardWidget without CustomButtonWidget integration.
|
||||
|
||||
4. **Asset Verification**
|
||||
- Ensure `assets/images/icons/borders/` and `panel-border-...` are used correctly.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Task: Global Shake Settings
|
||||
|
||||
**Goal**: Make `shakeCount` and `shakeOffset` in `ShakeWidget` configurable via global settings, similar to `attackAnimationScale`.
|
||||
|
||||
## Context
|
||||
Currently, `ShakeWidget` uses hardcoded defaults or constructor parameters for `shakeOffset` and `shakeCount`. The user wants to adjust these values globally from the Settings screen to fine-tune the visual feedback.
|
||||
|
||||
## Requirements
|
||||
|
||||
1. **Settings Provider Updates**:
|
||||
- Add `shakeOffset` (double, default 30.0).
|
||||
- Add `shakeCount` (int, default 3).
|
||||
- Implement persistence using `SharedPreferences`.
|
||||
|
||||
2. **Settings UI**:
|
||||
- Add sliders to the Settings screen to control these values.
|
||||
- `Shake Offset`: Range approx 0-100?
|
||||
- `Shake Count`: Range approx 1-10?
|
||||
|
||||
3. **ShakeWidget Updates**:
|
||||
- Consume `SettingsProvider` to get the global values.
|
||||
- Apply these values to the shake animation logic.
|
||||
|
||||
## Plan
|
||||
1. Modify `lib/providers/settings_provider.dart` to add the new settings.
|
||||
2. Modify `lib/screens/settings_screen.dart` to add the sliders.
|
||||
3. Modify `lib/widgets/battle/shake_widget.dart` to use the provider.
|
||||
@@ -30,3 +30,10 @@ flutter:
|
||||
- assets/images/enemies/
|
||||
- assets/images/background/
|
||||
- assets/images/character/
|
||||
- assets/images/icons/borders/
|
||||
- assets/images/icons/weapons/
|
||||
- assets/images/icons/subweapons/
|
||||
- assets/images/icons/accessories/
|
||||
- assets/images/icons/potions/
|
||||
- assets/images/icons/armors/
|
||||
- assets/images/effects/
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:game_test/game/enums.dart';
|
||||
import 'package:game_test/game/models.dart';
|
||||
import 'package:game_test/providers/battle_provider.dart';
|
||||
import 'package:game_test/providers/shop_provider.dart';
|
||||
|
||||
void main() {
|
||||
late BattleProvider battleProvider;
|
||||
|
||||
setUp(() {
|
||||
battleProvider = BattleProvider(shopProvider: ShopProvider());
|
||||
battleProvider.player = Character(
|
||||
name: 'Warrior',
|
||||
hp: 50,
|
||||
maxHp: 100,
|
||||
armor: 0,
|
||||
atk: 10,
|
||||
baseDefense: 5,
|
||||
);
|
||||
});
|
||||
|
||||
test('healing potion restores HP and is consumed', () async {
|
||||
final potion = Item(
|
||||
id: 'potion_heal_small',
|
||||
name: 'Healing Potion',
|
||||
description: 'Restores HP.',
|
||||
atkBonus: 0,
|
||||
hpBonus: 20,
|
||||
slot: EquipmentSlot.consumable,
|
||||
);
|
||||
final healEvents = <HealEvent>[];
|
||||
final subscription = battleProvider.healStream.listen(healEvents.add);
|
||||
battleProvider.player.addToInventory(potion);
|
||||
|
||||
battleProvider.useConsumable(potion);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(battleProvider.player.hp, 70);
|
||||
expect(battleProvider.player.inventory, isNot(contains(potion)));
|
||||
expect(healEvents.single.amount, 20);
|
||||
|
||||
await subscription.cancel();
|
||||
});
|
||||
|
||||
test('armor potion grants armor and is consumed', () {
|
||||
final potion = Item(
|
||||
id: 'potion_armor_small',
|
||||
name: 'Iron Skin Potion',
|
||||
description: 'Grants armor.',
|
||||
atkBonus: 0,
|
||||
hpBonus: 0,
|
||||
armorBonus: 10,
|
||||
slot: EquipmentSlot.consumable,
|
||||
);
|
||||
battleProvider.player.addToInventory(potion);
|
||||
|
||||
battleProvider.useConsumable(potion);
|
||||
|
||||
expect(battleProvider.player.armor, 10);
|
||||
expect(battleProvider.player.inventory, isNot(contains(potion)));
|
||||
});
|
||||
|
||||
test('strength potion applies attack buff and is consumed', () {
|
||||
final potion = Item(
|
||||
id: 'potion_strength_small',
|
||||
name: 'Strength Potion',
|
||||
description: 'Increases attack.',
|
||||
atkBonus: 0,
|
||||
hpBonus: 0,
|
||||
slot: EquipmentSlot.consumable,
|
||||
effects: [
|
||||
ItemEffect(
|
||||
type: StatusEffectType.attackUp,
|
||||
probability: 100,
|
||||
duration: 1,
|
||||
value: 5,
|
||||
),
|
||||
],
|
||||
);
|
||||
battleProvider.player.addToInventory(potion);
|
||||
|
||||
battleProvider.useConsumable(potion);
|
||||
|
||||
expect(battleProvider.player.hasStatus(StatusEffectType.attackUp), isTrue);
|
||||
expect(battleProvider.player.totalAtk, 15);
|
||||
expect(battleProvider.player.inventory, isNot(contains(potion)));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:game_test/game/enums.dart';
|
||||
import 'package:game_test/game/logic/effect_event_factory.dart';
|
||||
import 'package:game_test/game/models.dart';
|
||||
|
||||
void main() {
|
||||
test('enemy attack can target a named player character', () {
|
||||
final enemy = Character(name: 'Scrawny Piso', maxHp: 20, armor: 0, atk: 6);
|
||||
final player = Character(name: 'Warrior', maxHp: 50, armor: 0, atk: 5);
|
||||
|
||||
final event = EffectEventFactory.createAttackEvent(
|
||||
attacker: enemy,
|
||||
target: player,
|
||||
effectTarget: EffectTarget.player,
|
||||
risk: RiskLevel.safe,
|
||||
damage: 6,
|
||||
random: Random(0),
|
||||
);
|
||||
|
||||
expect(event.target, EffectTarget.player);
|
||||
expect(event.attacker, enemy);
|
||||
expect(event.targetEntity, player);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||