feat: add subweapon support, weapon type logic, and compact item card UI options

This commit is contained in:
Horoli 2026-04-28 01:39:39 +09:00
parent 0420e23939
commit 0e0748540e
46 changed files with 1795 additions and 577 deletions

256
PROJECT_OVERVIEW.md Normal file
View File

@ -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` 등록 여부를 반드시 확인해야 합니다.

View File

@ -1,5 +1,6 @@
#Tue Mar 31 23:58:22 KST 2026
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip

View File

@ -9,7 +9,8 @@
"price": 30, "price": 30,
"image": "assets/images/items/gladius.png", "image": "assets/images/items/gladius.png",
"rarity": "normal", "rarity": "normal",
"tier": "tier1" "tier": "tier1",
"weaponType": "oneHanded"
}, },
{ {
"id": "flail", "id": "flail",
@ -21,7 +22,8 @@
"price": 45, "price": 45,
"image": "assets/images/items/flail.png", "image": "assets/images/items/flail.png",
"rarity": "magic", "rarity": "magic",
"tier": "tier1" "tier": "tier1",
"weaponType": "oneHanded"
}, },
{ {
"id": "trident", "id": "trident",
@ -33,7 +35,8 @@
"price": 70, "price": 70,
"image": "assets/images/items/trident.png", "image": "assets/images/items/trident.png",
"rarity": "normal", "rarity": "normal",
"tier": "tier2" "tier": "tier2",
"weaponType": "twoHanded"
}, },
{ {
"id": "scimitar", "id": "scimitar",
@ -45,7 +48,8 @@
"price": 90, "price": 90,
"image": "assets/images/items/scimitar.png", "image": "assets/images/items/scimitar.png",
"rarity": "magic", "rarity": "magic",
"tier": "tier2" "tier": "tier2",
"weaponType": "oneHanded"
}, },
{ {
"id": "war_axe", "id": "war_axe",
@ -56,7 +60,8 @@
"price": 120, "price": 120,
"image": "assets/images/items/war_axe.png", "image": "assets/images/items/war_axe.png",
"rarity": "magic", "rarity": "magic",
"tier": "tier2" "tier": "tier2",
"weaponType": "twoHanded"
}, },
{ {
"id": "war_hammer", "id": "war_hammer",
@ -74,7 +79,8 @@
} }
], ],
"rarity": "rare", "rarity": "rare",
"tier": "tier2" "tier": "tier2",
"weaponType": "twoHanded"
}, },
{ {
"id": "barbed_net", "id": "barbed_net",
@ -89,11 +95,12 @@
"type": "bleed", "type": "bleed",
"probability": 100, "probability": 100,
"duration": 3, "duration": 3,
"value": 30 "value": 5
} }
], ],
"rarity": "rare", "rarity": "rare",
"tier": "tier1" "tier": "tier1",
"weaponType": "oneHanded"
}, },
{ {
"id": "steel_greatsword", "id": "steel_greatsword",
@ -104,7 +111,8 @@
"price": 180, "price": 180,
"image": "assets/images/items/steel_greatsword.png", "image": "assets/images/items/steel_greatsword.png",
"rarity": "magic", "rarity": "magic",
"tier": "tier3" "tier": "tier3",
"weaponType": "twoHanded"
}, },
{ {
"id": "hooked_spear", "id": "hooked_spear",
@ -122,7 +130,8 @@
} }
], ],
"rarity": "rare", "rarity": "rare",
"tier": "tier3" "tier": "tier3",
"weaponType": "twoHanded"
}, },
{ {
"id": "executioners_axe", "id": "executioners_axe",
@ -140,7 +149,8 @@
} }
], ],
"rarity": "legendary", "rarity": "legendary",
"tier": "tier3" "tier": "tier3",
"weaponType": "twoHanded"
} }
], ],
"armors": [ "armors": [
@ -373,4 +383,4 @@
"image": "assets/images/items/potion.png" "image": "assets/images/items/potion.png"
} }
] ]
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 B

View File

@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"

View File

@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"

43
ios/Podfile Normal file
View File

@ -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

View File

@ -8,7 +8,7 @@ class AnimationConfig {
static const Duration fadeDuration = Duration(milliseconds: 200); static const Duration fadeDuration = Duration(milliseconds: 200);
// Attack Animations // 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 attackNormal = Duration(milliseconds: 400);
static const Duration attackRiskyTotal = Duration(milliseconds: 1100); static const Duration attackRiskyTotal = Duration(milliseconds: 1100);
static const Duration attackRiskyScale = Duration(milliseconds: 600); static const Duration attackRiskyScale = Duration(milliseconds: 600);
@ -17,7 +17,7 @@ class AnimationConfig {
// Curves // Curves
static const Curve floatingTextCurve = Curves.easeOut; static const Curve floatingTextCurve = Curves.easeOut;
static const Curve floatingEffectScaleCurve = Curves.elasticOut; 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 attackNormalCurve = Curves.easeOutQuad;
static const Curve attackRiskyDashCurve = Curves.easeInExpo; static const Curve attackRiskyDashCurve = Curves.easeInExpo;

View File

@ -7,6 +7,8 @@ import '../config/game_config.dart';
import 'item_table.dart'; import 'item_table.dart';
class EnemyTemplate { class EnemyTemplate {
static const String fallbackImage = 'assets/images/enemies/Orc.png';
final String name; final String name;
final int baseHp; final int baseHp;
final int baseAtk; final int baseAtk;
@ -27,19 +29,30 @@ class EnemyTemplate {
this.tier = 1, 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( return EnemyTemplate(
name: json['name'], name: json['name'],
baseHp: json['baseHp'] ?? 10, baseHp: json['baseHp'] ?? 10,
baseAtk: json['baseAtk'] ?? 1, baseAtk: json['baseAtk'] ?? 1,
baseDefense: json['baseDefense'] ?? 0, baseDefense: json['baseDefense'] ?? 0,
baseDodge: json['baseDodge'] ?? 1, // Parse from JSON or default to 1 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>() ?? [], equipmentIds: (json['equipment'] as List<dynamic>?)?.cast<String>() ?? [],
tier: json['tier'] ?? 1, 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}) { Character createCharacter({int stage = 1}) {
// Stage-based scaling for enemy stats is removed to simplify balancing. // Stage-based scaling for enemy stats is removed to simplify balancing.
// Enemy stats are now fixed as defined in the EnemyTemplate. // Enemy stats are now fixed as defined in the EnemyTemplate.
@ -79,15 +92,25 @@ class EnemyTable {
'assets/data/enemies.json', 'assets/data/enemies.json',
); );
final Map<String, dynamic> data = jsonDecode(jsonString); final Map<String, dynamic> data = jsonDecode(jsonString);
final availableAssets = await _loadAvailableAssets();
normalEnemies = (data['normal'] as List) normalEnemies = (data['normal'] as List)
.map((e) => EnemyTemplate.fromJson(e)) .map((e) => EnemyTemplate.fromJson(e, availableAssets: availableAssets))
.toList(); .toList();
eliteEnemies = (data['elite'] as List) eliteEnemies = (data['elite'] as List)
.map((e) => EnemyTemplate.fromJson(e)) .map((e) => EnemyTemplate.fromJson(e, availableAssets: availableAssets))
.toList(); .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. /// Returns a random enemy suitable for the current stage.
static EnemyTemplate getRandomEnemy({ static EnemyTemplate getRandomEnemy({
required int stage, required int stage,

View File

@ -23,6 +23,7 @@ class ItemTemplate {
final int luck; final int luck;
final ItemRarity rarity; final ItemRarity rarity;
final ItemTier tier; final ItemTier tier;
final WeaponType? weaponType; // New: oneHanded or twoHanded
const ItemTemplate({ const ItemTemplate({
required this.id, required this.id,
@ -39,6 +40,7 @@ class ItemTemplate {
this.luck = 0, this.luck = 0,
this.rarity = ItemRarity.magic, this.rarity = ItemRarity.magic,
this.tier = ItemTier.tier1, this.tier = ItemTier.tier1,
this.weaponType,
}); });
factory ItemTemplate.fromJson(Map<String, dynamic> json) { factory ItemTemplate.fromJson(Map<String, dynamic> json) {
@ -57,7 +59,7 @@ class ItemTemplate {
hpBonus: json['hpBonus'] ?? json['baseHp'] ?? 0, hpBonus: json['hpBonus'] ?? json['baseHp'] ?? 0,
armorBonus: json['armorBonus'] ?? json['baseArmor'] ?? 0, armorBonus: json['armorBonus'] ?? json['baseArmor'] ?? 0,
dodge: json['dodge'] ?? 0, dodge: json['dodge'] ?? 0,
slot: EquipmentSlot.values.firstWhere((e) => e.name == json['slot']), slot: equipmentSlotFromName(json['slot']),
effects: effectsList, effects: effectsList,
price: json['price'] ?? 10, price: json['price'] ?? 10,
image: json['image'], image: json['image'],
@ -68,6 +70,9 @@ class ItemTemplate {
tier: json['tier'] != null tier: json['tier'] != null
? ItemTier.values.firstWhere((e) => e.name == json['tier']) ? ItemTier.values.firstWhere((e) => e.name == json['tier'])
: ItemTier.tier1, : 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 List<ItemTemplate> consumables = [];
static final Map<String, ItemTemplate> _items = {}; static final Map<String, ItemTemplate> _items = {};
static List<ItemTemplate> get subweapons => shields;
static void initialize() { static void initialize() {
// This function is now a placeholder. All loading is handled in load(). // This function is now a placeholder. All loading is handled in load().
@ -95,16 +101,22 @@ class ItemTable {
// Clear previous data // Clear previous data
_items.clear(); _items.clear();
final String jsonString = final String jsonString = await rootBundle.loadString(
await rootBundle.loadString('assets/data/items.json'); 'assets/data/items.json',
);
final Map<String, dynamic> data = jsonDecode(jsonString); final Map<String, dynamic> data = jsonDecode(jsonString);
// Helper function to load and register items // 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) { if (data[key] != null) {
list.clear(); if (clear) list.clear();
var loadedItems = var loadedItems = (data[key] as List)
(data[key] as List).map((e) => ItemTemplate.fromJson(e)).toList(); .map((e) => ItemTemplate.fromJson(e))
.toList();
list.addAll(loadedItems); list.addAll(loadedItems);
for (var item in loadedItems) { for (var item in loadedItems) {
_items[item.id] = item; _items[item.id] = item;
@ -112,11 +124,12 @@ class ItemTable {
} }
} }
_loadAndRegister('weapons', weapons); loadAndRegister('weapons', weapons);
_loadAndRegister('armors', armors); loadAndRegister('armors', armors);
_loadAndRegister('shields', shields); loadAndRegister('shields', shields);
_loadAndRegister('accessories', accessories); loadAndRegister('subweapons', shields, clear: false);
_loadAndRegister('consumables', consumables); loadAndRegister('accessories', accessories);
loadAndRegister('consumables', consumables);
} }
static List<ItemTemplate> get allItems => _items.values.toList(); static List<ItemTemplate> get allItems => _items.values.toList();
@ -124,6 +137,7 @@ class ItemTable {
static ItemTemplate? get(String id) { static ItemTemplate? get(String id) {
return _items[id]; return _items[id];
} }
static final Random _random = Random(); static final Random _random = Random();
/// Returns all items matching the given tier. /// Returns all items matching the given tier.

View File

@ -34,6 +34,42 @@ enum StageType {
enum EquipmentSlot { weapon, armor, shield, accessory, consumable } 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 DamageType { normal, bleed, vulnerable }
enum StatType { maxHp, atk, defense, luck, dodge } enum StatType { maxHp, atk, defense, luck, dodge }

View File

@ -4,7 +4,6 @@ import '../model/status_effect.dart';
import '../enums.dart'; import '../enums.dart';
import '../config/game_config.dart'; import '../config/game_config.dart';
import '../config/battle_config.dart'; // Import BattleConfig import '../config/battle_config.dart'; // Import BattleConfig
import '../model/damage_event.dart';
class CombatResult { class CombatResult {
final bool success; final bool success;

View File

@ -28,6 +28,7 @@ class LootGenerator {
luck: template.luck, luck: template.luck,
rarity: template.rarity, rarity: template.rarity,
tier: template.tier, tier: template.tier,
weaponType: template.weaponType,
); );
} }
@ -167,6 +168,7 @@ class LootGenerator {
luck: finalLuck, luck: finalLuck,
rarity: template.rarity, rarity: template.rarity,
tier: template.tier, tier: template.tier,
weaponType: template.weaponType,
); );
} }
} }

View File

@ -7,11 +7,13 @@ class DamageEvent {
final int damage; final int damage;
final DamageTarget target; final DamageTarget target;
final DamageType type; final DamageType type;
final RiskLevel? risk;
DamageEvent({ DamageEvent({
required this.damage, required this.damage,
required this.target, required this.target,
this.type = DamageType.normal, this.type = DamageType.normal,
this.risk,
}); });
Color get color { Color get color {

View File

@ -51,7 +51,9 @@ class Character {
'baseDodge': baseDodge, 'baseDodge': baseDodge,
'gold': gold, 'gold': gold,
'image': image, '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(), 'inventory': inventory.map((e) => e.id).toList(),
'statusEffects': statusEffects.map((e) => e.toJson()).toList(), 'statusEffects': statusEffects.map((e) => e.toJson()).toList(),
'permanentModifiers': permanentModifiers.map((e) => e.toJson()).toList(), 'permanentModifiers': permanentModifiers.map((e) => e.toJson()).toList(),
@ -76,11 +78,7 @@ class Character {
equipMap.forEach((slotName, itemId) { equipMap.forEach((slotName, itemId) {
final template = ItemTable.get(itemId); final template = ItemTable.get(itemId);
if (template != null) { if (template != null) {
// Find slot enum final slot = equipmentSlotFromName(slotName);
final slot = EquipmentSlot.values.firstWhere(
(e) => e.name == slotName,
orElse: () => EquipmentSlot.weapon, // Fallback
);
char.equipment[slot] = template.createItem(); 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. /// 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) { void addStatusEffect(StatusEffect newEffect) {
// Check if effect exists
var existing = statusEffects var existing = statusEffects
.where((e) => e.type == newEffect.type) .where((e) => e.type == newEffect.type)
.firstOrNull; .firstOrNull;
if (existing != null) { if (existing != null) {
// Refresh duration if the new one is longer, or just reset it? if (existing.type == StatusEffectType.bleed) {
// Let's max the duration for now. // Stack bleed damage
if (newEffect.duration > existing.duration) { 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; 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 { } 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. /// Decrements duration of start-of-turn effects (Bleed, Stun).
/// Returns a list of expired effects if needed for UI logs. void updateStartOfTurnStatusEffects() {
void updateStatusEffects() {
// Remove effects with 0 or less duration first (safety cleanup)
statusEffects.removeWhere((e) => e.duration <= 0);
for (var effect in statusEffects) { 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); statusEffects.removeWhere((e) => e.duration <= 0);
} }
@ -210,23 +233,65 @@ class Character {
// Equips an item (swapping if necessary) // Equips an item (swapping if necessary)
// Returns true if successful // Returns true if successful
bool equip(Item newItem) { bool equip(Item newItem) {
return equipToSlot(newItem, newItem.defaultEquipSlot);
}
bool equipToSlot(Item newItem, EquipmentSlot targetSlot) {
if (!inventory.contains(newItem)) return false; 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 // 1. Calculate current HP ratio before any changes
double hpRatio = totalMaxHp > 0 double hpRatio = totalMaxHp > 0
? hp / totalMaxHp ? hp / totalMaxHp
: 0.0; // Avoid division by zero : 0.0; // Avoid division by zero
// 2. Handle Swap: If slot is occupied, unequip the old item first // 2. Handle 2H / 1H rules
if (equipment.containsKey(newItem.slot)) { if (targetSlot == EquipmentSlot.weapon && newItem.weaponType == WeaponType.twoHanded) {
Item oldItem = equipment[newItem.slot]!; // If equipping 2H weapon, unequip shield slot if occupied
equipment.remove(newItem.slot); 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); inventory.add(oldItem);
} }
// 3. Move new item: Inventory -> Equipment // 4. Move new item: Inventory -> Equipment
inventory.remove(newItem); inventory.remove(newItem);
equipment[newItem.slot] = newItem; equipment[targetSlot] = newItem;
// 4. Update current HP based on the new totalMaxHp and previous ratio // 4. Update current HP based on the new totalMaxHp and previous ratio
hp = (totalMaxHp * hpRatio).toInt(); hp = (totalMaxHp * hpRatio).toInt();
@ -244,13 +309,17 @@ class Character {
bool unequip(Item item) { bool unequip(Item item) {
if (!equipment.containsValue(item)) return false; if (!equipment.containsValue(item)) return false;
final slot = equipment.entries
.firstWhere((entry) => entry.value == item)
.key;
// 1. Calculate current HP ratio before any changes // 1. Calculate current HP ratio before any changes
double hpRatio = totalMaxHp > 0 double hpRatio = totalMaxHp > 0
? hp / totalMaxHp ? hp / totalMaxHp
: 0.0; // Avoid division by zero : 0.0; // Avoid division by zero
if (inventory.length < maxInventorySize) { if (inventory.length < maxInventorySize) {
equipment.remove(item.slot); equipment.remove(slot);
inventory.add(item); inventory.add(item);
// 2. Update current HP based on the new totalMaxHp and previous ratio // 2. Update current HP based on the new totalMaxHp and previous ratio

View File

@ -0,0 +1,11 @@
enum HealTarget { player, enemy }
class HealEvent {
final int amount;
final HealTarget target;
HealEvent({
required this.amount,
required this.target,
});
}

View File

@ -32,7 +32,7 @@ class ItemEffect {
String durationStr = "${duration}t"; String durationStr = "${duration}t";
String valStr = value > 0 ? " ($value dmg)" : ""; 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 int luck; // Success rate bonus (e.g. 5 = 5%)
final ItemRarity rarity; final ItemRarity rarity;
final ItemTier tier; final ItemTier tier;
final WeaponType? weaponType; // New: oneHanded or twoHanded
const Item({ const Item({
required this.id, required this.id,
@ -67,6 +68,7 @@ class Item {
this.luck = 0, this.luck = 0,
this.rarity = ItemRarity.magic, this.rarity = ItemRarity.magic,
this.tier = ItemTier.tier1, this.tier = ItemTier.tier1,
this.weaponType,
}); });
String get typeName { String get typeName {
@ -76,11 +78,35 @@ class Item {
case EquipmentSlot.armor: case EquipmentSlot.armor:
return "Armor"; return "Armor";
case EquipmentSlot.shield: case EquipmentSlot.shield:
return "Shield"; return "Subweapon";
case EquipmentSlot.accessory: case EquipmentSlot.accessory:
return "Accessory"; return "Accessory";
case EquipmentSlot.consumable: case EquipmentSlot.consumable:
return "Potion"; 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);
}
} }

View File

@ -3,15 +3,17 @@ import '../enums.dart';
class StatusEffect { class StatusEffect {
final StatusEffectType type; final StatusEffectType type;
int duration; // Turns remaining 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() { Map<String, dynamic> toJson() {
return { return {
'type': type.name, 'type': type.name,
'duration': duration, 'duration': duration,
'value': value, 'value': value,
'stacks': stacks,
}; };
} }
@ -20,6 +22,7 @@ class StatusEffect {
type: StatusEffectType.values.firstWhere((e) => e.name == json['type']), type: StatusEffectType.values.firstWhere((e) => e.name == json['type']),
duration: json['duration'], duration: json['duration'],
value: json['value'], value: json['value'],
stacks: json['stacks'] ?? 1,
); );
} }
} }

View File

@ -1,5 +1,6 @@
export 'model/damage_event.dart'; export 'model/damage_event.dart';
export 'model/effect_event.dart'; export 'model/effect_event.dart';
export 'model/heal_event.dart';
export 'model/entity.dart'; export 'model/entity.dart';
export 'model/item.dart'; export 'model/item.dart';
export 'model/stage.dart'; export 'model/stage.dart';

View File

@ -1,7 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../providers.dart'; import '../providers.dart';
import 'model/entity.dart';
import 'config/game_config.dart'; import 'config/game_config.dart';
class SaveManager { class SaveManager {

View File

@ -43,6 +43,9 @@ class EnemyIntent {
} }
class BattleProvider with ChangeNotifier { class BattleProvider with ChangeNotifier {
static final Duration _turnEffectVisualDelay =
AnimationConfig.floatingTextDuration + const Duration(milliseconds: 100);
late Character player; late Character player;
late Character enemy; // Kept for compatibility, active during Battle/Elite late Character enemy; // Kept for compatibility, active during Battle/Elite
@ -52,8 +55,6 @@ class BattleProvider with ChangeNotifier {
final BattleLogManager _logManager = BattleLogManager(); final BattleLogManager _logManager = BattleLogManager();
bool isPlayerTurn = true; bool isPlayerTurn = true;
int _turnTransactionId = 0; // To prevent async race conditions int _turnTransactionId = 0; // To prevent async race conditions
bool skipAnimations = false; // Sync with SettingsProvider
int stage = 1; int stage = 1;
int turnCount = 1; int turnCount = 1;
List<Item> rewardOptions = []; List<Item> rewardOptions = [];
@ -62,6 +63,19 @@ class BattleProvider with ChangeNotifier {
List<String> get logs => _logManager.logs; List<String> get logs => _logManager.logs;
int get lastGoldReward => _lastGoldReward; int get lastGoldReward => _lastGoldReward;
StageType get nextStageType => getStageTypeFor(stage + 1);
StageType getStageTypeFor(int stageNumber) {
if (stageNumber % GameConfig.eliteStageInterval == 0) {
return StageType.elite;
} else if (stageNumber % GameConfig.shopStageInterval == 0) {
return StageType.shop;
} else if (stageNumber % GameConfig.restStageInterval == 0) {
return StageType.rest;
}
return StageType.battle;
}
void refreshUI() { void refreshUI() {
notifyListeners(); notifyListeners();
@ -75,6 +89,10 @@ class BattleProvider with ChangeNotifier {
final _effectEventController = StreamController<EffectEvent>.broadcast(); final _effectEventController = StreamController<EffectEvent>.broadcast();
Stream<EffectEvent> get effectStream => _effectEventController.stream; Stream<EffectEvent> get effectStream => _effectEventController.stream;
// Heal Event Stream
final _healEventController = StreamController<HealEvent>.broadcast();
Stream<HealEvent> get healStream => _healEventController.stream;
// Dependency injection // Dependency injection
final ShopProvider shopProvider; final ShopProvider shopProvider;
final Random _random; // Injected Random instance final Random _random; // Injected Random instance
@ -88,6 +106,7 @@ class BattleProvider with ChangeNotifier {
void dispose() { void dispose() {
_damageEventController.close(); // StreamController _damageEventController.close(); // StreamController
_effectEventController.close(); _effectEventController.close();
_healEventController.close();
super.dispose(); super.dispose();
} }
@ -136,8 +155,8 @@ class BattleProvider with ChangeNotifier {
player.gold = GameConfig.startingGold; player.gold = GameConfig.startingGold;
// Add new status effect items for testing // Add new status effect items for testing
player.addToInventory(ItemTable.weapons[3].createItem()); // Stunning Hammer player.addToInventory(ItemTable.weapons[6].createItem()); // Stunning Hammer
player.addToInventory(ItemTable.weapons[4].createItem()); // Jagged Dagger player.addToInventory(ItemTable.weapons[9].createItem()); // Jagged Dagger
player.addToInventory(ItemTable.weapons[5].createItem()); // Sunderer Axe player.addToInventory(ItemTable.weapons[5].createItem()); // Sunderer Axe
player.addToInventory(ItemTable.shields[3].createItem()); // Cursed Shield player.addToInventory(ItemTable.shields[3].createItem()); // Cursed Shield
@ -164,18 +183,7 @@ class BattleProvider with ChangeNotifier {
// Reset Player Armor at start of new stage // Reset Player Armor at start of new stage
player.armor = 0; player.armor = 0;
StageType type; StageType type = getStageTypeFor(stage);
// Stage Type Logic
if (stage % GameConfig.eliteStageInterval == 0) {
type = StageType.elite; // Every 10th stage is a Boss/Elite
} else if (stage % GameConfig.shopStageInterval == 0) {
type = StageType.shop; // Every 5th stage is a Shop (except 10, 20...)
} else if (stage % GameConfig.restStageInterval == 0) {
type = StageType.rest; // Every 8th stage is a Rest
} else {
type = StageType.battle;
}
// Prepare Data based on Type // Prepare Data based on Type
Character? newEnemy; Character? newEnemy;
@ -239,8 +247,7 @@ class BattleProvider with ChangeNotifier {
// 0. Ensure Pre-emptive Enemy Defense is applied (if not already via animation) // 0. Ensure Pre-emptive Enemy Defense is applied (if not already via animation)
applyPendingEnemyDefense(); applyPendingEnemyDefense();
// Update Enemy Status Effects at the start of Player's turn (user request) // 1. Check for Defense Forbidden status (Player)
enemy.updateStatusEffects(); // 1. Check for Defense Forbidden status
if (type == ActionType.defend && if (type == ActionType.defend &&
player.hasStatus(StatusEffectType.defenseForbidden)) { player.hasStatus(StatusEffectType.defenseForbidden)) {
_addLog("Cannot defend! You are under Defense Forbidden status."); _addLog("Cannot defend! You are under Defense Forbidden status.");
@ -262,7 +269,7 @@ class BattleProvider with ChangeNotifier {
// If a visual effect occurred (bleed, stun), wait a bit before action // If a visual effect occurred (bleed, stun), wait a bit before action
if (turnEffect.effectTriggered) { if (turnEffect.effectTriggered) {
await Future.delayed(const Duration(milliseconds: 800)); await Future.delayed(_turnEffectVisualDelay);
} }
if (!turnEffect.canAct) { if (!turnEffect.canAct) {
@ -390,7 +397,7 @@ class BattleProvider with ChangeNotifier {
void _endPlayerTurn() { void _endPlayerTurn() {
// Update durations at end of turn // Update durations at end of turn
player.updateStatusEffects(); player.updateEndOfTurnStatusEffects();
// Check if enemy is dead from bleed // Check if enemy is dead from bleed
if (enemy.isDead) { if (enemy.isDead) {
@ -519,6 +526,9 @@ class BattleProvider with ChangeNotifier {
effectTriggered = true; effectTriggered = true;
} }
// 3. Update durations for start-of-turn effects immediately
character.updateStartOfTurnStatusEffects();
return TurnEffectResult( return TurnEffectResult(
canAct: !isStunned, canAct: !isStunned,
effectTriggered: effectTriggered, effectTriggered: effectTriggered,
@ -550,7 +560,7 @@ class BattleProvider with ChangeNotifier {
// If a visual effect occurred (bleed, stun), wait a bit before action // If a visual effect occurred (bleed, stun), wait a bit before action
if (turnEffect.effectTriggered) { if (turnEffect.effectTriggered) {
await Future.delayed(const Duration(milliseconds: 800)); await Future.delayed(_turnEffectVisualDelay);
} }
if (turnEffect.canAct && currentEnemyIntent != null) { if (turnEffect.canAct && currentEnemyIntent != null) {
@ -665,7 +675,7 @@ class BattleProvider with ChangeNotifier {
if (player.isDead) return; // Game Over check if (player.isDead) return; // Game Over check
// Update enemy status at the end of their turn // Update enemy status at the end of their turn
enemy.updateStatusEffects(); enemy.updateEndOfTurnStatusEffects();
// Generate NEXT intent // Generate NEXT intent
_generateEnemyIntent(); _generateEnemyIntent();
@ -765,16 +775,20 @@ class BattleProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
bool selectReward(Item item) { bool selectReward(Item item, {bool completeStage = true}) {
if (item.id == "reward_skip") { if (item.id == "reward_skip") {
_addLog("Skipped reward."); _addLog("Skipped reward.");
_completeStage(); if (completeStage) {
_completeStage();
}
return true; return true;
} else { } else {
bool added = player.addToInventory(item); bool added = player.addToInventory(item);
if (added) { if (added) {
_addLog("Added ${item.name} to inventory."); _addLog("Added ${item.name} to inventory.");
_completeStage(); if (completeStage) {
_completeStage();
}
return true; return true;
} else { } else {
_addLog("Inventory is full! Could not take ${item.name}."); _addLog("Inventory is full! Could not take ${item.name}.");
@ -783,6 +797,10 @@ class BattleProvider with ChangeNotifier {
} }
} }
void completeStage() {
_completeStage();
}
void _completeStage() { void _completeStage() {
// Heal player after selecting reward // Heal player after selecting reward
int healAmount = GameMath.floor( int healAmount = GameMath.floor(
@ -802,9 +820,20 @@ class BattleProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void equipItem(Item item) { void equipItem(Item item, {EquipmentSlot? targetSlot}) {
if (player.equip(item)) { final success = targetSlot == null
_addLog("Equipped ${item.name}."); ? player.equip(item)
: player.equipToSlot(item, targetSlot);
if (success) {
final slotName = targetSlot == null
? item.typeName
: targetSlot == EquipmentSlot.weapon
? "Main Weapon"
: targetSlot == EquipmentSlot.shield
? "Subweapon"
: targetSlot.name;
_addLog("Equipped ${item.name} as $slotName.");
} else { } else {
_addLog( _addLog(
"Failed to equip ${item.name}.", "Failed to equip ${item.name}.",
@ -857,6 +886,7 @@ class BattleProvider with ChangeNotifier {
int healedAmount = player.hp - currentHp; int healedAmount = player.hp - currentHp;
if (healedAmount > 0) { if (healedAmount > 0) {
_addLog("Used ${item.name}. Recovered $healedAmount HP."); _addLog("Used ${item.name}. Recovered $healedAmount HP.");
_healEventController.sink.add(HealEvent(amount: healedAmount, target: HealTarget.player));
effectApplied = true; effectApplied = true;
} else { } else {
_addLog("Used ${item.name}. HP is already full."); _addLog("Used ${item.name}. HP is already full.");
@ -1095,6 +1125,7 @@ class BattleProvider with ChangeNotifier {
type: target.hasStatus(StatusEffectType.vulnerable) type: target.hasStatus(StatusEffectType.vulnerable)
? DamageType.vulnerable ? DamageType.vulnerable
: DamageType.normal, : DamageType.normal,
risk: event.risk,
), ),
); );
_addLog("${attacker.name} dealt $damageToHp damage to ${target.name}."); _addLog("${attacker.name} dealt $damageToHp damage to ${target.name}.");

View File

@ -17,9 +17,9 @@ class ShopProvider with ChangeNotifier {
void generateShopItems(int stage) { void generateShopItems(int stage) {
ItemTier currentTier = ItemTier.tier1; ItemTier currentTier = ItemTier.tier1;
if (stage > GameConfig.tier2StageMax) if (stage > GameConfig.tier2StageMax) {
currentTier = ItemTier.tier3; currentTier = ItemTier.tier3;
else if (stage > GameConfig.tier1StageMax) } else if (stage > GameConfig.tier1StageMax)
currentTier = ItemTier.tier2; currentTier = ItemTier.tier2;
availableItems = []; availableItems = [];

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,6 @@ import '../widgets.dart';
import '../game/save_manager.dart'; import '../game/save_manager.dart';
import '../providers.dart'; import '../providers.dart';
import '../game/config.dart'; import '../game/config.dart';
import '../widgets/test/sprite_animation_widget.dart';
class MainMenuScreen extends StatefulWidget { class MainMenuScreen extends StatefulWidget {
const MainMenuScreen({super.key}); const MainMenuScreen({super.key});

View File

@ -39,7 +39,7 @@ class SettingsScreen extends StatelessWidget {
onChanged: (value) { onChanged: (value) {
settings.toggleEnemyAnimations(value); settings.toggleEnemyAnimations(value);
}, },
activeColor: ThemeConfig.btnActionActive, activeThumbColor: ThemeConfig.btnActionActive,
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
const Text( const Text(

View File

@ -33,6 +33,36 @@ class ItemUtils {
} }
} }
static String getSlotLabel(EquipmentSlot slot) {
switch (slot) {
case EquipmentSlot.weapon:
return 'MAIN';
case EquipmentSlot.shield:
return 'SUB';
case EquipmentSlot.armor:
return 'ARMOR';
case EquipmentSlot.accessory:
return 'ACCESSORY';
case EquipmentSlot.consumable:
return 'ITEM';
}
}
static String getSlotName(EquipmentSlot slot) {
switch (slot) {
case EquipmentSlot.weapon:
return 'Main Weapon';
case EquipmentSlot.shield:
return 'Subweapon';
case EquipmentSlot.armor:
return 'Armor';
case EquipmentSlot.accessory:
return 'Accessory';
case EquipmentSlot.consumable:
return 'Item';
}
}
static String getBorderPath(ItemRarity rarity) { static String getBorderPath(ItemRarity rarity) {
switch (rarity) { switch (rarity) {
case ItemRarity.normal: case ItemRarity.normal:

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../providers/settings_provider.dart'; import '../../providers/settings_provider.dart';
import '../../game/enums.dart'; import '../../game/enums.dart';
import '../../game/config.dart';
class BattleAnimationWidget extends StatefulWidget { class BattleAnimationWidget extends StatefulWidget {
final Widget child; final Widget child;
@ -24,11 +25,11 @@ class BattleAnimationWidgetState extends State<BattleAnimationWidget>
super.initState(); super.initState();
_scaleController = AnimationController( _scaleController = AnimationController(
vsync: this, vsync: this,
duration: const Duration(milliseconds: 800), duration: AnimationConfig.attackRiskyScale,
); );
_translateController = AnimationController( _translateController = AnimationController(
vsync: this, vsync: this,
duration: const Duration(milliseconds: 1000), duration: AnimationConfig.attackRiskyDash,
); );
_scaleAnimation = Tween<double>( _scaleAnimation = Tween<double>(
@ -58,80 +59,69 @@ class BattleAnimationWidgetState extends State<BattleAnimationWidget>
VoidCallback? onAnimationMiddle, VoidCallback? onAnimationMiddle,
VoidCallback? onAnimationEnd, VoidCallback? onAnimationEnd,
}) async { }) async {
// onAnimationStart?.call(); // Start Phase _resetControllers();
onAnimationStart?.call();
if (risk == RiskLevel.safe || risk == RiskLevel.normal) { if (risk == RiskLevel.safe || risk == RiskLevel.normal) {
// Safe & Normal: Dash/Wobble without scale
final isSafe = risk == RiskLevel.safe; final isSafe = risk == RiskLevel.safe;
final duration = isSafe ? 500 : 400; final duration = AnimationConfig.getAttackDuration(risk);
final offsetFactor = isSafe ? 0.2 : 0.5; final offsetFactor = isSafe ? 0.2 : 0.5;
final curve = isSafe
? AnimationConfig.attackSafeCurve
: AnimationConfig.attackNormalCurve;
_translateController.duration = Duration(milliseconds: duration); _translateController.duration = duration;
_translateAnimation = _translateAnimation = Tween<Offset>(
Tween<Offset>( begin: Offset.zero,
begin: Offset.zero, end: targetOffset * offsetFactor,
end: targetOffset * offsetFactor, ).animate(CurvedAnimation(parent: _translateController, curve: curve));
).animate(
CurvedAnimation(
parent: _translateController,
curve: Curves.easeOutQuad,
),
);
await _translateController.forward(); await _translateController.forward();
if (!mounted) return; if (!mounted) return;
// onAnimationMiddle?.call(); // Middle Phase onAnimationEnd?.call();
onImpact(); onImpact();
await _translateController.reverse(); await _translateController.reverse();
} else { } else {
onAnimationStart?.call(); // Start Phase
// Risky: Scale + Heavy Dash
final attackScale = context.read<SettingsProvider>().attackAnimScale; final attackScale = context.read<SettingsProvider>().attackAnimScale;
_scaleAnimation = Tween<double>(begin: 1.0, end: attackScale).animate( _scaleAnimation = Tween<double>(begin: 1.0, end: attackScale).animate(
CurvedAnimation(parent: _scaleController, curve: Curves.easeOut), CurvedAnimation(parent: _scaleController, curve: Curves.easeOut),
); );
_scaleController.duration = const Duration(milliseconds: 600); _scaleController.duration = AnimationConfig.attackRiskyScale;
_translateController.duration = const Duration(milliseconds: 500); _translateController.duration = AnimationConfig.attackRiskyDash;
// 1. Scale Up (Preparation)
await _scaleController.forward(); await _scaleController.forward();
if (!mounted) return; 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; final adjustedOffset = targetOffset * 0.5;
_translateAnimation = _translateAnimation =
Tween<Offset>(begin: Offset.zero, end: adjustedOffset).animate( Tween<Offset>(begin: Offset.zero, end: adjustedOffset).animate(
CurvedAnimation( CurvedAnimation(
parent: _translateController, parent: _translateController,
curve: Curves.easeInExpo, // Heavy impact curve curve: AnimationConfig.attackRiskyDashCurve,
), ),
); );
await _translateController.forward(); await _translateController.forward();
if (!mounted) return; if (!mounted) return;
// onAnimationEnd?.call(); // End Phase (Moved before Impact) onAnimationEnd?.call();
// 3. Impact Callback (Shake)
onImpact(); onImpact();
// 4. Return (Reset) await Future.wait([
_scaleController.reverse(); _scaleController.reverse(),
await _translateController.reverse(); _translateController.reverse(),
]);
} }
// onAnimationEnd removed from here
} }
Future<void> animateDefense(VoidCallback onImpact) async { Future<void> animateDefense(VoidCallback onImpact) async {
// Defense: Wobble/Shake horizontally _resetControllers();
_translateController.duration = const Duration(milliseconds: 800); _translateController.duration = const Duration(milliseconds: 800);
// Sequence: Left -> Right -> Center // Sequence: Left -> Right -> Center
@ -165,6 +155,17 @@ class BattleAnimationWidgetState extends State<BattleAnimationWidget>
_translateController.reset(); _translateController.reset();
} }
void _resetControllers() {
if (_scaleController.isAnimating) {
_scaleController.stop();
}
if (_translateController.isAnimating) {
_translateController.stop();
}
_scaleController.reset();
_translateController.reset();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnimatedBuilder( return AnimatedBuilder(

View File

@ -84,7 +84,9 @@ class CharacterStatusCard extends StatelessWidget {
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: Text( 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( style: const TextStyle(
color: ThemeConfig.effectText, color: ThemeConfig.effectText,
fontSize: ThemeConfig.statusEffectFontSize, fontSize: ThemeConfig.statusEffectFontSize,

View File

@ -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
}
}

View File

@ -32,6 +32,8 @@ class ExplosionWidgetState extends State<ExplosionWidget>
final List<Particle> _particles = []; final List<Particle> _particles = [];
final Random _random = Random(); final Random _random = Random();
bool get isAnimating => _controller.isAnimating || _particles.isNotEmpty;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -127,7 +129,7 @@ class ExplosionPainter extends CustomPainter {
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
for (final p in particles) { for (final p in particles) {
final paint = Paint() 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; ..style = PaintingStyle.fill;
canvas.drawCircle(p.position, p.size, paint); canvas.drawCircle(p.position, p.size, paint);

View File

@ -8,11 +8,11 @@ class FloatingDamageText extends StatefulWidget {
final VoidCallback onRemove; final VoidCallback onRemove;
const FloatingDamageText({ const FloatingDamageText({
Key? key, super.key,
required this.damage, required this.damage,
required this.color, required this.color,
required this.onRemove, required this.onRemove,
}) : super(key: key); });
@override @override
FloatingDamageTextState createState() => FloatingDamageTextState(); FloatingDamageTextState createState() => FloatingDamageTextState();
@ -111,12 +111,12 @@ class FloatingEffect extends StatefulWidget {
final VoidCallback onRemove; final VoidCallback onRemove;
const FloatingEffect({ const FloatingEffect({
Key? key, super.key,
required this.icon, required this.icon,
required this.color, required this.color,
required this.size, required this.size,
required this.onRemove, required this.onRemove,
}) : super(key: key); });
@override @override
FloatingEffectState createState() => FloatingEffectState(); FloatingEffectState createState() => FloatingEffectState();
@ -193,11 +193,11 @@ class FloatingFeedbackText extends StatefulWidget {
final VoidCallback onRemove; final VoidCallback onRemove;
const FloatingFeedbackText({ const FloatingFeedbackText({
Key? key, super.key,
required this.feedback, required this.feedback,
required this.color, required this.color,
required this.onRemove, required this.onRemove,
}) : super(key: key); });
@override @override
FloatingFeedbackTextState createState() => FloatingFeedbackTextState(); FloatingFeedbackTextState createState() => FloatingFeedbackTextState();

View File

@ -9,6 +9,7 @@ class ItemCardWidget extends StatelessWidget {
final VoidCallback? onTap; final VoidCallback? onTap;
final bool showPrice; final bool showPrice;
final bool canBuy; final bool canBuy;
final bool compact;
const ItemCardWidget({ const ItemCardWidget({
super.key, super.key,
@ -16,6 +17,7 @@ class ItemCardWidget extends StatelessWidget {
this.onTap, this.onTap,
this.showPrice = false, this.showPrice = false,
this.canBuy = true, this.canBuy = true,
this.compact = false,
}); });
@override @override
@ -38,12 +40,12 @@ class ItemCardWidget extends StatelessWidget {
children: [ children: [
// Background Watermark/Silhouette Icon (Top-Left) // Background Watermark/Silhouette Icon (Top-Left)
Positioned( Positioned(
left: 8, left: compact ? 4 : 8,
top: 8, top: compact ? 4 : 8,
child: Image.asset( child: Image.asset(
ItemUtils.getIconPath(item.slot), ItemUtils.getIconPath(item.slot),
width: 32, width: compact ? 24 : 32,
height: 32, height: compact ? 24 : 32,
fit: BoxFit.contain, fit: BoxFit.contain,
color: Colors.black12, // Shadow silhouette color: Colors.black12, // Shadow silhouette
), ),
@ -51,12 +53,12 @@ class ItemCardWidget extends StatelessWidget {
// Main Content (Centered) // Main Content (Centered)
Center( Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(4.0), padding: EdgeInsets.all(compact ? 2.0 : 4.0),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
const SizedBox(height: 12), if (!compact) const SizedBox(height: 12),
Text( Text(
item.name, item.name,
maxLines: 1, maxLines: 1,
@ -65,15 +67,23 @@ class ItemCardWidget extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: ItemUtils.getRarityColor(item.rarity), color: ItemUtils.getRarityColor(item.rarity),
fontSize: 12, fontSize: compact ? 10 : 12,
), ),
), ),
const SizedBox(height: 4), if (item.weaponType != null)
Text(
item.weaponType == WeaponType.oneHanded ? "1-Handed" : "2-Handed",
style: const TextStyle(fontSize: 9, color: ThemeConfig.textColorGrey),
),
SizedBox(height: compact ? 1 : 4),
// Show Item Stats // Show Item Stats
FittedBox( if (compact)
fit: BoxFit.scaleDown, _buildCompactItemStatText(item)
child: _buildItemStatText(item), else
), FittedBox(
fit: BoxFit.scaleDown,
child: _buildItemStatText(item),
),
if (showPrice) ...[ if (showPrice) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
@ -98,12 +108,52 @@ class ItemCardWidget extends StatelessWidget {
); );
} }
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) { Widget _buildItemStatText(Item item) {
List<String> stats = []; List<String> stats = [];
// Helper to format stat string // Helper to format stat string
String formatStat(int value, String label) { 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"; return "$sign$value $label";
} }

View File

@ -33,6 +33,14 @@ class EquippedItemsWidget extends StatelessWidget {
.where((slot) => slot != EquipmentSlot.consumable) .where((slot) => slot != EquipmentSlot.consumable)
.map((slot) { .map((slot) {
final item = player.equipment[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( return Expanded(
child: InkWell( child: InkWell(
onTap: item != null onTap: item != null
@ -45,7 +53,7 @@ class EquippedItemsWidget extends StatelessWidget {
child: Card( child: Card(
color: item != null color: item != null
? ThemeConfig.equipmentCardBg ? ThemeConfig.equipmentCardBg
: ThemeConfig.emptySlotBg, : (isShieldLocked ? Colors.black26 : ThemeConfig.emptySlotBg),
shape: shape:
item != null && item.rarity != ItemRarity.magic item != null && item.rarity != ItemRarity.magic
? RoundedRectangleBorder( ? RoundedRectangleBorder(
@ -65,7 +73,7 @@ class EquippedItemsWidget extends StatelessWidget {
right: 4, right: 4,
top: 4, top: 4,
child: Text( child: Text(
slot.name.toUpperCase(), ItemUtils.getSlotLabel(slot),
style: const TextStyle( style: const TextStyle(
fontSize: ThemeConfig.fontSizeTiny, fontSize: ThemeConfig.fontSizeTiny,
fontWeight: ThemeConfig.fontWeightBold, fontWeight: ThemeConfig.fontWeightBold,
@ -78,7 +86,7 @@ class EquippedItemsWidget extends StatelessWidget {
left: 4, left: 4,
top: 4, top: 4,
child: Opacity( child: Opacity(
opacity: item != null ? 0.5 : 0.2, opacity: item != null ? 0.5 : (isShieldLocked ? 0.1 : 0.2),
child: Image.asset( child: Image.asset(
ItemUtils.getIconPath(slot), ItemUtils.getIconPath(slot),
width: 40, width: 40,
@ -100,7 +108,7 @@ class EquippedItemsWidget extends StatelessWidget {
FittedBox( FittedBox(
fit: BoxFit.scaleDown, fit: BoxFit.scaleDown,
child: Text( child: Text(
item?.name ?? AppStrings.emptySlot, item?.name ?? (isShieldLocked ? "Locked (2H)" : AppStrings.emptySlot),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontSize: fontSize:
@ -111,7 +119,7 @@ class EquippedItemsWidget extends StatelessWidget {
? ItemUtils.getRarityColor( ? ItemUtils.getRarityColor(
item.rarity, item.rarity,
) )
: ThemeConfig.textColorGrey, : (isShieldLocked ? Colors.red.withOpacity(0.5) : ThemeConfig.textColorGrey),
), ),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@ -181,6 +189,16 @@ class EquippedItemsWidget extends StatelessWidget {
_buildStatChangeRow("Current HP", currentHp, newHp), _buildStatChangeRow("Current HP", currentHp, newHp),
_buildStatChangeRow(AppStrings.atk, currentAtk, newAtk), _buildStatChangeRow(AppStrings.atk, currentAtk, newAtk),
_buildStatChangeRow(AppStrings.def, currentDef, newDef), _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: [ actions: [

View File

@ -4,75 +4,123 @@ import '../../providers.dart';
import '../../game/models.dart'; import '../../game/models.dart';
import '../../game/enums.dart'; import '../../game/enums.dart';
import '../../game/config.dart'; import '../../game/config.dart';
import '../../utils.dart';
import '../common/item_card_widget.dart'; import '../common/item_card_widget.dart';
enum InventoryGridMode { normal, shop, equipmentSwap }
class InventoryGridWidget extends StatelessWidget { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<BattleProvider>( return Consumer<BattleProvider>(
builder: (context, battleProvider, child) { builder: (context, battleProvider, child) {
final player = battleProvider.player; 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( return Column(
children: [ children: [
Padding( if (showHeader)
padding: const EdgeInsets.symmetric( Padding(
horizontal: 16.0, padding: const EdgeInsets.symmetric(
vertical: 8.0, horizontal: 16.0,
), vertical: 8.0,
child: Align( ),
alignment: Alignment.centerLeft, child: Align(
child: Text( alignment: Alignment.centerLeft,
"${AppStrings.bag} (${player.inventory.length}/${player.maxInventorySize})", child: Text(
style: const TextStyle( "${equipmentOnly ? AppStrings.equipment : AppStrings.bag} (${items.length}/${player.maxInventorySize})",
fontSize: ThemeConfig.fontSizeHeader, style: const TextStyle(
fontWeight: ThemeConfig.fontWeightBold, fontSize: ThemeConfig.fontSizeHeader,
fontWeight: ThemeConfig.fontWeightBold,
),
), ),
), ),
), ),
),
Expanded( Expanded(
child: GridView.builder( child: itemCount == 0
padding: const EdgeInsets.all(16.0), ? const Center(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( child: Text(
crossAxisCount: 4, "No equipment",
crossAxisSpacing: 8.0, style: TextStyle(color: ThemeConfig.textColorGrey),
mainAxisSpacing: 8.0, ),
), )
itemCount: player.maxInventorySize, : GridView.builder(
itemBuilder: (context, index) { padding: gridPadding,
if (index < player.inventory.length) { gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
final item = player.inventory[index]; crossAxisCount: crossAxisCount,
return InkWell( crossAxisSpacing: 8.0,
onTap: () { mainAxisSpacing: 8.0,
_showItemActionDialog(context, battleProvider, item); 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, BattleProvider provider,
Item item, 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(); int sellPrice = (item.price * GameConfig.sellPriceMultiplier).floor();
showDialog( showDialog(
@ -114,7 +166,7 @@ class InventoryGridWidget extends StatelessWidget {
SimpleDialogOption( SimpleDialogOption(
onPressed: () { onPressed: () {
Navigator.pop(ctx); Navigator.pop(ctx);
_showEquipConfirmationDialog(context, provider, item); _showEquipSlotDialog(context, provider, item);
}, },
child: const Padding( child: const Padding(
padding: EdgeInsets.symmetric(vertical: 8.0), padding: EdgeInsets.symmetric(vertical: 8.0),
@ -147,27 +199,84 @@ class InventoryGridWidget extends StatelessWidget {
), ),
), ),
), ),
SimpleDialogOption( if (!isEquipmentSwap)
onPressed: () { SimpleDialogOption(
Navigator.pop(ctx); onPressed: () {
_showDiscardConfirmationDialog(context, provider, item); Navigator.pop(ctx);
}, _showDiscardConfirmationDialog(context, provider, item);
child: const Padding( },
padding: EdgeInsets.symmetric(vertical: 8.0), child: const Padding(
child: Row( padding: EdgeInsets.symmetric(vertical: 8.0),
children: [ child: Row(
Icon(Icons.delete, color: ThemeConfig.btnActionActive), children: [
SizedBox(width: 10), Icon(Icons.delete, color: ThemeConfig.btnActionActive),
Text(AppStrings.discard), 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( void _showSellConfirmationDialog(
BuildContext context, BuildContext context,
BattleProvider provider, BattleProvider provider,
@ -237,9 +346,10 @@ class InventoryGridWidget extends StatelessWidget {
BuildContext context, BuildContext context,
BattleProvider provider, BattleProvider provider,
Item newItem, Item newItem,
EquipmentSlot targetSlot,
) { ) {
final player = provider.player; final player = provider.player;
final oldItem = player.equipment[newItem.slot]; final oldItem = player.equipment[targetSlot];
final currentMaxHp = player.totalMaxHp; final currentMaxHp = player.totalMaxHp;
final currentAtk = player.totalAtk; final currentAtk = player.totalAtk;
@ -263,7 +373,7 @@ class InventoryGridWidget extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
"${AppStrings.equip} ${newItem.name}?", "${AppStrings.equip} ${newItem.name} as ${ItemUtils.getSlotName(targetSlot)}?",
style: const TextStyle(fontWeight: ThemeConfig.fontWeightBold), style: const TextStyle(fontWeight: ThemeConfig.fontWeightBold),
), ),
if (oldItem != null) if (oldItem != null)
@ -289,6 +399,25 @@ class InventoryGridWidget extends StatelessWidget {
player.totalDodge, player.totalDodge,
player.totalDodge - (oldItem?.dodge ?? 0) + newItem.dodge, 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: [ actions: [
@ -298,7 +427,7 @@ class InventoryGridWidget extends StatelessWidget {
), ),
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () {
provider.equipItem(newItem); provider.equipItem(newItem, targetSlot: targetSlot);
Navigator.pop(ctx); Navigator.pop(ctx);
}, },
child: const Text(AppStrings.confirm), child: const Text(AppStrings.confirm),

View File

@ -6,11 +6,11 @@ class ResponsiveContainer extends StatelessWidget {
final double maxHeight; final double maxHeight;
const ResponsiveContainer({ const ResponsiveContainer({
Key? key, super.key,
required this.child, required this.child,
this.maxWidth = 600.0, this.maxWidth = 600.0,
this.maxHeight = 1000.0, this.maxHeight = 1000.0,
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -134,7 +134,10 @@ class ShopUI extends StatelessWidget {
const Divider(color: ThemeConfig.textColorGrey), const Divider(color: ThemeConfig.textColorGrey),
// Player Inventory (Bottom Half) // Player Inventory (Bottom Half)
const Expanded(flex: 5, child: InventoryGridWidget()), const Expanded(
flex: 5,
child: InventoryGridWidget(mode: InventoryGridMode.shop),
),
const SizedBox(height: 8), const SizedBox(height: 8),

View File

@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig"

View File

@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig"

42
macos/Podfile Normal file
View File

@ -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

View File

@ -36,3 +36,4 @@ flutter:
- assets/images/icons/accessories/ - assets/images/icons/accessories/
- assets/images/icons/potions/ - assets/images/icons/potions/
- assets/images/icons/armors/ - assets/images/icons/armors/
- assets/images/effects/

View File

@ -2,7 +2,6 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart'; // Import SharedPreferences import 'package:shared_preferences/shared_preferences.dart'; // Import SharedPreferences
import 'package:game_test/providers/battle_provider.dart'; import 'package:game_test/providers/battle_provider.dart';
import 'package:game_test/providers/shop_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/enums.dart';
import 'package:game_test/game/data/item_table.dart'; import 'package:game_test/game/data/item_table.dart';

View File

@ -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);
});
}); });
} }

View File

@ -1,6 +1,5 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:game_test/game/data/item_table.dart'; import 'package:game_test/game/data/item_table.dart';
import 'package:flutter/services.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();