diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..147a081 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["google.gemini-cli-vscode-ide-companion"] +} diff --git a/assets/data/items.json b/assets/data/items.json index 905baaa..5e917d1 100644 --- a/assets/data/items.json +++ b/assets/data/items.json @@ -49,9 +49,9 @@ "effects": [ { "type": "bleed", - "probability": 30, + "probability": 100, "duration": 3, - "value": 5 + "value": 30 } ] }, diff --git a/google.gemini-cli-vscode-ide-companion-0.7.0.vsix b/google.gemini-cli-vscode-ide-companion-0.7.0.vsix new file mode 100644 index 0000000..30ea7e8 Binary files /dev/null and b/google.gemini-cli-vscode-ide-companion-0.7.0.vsix differ diff --git a/img_ref/01_battle_screen.png b/img_ref/01_battle_screen.png new file mode 100644 index 0000000..8b6427a Binary files /dev/null and b/img_ref/01_battle_screen.png differ diff --git a/lib/game/enums.dart b/lib/game/enums.dart index f591b00..b57b9ff 100644 --- a/lib/game/enums.dart +++ b/lib/game/enums.dart @@ -11,6 +11,12 @@ enum StatusEffectType { defenseForbidden, // Cannot use Defend action } +/// 공격 실패 시 이펙트 피드백 타입 정의 +enum BattleFeedbackType { + miss, // 공격이 빗나감 + failed, // 방어 실패 +} + /// 스탯에 적용될 수 있는 수정자(Modifier)의 타입 정의. /// Flat: 기본 값에 직접 더해지는 값. /// Percent: 기본 값에 비율로 곱해지는 값. @@ -26,3 +32,4 @@ enum StageType { enum EquipmentSlot { weapon, armor, shield, accessory } enum DamageType { normal, bleed, vulnerable } + diff --git a/lib/game/model/effect_event.dart b/lib/game/model/effect_event.dart index 2a153e9..2fe4f11 100644 --- a/lib/game/model/effect_event.dart +++ b/lib/game/model/effect_event.dart @@ -6,10 +6,12 @@ class EffectEvent { final ActionType type; // attack, defend final RiskLevel risk; final EffectTarget target; // 이펙트가 표시될 위치의 대상 + final BattleFeedbackType? feedbackType; // 새로운 피드백 타입 EffectEvent({ required this.type, required this.risk, required this.target, + this.feedbackType, // feedbackType 필드를 생성자에 추가 }); } diff --git a/lib/providers/battle_provider.dart b/lib/providers/battle_provider.dart index 3a0319d..fc0a57c 100644 --- a/lib/providers/battle_provider.dart +++ b/lib/providers/battle_provider.dart @@ -235,13 +235,14 @@ class BattleProvider with ChangeNotifier { return; // Update Enemy Status Effects at the start of Player's turn (user request) - enemy.updateStatusEffects(); // 1. Check for Defense Forbidden status if (type == ActionType.defend && player.hasStatus(StatusEffectType.defenseForbidden)) { _addLog("Cannot defend! You are under Defense Forbidden status."); + notifyListeners(); // 상태 변경을 알림 + _endPlayerTurn(); return; } @@ -283,10 +284,9 @@ class BattleProvider with ChangeNotifier { _effectEventController.sink.add( EffectEvent( type: ActionType.attack, - risk: risk, - target: EffectTarget.enemy, + feedbackType: null, // 공격 성공이므로 feedbackType 없음 ), ); @@ -313,27 +313,43 @@ class BattleProvider with ChangeNotifier { } // Try applying status effects from items - _tryApplyStatusEffects(player, enemy); } else { _effectEventController.sink.add( EffectEvent( type: ActionType.defend, - risk: risk, - target: EffectTarget.player, + feedbackType: null, // 방어 성공이므로 feedbackType 없음 ), ); int armorGained = (player.totalDefense * efficiency).toInt(); - player.armor += armorGained; - _addLog("Player gained $armorGained armor."); } } else { - _addLog("Player's action missed!"); + if (type == ActionType.attack) { + _addLog("Player's attack missed!"); + _effectEventController.sink.add( + EffectEvent( + type: type, + risk: risk, + target: EffectTarget.enemy, // 공격 실패는 적 위치에 MISS + feedbackType: BattleFeedbackType.miss, + ), + ); + } else { + _addLog("Player's defense failed!"); + _effectEventController.sink.add( + EffectEvent( + type: type, + risk: risk, + target: EffectTarget.player, // 방어 실패는 내 위치에 FAILED + feedbackType: BattleFeedbackType.failed, + ), + ); + } } if (enemy.isDead) { @@ -394,6 +410,7 @@ class BattleProvider with ChangeNotifier { type: ActionType.attack, risk: intent.risk, target: EffectTarget.player, + feedbackType: null, // 공격 성공이므로 feedbackType 없음 ), ); @@ -421,6 +438,14 @@ class BattleProvider with ChangeNotifier { } } else { _addLog("Enemy's ${intent.risk.name} attack missed!"); + _effectEventController.sink.add( + EffectEvent( + type: ActionType.attack, // 적의 공격이므로 ActionType.attack + risk: intent.risk, + target: EffectTarget.player, // 플레이어가 회피했으므로 플레이어 위치에 이펙트 + feedbackType: BattleFeedbackType.miss, // 변경: MISS 피드백 + ), + ); } } } else if (!canAct) { @@ -636,6 +661,10 @@ class BattleProvider with ChangeNotifier { // Decide Action Type // If baseDefense is 0, CANNOT defend. bool canDefend = enemy.baseDefense > 0; + // Check for DefenseForbidden status + if (enemy.hasStatus(StatusEffectType.defenseForbidden)) { + canDefend = false; + } bool isAttack = true; if (canDefend) { @@ -662,9 +691,8 @@ class BattleProvider with ChangeNotifier { if (isAttack) { // Attack Intent - // Variance: +/- 20% - double variance = 0.8 + random.nextDouble() * 0.4; - int damage = (enemy.totalAtk * efficiency * variance).toInt(); + // Variance removed as per request + int damage = (enemy.totalAtk * efficiency).toInt(); if (damage < 1) damage = 1; // Calculate success immediately @@ -692,9 +720,8 @@ class BattleProvider with ChangeNotifier { } else { // Defend Intent int baseDef = enemy.totalDefense; - // Variance - double variance = 0.8 + random.nextDouble() * 0.4; - int armor = (baseDef * 2 * efficiency * variance).toInt(); + // Variance removed + int armor = (baseDef * 2 * efficiency).toInt(); // Calculate success immediately bool success = false; @@ -728,6 +755,7 @@ class BattleProvider with ChangeNotifier { type: ActionType.defend, risk: risk, target: EffectTarget.enemy, + feedbackType: null, // 방어 성공이므로 feedbackType 없음 ), ); } else { diff --git a/lib/screens/battle_screen.dart b/lib/screens/battle_screen.dart index 93751a1..d26ff68 100644 --- a/lib/screens/battle_screen.dart +++ b/lib/screens/battle_screen.dart @@ -21,6 +21,7 @@ class _BattleScreenState extends State { final ScrollController _scrollController = ScrollController(); final List<_DamageTextData> _floatingDamageTexts = []; final List<_FloatingEffectData> _floatingEffects = []; + final List<_FeedbackTextData> _floatingFeedbackTexts = []; StreamSubscription? _damageSubscription; StreamSubscription? _effectSubscription; final GlobalKey _playerKey = GlobalKey(); @@ -131,6 +132,51 @@ class _BattleScreenState extends State { position + Offset(renderBox.size.width / 2 - 30, renderBox.size.height / 2 - 30); + // feedbackType이 존재하면 해당 텍스트를 표시하고 기존 이펙트 아이콘은 건너뜜 + if (event.feedbackType != null) { + String feedbackText; + Color feedbackColor; + switch (event.feedbackType) { + case BattleFeedbackType.miss: + feedbackText = "MISS"; + feedbackColor = Colors.grey; + break; + case BattleFeedbackType.failed: + feedbackText = "FAILED"; + feedbackColor = Colors.redAccent; + break; + default: + feedbackText = ""; // Should not happen with current enums + feedbackColor = Colors.white; + } + + final String id = UniqueKey().toString(); + setState(() { + _floatingFeedbackTexts.add( + _FeedbackTextData( + id: id, + widget: Positioned( + 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); + }); + } + }, + ), + ), + ), + ); + }); + return; // feedbackType이 있으면 아이콘 이펙트는 표시하지 않음 + } + IconData icon; Color color; double size; @@ -321,29 +367,34 @@ class _BattleScreenState extends State { // Battle Area Expanded( - child: Center( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + child: Padding( + // padding: const EdgeInsets.symmetric(horizontal: 40.0), + padding: const EdgeInsets.all(70.0), + child: Column( children: [ - _buildCharacterStatus( - battleProvider.player, - isPlayer: true, - isTurn: battleProvider.isPlayerTurn, - key: _playerKey, + // 적 영역 (우측 상단) + Expanded( + child: Align( + alignment: Alignment.topRight, + child: _buildCharacterStatus( + battleProvider.enemy, + isPlayer: false, + isTurn: !battleProvider.isPlayerTurn, + key: _enemyKey, + ), + ), ), - // const Text( - // "VS", - // style: TextStyle( - // color: Colors.red, - // fontSize: 24, - // fontWeight: FontWeight.bold, - // ), - // ), - _buildCharacterStatus( - battleProvider.enemy, - isPlayer: false, - isTurn: !battleProvider.isPlayerTurn, - key: _enemyKey, + // 플레이어 영역 (좌측 하단) + Expanded( + child: Align( + alignment: Alignment.bottomLeft, + child: _buildCharacterStatus( + battleProvider.player, + isPlayer: true, + isTurn: battleProvider.isPlayerTurn, + key: _playerKey, + ), + ), ), ], ), @@ -470,6 +521,7 @@ class _BattleScreenState extends State { ), ..._floatingDamageTexts.map((e) => e.widget), ..._floatingEffects.map((e) => e.widget), + ..._floatingFeedbackTexts.map((e) => e.widget), // 새로운 피드백 텍스트 추가 ], ); }, @@ -609,6 +661,31 @@ class _BattleScreenState extends State { ), Text("ATK: ${character.totalAtk}"), Text("DEF: ${character.totalDefense}"), + // 캐릭터 아이콘/이미지 영역 추가 + Container( + width: 100, // 임시 크기 + height: 100, // 임시 크기 + decoration: BoxDecoration( + color: isPlayer + ? Colors.lightBlue + : Colors.deepOrange, // 플레이어/적 구분 색상 + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: isPlayer + ? const Icon( + Icons.person, + size: 60, + color: Colors.white, + ) // 플레이어 아이콘 + : const Icon( + Icons.psychology, + size: 60, + color: Colors.white, + ), // 적 아이콘 (몬스터 대신) + ), + ), + const SizedBox(height: 8), // 아이콘과 정보 사이 간격 if (!isPlayer) Consumer( @@ -863,3 +940,100 @@ class _FloatingEffectData { _FloatingEffectData({required this.id, required this.widget}); } + +// 새로운 _FloatingFeedbackText 위젯 +class _FloatingFeedbackText extends StatefulWidget { + final String feedback; + final Color color; + final VoidCallback onRemove; + + const _FloatingFeedbackText({ + Key? key, + required this.feedback, + required this.color, + required this.onRemove, + }) : super(key: key); + + @override + __FloatingFeedbackTextState createState() => __FloatingFeedbackTextState(); +} + +class __FloatingFeedbackTextState extends State<_FloatingFeedbackText> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _offsetAnimation; + late Animation _opacityAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 1000), + vsync: this, + ); + + _offsetAnimation = Tween( + begin: const Offset(0.0, 0.0), + end: const Offset(0.0, -1.5), + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + + _opacityAnimation = Tween(begin: 1.0, end: 0.0).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0.5, 1.0, curve: Curves.easeOut), + ), + ); + + _controller.forward().then((_) { + if (mounted) { + widget.onRemove(); + } + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return FractionalTranslation( + translation: _offsetAnimation.value, + child: Opacity( + opacity: _opacityAnimation.value, + child: Material( + color: Colors.transparent, + child: Text( + widget.feedback, + style: TextStyle( + color: widget.color, + fontSize: 20, + fontWeight: FontWeight.bold, + shadows: const [ + Shadow( + blurRadius: 2.0, + color: Colors.black, + offset: Offset(1.0, 1.0), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} + +class _FeedbackTextData { + final String id; + final Widget widget; + + _FeedbackTextData({required this.id, required this.widget}); +} diff --git a/prompt/00_project_context_restore.md b/prompt/00_project_context_restore.md index 4cd76e6..5bd5d46 100644 --- a/prompt/00_project_context_restore.md +++ b/prompt/00_project_context_restore.md @@ -19,6 +19,7 @@ 4. **반응형 레이아웃 (Responsive UI):** - `ResponsiveContainer` 위젯을 통해 최대 너비(600px) 및 높이(1000px) 제한. - 웹/태블릿 환경에서도 모바일 앱처럼 중앙 정렬된 화면 제공. + - **Battle UI Layout:** 플레이어(좌측 하단) vs 적(우측 상단) 대각선 구도 배치 및 캐릭터 임시 아이콘 적용. ### B. 전투 시스템 (`BattleProvider`) @@ -28,8 +29,11 @@ - **적 인공지능 (Enemy AI & Intent):** - **Intent UI:** 적의 다음 행동(공격/방어, 리스크)을 미리 표시. - **선제 방어 (Pre-emptive Defense):** 적이 방어를 선택하면 턴 시작 전에 즉시 방어도가 적용됨. + - **Defense Restriction:** `DefenseForbidden` 상태 시 방어 행동 선택 불가. + - **Variance Removed:** 적의 공격/방어 수치 계산 시 랜덤 분산(Variance) 제거 (고정 수치). - **시각 효과 (Visual Effects):** - **Floating Text:** 데미지 발생 시 캐릭터 위에 데미지 수치가 떠오름 (일반: 빨강, 출혈: 보라, 취약: 주황). + - **Action Feedback:** 공격 빗나감(MISS - 적 위치/회색) 및 방어 실패(FAILED - 내 위치/빨강) 텍스트 오버레이. - **Effect Icons:** 공격/방어 및 리스크 레벨에 따른 아이콘 애니메이션 출력. - **상태이상:** `Stun`, `Bleed`, `Vulnerable`, `DefenseForbidden`. @@ -83,6 +87,13 @@ ## 6. 장기 목표 (Future Roadmap / TODO) +- [ ] **출혈 상태 이상 조건 변경:** 공격 시 상대방의 방어도에 의해 공격이 완전히 막힐 경우, 출혈 상태 이상이 적용되지 않도록 로직 변경. +- [ ] **장비 분해 시스템 (적 장비):** 플레이어 장비의 옵션으로 상대방의 장비를 분해하여 '언암드' 상태로 만들 수 있는 시스템 구현. +- [ ] **플레이어 공격 데미지 분산(Variance) 적용 여부 검토:** 현재 적에게만 적용된 +/- 20% 데미지 분산을 플레이어에게도 적용할지 결정. +- [ ] **애니메이션 및 타격감 고도화:** + - 캐릭터별 이미지 추가 및 하스스톤 스타일의 공격 모션(대상에게 돌진 후 타격) 구현. + - **Risky 공격:** 하스스톤의 7데미지 이상 타격감(화면 흔들림, 강렬한 파티클 및 임팩트) 재현. +- [ ] **체력 조건부 특수 능력:** 캐릭터의 체력이 30% 미만일 때 발동 가능한 특수 능력 시스템 구현. - [ ] **Google OAuth 로그인 및 계정 연동:** - Firebase Auth 등을 활용한 구글 로그인 구현. - Firestore 또는 Realtime Database에 유저 계정 정보(진행 상황, 재화 등) 저장 및 불러오기 기능 추가. diff --git a/prompt/33_summary_after_32.md b/prompt/33_summary_after_32.md new file mode 100644 index 0000000..4bd026e --- /dev/null +++ b/prompt/33_summary_after_32.md @@ -0,0 +1,37 @@ +# 33. 32번 프롬프트 이후 작업 요약 (Summary of work after prompt 32) + +이 프롬프트는 32번 프롬프트 이후 진행된 주요 작업 내용을 요약하여 기록합니다. + +## 1. 행동 실패 시 이펙트 추가 (Visual Effects for Failed Actions) + +- **`lib/game/enums.dart`:** `BattleFeedbackType` enum에 `miss`와 `failed` (방어 실패)를 추가했습니다. +- **`lib/game/model/effect_event.dart`:** `EffectEvent` 클래스에 `feedbackType` 필드를 추가하여 전투 피드백 타입을 전달할 수 있도록 했습니다. +- **`lib/screens/battle_screen.dart`:** + - `_FloatingFeedbackText` 위젯과 관련 데이터 클래스 (`_FeedbackTextData`)를 새로 추가하여 "MISS" 또는 "FAILED" 텍스트를 화면에 오버레이로 표시하도록 했습니다. + - `_addFloatingEffect` 함수를 수정하여 `feedbackType`이 존재할 경우 해당 텍스트 이펙트를 발생시키고, 기존의 아이콘 이펙트는 건너뛰도록 처리했습니다. +- **`lib/providers/battle_provider.dart`:** + - `playerAction` 함수에서 플레이어의 공격 실패 시 `BattleFeedbackType.miss` 이벤트를 적 위치에, 방어 실패 시 `BattleFeedbackType.failed` 이벤트를 플레이어 위치에 발생시키도록 로직을 추가했습니다. + - `_enemyTurn` 함수에서 적의 공격 실패 시 `BattleFeedbackType.miss` 이벤트를 플레이어 위치에 발생시키도록 수정했습니다. + - 모든 `EffectEvent` 생성자 호출에 `feedbackType: null` 인자를 추가하여 생성자 변경 사항을 반영했습니다. + +## 2. 적 `DefenseForbidden` 상태 로직 수정 (Enemy DefenseForbidden Logic) + +- **`lib/providers/battle_provider.dart`:** `_generateEnemyIntent` 함수에서 적이 `StatusEffectType.defenseForbidden` 상태일 때 방어 행동을 의도하지 않도록 `canDefend` 플래그를 수정했습니다. + +## 3. 배틀 스크린 UI 개선 (캐릭터 배치 및 임시 아이콘) (Battle Screen UI Improvement) + +- **`lib/screens/battle_screen.dart`:** + - `_buildCharacterStatus` 위젯 내에 플레이어와 적 캐릭터를 위한 임시 아이콘(Container+Icon)을 추가했습니다. + - 플레이어는 좌측 하단, 적은 우측 상단으로 배치되도록 배틀 영역의 레이아웃 (`Expanded` 내 `Row` -> `Column` 내 `Expanded` + `Align`)을 수정하여 포켓몬 배틀과 유사한 대각선 구도를 구현했습니다. + +## 4. TODO 목록 업데이트 (TODO List Updates) + +- **`d:\project\project_flutter\game\prompt\00_project_context_restore.md`:** + - 새로운 TODO 항목을 추가했습니다: + - `[ ] **출혈 상태 이상 조건 변경:** 공격 시 상대방의 방어도에 의해 공격이 완전히 막힐 경우, 출혈 상태 이상이 적용되지 않도록 로직 변경.` + - `[ ] **플레이어 공격 데미지 분산(Variance) 적용 여부 검토:** 현재 적에게만 적용된 +/- 20% 데미지 분산을 플레이어에게도 적용할지 결정.` + - `장비 분해 시스템` TODO의 내용을 "플레이어 장비의 옵션으로 상대방의 장비를 분해하여 '언암드' 상태로 만들 수 있는 시스템 구현"으로 수정했습니다. + +## 5. 적 공격 계산식에서 분산(Variance) 제거 (Enemy Attack Variance Removal) + +- **`lib/providers/battle_provider.dart`:** `_generateEnemyIntent` 함수에서 적의 공격 및 방어 수치 계산 시 사용되던 분산(variance) 로직(`double variance = 0.8 + random.nextDouble() * 0.4;`)을 제거하여, 적의 행동 수치가 기본 스탯과 리스크 효율(Efficiency)에 의해서만 결정되도록 수정했습니다.