This commit is contained in:
2025-12-16 02:13:31 +09:00
parent c029cd1e10
commit f5a7eb2db9
19 changed files with 378 additions and 87 deletions
+1 -1
View File
@@ -13,5 +13,5 @@ class ItemConfig {
};
// Loot Generation
static const double magicPrefixChance = 0.5; // 50%
static const double magicPrefixChance = 1.0; // 100%
}
+9
View File
@@ -105,4 +105,13 @@ class ThemeConfig {
static const Color riskSafe = Colors.green;
static const Color riskNormal = Colors.blue;
static const Color riskRisky = Colors.red;
// Character Status Card
static const double characterIconSize = 60.0;
static const double playerImageSize = 200.0;
static const double enemyImageSize = 200.0;
static const Color playerImageBgColor = Colors.lightBlue;
static const double statusEffectFontSize = 10.0;
static const double intentFontSize = 12.0;
static const double intentIconSize = 16.0;
}
+1
View File
@@ -44,6 +44,7 @@ class PlayerTemplate {
baseDefense: baseDefense,
baseDodge: baseDodge, // Use template value
armor: 0,
image: image, // Pass image path
);
}
}
+17 -4
View File
@@ -90,6 +90,15 @@ class BattleProvider with ChangeNotifier {
turnCount = data['turnCount'];
player = Character.fromJson(data['player']);
// [Fix] Update player image path from latest data (in case of legacy save data)
// This ensures that even if the save file has an old path, the UI uses the correct asset.
if (player.name == "Warrior") {
final template = PlayerTable.get("warrior");
if (template != null && template.image != null) {
player.image = template.image;
}
}
_logManager.clear();
_addLog("Game Loaded! Resuming Stage $stage");
@@ -513,8 +522,7 @@ class BattleProvider with ChangeNotifier {
}
// Process Start-of-Turn Effects
final result = CombatCalculator.processStartTurnEffects(enemy);
bool canAct = !result['isStunned'];
bool canAct = _processStartTurnEffects(enemy);
if (enemy.isDead) {
_onVictory();
@@ -1074,7 +1082,7 @@ class BattleProvider with ChangeNotifier {
}
// Try applying status effects
_tryApplyStatusEffects(attacker, target);
_tryApplyStatusEffects(attacker, target, damageToHp);
// If target is enemy, update intent to reflect potential status changes (e.g. Disarmed)
if (target == enemy) {
@@ -1105,13 +1113,18 @@ class BattleProvider with ChangeNotifier {
}
/// Tries to applyStatus effects from attacker's equipment to the target.
void _tryApplyStatusEffects(Character attacker, Character target) {
void _tryApplyStatusEffects(Character attacker, Character target, int damageToHp) {
List<StatusEffect> effectsToApply = CombatCalculator.getAppliedEffects(
attacker,
random: _random, // Pass injected random
);
for (var effect in effectsToApply) {
// Logic: Bleed requires HP damage (penetrating armor)
if (effect.type == StatusEffectType.bleed && damageToHp <= 0) {
continue;
}
target.addStatusEffect(effect);
_addLog("Applied ${effect.type.name} to ${target.name}!");
}
+1
View File
@@ -4,3 +4,4 @@ export 'screens/inventory_screen.dart';
export 'screens/main_menu_screen.dart';
export 'screens/main_wrapper.dart';
export 'screens/settings_screen.dart';
export 'screens/story_screen.dart';
+57 -16
View File
@@ -11,6 +11,8 @@ import '../utils.dart';
import 'main_menu_screen.dart';
import '../game/config.dart';
enum AnimationPhase { none, start, middle, end }
class BattleScreen extends StatefulWidget {
const BattleScreen({super.key});
@@ -42,6 +44,22 @@ class _BattleScreenState extends State<BattleScreen> {
// New State for Interactive Defense Animation
int _lastTurnCount = -1;
bool _hasShownEnemyDefense = false;
AnimationPhase _playerAnimPhase = AnimationPhase.none;
String? _getOverrideImage(bool isPlayer) {
if (!isPlayer)
return null; // Enemy animation image logic can be added later
if (_playerAnimPhase == AnimationPhase.start) {
return "assets/images/character/warrior_attack_1.png";
} else if (_playerAnimPhase == AnimationPhase.middle) {
return null; // Middle phase now uses default image or another image
} else if (_playerAnimPhase == AnimationPhase.end) {
return "assets/images/character/warrior_attack_2.png";
}
return null;
}
@override
void initState() {
@@ -348,28 +366,50 @@ class _BattleScreenState extends State<BattleScreen> {
: event.risk;
_playerAnimKey.currentState
?.animateAttack(offset, () {
showEffect();
context.read<BattleProvider>().handleImpact(event);
?.animateAttack(
offset,
() {
showEffect();
context.read<BattleProvider>().handleImpact(event);
if (event.risk == RiskLevel.risky && event.feedbackType == null) {
_shakeKey.currentState?.shake();
RenderBox? stackBox =
_stackKey.currentContext?.findRenderObject() as RenderBox?;
if (stackBox != null) {
Offset localEnemyPos = stackBox.globalToLocal(enemyPos);
localEnemyPos += Offset(
enemyBox.size.width / 2,
enemyBox.size.height / 2,
);
_explosionKey.currentState?.explode(localEnemyPos);
if (event.risk == RiskLevel.risky &&
event.feedbackType == null) {
_shakeKey.currentState?.shake();
RenderBox? stackBox =
_stackKey.currentContext?.findRenderObject()
as RenderBox?;
if (stackBox != null) {
Offset localEnemyPos = stackBox.globalToLocal(enemyPos);
localEnemyPos += Offset(
enemyBox.size.width / 2,
enemyBox.size.height / 2,
);
_explosionKey.currentState?.explode(localEnemyPos);
}
}
}
}, animRisk)
},
animRisk,
onAnimationStart: () {
if (mounted) {
setState(() => _playerAnimPhase = AnimationPhase.start);
}
},
onAnimationMiddle: () {
if (mounted) {
setState(() => _playerAnimPhase = AnimationPhase.middle);
}
},
onAnimationEnd: () {
if (mounted) {
setState(() => _playerAnimPhase = AnimationPhase.end);
}
},
)
.then((_) {
if (mounted) {
setState(() {
_isPlayerAttacking = false;
_playerAnimPhase = AnimationPhase.none;
});
}
});
@@ -685,6 +725,7 @@ class _BattleScreenState extends State<BattleScreen> {
key: _playerKey,
animationKey: _playerAnimKey,
hideStats: _isPlayerAttacking,
overrideImage: _getOverrideImage(true),
),
),
// Enemy (Top Right) - Rendered Last (On Top)
+3 -3
View File
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers.dart';
import '../game/data.dart';
import 'main_wrapper.dart';
import 'story_screen.dart';
import '../widgets.dart';
import '../game/config.dart';
@@ -37,12 +37,12 @@ class CharacterSelectionScreen extends StatelessWidget {
// Initialize Game
context.read<BattleProvider>().initializeBattle();
// Navigate to Game Screen (MainWrapper)
// Navigate to Story Screen first
// Using pushReplacement to prevent going back to selection
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
builder: (context) => const MainWrapper(),
builder: (context) => const StoryScreen(),
),
(route) => false,
);
+167
View File
@@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import 'main_wrapper.dart';
import '../widgets.dart';
import '../game/config.dart';
class StoryScreen extends StatefulWidget {
const StoryScreen({super.key});
@override
State<StoryScreen> createState() => _StoryScreenState();
}
class _StoryScreenState extends State<StoryScreen> {
final PageController _pageController = PageController();
int _currentPage = 0;
final int _totalPages = 3;
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _onSkip() {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (context) => const MainWrapper()),
(route) => false,
);
}
void _onNext() {
if (_currentPage < _totalPages - 1) {
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
} else {
_onSkip(); // Start Game
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black, // Dark background for story
body: Center(
child: ResponsiveContainer(
child: SafeArea(
child: Stack(
children: [
// 1. PageView for Story Content
PageView(
controller: _pageController,
physics:
const NeverScrollableScrollPhysics(), // Disable swipe
onPageChanged: (index) {
setState(() {
_currentPage = index;
});
},
children: [
_buildStoryPage(1),
_buildStoryPage(2),
_buildStoryPage(3),
],
),
// 2. Skip Button (Top Right)
Positioned(
top: 16,
right: 16,
child: TextButton(
onPressed: _onSkip,
child: const Text(
"SKIP",
style: TextStyle(
color: ThemeConfig.textColorGrey, // Reverted to Grey
fontSize:
ThemeConfig.fontSizeMedium, // Reverted to Medium
fontWeight: FontWeight.bold,
),
),
),
),
// 3. Next/Start Button (Bottom Center or Right)
Positioned(
bottom: 32,
left: 16,
right: 16,
child: Center(
child: SizedBox(
width: 200,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: ThemeConfig.btnActionActive,
padding: const EdgeInsets.symmetric(vertical: 16),
),
onPressed: _onNext,
child: Text(
_currentPage == _totalPages - 1
? "START BATTLE"
: "NEXT",
style: const TextStyle(
color: ThemeConfig.textColorWhite,
fontSize: ThemeConfig.fontSizeHeader,
fontWeight: FontWeight.bold,
),
),
),
),
),
),
],
),
),
),
),
);
}
Widget _buildStoryPage(int pageIndex) {
return Container(
color: Colors.black,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Placeholder for Image
Container(
width: 300,
height: 300,
decoration: BoxDecoration(
color: Colors.grey[800],
border: Border.all(color: ThemeConfig.textColorGrey),
),
child: Center(
child: Icon(Icons.image, size: 64, color: Colors.grey[600]),
),
),
const SizedBox(height: 32),
Text(
"Story Image $pageIndex",
style: const TextStyle(
color: ThemeConfig.textColorWhite,
fontSize: ThemeConfig.fontSizeHeader,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32.0),
child: Text(
"This is the placeholder text for the story part $pageIndex. Describe the lore or setting here.",
textAlign: TextAlign.center,
style: const TextStyle(
color: ThemeConfig.textColorGrey,
fontSize: ThemeConfig.fontSizeMedium,
),
),
),
],
),
),
);
}
}
@@ -53,8 +53,13 @@ class BattleAnimationWidgetState extends State<BattleAnimationWidget>
Future<void> animateAttack(
Offset targetOffset,
VoidCallback onImpact,
RiskLevel risk,
) async {
RiskLevel risk, {
VoidCallback? onAnimationStart,
VoidCallback? onAnimationMiddle,
VoidCallback? onAnimationEnd,
}) async {
// onAnimationStart?.call(); // Start Phase
if (risk == RiskLevel.safe || risk == RiskLevel.normal) {
// Safe & Normal: Dash/Wobble without scale
final isSafe = risk == RiskLevel.safe;
@@ -75,9 +80,13 @@ class BattleAnimationWidgetState extends State<BattleAnimationWidget>
await _translateController.forward();
if (!mounted) return;
// onAnimationMiddle?.call(); // Middle Phase
onImpact();
await _translateController.reverse();
} else {
onAnimationStart?.call(); // Start Phase
// Risky: Scale + Heavy Dash
final attackScale = context.read<SettingsProvider>().attackAnimScale;
_scaleAnimation = Tween<double>(begin: 1.0, end: attackScale).animate(
@@ -91,6 +100,8 @@ class BattleAnimationWidgetState extends State<BattleAnimationWidget>
await _scaleController.forward();
if (!mounted) return;
onAnimationMiddle?.call(); // Middle Phase
// 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;
@@ -106,13 +117,17 @@ class BattleAnimationWidgetState extends State<BattleAnimationWidget>
await _translateController.forward();
if (!mounted) return;
// onAnimationEnd?.call(); // End Phase (Moved before Impact)
// 3. Impact Callback (Shake)
onImpact();
// 4. Return (Reset)
_scaleController.reverse();
_translateController.reverse();
await _translateController.reverse();
}
// onAnimationEnd removed from here
}
Future<void> animateDefense(VoidCallback onImpact) async {
+31 -52
View File
@@ -12,6 +12,7 @@ class CharacterStatusCard extends StatelessWidget {
final bool isTurn;
final GlobalKey<BattleAnimationWidgetState>? animationKey;
final bool hideStats;
final String? overrideImage;
const CharacterStatusCard({
super.key,
@@ -20,6 +21,7 @@ class CharacterStatusCard extends StatelessWidget {
this.isTurn = false,
this.animationKey,
this.hideStats = false,
this.overrideImage,
});
@override
@@ -51,7 +53,7 @@ class CharacterStatusCard extends StatelessWidget {
),
),
SizedBox(
width: 100,
width: ThemeConfig.playerImageSize,
child: LinearProgressIndicator(
value: character.totalMaxHp > 0
? character.hp / character.totalMaxHp
@@ -84,7 +86,7 @@ class CharacterStatusCard extends StatelessWidget {
"${effect.type.name.toUpperCase()} (${effect.duration})",
style: const TextStyle(
color: ThemeConfig.effectText,
fontSize: 10,
fontSize: ThemeConfig.statusEffectFontSize,
fontWeight: FontWeight.bold,
),
),
@@ -92,64 +94,51 @@ class CharacterStatusCard extends StatelessWidget {
}).toList(),
),
),
// Text(
// "ATK: ${character.totalAtk}",
// style: const TextStyle(color: ThemeConfig.textColorWhite),
// ),
// Text(
// "DEF: ${character.totalDefense}",
// style: const TextStyle(color: ThemeConfig.textColorWhite),
// ),
// Text(
// "LUCK: ${character.totalLuck}",
// style: const TextStyle(color: ThemeConfig.textColorWhite),
// ),
],
),
),
const SizedBox(height: 8), // 아이콘과 정보 사이 간격
// 캐릭터 아이콘/이미지 영역 추가
const SizedBox(height: 8),
BattleAnimationWidget(
key: animationKey,
child: Container(
width: isPlayer ? 100 : 200, // 플레이어 100, 적 200
height: isPlayer ? 100 : 200, // 플레이어 100, 적 200
width: isPlayer
? ThemeConfig.playerImageSize
: ThemeConfig.enemyImageSize,
height: isPlayer
? ThemeConfig.playerImageSize
: ThemeConfig.enemyImageSize,
decoration: BoxDecoration(
color: isPlayer ? Colors.lightBlue : null,
// color: isPlayer ? ThemeConfig.playerImageBgColor : null,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: isPlayer
? const Icon(
Icons.person,
size: 60,
color: ThemeConfig.textColorWhite,
) // 플레이어 아이콘
: (character.image != null && character.image!.isNotEmpty)
child: (overrideImage != null ||
(character.image != null && character.image!.isNotEmpty))
? Image.asset(
character.image!,
width: 200,
height: 200,
overrideImage ?? character.image!,
width: isPlayer
? ThemeConfig.playerImageSize
: ThemeConfig.enemyImageSize,
height: isPlayer
? ThemeConfig.playerImageSize
: ThemeConfig.enemyImageSize,
fit: BoxFit.contain,
// color: Colors.white,
// colorBlendMode: BlendMode.screen,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.psychology,
size: 60,
Icons.error_outline,
size: ThemeConfig.characterIconSize,
color: ThemeConfig.textColorWhite,
);
},
)
: const Icon(
Icons.psychology,
size: 60,
: Icon(
isPlayer ? Icons.person : Icons.psychology,
size: ThemeConfig.characterIconSize,
color: ThemeConfig.textColorWhite,
), // 적 이미지
),
),
),
),
// const SizedBox(height: 8), // 아이콘과 정보 사이 간격
if (!isPlayer && !hideStats)
Consumer<BattleProvider>(
builder: (context, provider, child) {
@@ -166,14 +155,6 @@ class CharacterStatusCard extends StatelessWidget {
),
child: Column(
children: [
// Text(
// "INTENT",
// style: TextStyle(
// color: ThemeConfig.enemyIntentBorder,
// fontSize: 10,
// fontWeight: FontWeight.bold,
// ),
// ),
Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -181,20 +162,18 @@ class CharacterStatusCard extends StatelessWidget {
intent.type == EnemyActionType.attack
? Icons.flash_on
: Icons.shield,
color: ThemeConfig.rarityRare, // Yellow
size: 16,
color: ThemeConfig.rarityRare,
size: ThemeConfig.intentIconSize,
),
const SizedBox(width: 4),
Flexible(
// Use Flexible to allow text to shrink
child: FittedBox(
fit:
BoxFit.scaleDown, // Shrink text if too long
fit: BoxFit.scaleDown,
child: Text(
intent.description,
style: const TextStyle(
color: ThemeConfig.textColorWhite,
fontSize: 12,
fontSize: ThemeConfig.intentFontSize,
),
),
),
@@ -86,6 +86,7 @@ class InventoryGridWidget extends StatelessWidget {
Item item,
) {
bool isShop = provider.currentStage.type == StageType.shop;
int sellPrice = (item.price * GameConfig.sellPriceMultiplier).floor();
showDialog(
context: context,
@@ -141,7 +142,7 @@ class InventoryGridWidget extends StatelessWidget {
color: ThemeConfig.statGoldColor,
),
const SizedBox(width: 10),
Text("${AppStrings.sell} (${item.price} G)"),
Text("${AppStrings.sell} ($sellPrice G)"),
],
),
),
@@ -172,11 +173,13 @@ class InventoryGridWidget extends StatelessWidget {
BattleProvider provider,
Item item,
) {
int sellPrice = (item.price * GameConfig.sellPriceMultiplier).floor();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Sell Item"),
content: Text("Sell ${item.name} for ${item.price} G?"),
content: Text("Sell ${item.name} for $sellPrice G?"),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),