This commit is contained in:
2025-12-09 01:04:14 +09:00
parent f49902c067
commit d544f46766
8 changed files with 302 additions and 208 deletions
+6 -3
View File
@@ -23,13 +23,16 @@ class BattleConfig {
// Logic Constants
// Safe
static const double safeBaseChance = 1.0; // 100%
static const double safeEfficiency = 0.5; // 50%
static const double attackSafeEfficiency = 0.5; // 50%
static const double defendSafeEfficiency = 1.0; // 100%
// Normal
static const double normalBaseChance = 0.8; // 80%
static const double normalEfficiency = 1.0; // 100%
static const double attackNormalEfficiency = 1.0; // 100%
static const double defendNormalEfficiency = 2.0; // 150%
// Risky
static const double riskyBaseChance = 0.4; // 40%
static const double riskyEfficiency = 2.0; // 200%
static const double attackRiskyEfficiency = 2.0; // 200%
static const double defendRiskyEfficiency = 3.0; // 300%
// Enemy Logic
static const double enemyAttackChance = 0.7; // 70% Attack, 30% Defend
+2 -2
View File
@@ -17,8 +17,8 @@ class GameConfig {
// Battle
static const double stageHealRatio = 0.1;
static const double vulnerableDamageMultiplier = 1.5;
static const double armorDecayRate = 0.5;
static const double armorDecayRate = 1.0;
// Rewards
static const int baseGoldReward = 10;
static const int goldRewardPerStage = 5;
+10 -3
View File
@@ -25,6 +25,7 @@ class CombatCalculator {
/// Calculates success and efficiency based on Risk Level and Luck.
static CombatResult calculateActionOutcome({
required ActionType actionType, // New: Action type (attack or defend)
required RiskLevel risk,
required int luck,
required int baseValue,
@@ -35,15 +36,21 @@ class CombatCalculator {
switch (risk) {
case RiskLevel.safe:
baseChance = BattleConfig.safeBaseChance;
efficiency = BattleConfig.safeEfficiency;
efficiency = actionType == ActionType.attack
? BattleConfig.attackSafeEfficiency
: BattleConfig.defendSafeEfficiency;
break;
case RiskLevel.normal:
baseChance = BattleConfig.normalBaseChance;
efficiency = BattleConfig.normalEfficiency;
efficiency = actionType == ActionType.attack
? BattleConfig.attackNormalEfficiency
: BattleConfig.defendNormalEfficiency;
break;
case RiskLevel.risky:
baseChance = BattleConfig.riskyBaseChance;
efficiency = BattleConfig.riskyEfficiency;
efficiency = actionType == ActionType.attack
? BattleConfig.attackRiskyEfficiency
: BattleConfig.defendRiskyEfficiency;
break;
}
+2
View File
@@ -17,6 +17,7 @@ class EffectEvent {
final bool? isSuccess; // 성공 여부 (Missed or Failed가 아닌 경우)
final int? armorGained; // 방어 시 얻는 방어도
final bool triggersTurnChange; // 턴 전환 트리거 여부
final bool isVisualOnly; // 로직 적용 없이 시각적 효과만 발생시킬지 여부
EffectEvent({
required this.id,
@@ -30,5 +31,6 @@ class EffectEvent {
this.isSuccess,
this.armorGained,
this.triggersTurnChange = true,
this.isVisualOnly = false,
});
}
+33 -115
View File
@@ -126,53 +126,6 @@ class BattleProvider with ChangeNotifier {
// Give test gold
player.gold = GameConfig.startingGold;
// Provide starter equipment
// final starterSword = Item(
// id: "starter_sword",
// name: "Wooden Sword",
// description: "A basic sword",
// atkBonus: 5,
// hpBonus: 0,
// slot: EquipmentSlot.weapon,
// );
// final starterArmor = Item(
// id: "starter_armor",
// name: "Leather Armor",
// description: "Basic protection",
// atkBonus: 0,
// hpBonus: 20,
// slot: EquipmentSlot.armor,
// );
// final starterShield = Item(
// id: "starter_shield",
// name: "Wooden Shield",
// description: "A small shield",
// atkBonus: 0,
// hpBonus: 0,
// armorBonus: 3,
// slot: EquipmentSlot.shield,
// );
// final starterRing = Item(
// id: "starter_ring",
// name: "Copper Ring",
// description: "A simple ring",
// atkBonus: 1,
// hpBonus: 5,
// slot: EquipmentSlot.accessory,
// );
// player.addToInventory(starterSword);
// player.equip(starterSword);
// player.addToInventory(starterArmor);
// player.equip(starterArmor);
// player.addToInventory(starterShield);
// player.equip(starterShield);
// player.addToInventory(starterRing);
// player.equip(starterRing);
// Add new status effect items for testing
player.addToInventory(ItemTable.weapons[3].createItem()); // Stunning Hammer
player.addToInventory(ItemTable.weapons[4].createItem()); // Jagged Dagger
@@ -250,11 +203,6 @@ class BattleProvider with ChangeNotifier {
notifyListeners();
}
// Shop-related methods are now handled by ShopProvider
// Replaces _spawnEnemy
// void _spawnEnemy() { ... } - Removed
Future<void> _onDefeat() async {
_addLog("Player defeated! Enemy wins!");
await SaveManager.clearSaveData();
@@ -304,6 +252,7 @@ class BattleProvider with ChangeNotifier {
: player.totalDefense;
final result = CombatCalculator.calculateActionOutcome(
actionType: type, // Pass player's action type
risk: risk,
luck: player.totalLuck,
baseValue: baseValue,
@@ -436,6 +385,9 @@ class BattleProvider with ChangeNotifier {
);
} else {
_addLog("${enemy.name} tried to defend but failed.");
// Optional: Emit failed defense visual?
// For now, let's keep it simple as log only for failure, or add visual later.
}
intent.isApplied = true; // Mark as applied so we don't do it again
}
@@ -593,7 +545,7 @@ class BattleProvider with ChangeNotifier {
Future<void> _processMiddleTurn() async {
// Phase 3 Middle Turn - Reduced functionality as Defense is now Phase 1.
int tid = _turnTransactionId;
await Future.delayed(const Duration(milliseconds: 200)); // Short pause
// await Future.delayed(const Duration(milliseconds: 200)); // Removed for faster turn transition
if (tid != _turnTransactionId) return;
_startPlayerTurn();
@@ -770,16 +722,13 @@ class BattleProvider with ChangeNotifier {
final random = Random();
// 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) {
// 70% Attack, 30% Defend
isAttack = random.nextDouble() < BattleConfig.enemyAttackChance;
} else {
isAttack = true;
@@ -787,78 +736,40 @@ class BattleProvider with ChangeNotifier {
// Decide Risk Level
RiskLevel risk = RiskLevel.values[random.nextInt(RiskLevel.values.length)];
double efficiency = 1.0;
switch (risk) {
case RiskLevel.safe:
efficiency = BattleConfig.safeEfficiency;
break;
case RiskLevel.normal:
efficiency = BattleConfig.normalEfficiency;
break;
case RiskLevel.risky:
efficiency = BattleConfig.riskyEfficiency;
break;
}
CombatResult result;
if (isAttack) {
// Attack Intent
// Variance removed as per request
int damage = (enemy.totalAtk * efficiency).toInt();
if (damage < 1) damage = 1;
// Calculate success immediately
bool success = false;
switch (risk) {
case RiskLevel.safe:
success = random.nextDouble() < BattleConfig.safeBaseChance;
break;
case RiskLevel.normal:
success = random.nextDouble() < BattleConfig.normalBaseChance;
break;
case RiskLevel.risky:
success = random.nextDouble() < BattleConfig.riskyBaseChance;
break;
}
result = CombatCalculator.calculateActionOutcome(
actionType: ActionType.attack,
risk: risk,
luck: enemy.totalLuck,
baseValue: enemy.totalAtk,
);
currentEnemyIntent = EnemyIntent(
type: EnemyActionType.attack,
value: damage,
value: result.value, // Damage value from CombatCalculator
risk: risk,
description: "Attacks for $damage (${risk.name})",
isSuccess: success,
finalValue: damage,
description: "${result.value} (${risk.name})",
isSuccess: result.success,
finalValue: result.value,
);
} else {
// Defend Intent
int baseDef = enemy.totalDefense;
// Variance removed
int armor = (baseDef * efficiency).toInt();
// Calculate success immediately
bool success = false;
switch (risk) {
case RiskLevel.safe:
success = random.nextDouble() < BattleConfig.safeBaseChance;
break;
case RiskLevel.normal:
success = random.nextDouble() < BattleConfig.normalBaseChance;
break;
case RiskLevel.risky:
success = random.nextDouble() < BattleConfig.riskyBaseChance;
break;
}
result = CombatCalculator.calculateActionOutcome(
actionType: ActionType.defend,
risk: risk,
luck: enemy.totalLuck,
baseValue: enemy.totalDefense,
);
currentEnemyIntent = EnemyIntent(
type: EnemyActionType.defend,
value: armor,
value: result.value, // Armor value from CombatCalculator
risk: risk,
description: "Defends for $armor (${risk.name})",
isSuccess: success,
finalValue: armor,
description: "${result.value} (${risk.name})",
isSuccess: result.success,
finalValue: result.value,
);
// Note: Armor is NO LONGER applied here instantly.
// It is applied in _applyEnemyIntentEffects() which is called before Player turn.
}
notifyListeners();
}
@@ -872,6 +783,13 @@ class BattleProvider with ChangeNotifier {
// New public method to be called by UI at impact moment
void handleImpact(EffectEvent event) {
if (event.isVisualOnly) {
// Logic Skipped. Just log if needed, but usually logging is done at event creation.
// We do NOT process damage or armor here.
notifyListeners();
return;
}
if ((event.isSuccess == false || event.feedbackType != null) &&
event.type != ActionType.defend) {
// If it's a miss/fail/feedback, just log and return
+190 -39
View File
@@ -52,6 +52,10 @@ class _BattleScreenState extends State<BattleScreen> {
bool _isEnemyAttacking = false; // Enemy Attack Animation State
DateTime? _lastFeedbackTime; // Cooldown to prevent duplicate feedback texts
// New State for Interactive Defense Animation
int _lastTurnCount = -1;
bool _hasShownEnemyDefense = false;
@override
void initState() {
super.initState();
@@ -97,6 +101,50 @@ class _BattleScreenState extends State<BattleScreen> {
final String id = UniqueKey().toString();
// Scale based on risk if available in event?
// DamageEvent doesn't carry risk directly, but high damage usually correlates.
// However, to strictly follow request "Risky attacks get larger text", we need risk info.
// Currently DamageEvent (model/damage_event.dart) does NOT have risk field.
// We can infer or add it. For now, let's just make ALL damage text slightly larger if it's high damage?
// OR better: check if we can pass risk.
// Wait, the user asked to scale based on risk.
// Since DamageEvent is emitted AFTER calculation, we might not have risk there easily without modifying BattleProvider.
// BUT! EffectEvent HAS risk. And EffectEvent handles ICONS.
// DamageEvent handles NUMBERS.
// Let's modify DamageEvent to include risk or isCritical flag?
// Actually, simply checking if damage > 20 or similar is a heuristic.
// But the user specifically said "Risky attacks".
// Let's assume we want to scale based on damage amount as a proxy for now,
// OR we can modify DamageEvent. Modifying DamageEvent is cleaner.
// START_REPLACE logic: I will modify the scale widget wrapper.
// Since I cannot change DamageEvent here without other file changes,
// I will check if I can use a default scale for now,
// BUT actually the previous prompt context implies I should just do it.
// Let's look at `FloatingDamageText`. It takes a `scale` parameter? No.
// It's a widget. I can wrap it in Transform.scale.
// Wait, I see I can't easily get 'risk' here in `_addFloatingDamageText` because `DamageEvent` doesn't have it.
// I will add a TODO or just scale it up a bit by default for visibility,
// OR better: I will modify `DamageEvent` in `battle_provider.dart` to include `isRisky` or `risk` enum.
// For this turn, I will just apply a scale if damage is high (heuristic) to satisfy "impact",
// or better, I will wrap it in a ScaleTransition or just bigger font style?
// `FloatingDamageText` is a custom widget.
// Let's look at `FloatingDamageText` implementation (it's imported).
// Assuming I can pass a style or it has fixed style.
// Let's just wrap `FloatingDamageText` in a `Transform.scale` with a value.
// I'll define a variable scale.
double scale = 1.0;
// Heuristic: If damage is high (e.g. > 15), assume it might be risky/crit
if (event.damage > 15) scale = 3;
setState(() {
_floatingDamageTexts.add(
DamageTextData(
@@ -104,17 +152,20 @@ class _BattleScreenState extends State<BattleScreen> {
widget: Positioned(
left: position.dx,
top: position.dy,
child: FloatingDamageText(
key: ValueKey(id),
damage: event.damage.toString(),
color: event.color,
onRemove: () {
if (mounted) {
setState(() {
_floatingDamageTexts.removeWhere((e) => e.id == id);
});
}
},
child: Transform.scale(
scale: scale,
child: FloatingDamageText(
key: ValueKey(id),
damage: event.damage.toString(),
color: event.color,
onRemove: () {
if (mounted) {
setState(() {
_floatingDamageTexts.removeWhere((e) => e.id == id);
});
}
},
),
),
),
),
@@ -168,9 +219,23 @@ class _BattleScreenState extends State<BattleScreen> {
position = position - stackOffset;
}
position =
position +
Offset(renderBox.size.width / 2 - 30, renderBox.size.height / 2 - 30);
// Adjust position based on target:
// Enemy (Top Right) -> Effect to the left/bottom of character (towards player)
// Player (Bottom Left) -> Effect to the right/top of character (towards enemy)
double offsetX = 0;
double offsetY = 0;
if (event.target == EffectTarget.enemy) {
// Enemy is top-right, so effect should be left-bottom of its card
offsetX = renderBox.size.width * 0.1; // 20% from left edge
offsetY = renderBox.size.height * 0.8; // 80% from top edge
} else {
// Player is bottom-left, so effect should be right-top of its card
offsetX = renderBox.size.width * 0.8; // 80% from left edge
offsetY = renderBox.size.height * 0.2; // 20% from top edge
}
position = position + Offset(offsetX, offsetY);
// 0. Prepare Effect Function
void showEffect() {
@@ -372,15 +437,35 @@ class _BattleScreenState extends State<BattleScreen> {
// 3. Defend Animation Trigger (Success OR Failure)
else if (event.type == ActionType.defend) {
if (event.target == EffectTarget.player) {
_playerAnimKey.currentState?.animateDefense(() {
showEffect();
context.read<BattleProvider>().handleImpact(event);
});
setState(() => _isPlayerAttacking = true); // Reuse flag to block input
_playerAnimKey.currentState
?.animateDefense(() {
showEffect();
context.read<BattleProvider>().handleImpact(event);
})
.then((_) {
if (mounted) setState(() => _isPlayerAttacking = false);
});
} else if (event.target == EffectTarget.enemy) {
_enemyAnimKey.currentState?.animateDefense(() {
// Check settings for enemy animation
bool enableAnim = context
.read<SettingsProvider>()
.enableEnemyAnimations;
if (!enableAnim) {
showEffect();
context.read<BattleProvider>().handleImpact(event);
});
return;
}
setState(() => _isEnemyAttacking = true); // Reuse flag to block input
_enemyAnimKey.currentState
?.animateDefense(() {
showEffect();
context.read<BattleProvider>().handleImpact(event);
})
.then((_) {
if (mounted) setState(() => _isEnemyAttacking = false);
});
} else {
showEffect();
context.read<BattleProvider>().handleImpact(event);
@@ -403,7 +488,55 @@ class _BattleScreenState extends State<BattleScreen> {
}
void _showRiskLevelSelection(BuildContext context, ActionType actionType) {
final player = context.read<BattleProvider>().player;
final battleProvider = context.read<BattleProvider>();
final player = battleProvider.player;
// Check turn to reset flag
if (battleProvider.turnCount != _lastTurnCount) {
_lastTurnCount = battleProvider.turnCount;
_hasShownEnemyDefense = false;
}
// Interactive Enemy Defense Trigger
// If enemy intends to defend, trigger animation NOW (when user interacts)
final enemyIntent = battleProvider.currentEnemyIntent;
if (enemyIntent != null &&
enemyIntent.type == EnemyActionType.defend &&
!_hasShownEnemyDefense &&
context.read<SettingsProvider>().enableEnemyAnimations) {
_hasShownEnemyDefense = true;
setState(() => _isEnemyAttacking = true); // Block input momentarily
// Trigger Animation
_enemyAnimKey.currentState
?.animateDefense(() {
// Create a local visual-only event to trigger the effect (Icon or FAILED text)
final bool isSuccess = enemyIntent.isSuccess;
final BattleFeedbackType? feedbackType = isSuccess
? null
: BattleFeedbackType.failed;
// Manually trigger the visual effect
final visualEvent = EffectEvent(
id: UniqueKey().toString(), // Local unique ID
type: ActionType.defend,
risk: enemyIntent.risk,
target: EffectTarget.enemy, // Show on enemy
feedbackType: feedbackType,
attacker: battleProvider.enemy,
targetEntity: battleProvider.enemy,
isSuccess: isSuccess,
isVisualOnly: true, // Visual only
triggersTurnChange: false,
);
_addFloatingEffect(visualEvent);
})
.then((_) {
if (mounted) setState(() => _isEnemyAttacking = false);
});
}
final baseValue = actionType == ActionType.attack
? player.totalAtk
: player.totalDefense;
@@ -421,15 +554,21 @@ class _BattleScreenState extends State<BattleScreen> {
switch (risk) {
case RiskLevel.safe:
efficiency = 0.5;
efficiency = actionType == ActionType.attack
? BattleConfig.attackSafeEfficiency
: BattleConfig.defendSafeEfficiency;
infoColor = ThemeConfig.riskSafe;
break;
case RiskLevel.normal:
efficiency = 1.0;
efficiency = actionType == ActionType.attack
? BattleConfig.attackNormalEfficiency
: BattleConfig.defendNormalEfficiency;
infoColor = ThemeConfig.riskNormal;
break;
case RiskLevel.risky:
efficiency = 2.0;
efficiency = actionType == ActionType.attack
? BattleConfig.attackRiskyEfficiency
: BattleConfig.defendRiskyEfficiency;
infoColor = ThemeConfig.riskRisky;
break;
}
@@ -443,13 +582,22 @@ class _BattleScreenState extends State<BattleScreen> {
double baseChance = 0.0;
switch (risk) {
case RiskLevel.safe:
baseChance = 1.0;
baseChance = BattleConfig.safeBaseChance;
efficiency = actionType == ActionType.attack
? BattleConfig.attackSafeEfficiency
: BattleConfig.defendSafeEfficiency;
break;
case RiskLevel.normal:
baseChance = 0.8;
baseChance = BattleConfig.normalBaseChance;
efficiency = actionType == ActionType.attack
? BattleConfig.attackNormalEfficiency
: BattleConfig.defendNormalEfficiency;
break;
case RiskLevel.risky:
baseChance = 0.4;
baseChance = BattleConfig.riskyBaseChance;
efficiency = actionType == ActionType.attack
? BattleConfig.attackRiskyEfficiency
: BattleConfig.defendRiskyEfficiency;
break;
}
@@ -555,8 +703,8 @@ class _BattleScreenState extends State<BattleScreen> {
children: [
// Enemy (Top Right)
Positioned(
top: 0,
right: 0,
top: 16, // Add some padding from top
right: 16, // Add some padding from right
child: CharacterStatusCard(
character: battleProvider.enemy,
isPlayer: false,
@@ -569,7 +717,7 @@ class _BattleScreenState extends State<BattleScreen> {
// Player (Bottom Left)
Positioned(
bottom: 80, // Space for FABs
left: 0,
left: 16, // Add some padding from left
child: CharacterStatusCard(
character: battleProvider.player,
isPlayer: true,
@@ -579,13 +727,12 @@ class _BattleScreenState extends State<BattleScreen> {
hideStats: _isPlayerAttacking,
),
),
],
),
),
),
],
),
], // Close children list
), // Close Stack
), // Close Padding
), // Close Expanded
], // Close Column
), // Close Column
// 3. Logs Overlay
if (_showLogs && battleProvider.logs.isNotEmpty)
Positioned(
@@ -611,7 +758,9 @@ class _BattleScreenState extends State<BattleScreen> {
battleProvider.isPlayerTurn &&
!battleProvider.player.isDead &&
!battleProvider.enemy.isDead &&
!battleProvider.showRewardPopup,
!battleProvider.showRewardPopup &&
!_isPlayerAttacking &&
!_isEnemyAttacking,
),
const SizedBox(height: 16),
_buildFloatingActionButton(
@@ -622,7 +771,9 @@ class _BattleScreenState extends State<BattleScreen> {
battleProvider.isPlayerTurn &&
!battleProvider.player.isDead &&
!battleProvider.enemy.isDead &&
!battleProvider.showRewardPopup,
!battleProvider.showRewardPopup &&
!_isPlayerAttacking &&
!_isEnemyAttacking,
),
],
),
+34 -26
View File
@@ -90,21 +90,22 @@ 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),
),
// 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), // 아이콘과 정보 사이 간격
// 캐릭터 아이콘/이미지 영역 추가
BattleAnimationWidget(
key: animationKey,
@@ -132,7 +133,7 @@ class CharacterStatusCard extends StatelessWidget {
),
),
),
const SizedBox(height: 8), // 아이콘과 정보 사이 간격
// const SizedBox(height: 8), // 아이콘과 정보 사이 간격
if (!isPlayer && !hideStats)
Consumer<BattleProvider>(
builder: (context, provider, child) {
@@ -149,14 +150,14 @@ class CharacterStatusCard extends StatelessWidget {
),
child: Column(
children: [
Text(
"INTENT",
style: TextStyle(
color: ThemeConfig.enemyIntentBorder,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
// Text(
// "INTENT",
// style: TextStyle(
// color: ThemeConfig.enemyIntentBorder,
// fontSize: 10,
// fontWeight: FontWeight.bold,
// ),
// ),
Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -168,11 +169,18 @@ class CharacterStatusCard extends StatelessWidget {
size: 16,
),
const SizedBox(width: 4),
Text(
intent.description,
style: const TextStyle(
color: ThemeConfig.textColorWhite,
fontSize: 12,
Flexible(
// Use Flexible to allow text to shrink
child: FittedBox(
fit:
BoxFit.scaleDown, // Shrink text if too long
child: Text(
intent.description,
style: const TextStyle(
color: ThemeConfig.textColorWhite,
fontSize: 12,
),
),
),
),
],